From 1bdcbad54013349525d79083bff2412721aa4c09 Mon Sep 17 00:00:00 2001 From: Zhang Sheng Date: Fri, 17 Jul 2026 15:05:21 +0800 Subject: [PATCH] feat: enhance preview command with indexed content validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added PreviewStatus enum to distinguish successful (0) and not- indexed (1) preview results 2. Moved preview command logic to separate preview_command module 3. Added thorough validation for indexed content paths 4. Improved error reporting for non-indexed paths with detailed messages 5. Added new test cases for not-indexed preview scenarios 6. Extended ContentRetriever to return NotIndexed status for invalid paths/types Log: Added validation and error handling for preview command when accessing non-indexed content Influence: 1. Test preview command with paths to non-indexed content 2. Verify error messages for non-indexed paths 3. Check behavior with different search types (content/ocr/semantic) 4. Test preview output formats (JSON/text) with invalid inputs 5. Verify PreviewStatus values in API responses 6. Test handling of semantic search routing failures feat: 增强预览命令的索引内容验证功能 1. 添加 PreviewStatus 枚举区分成功(0)和未索引(1)的预览结果 2. 将预览命令逻辑移动到独立的 preview_command 模块 3. 添加对索引内容路径的全面验证 4. 为未索引路径改进错误报告,包含详细消息 5. 新增未索引场景的测试用例 6. 扩展 ContentRetriever 对无效路径/类型返回 NotIndexed 状态 Log: 新增预览命令访问非索引内容时的验证和错误处理 Influence: 1. 测试预览命令在访问非索引内容时的行为 2. 验证非索引路径的错误消息 3. 检查不同搜索类型(content/ocr/semantic)的行为差异 4. 测试无效输入时的输出格式(JSON/文本) 5. 验证 API 响应中的 PreviewStatus 值 6. 测试语义搜索路由失败时的处理情况 --- autotests/dfm-search-tests/CMakeLists.txt | 1 + .../tst_content_retriever.cpp | 33 ++++ .../dfm-search-tests/tst_search_output.cpp | 27 ++++ .../dfm-search/dfm-search/contentretriever.h | 10 +- .../dfm-search-client/CMakeLists.txt | 2 + src/dfm-search/dfm-search-client/main.cpp | 58 +------ .../dfm-search-client/preview_command.cpp | 151 ++++++++++++++++++ .../dfm-search-client/preview_command.h | 28 ++++ .../dfm-search-lib/utils/contentretriever.cpp | 21 ++- .../dfm-search-lib/utils/previewresult_p.h | 1 + 10 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 src/dfm-search/dfm-search-client/preview_command.cpp create mode 100644 src/dfm-search/dfm-search-client/preview_command.h diff --git a/autotests/dfm-search-tests/CMakeLists.txt b/autotests/dfm-search-tests/CMakeLists.txt index c5091f7..99c09d0 100644 --- a/autotests/dfm-search-tests/CMakeLists.txt +++ b/autotests/dfm-search-tests/CMakeLists.txt @@ -29,6 +29,7 @@ target_sources(dfm-search-test PRIVATE ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/size_parser.cpp ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/time_parser.cpp ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/cli_options.cpp + ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/preview_command.cpp ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/preview_output_utils.cpp ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/output/output_formatter.h ${CMAKE_SOURCE_DIR}/src/dfm-search/dfm-search-client/output/text_output.cpp diff --git a/autotests/dfm-search-tests/tst_content_retriever.cpp b/autotests/dfm-search-tests/tst_content_retriever.cpp index 8cb84e4..1b9ff9d 100644 --- a/autotests/dfm-search-tests/tst_content_retriever.cpp +++ b/autotests/dfm-search-tests/tst_content_retriever.cpp @@ -96,6 +96,7 @@ private Q_SLOTS: void fetchPreview_noKeyword(); void fetchPreview_withKeyword(); void fetchPreview_keywordNotFound(); + void fetchPreview_notIndexed(); void fetchPreview_offsetBeyondContent(); void fetchPreview_unlimitedNoKeyword(); void previewSnippet_basic(); @@ -253,6 +254,7 @@ void tst_ContentRetriever::fetchPreview_noKeyword() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QCOMPARE(result.content(), QString("world")); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), -1); @@ -279,6 +281,7 @@ void tst_ContentRetriever::fetchPreview_withKeyword() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QCOMPARE(result.content(), QString("world from")); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), 6); @@ -305,11 +308,38 @@ void tst_ContentRetriever::fetchPreview_keywordNotFound() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QVERIFY(result.content().isEmpty()); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), -1); } +void tst_ContentRetriever::fetchPreview_notIndexed() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + const PreviewResult missingPathResult = retriever.fetchPreview("/tmp/missing.txt", + SearchType::Content); + QVERIFY(missingPathResult.status() == PreviewStatus::NotIndexed); + QVERIFY(missingPathResult.content().isEmpty()); + QCOMPARE(missingPathResult.charCount(), 0); + QCOMPARE(missingPathResult.keywordOffset(), -1); + + const PreviewResult unsupportedSemanticResult = retriever.fetchPreview("/tmp/package.deb", + SearchType::Semantic); + QVERIFY(unsupportedSemanticResult.status() == PreviewStatus::NotIndexed); + QVERIFY(unsupportedSemanticResult.content().isEmpty()); + QCOMPARE(unsupportedSemanticResult.charCount(), 0); + QCOMPARE(unsupportedSemanticResult.keywordOffset(), -1); +} + void tst_ContentRetriever::fetchPreview_offsetBeyondContent() { QTemporaryDir tempDir; @@ -330,6 +360,7 @@ void tst_ContentRetriever::fetchPreview_offsetBeyondContent() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QVERIFY(result.content().isEmpty()); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), -1); @@ -357,6 +388,7 @@ void tst_ContentRetriever::fetchPreview_unlimitedNoKeyword() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QCOMPARE(result.content(), QString("hello world from content index")); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), -1); @@ -372,6 +404,7 @@ void tst_ContentRetriever::fetchPreview_unlimitedNoKeyword() const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", SearchType::Content, options); + QVERIFY(result.status() == PreviewStatus::Success); QCOMPARE(result.content(), QString("world from content index")); QCOMPARE(result.charCount(), QString("hello world from content index").size()); QCOMPARE(result.keywordOffset(), -1); diff --git a/autotests/dfm-search-tests/tst_search_output.cpp b/autotests/dfm-search-tests/tst_search_output.cpp index 74d9590..3910750 100644 --- a/autotests/dfm-search-tests/tst_search_output.cpp +++ b/autotests/dfm-search-tests/tst_search_output.cpp @@ -31,6 +31,7 @@ #include "output/json_output.h" #include "output/text_output.h" +#include "preview_command.h" #include "preview_output_utils.h" using namespace DFMSEARCH; @@ -122,6 +123,7 @@ class tst_SearchOutput : public QObject private Q_SLOTS: void previewOutputHelpers_includeCharCount(); + void previewCommand_failsForUnindexedPath(); void jsonOutput_contentAndFilenameCharCountContract(); void jsonOutput_ocrAndSemanticCharCountContract(); void textOutput_contentAndSemanticCharCountContract(); @@ -156,6 +158,31 @@ void tst_SearchOutput::previewOutputHelpers_includeCharCount() QVERIFY(text.contains("world from")); } +void tst_SearchOutput::previewCommand_failsForUnindexedPath() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + SearchCliConfig config; + config.subcommand = "preview"; + config.searchType = SearchType::Content; + config.searchPath = "/tmp/doc-a.txt,/tmp/missing.txt"; + config.jsonOutput = true; + + const PreviewCommandResult result = dfmsearch::runPreviewCommand(config, retriever); + QCOMPARE(result.exitCode, 1); + QVERIFY(result.stdoutText.isEmpty()); + QVERIFY(result.stderrText.contains("Error: preview requires indexed content for:")); + QVERIFY(result.stderrText.contains("/tmp/missing.txt")); + QVERIFY(!result.stderrText.contains("/tmp/doc-a.txt")); +} + void tst_SearchOutput::jsonOutput_contentAndFilenameCharCountContract() { SearchOptions options; diff --git a/include/dfm-search/dfm-search/contentretriever.h b/include/dfm-search/dfm-search/contentretriever.h index 3d18bb5..720f2b1 100644 --- a/include/dfm-search/dfm-search/contentretriever.h +++ b/include/dfm-search/dfm-search/contentretriever.h @@ -80,11 +80,16 @@ class PreviewOptions QSharedDataPointer d; }; +enum class PreviewStatus { + Success = 0, + NotIndexed = 1, +}; + /** * @brief Result of a preview extraction (Pimpl-based, ABI-stable) * - * Contains the extracted content snippet and the position of the keyword - * in the full document text (-1 if no keyword or not matched). + * Contains the extraction status, content snippet, and the position of the + * keyword in the full document text (-1 if no keyword or not matched). * * Supports implicit sharing (COW) via QSharedDataPointer. */ @@ -98,6 +103,7 @@ class PreviewResult PreviewResult(PreviewResult &&other) noexcept = default; PreviewResult &operator=(PreviewResult &&other) noexcept = default; + PreviewStatus status() const; QString content() const; int charCount() const; int keywordOffset() const; diff --git a/src/dfm-search/dfm-search-client/CMakeLists.txt b/src/dfm-search/dfm-search-client/CMakeLists.txt index a38932b..ec10333 100644 --- a/src/dfm-search/dfm-search-client/CMakeLists.txt +++ b/src/dfm-search/dfm-search-client/CMakeLists.txt @@ -6,6 +6,8 @@ set(SRCS main.cpp cli_options.cpp cli_options.h + preview_command.cpp + preview_command.h preview_output_utils.cpp preview_output_utils.h time_parser.cpp diff --git a/src/dfm-search/dfm-search-client/main.cpp b/src/dfm-search/dfm-search-client/main.cpp index 6fb3f3f..025e49a 100644 --- a/src/dfm-search/dfm-search-client/main.cpp +++ b/src/dfm-search/dfm-search-client/main.cpp @@ -4,10 +4,6 @@ #include #include -#include -#include -#include -#include #include #include @@ -20,7 +16,7 @@ #include #include "cli_options.h" -#include "preview_output_utils.h" +#include "preview_command.h" #include "output/text_output.h" #include "output/json_output.h" @@ -197,54 +193,14 @@ int main(int argc, char *argv[]) // Preview subcommand: fetch content preview on demand if (config.subcommand == "preview") { DFMSEARCH::ContentRetriever retriever; - DFMSEARCH::PreviewOptions previewOptions; - previewOptions.setOffset(config.offset); - // 无 keyword 且未显式设置 --max-preview:maxLength=0 表示无限制(返回全文) - // 有 keyword 或显式设置了 --max-preview:使用 maxPreviewLength 值 - if (config.keyword.isEmpty() && !config.maxPreviewSet) { - previewOptions.setMaxLength(0); - } else { - previewOptions.setMaxLength(config.maxPreviewLength); + const PreviewCommandResult previewResult = runPreviewCommand(config, retriever); + if (!previewResult.stderrText.isEmpty()) { + std::cerr << previewResult.stderrText.toStdString(); } - previewOptions.setKeyword(config.keyword); - - // Paths are stored as comma-separated in config.searchPath -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - QStringList paths = config.searchPath.split(',', Qt::SkipEmptyParts); -#else - QStringList paths = config.searchPath.split(',', QString::SkipEmptyParts); -#endif - - if (config.jsonOutput) { - // JSON output - QJsonObject root; - root["type"] = "preview"; - root["searchType"] = (config.searchType == SearchType::Content) ? "content" - : (config.searchType == SearchType::Ocr) ? "ocr" : "semantic"; - root["keyword"] = config.keyword; - root["offset"] = config.offset; - root["maxLength"] = previewOptions.maxLength(); - - QJsonArray results; - for (const QString &path : paths) { - DFMSEARCH::PreviewResult result = retriever.fetchPreview(path, config.searchType, previewOptions); - results.append(previewResultToJson(path, result)); - } - - root["totalResults"] = results.size(); - root["results"] = results; - - QJsonDocument doc(root); - std::cout << doc.toJson(QJsonDocument::Indented).toStdString() << std::endl; - } else { - // Text output - QTextStream out(stdout); - for (const QString &path : paths) { - DFMSEARCH::PreviewResult result = retriever.fetchPreview(path, config.searchType, previewOptions); - writePreviewResultText(out, path, result); - } + if (!previewResult.stdoutText.isEmpty()) { + std::cout << previewResult.stdoutText.toStdString(); } - return 0; + return previewResult.exitCode; } // Semantic search mode diff --git a/src/dfm-search/dfm-search-client/preview_command.cpp b/src/dfm-search/dfm-search-client/preview_command.cpp new file mode 100644 index 0000000..cfc1a36 --- /dev/null +++ b/src/dfm-search/dfm-search-client/preview_command.cpp @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "preview_command.h" + +#include +#include +#include +#include + +namespace dfmsearch { + +namespace { + +struct PreviewEntry +{ + QString path; + DFMSEARCH::PreviewStatus status = DFMSEARCH::PreviewStatus::Success; + QString content; + int charCount = 0; + int keywordOffset = -1; +}; + +QString formatPreviewNotIndexedError(const QStringList &paths) +{ + QString text; + QTextStream stream(&text); + stream << "Error: preview requires indexed content for:\n"; + for (const QString &path : paths) { + stream << " " << path << "\n"; + } + return text; +} + +PreviewEntry toPreviewEntry(const QString &path, const DFMSEARCH::PreviewResult &result) +{ + PreviewEntry entry; + entry.path = path; + entry.status = result.status(); + entry.content = result.content(); + entry.charCount = result.charCount(); + entry.keywordOffset = result.keywordOffset(); + return entry; +} + +QList fetchPreviewEntries(const QStringList &paths, + const SearchCliConfig &config, + DFMSEARCH::ContentRetriever &retriever, + const DFMSEARCH::PreviewOptions &options, + QStringList *notIndexedPaths) +{ + QList entries; + entries.reserve(paths.size()); + + for (const QString &path : paths) { + DFMSEARCH::PreviewResult result = retriever.fetchPreview(path, config.searchType, options); + if (result.status() == DFMSEARCH::PreviewStatus::NotIndexed) { + notIndexedPaths->append(path); + } + entries.append(toPreviewEntry(path, result)); + } + + return entries; +} + +QString buildPreviewJson(const QList &entries, + const SearchCliConfig &config, + const DFMSEARCH::PreviewOptions &options) +{ + QJsonObject root; + root["type"] = "preview"; + root["searchType"] = (config.searchType == SearchType::Content) ? "content" + : (config.searchType == SearchType::Ocr) ? "ocr" : "semantic"; + root["keyword"] = config.keyword; + root["offset"] = config.offset; + root["maxLength"] = options.maxLength(); + + QJsonArray results; + for (const PreviewEntry &entry : entries) { + QJsonObject item; + item["path"] = entry.path; + item["content"] = entry.content; + item["charCount"] = entry.charCount; + item["keywordOffset"] = entry.keywordOffset; + results.append(item); + } + + root["totalResults"] = results.size(); + root["results"] = results; + + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} + +QString buildPreviewText(const QList &entries) +{ + QString text; + QTextStream out(&text); + for (const PreviewEntry &entry : entries) { + out << entry.path << "\n"; + if (!entry.content.isEmpty()) { + out << " " << entry.content << "\n"; + } else { + out << " (no content)\n"; + } + out << " Char count: " << entry.charCount << "\n"; + out << Qt::endl; + } + return text; +} + +} // namespace + +PreviewCommandResult runPreviewCommand(const SearchCliConfig &config, + DFMSEARCH::ContentRetriever &retriever) +{ + DFMSEARCH::PreviewOptions options; + options.setOffset(config.offset); + if (config.keyword.isEmpty() && !config.maxPreviewSet) { + options.setMaxLength(0); + } else { + options.setMaxLength(config.maxPreviewLength); + } + options.setKeyword(config.keyword); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + const QStringList paths = config.searchPath.split(',', Qt::SkipEmptyParts); +#else + const QStringList paths = config.searchPath.split(',', QString::SkipEmptyParts); +#endif + + QStringList notIndexedPaths; + const QList entries = fetchPreviewEntries(paths, config, retriever, options, ¬IndexedPaths); + + PreviewCommandResult commandResult; + if (!notIndexedPaths.isEmpty()) { + commandResult.exitCode = 1; + commandResult.stderrText = formatPreviewNotIndexedError(notIndexedPaths); + return commandResult; + } + + if (config.jsonOutput) { + commandResult.stdoutText = buildPreviewJson(entries, config, options); + } else { + commandResult.stdoutText = buildPreviewText(entries); + } + + return commandResult; +} + +} // namespace dfmsearch diff --git a/src/dfm-search/dfm-search-client/preview_command.h b/src/dfm-search/dfm-search-client/preview_command.h new file mode 100644 index 0000000..0feec67 --- /dev/null +++ b/src/dfm-search/dfm-search-client/preview_command.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef PREVIEW_COMMAND_H +#define PREVIEW_COMMAND_H + +#include "cli_options.h" + +#include + +#include + +namespace dfmsearch { + +struct PreviewCommandResult +{ + int exitCode = 0; + QString stdoutText; + QString stderrText; +}; + +PreviewCommandResult runPreviewCommand(const SearchCliConfig &config, + DFMSEARCH::ContentRetriever &retriever); + +} // namespace dfmsearch + +#endif // PREVIEW_COMMAND_H diff --git a/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp b/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp index 82d4774..42d9290 100644 --- a/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp +++ b/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp @@ -223,6 +223,11 @@ PreviewResult::PreviewResult(const PreviewResult &other) = default; PreviewResult &PreviewResult::operator=(const PreviewResult &other) = default; +PreviewStatus PreviewResult::status() const +{ + return d->status; +} + QString PreviewResult::content() const { return d->content; @@ -530,7 +535,10 @@ PreviewResult ContentRetriever::fetchPreview(const QString &path, SearchType typ const PreviewOptions &options) const { PreviewResult result; - if (path.isEmpty()) return result; + if (path.isEmpty()) { + result.d->status = PreviewStatus::NotIndexed; + return result; + } // Route SearchType::Semantic to Content or Ocr based on file extension if (type == SearchType::Semantic) { @@ -538,10 +546,12 @@ PreviewResult ContentRetriever::fetchPreview(const QString &path, SearchType typ if (!resolved) { qWarning() << "ContentRetriever: cannot route Semantic type for" << path << "- extension does not match doc or pic suffixes"; + result.d->status = PreviewStatus::NotIndexed; return result; } type = *resolved; } else if (type != SearchType::Content && type != SearchType::Ocr) { + result.d->status = PreviewStatus::NotIndexed; return result; } @@ -550,16 +560,19 @@ PreviewResult ContentRetriever::fetchPreview(const QString &path, SearchType typ QMutexLocker locker(&d->mutex); CachedIndexContext *ctx = d->ensureIndexContext(type, indexDir); if (!ctx || !ctx->searcher) { + result.d->status = PreviewStatus::NotIndexed; return result; } try { const DocumentPtr doc = findDocumentByPath(ctx->searcher, path, type); - const QString content = storedContentFromDocument(doc, type); - if (content.isEmpty()) { + if (!doc) { + result.d->status = PreviewStatus::NotIndexed; return result; } + const QString content = storedContentFromDocument(doc, type); + // Keep preview metadata in the same QString character unit as offset/keywordOffset. result.d->charCount = content.size(); int keywordOffset = -1; @@ -572,8 +585,10 @@ PreviewResult ContentRetriever::fetchPreview(const QString &path, SearchType typ } catch (const LuceneException &e) { qWarning() << "ContentRetriever: error fetching preview for" << path << QString::fromStdWString(e.getError()); + result.d->status = PreviewStatus::NotIndexed; } catch (const std::exception &e) { qWarning() << "ContentRetriever: std error for" << path << e.what(); + result.d->status = PreviewStatus::NotIndexed; } return result; diff --git a/src/dfm-search/dfm-search-lib/utils/previewresult_p.h b/src/dfm-search/dfm-search-lib/utils/previewresult_p.h index 1cf798a..1e5c54c 100644 --- a/src/dfm-search/dfm-search-lib/utils/previewresult_p.h +++ b/src/dfm-search/dfm-search-lib/utils/previewresult_p.h @@ -18,6 +18,7 @@ class PreviewResultPrivate : public QSharedData PreviewResultPrivate() = default; PreviewResultPrivate(const PreviewResultPrivate &other) = default; + PreviewStatus status = PreviewStatus::Success; QString content; ///< Extracted content snippet int charCount = 0; ///< Full stored content length in QString character units int keywordOffset = -1; ///< Position of keyword in full content (-1 if none/not matched)