diff --git a/.gitea/workflows/ci-release.yaml b/.gitea/workflows/ci-release.yaml index 6fd9271..7517c34 100644 --- a/.gitea/workflows/ci-release.yaml +++ b/.gitea/workflows/ci-release.yaml @@ -34,6 +34,10 @@ jobs: shell: bash run: cmake --build build --parallel + - name: Run tests + shell: bash + run: ctest --test-dir build --output-on-failure + - name: Smoke test GUI shell: bash run: | diff --git a/.gitignore b/.gitignore index 84c048a..018b973 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /build/ +/cmake-build-*/ +/out/ +/.idea/ +/.vscode/ +/CMakeLists.txt.user +/CMakeLists.txt.user.* diff --git a/CMakeLists.txt b/CMakeLists.txt index 985c181..1031a67 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,14 +14,51 @@ set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus) +add_library(folderscope_core STATIC + src/search_core.cpp + src/search_core.h +) +target_link_libraries(folderscope_core PUBLIC Qt6::Core) +target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic) +target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") + qt_add_executable(searchmyfiles + src/long_long_spin_box.cpp + src/long_long_spin_box.h src/main.cpp ) -target_link_libraries(searchmyfiles PRIVATE Qt6::Widgets Qt6::Concurrent Qt6::DBus) +target_link_libraries(searchmyfiles PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus) target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic) target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}") install(TARGETS searchmyfiles RUNTIME DESTINATION bin) install(FILES packaging/searchmyfiles.desktop DESTINATION share/applications) + +include(CTest) +if (BUILD_TESTING) + find_package(Qt6 REQUIRED COMPONENTS Test) + + qt_add_executable(search_core_tests + tests/search_core_test.cpp + ) + target_link_libraries(search_core_tests PRIVATE folderscope_core Qt6::Test) + target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic) + add_test(NAME search-core COMMAND search_core_tests) + + qt_add_executable(long_long_spin_box_tests + src/long_long_spin_box.cpp + src/long_long_spin_box.h + tests/long_long_spin_box_test.cpp + ) + target_link_libraries(long_long_spin_box_tests PRIVATE Qt6::Widgets Qt6::Test) + target_compile_options(long_long_spin_box_tests PRIVATE -Wall -Wextra -Wpedantic) + target_include_directories(long_long_spin_box_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") + add_test(NAME long-long-spin-box COMMAND long_long_spin_box_tests) + set_tests_properties(long-long-spin-box PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + + add_test(NAME release-rejects-untracked + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tests/release_dirty_test.sh" + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/release.sh") +endif() diff --git a/README.md b/README.md index bef7123..b6e6831 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ cmake --build build -j ./build/searchmyfiles ``` +Run the automated tests: + +```bash +ctest --test-dir build --output-on-failure +``` + Install into the current system: ```bash diff --git a/scripts/release.sh b/scripts/release.sh index 04b3323..257fd6c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -9,7 +9,7 @@ fi version="$1" tag="v${version}" -if ! git diff --quiet || ! git diff --cached --quiet; then +if [[ -n "$(git status --porcelain)" ]]; then echo "Refusing to release with uncommitted changes." >&2 exit 1 fi diff --git a/src/long_long_spin_box.cpp b/src/long_long_spin_box.cpp new file mode 100644 index 0000000..ec60976 --- /dev/null +++ b/src/long_long_spin_box.cpp @@ -0,0 +1,130 @@ +#include "long_long_spin_box.h" + +#include + +#include + +LongLongSpinBox::LongLongSpinBox(QWidget *parent) : QAbstractSpinBox(parent) +{ + setValue(0); + connect(this, &QAbstractSpinBox::editingFinished, this, &LongLongSpinBox::commitText); +} + +qint64 LongLongSpinBox::value() const +{ + qint64 parsed = value_; + return valueFromText(lineEdit()->text(), parsed) ? parsed : value_; +} + +void LongLongSpinBox::setValue(qint64 value) +{ + value_ = std::clamp(value, minimum_, maximum_); + lineEdit()->setText(textFromValue(value_)); +} + +void LongLongSpinBox::setRange(qint64 minimum, qint64 maximum) +{ + minimum_ = minimum; + maximum_ = std::max(minimum, maximum); + setValue(value_); +} + +void LongLongSpinBox::setSingleStep(qint64 step) +{ + singleStep_ = std::max(1, step); +} + +void LongLongSpinBox::setSuffix(const QString &suffix) +{ + suffix_ = suffix; + setValue(value()); +} + +void LongLongSpinBox::setSpecialValueText(const QString &text) +{ + specialValueText_ = text; + setValue(value()); +} + +QValidator::State LongLongSpinBox::validate(QString &input, int &position) const +{ + Q_UNUSED(position); + if (!specialValueText_.isEmpty() && input == specialValueText_) + return QValidator::Acceptable; + + QString number = input; + if (!suffix_.isEmpty() && number.endsWith(suffix_)) + number.chop(suffix_.size()); + number = number.trimmed(); + if (number.isEmpty() || number == QStringLiteral("-")) + return QValidator::Intermediate; + + qint64 parsed = 0; + if (!valueFromText(input, parsed)) + return QValidator::Invalid; + return parsed >= minimum_ && parsed <= maximum_ + ? QValidator::Acceptable : QValidator::Invalid; +} + +void LongLongSpinBox::fixup(QString &input) const +{ + qint64 parsed = value_; + input = valueFromText(input, parsed) + ? textFromValue(std::clamp(parsed, minimum_, maximum_)) + : textFromValue(value_); +} + +void LongLongSpinBox::stepBy(int steps) +{ + commitText(); + qint64 next = value_; + if (steps > 0) { + const qint64 count = steps; + next = singleStep_ > (maximum_ - value_) / count + ? maximum_ : value_ + singleStep_ * count; + } else if (steps < 0) { + const qint64 count = -qint64(steps); + next = singleStep_ > (value_ - minimum_) / count + ? minimum_ : value_ - singleStep_ * count; + } + setValue(next); +} + +QAbstractSpinBox::StepEnabled LongLongSpinBox::stepEnabled() const +{ + StepEnabled enabled = StepNone; + const qint64 current = value(); + if (current > minimum_) + enabled |= StepDownEnabled; + if (current < maximum_) + enabled |= StepUpEnabled; + return enabled; +} + +QString LongLongSpinBox::textFromValue(qint64 value) const +{ + if (value == minimum_ && !specialValueText_.isEmpty()) + return specialValueText_; + return QString::number(value) + suffix_; +} + +bool LongLongSpinBox::valueFromText(const QString &text, qint64 &value) const +{ + if (!specialValueText_.isEmpty() && text == specialValueText_) { + value = minimum_; + return true; + } + QString number = text; + if (!suffix_.isEmpty() && number.endsWith(suffix_)) + number.chop(suffix_.size()); + bool ok = false; + const qint64 parsed = number.trimmed().toLongLong(&ok); + if (ok) + value = parsed; + return ok; +} + +void LongLongSpinBox::commitText() +{ + setValue(value()); +} diff --git a/src/long_long_spin_box.h b/src/long_long_spin_box.h new file mode 100644 index 0000000..29cf922 --- /dev/null +++ b/src/long_long_spin_box.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include + +class LongLongSpinBox final : public QAbstractSpinBox { +public: + explicit LongLongSpinBox(QWidget *parent = nullptr); + + qint64 value() const; + void setValue(qint64 value); + void setRange(qint64 minimum, qint64 maximum); + void setSingleStep(qint64 step); + void setSuffix(const QString &suffix); + void setSpecialValueText(const QString &text); + +protected: + QValidator::State validate(QString &input, int &position) const override; + void fixup(QString &input) const override; + void stepBy(int steps) override; + StepEnabled stepEnabled() const override; + +private: + QString textFromValue(qint64 value) const; + bool valueFromText(const QString &text, qint64 &value) const; + void commitText(); + + qint64 minimum_ = 0; + qint64 maximum_ = std::numeric_limits::max(); + qint64 value_ = 0; + qint64 singleStep_ = 1; + QString suffix_; + QString specialValueText_; +}; diff --git a/src/main.cpp b/src/main.cpp index 3e0d944..66f4ea4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -55,13 +55,15 @@ #include #include +#include "long_long_spin_box.h" +#include "search_core.h" + #include #include #include #include #include #include -#include #include #include #include @@ -69,220 +71,6 @@ namespace fs = std::filesystem; -struct SearchOptions { - QString roots = QDir::homePath(); - QString fileWildcards = QStringLiteral("*"); - QString subfolderWildcards = QStringLiteral("*"); - QString excludeFiles; - QString excludeFolders; - QString includeFolders; - QString contains; - bool binary = false; - bool multipleValues = false; - bool multipleAnd = false; - bool caseSensitive = false; - qint64 minSize = 0; - qint64 maxSize = 0; - qint64 minTime = 0; - qint64 maxTime = 0; - QString timeField = QStringLiteral("Modified time"); - int lastMinutes = 0; - bool recursive = true; - bool findFiles = true; - bool findFolders = false; - bool followLinks = false; - bool retrieveOwner = false; - bool accurateProgress = true; - bool useCache = true; - int maxDepth = 0; - int maxResults = 0; - QString mode = QStringLiteral("Standard search"); - QString duplicateNameMode = QStringLiteral("All files and folders"); - bool duplicateNameWithoutExtension = false; - bool showOnlyDuplicateFiles = true; - bool includeSubfoldersInSummary = false; - QString hidden = QStringLiteral("Any"); - QString readonly = QStringLiteral("Any"); - QString executable = QStringLiteral("Any"); -}; - -struct FileRecord { - QString path; - QString name; - QString folder; - qint64 size = 0; - qint64 sizeOnDisk = 0; - qint64 modified = 0; - qint64 created = 0; - qint64 accessed = 0; - qint64 changed = 0; - QString type; - QString owner; - QString attributes; - int group = 0; - int duplicateCopy = 0; -}; - -Q_DECLARE_METATYPE(SearchOptions) -Q_DECLARE_METATYPE(FileRecord) -Q_DECLARE_METATYPE(QVector) - -static QStringList patterns(const QString &text) -{ - QStringList out; - QString item; - bool quoted = false; - for (QChar ch : text) { - if (ch == u'"') { - quoted = !quoted; - } else if (!quoted && (ch == u';' || ch == u',')) { - if (!item.trimmed().isEmpty()) - out.push_back(item.trimmed()); - item.clear(); - } else { - item += ch; - } - } - if (!item.trimmed().isEmpty()) - out.push_back(item.trimmed()); - return out; -} - -static QStringList exclusionPatterns(const QString &text) -{ - QString normalized = text; - normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";")); - QStringList result = patterns(normalized); - for (QString &pattern : result) { - if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/')) - pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\."))); - } - return result; -} - -static bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs) -{ - for (const QString &pattern : items) { - const auto regex = QRegularExpression( - QRegularExpression::wildcardToRegularExpression(pattern), - cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption - : QRegularExpression::NoPatternOption); - if (regex.match(value).hasMatch()) - return true; - } - return false; -} - -static QString humanSize(qint64 bytes) -{ - static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"), - QStringLiteral("MB"), QStringLiteral("GB"), - QStringLiteral("TB")}; - double value = static_cast(bytes); - int unit = 0; - while (value >= 1024.0 && unit < units.size() - 1) { - value /= 1024.0; - ++unit; - } - return unit == 0 ? QStringLiteral("%1 B").arg(bytes) - : QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]); -} - -static QString ownerName(uid_t uid) -{ - struct passwd pwd {}; - struct passwd *result = nullptr; - QByteArray buffer(16384, Qt::Uninitialized); - if (getpwuid_r(uid, &pwd, buffer.data(), static_cast(buffer.size()), &result) == 0 && result) - return QString::fromLocal8Bit(result->pw_name); - return QString::number(uid); -} - -static std::optional hexNeedle(QString text) -{ - text.remove(QRegularExpression(QStringLiteral("\\s+"))); - if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch()) - return std::nullopt; - return QByteArray::fromHex(text.toLatin1()); -} - -static bool fileContains(const QString &path, const SearchOptions &o) -{ - if (o.contains.isEmpty()) - return true; - - QStringList terms = o.multipleValues ? patterns(o.contains) : QStringList{o.contains}; - QList needles; - for (const QString &term : terms) { - if (o.binary) { - const auto bytes = hexNeedle(term); - if (!bytes) - return false; - needles.push_back(*bytes); - } else { - needles.push_back(term.toUtf8()); - } - } - if (needles.isEmpty()) - return true; - - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) - return false; - - QVector found(needles.size(), false); - qsizetype overlap = 1; - for (const QByteArray &needle : needles) - overlap = std::max(overlap, needle.size()); - QByteArray tail; - while (!file.atEnd()) { - QByteArray data = tail + file.read(1024 * 1024); - if (!o.caseSensitive && !o.binary) - data = data.toLower(); - for (qsizetype i = 0; i < needles.size(); ++i) { - QByteArray needle = needles[i]; - if (!o.caseSensitive && !o.binary) - needle = needle.toLower(); - if (!found[i] && data.contains(needle)) - found[i] = true; - } - const bool matched = o.multipleAnd - ? std::all_of(found.cbegin(), found.cend(), [](bool x) { return x; }) - : std::any_of(found.cbegin(), found.cend(), [](bool x) { return x; }); - if (matched) - return true; - tail = data.right(overlap - 1); - } - return false; -} - -static bool filesEqual(const QString &leftPath, const QString &rightPath) -{ - QFile left(leftPath); - QFile right(rightPath); - if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly) - || left.size() != right.size()) - return false; - while (!left.atEnd()) { - const QByteArray a = left.read(1024 * 1024); - const QByteArray b = right.read(a.size()); - if (a != b) - return false; - } - return right.atEnd(); -} - -static QByteArray sha256(const QString &path) -{ - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) - return {}; - QCryptographicHash hash(QCryptographicHash::Sha256); - while (!file.atEnd()) - hash.addData(file.read(1024 * 1024)); - return hash.result(); -} - struct SearchCacheEntry { qint64 size = -1; qint64 modified = -1; @@ -1080,7 +868,7 @@ public: binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary); multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues); multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")}); - multipleMode_->setCurrentText(o.multipleAnd ? tr("And") : tr("Or")); + multipleMode_->setCurrentIndex(o.multipleAnd ? 1 : 0); caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive); contentForm->addRow(binary_); contentForm->addRow(multiple_); @@ -1119,7 +907,7 @@ public: tabs->addTab(filterPage, tr("Size, time & attributes")); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::accepted, this, &OptionsDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(browse, &QPushButton::clicked, this, [this] { const QStringList existingFolders = patterns(roots_->text()); @@ -1151,14 +939,14 @@ public: o.contains = contains_->toPlainText(); o.binary = binary_->isChecked(); o.multipleValues = multiple_->isChecked(); - o.multipleAnd = multipleMode_->currentText() == tr("And"); + o.multipleAnd = multipleMode_->currentIndex() == 1; o.caseSensitive = caseSensitive_->isChecked(); o.minSize = minSize_->value(); o.maxSize = maxSize_->value(); o.timeField = timeField_->currentText(); o.lastMinutes = lastMinutes_->value(); - o.minTime = QDateTime::fromString(minTime_->text(), Qt::ISODate).toSecsSinceEpoch(); - o.maxTime = QDateTime::fromString(maxTime_->text(), Qt::ISODate).toSecsSinceEpoch(); + o.minTime = dateValue(minTime_); + o.maxTime = dateValue(maxTime_); o.recursive = recursive_->isChecked(); o.findFiles = findFiles_->isChecked(); o.findFolders = findFolders_->isChecked(); @@ -1177,6 +965,15 @@ public: return o; } +protected: + void accept() override + { + if (!validateDate(minTime_, tr("From time")) + || !validateDate(maxTime_, tr("To time"))) + return; + QDialog::accept(); + } + private: static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value) { @@ -1184,14 +981,33 @@ private: layout->addRow(label, edit); return edit; } - static QSpinBox *sizeSpin(qint64 value) + static LongLongSpinBox *sizeSpin(qint64 value) { - auto *spin = new QSpinBox; - spin->setRange(0, 2'000'000'000); + auto *spin = new LongLongSpinBox; + spin->setRange(0, std::numeric_limits::max()); spin->setSuffix(QObject::tr(" bytes")); - spin->setValue(static_cast(std::min(value, spin->maximum()))); + spin->setValue(value); return spin; } + static qint64 dateValue(const QLineEdit *edit) + { + qint64 seconds = 0; + parseOptionalIsoDate(edit->text(), seconds); + return seconds; + } + bool validateDate(QLineEdit *edit, const QString &label) + { + qint64 seconds = 0; + if (parseOptionalIsoDate(edit->text(), seconds)) + return true; + QMessageBox::warning( + this, tr("Invalid date"), + tr("%1 must be a valid ISO date and time, for example:\n2026-07-25T20:30:00") + .arg(label)); + edit->setFocus(); + edit->selectAll(); + return false; + } static QComboBox *attrCombo(const QString &value) { auto *combo = new QComboBox; @@ -1214,8 +1030,8 @@ private: QCheckBox *multiple_{}; QComboBox *multipleMode_{}; QCheckBox *caseSensitive_{}; - QSpinBox *minSize_{}; - QSpinBox *maxSize_{}; + LongLongSpinBox *minSize_{}; + LongLongSpinBox *maxSize_{}; QComboBox *timeField_{}; QSpinBox *lastMinutes_{}; QLineEdit *minTime_{}; @@ -1925,7 +1741,7 @@ private slots: this, &MainWindow::showProperties); menu.addSeparator(); menu.addAction(tr("Refresh"), QKeySequence(Qt::Key_F5), this, &MainWindow::refreshSearch); - menu.exec(table_->viewport()->mapToGlobal(point)); + menu.exec(table_->mapToGlobal(point)); } void headerContextMenu(const QPoint &point) @@ -2132,11 +1948,16 @@ private: QStringList selectedPaths() const { QStringList paths; - QSet sourceRows; - for (const QModelIndex &index : table_->selectionModel()->selectedRows()) - sourceRows.insert(proxy_->mapToSource(index).row()); - for (int row : sourceRows) - paths.push_back(model_->data(model_->index(row, 0), Qt::UserRole).toString()); + QModelIndexList selected = table_->selectionModel()->selectedRows(); + std::sort(selected.begin(), selected.end(), + [](const QModelIndex &left, const QModelIndex &right) { + return left.row() < right.row(); + }); + for (const QModelIndex &index : selected) { + const int sourceRow = proxy_->mapToSource(index).row(); + paths.push_back(model_->data( + model_->index(sourceRow, ResultsModel::Name), Qt::UserRole).toString()); + } return paths; } diff --git a/src/search_core.cpp b/src/search_core.cpp new file mode 100644 index 0000000..a701535 --- /dev/null +++ b/src/search_core.cpp @@ -0,0 +1,180 @@ +#include "search_core.h" + +#include +#include +#include +#include + +#include +#include + +QStringList patterns(const QString &text) +{ + QStringList out; + QString item; + bool quoted = false; + for (QChar ch : text) { + if (ch == u'"') { + quoted = !quoted; + } else if (!quoted && (ch == u';' || ch == u',')) { + if (!item.trimmed().isEmpty()) + out.push_back(item.trimmed()); + item.clear(); + } else { + item += ch; + } + } + if (!item.trimmed().isEmpty()) + out.push_back(item.trimmed()); + return out; +} + +QStringList exclusionPatterns(const QString &text) +{ + QString normalized = text; + normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";")); + QStringList result = patterns(normalized); + for (QString &pattern : result) { + if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/')) + pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\."))); + } + return result; +} + +bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs) +{ + for (const QString &pattern : items) { + const auto regex = QRegularExpression( + QRegularExpression::wildcardToRegularExpression(pattern), + cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption + : QRegularExpression::NoPatternOption); + if (regex.match(value).hasMatch()) + return true; + } + return false; +} + +QString humanSize(qint64 bytes) +{ + static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"), + QStringLiteral("MB"), QStringLiteral("GB"), + QStringLiteral("TB")}; + double value = static_cast(bytes); + int unit = 0; + while (value >= 1024.0 && unit < units.size() - 1) { + value /= 1024.0; + ++unit; + } + return unit == 0 ? QStringLiteral("%1 B").arg(bytes) + : QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]); +} + +QString ownerName(uid_t uid) +{ + struct passwd pwd {}; + struct passwd *result = nullptr; + QByteArray buffer(16384, Qt::Uninitialized); + if (getpwuid_r(uid, &pwd, buffer.data(), static_cast(buffer.size()), &result) == 0 && result) + return QString::fromLocal8Bit(result->pw_name); + return QString::number(uid); +} + +std::optional hexNeedle(QString text) +{ + text.remove(QRegularExpression(QStringLiteral("\\s+"))); + if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch()) + return std::nullopt; + return QByteArray::fromHex(text.toLatin1()); +} + +bool fileContains(const QString &path, const SearchOptions &options) +{ + if (options.contains.isEmpty()) + return true; + + const QStringList terms = options.multipleValues + ? patterns(options.contains) : QStringList{options.contains}; + QList needles; + for (const QString &term : terms) { + if (options.binary) { + const auto bytes = hexNeedle(term); + if (!bytes) + return false; + needles.push_back(*bytes); + } else { + needles.push_back(term.toUtf8()); + } + } + if (needles.isEmpty()) + return true; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return false; + + QVector found(needles.size(), false); + qsizetype overlap = 1; + for (const QByteArray &needle : needles) + overlap = std::max(overlap, needle.size()); + QByteArray tail; + while (!file.atEnd()) { + QByteArray data = tail + file.read(1024 * 1024); + if (!options.caseSensitive && !options.binary) + data = data.toLower(); + for (qsizetype i = 0; i < needles.size(); ++i) { + QByteArray needle = needles[i]; + if (!options.caseSensitive && !options.binary) + needle = needle.toLower(); + if (!found[i] && data.contains(needle)) + found[i] = true; + } + const bool matched = options.multipleAnd + ? std::all_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; }) + : std::any_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; }); + if (matched) + return true; + tail = data.right(overlap - 1); + } + return false; +} + +bool filesEqual(const QString &leftPath, const QString &rightPath) +{ + QFile left(leftPath); + QFile right(rightPath); + if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly) + || left.size() != right.size()) + return false; + while (!left.atEnd()) { + const QByteArray leftData = left.read(1024 * 1024); + const QByteArray rightData = right.read(leftData.size()); + if (leftData != rightData) + return false; + } + return right.atEnd(); +} + +QByteArray sha256(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + while (!file.atEnd()) + hash.addData(file.read(1024 * 1024)); + return hash.result(); +} + +bool parseOptionalIsoDate(const QString &text, qint64 &seconds) +{ + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) { + seconds = 0; + return true; + } + const QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate); + if (!parsed.isValid()) + return false; + seconds = parsed.toSecsSinceEpoch(); + return true; +} diff --git a/src/search_core.h b/src/search_core.h new file mode 100644 index 0000000..39d2e50 --- /dev/null +++ b/src/search_core.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +struct SearchOptions { + QString roots = QDir::homePath(); + QString fileWildcards = QStringLiteral("*"); + QString subfolderWildcards = QStringLiteral("*"); + QString excludeFiles; + QString excludeFolders; + QString includeFolders; + QString contains; + bool binary = false; + bool multipleValues = false; + bool multipleAnd = false; + bool caseSensitive = false; + qint64 minSize = 0; + qint64 maxSize = 0; + qint64 minTime = 0; + qint64 maxTime = 0; + QString timeField = QStringLiteral("Modified time"); + int lastMinutes = 0; + bool recursive = true; + bool findFiles = true; + bool findFolders = false; + bool followLinks = false; + bool retrieveOwner = false; + bool accurateProgress = true; + bool useCache = true; + int maxDepth = 0; + int maxResults = 0; + QString mode = QStringLiteral("Standard search"); + QString duplicateNameMode = QStringLiteral("All files and folders"); + bool duplicateNameWithoutExtension = false; + bool showOnlyDuplicateFiles = true; + bool includeSubfoldersInSummary = false; + QString hidden = QStringLiteral("Any"); + QString readonly = QStringLiteral("Any"); + QString executable = QStringLiteral("Any"); +}; + +struct FileRecord { + QString path; + QString name; + QString folder; + qint64 size = 0; + qint64 sizeOnDisk = 0; + qint64 modified = 0; + qint64 created = 0; + qint64 accessed = 0; + qint64 changed = 0; + QString type; + QString owner; + QString attributes; + int group = 0; + int duplicateCopy = 0; +}; + +Q_DECLARE_METATYPE(SearchOptions) +Q_DECLARE_METATYPE(FileRecord) +Q_DECLARE_METATYPE(QVector) + +QStringList patterns(const QString &text); +QStringList exclusionPatterns(const QString &text); +bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs); +QString humanSize(qint64 bytes); +QString ownerName(uid_t uid); +std::optional hexNeedle(QString text); +bool fileContains(const QString &path, const SearchOptions &options); +bool filesEqual(const QString &leftPath, const QString &rightPath); +QByteArray sha256(const QString &path); +bool parseOptionalIsoDate(const QString &text, qint64 &seconds); diff --git a/tests/long_long_spin_box_test.cpp b/tests/long_long_spin_box_test.cpp new file mode 100644 index 0000000..64627aa --- /dev/null +++ b/tests/long_long_spin_box_test.cpp @@ -0,0 +1,40 @@ +#include "long_long_spin_box.h" + +#include +#include + +class LongLongSpinBoxTest final : public QObject { + Q_OBJECT + +private slots: + void preservesValuesAboveTwoGigabytes() + { + LongLongSpinBox spin; + spin.setRange(0, std::numeric_limits::max()); + spin.setSuffix(QStringLiteral(" bytes")); + spin.setValue(5'000'000'000); + QCOMPARE(spin.value(), qint64(5'000'000'000)); + QCOMPARE(spin.findChild()->text(), QStringLiteral("5000000000 bytes")); + spin.stepUp(); + QCOMPARE(spin.value(), qint64(5'000'000'001)); + + spin.findChild()->setText(QStringLiteral("6000000000 bytes")); + QVERIFY(QMetaObject::invokeMethod(&spin, "editingFinished", Qt::DirectConnection)); + QCOMPARE(spin.value(), qint64(6'000'000'000)); + } + + void clampsAtRangeEdges() + { + LongLongSpinBox spin; + spin.setRange(0, 10); + spin.setValue(10); + spin.stepUp(); + QCOMPARE(spin.value(), qint64(10)); + spin.setValue(-50); + QCOMPARE(spin.value(), qint64(0)); + } +}; + +QTEST_MAIN(LongLongSpinBoxTest) + +#include "long_long_spin_box_test.moc" diff --git a/tests/release_dirty_test.sh b/tests/release_dirty_test.sh new file mode 100755 index 0000000..99d3e93 --- /dev/null +++ b/tests/release_dirty_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_script="$(realpath "$1")" +test_directory="$(mktemp -d)" +trap 'rm -rf "$test_directory"' EXIT + +git -C "$test_directory" init -q +git -C "$test_directory" config user.name "FolderScope Tests" +git -C "$test_directory" config user.email "tests@folderscope.invalid" +printf '9.9.9\n' > "$test_directory/VERSION" +printf '# Changelog\n\n## [9.9.9]\n' > "$test_directory/CHANGELOG.md" +git -C "$test_directory" add VERSION CHANGELOG.md +git -C "$test_directory" commit -q -m "test fixture" +touch "$test_directory/untracked" + +if (cd "$test_directory" && "$release_script" 9.9.9 >/dev/null 2>&1); then + echo "release.sh accepted a repository with untracked files" >&2 + exit 1 +fi diff --git a/tests/search_core_test.cpp b/tests/search_core_test.cpp new file mode 100644 index 0000000..2c97da9 --- /dev/null +++ b/tests/search_core_test.cpp @@ -0,0 +1,86 @@ +#include "search_core.h" + +#include +#include +#include +#include + +class SearchCoreTest final : public QObject { + Q_OBJECT + +private slots: + void parsesPatternsAndExclusions() + { + QCOMPARE(patterns(QStringLiteral("/a; \"/b,with-comma\" , /c")), + QStringList({QStringLiteral("/a"), QStringLiteral("/b,with-comma"), + QStringLiteral("/c")})); + QCOMPARE(exclusionPatterns(QStringLiteral("tmp log *.bak")), + QStringList({QStringLiteral("*.tmp"), QStringLiteral("*.log"), + QStringLiteral("*.bak")})); + QVERIFY(wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")}, + Qt::CaseInsensitive)); + QVERIFY(!wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")}, + Qt::CaseSensitive)); + } + + void parsesOptionalIsoDates() + { + qint64 seconds = -1; + QVERIFY(parseOptionalIsoDate(QString{}, seconds)); + QCOMPARE(seconds, 0); + QVERIFY(parseOptionalIsoDate(QStringLiteral("2026-07-25T20:30:00"), seconds)); + QVERIFY(seconds > 0); + QVERIFY(!parseOptionalIsoDate(QStringLiteral("2026-not-a-date"), seconds)); + } + + void searchesContentsAndHashes() + { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString firstPath = directory.filePath(QStringLiteral("first.bin")); + const QString secondPath = directory.filePath(QStringLiteral("second.bin")); + const QString differentPath = directory.filePath(QStringLiteral("different.bin")); + const QByteArray content = QByteArray(1024 * 1024 - 2, 'x') + + QByteArray("Alpha needle BETA"); + + QFile first(firstPath); + QVERIFY(first.open(QIODevice::WriteOnly)); + QCOMPARE(first.write(content), content.size()); + first.close(); + + QFile second(secondPath); + QVERIFY(second.open(QIODevice::WriteOnly)); + QCOMPARE(second.write(content), content.size()); + second.close(); + + QFile different(differentPath); + QVERIFY(different.open(QIODevice::WriteOnly)); + QCOMPARE(different.write("different"), qint64(9)); + different.close(); + + SearchOptions options; + options.contains = QStringLiteral("needle"); + QVERIFY(fileContains(firstPath, options)); + options.contains = QStringLiteral("alpha,beta"); + options.multipleValues = true; + options.multipleAnd = true; + QVERIFY(fileContains(firstPath, options)); + options.caseSensitive = true; + QVERIFY(!fileContains(firstPath, options)); + options.binary = true; + options.multipleValues = false; + options.multipleAnd = false; + options.contains = QStringLiteral("41 6c 70 68 61"); + QVERIFY(fileContains(firstPath, options)); + options.contains = QStringLiteral("ABC"); + QVERIFY(!fileContains(firstPath, options)); + + QVERIFY(filesEqual(firstPath, secondPath)); + QVERIFY(!filesEqual(firstPath, differentPath)); + QCOMPARE(sha256(firstPath), QCryptographicHash::hash(content, QCryptographicHash::Sha256)); + } +}; + +QTEST_GUILESS_MAIN(SearchCoreTest) + +#include "search_core_test.moc"