diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6d9c34b89..5021f9430 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -359,6 +359,13 @@ previews are sanitized and bounded. JSON failures write the envelope to stdout and leave stderr empty. Human failures write the matching coded `Error`, `Hint`, and `Usage` lines to stderr and leave stdout empty. +Unsupported `search --regex` / `search --all` diagnostics add a command-specific, +display-only `find` recovery contract. Preserve exact normalized argument +semantics in typed `alternative_command.argv`, render both POSIX-shell and +PowerShell forms from that argv, mark the command as not executed, and report +non-equivalent options or blockers instead of emitting a lossy alternative. +Do not include implicit workspace defaults such as an undisclosed data directory. + Resolve project-config dependency before calling `CdidxConfigFile.Load`. Process-static commands (`license`, `--version`, help forms, completions, and subcommand help) must not discover or parse project config. `validate-config` @@ -3798,6 +3805,13 @@ sanitization し、上限を適用してから merge します。JSON の失敗 出し、stderr を空に保ちます。human の失敗は対応する code 付き `Error`、`Hint`、 `Usage` を stderr に出し、stdout を空に保ちます。 +未対応の `search --regex` / `search --all` diagnostic は、command 固有で表示専用の +`find` recovery 契約を追加します。正規化した引数の semantics を型付き +`alternative_command.argv` に正確に保持し、その argv から POSIX shell と PowerShell +の両形式を生成し、未実行であることを明示してください。不正確な代替 command を出さず、 +対応不能な option または blocker を報告し、未指定の data directory など暗黙の +workspace default を含めてはいけません。 + `CdidxConfigFile.Load` を呼ぶ前に project-config 依存性を解決します。process-static な command(`license`、`--version`、help 形式、completion、subcommand help)は project config を探索・parse しません。`validate-config` と `config show` は有効な設定を diff --git a/README.md b/README.md index 3cc9e9a4b..f876b9c4f 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ Bounded `map --compact` keeps the established top-level section arrays and compa `find --all --json` also makes bounded scans explicit. Repository-wide case-insensitive ASCII literals of at least three characters use the trigram index to select candidate files before the existing line matcher verifies every result; regex, `--exact`, short, non-ASCII, legacy, unsynchronized, and actively rebuilding trigram-index queries use an explicit line-scan fallback. `search_strategy` and optional `search_fallback_reason` report which path ran. Default streaming JSON rows end with a terminal record containing `scan_complete`, `authoritative_rows`, verified file/line counts, active caps, truncation reason, and recovery guidance; count JSON carries the same scan state in its single result object through `authoritative_count`. Row formats that cannot carry this metadata, including JSON array and location-only formats, are rejected with `--all`; use text, NDJSON, or count output. A candidate-file or line-scan cap returns partial-result exit code `11` unless `--allow-partial` is set. Ordinary result-limit early stops remain exit `0` but report `scan_complete=false` and `result_limit_reached=true`. +`search --regex` and `search --all` remain unsupported because `search` keeps its indexed-query semantics. When every argument has an exact `find` mapping, the usage error now includes a shell-safe, display-only `cdidx find` alternative and never executes it. JSON errors expose typed `alternative_command` (`argv`, `posix_sh`, `powershell`, `display_only`, and `executed`), `alternative_reason`, `non_equivalent_options`, `alternative_blockers`, and `automatic_execution` fields. When a search-only option, conflicting `--path` / `--all` scope, or unsafe one-line value prevents an exact mapping, `alternative_command` is `null` and the diagnostic explains the difference instead of suggesting a lossy command. + Use it with AI tools or editors: ```bash @@ -621,6 +623,8 @@ bounded な `map --compact` は、共通 metadata を追加しながら既存の `find --all --json` も上限付き scan を明示します。3 文字以上の大文字小文字を区別しない ASCII literal は、trigram index で候補 file を選んだ後、既存の行 matcher ですべての結果を検証します。regex、`--exact`、短い query、非 ASCII query、旧 trigram index、同期 trigger が欠けた index、再構築中の index は明示的な line-scan fallback を使います。実行経路は `search_strategy` と任意の `search_fallback_reason` で確認できます。既定の streaming JSON row は `scan_complete`、`authoritative_rows`、検証済み file / line 数、有効な cap、切り詰め理由、復旧案内を含む終端レコードで終了します。count JSON は単一 result object の `authoritative_count` と同じ scan 状態を返します。この metadata を表現できない JSON array や location-only 形式は `--all` との組み合わせを拒否するため、text、NDJSON、count 出力を使ってください。candidate-file cap または line-scan cap に達した場合は、`--allow-partial` を指定しない限り partial-result 終了コード `11` を返します。通常の result limit による早期停止は終了コード `0` のままですが、`scan_complete=false` と `result_limit_reached=true` を報告します。 +`search` は indexed query の semantics を維持するため、`search --regex` と `search --all` は引き続き未対応です。すべての引数を `find` へ正確に対応付けられる場合、usage error は shell-safe で表示専用の `cdidx find` 代替 command を示し、実行はしません。JSON error は型付きの `alternative_command`(`argv`、`posix_sh`、`powershell`、`display_only`、`executed`)、`alternative_reason`、`non_equivalent_options`、`alternative_blockers`、`automatic_execution` field を返します。search 固有 option、競合する `--path` / `--all` scope、安全な1行表示にできない値などで正確な対応付けができない場合、`alternative_command` は `null` となり、不正確な command を提案せず相違点を説明します。 + AI tool や editor から使います。 ```bash diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9fddebc2e..a0bc355ad 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -195,6 +195,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. + Search-to-find recovery coverage must exercise `--regex` and `--all` in human and JSON modes, option-shaped and shell-sensitive query values, normalized path/language/exclusion/limit arguments, and explicit non-equivalent or unsafe cases. Assert typed argv plus POSIX-shell and PowerShell displays, absence of implicit workspace defaults, and the no-execution markers; an unmappable request must return no alternative command. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. @@ -1146,6 +1147,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 + search から find への recovery coverage では、human / JSON mode の `--regex` と `--all`、option 風および shell-sensitive な query 値、正規化した path / language / exclude / limit 引数、明示的に非等価または unsafe な case を検証してください。型付き argv と POSIX shell / PowerShell 表示、暗黙の workspace default が含まれないこと、未実行 marker を固定し、対応不能な request が代替 command を返さないことを必須にします。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 diff --git a/changelog.d/unreleased/4906.fixed.md b/changelog.d/unreleased/4906.fixed.md new file mode 100644 index 000000000..a09f1f884 --- /dev/null +++ b/changelog.d/unreleased/4906.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4906 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Search.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchFindAlternative.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchIssue4906Tests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Unsupported search scan flags now point safely to find (#4906)** — `search --regex` and `search --all` usage errors now provide a shell-safe, display-only `cdidx find` alternative when every argument maps exactly, expose typed recovery fields in JSON, and explain non-equivalent or unsafe requests without executing or suggesting a lossy command. + +## 日本語 + +- **search の未対応 scan flag が find を安全に案内するようになりました (#4906)** — `search --regex` と `search --all` の usage error は、すべての引数を正確に対応付けられる場合に shell-safe で表示専用の `cdidx find` 代替 command を示し、JSON では型付き recovery field を返します。非等価または unsafe な request は、不正確な command の提案や実行をせず理由を説明します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 4c4d64641..7d2a4d196 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -67,6 +67,9 @@ private static int RunSearchCore( options.InvocationMachineErrorOutputRequested = ProgramRunner.ContainsJsonOutputFlag(validationArgs); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( options.Lang); + if (ReferenceEquals(invocationContext, QueryCommandInvocationContext.Search) + && TryWriteSearchFindAlternativeError(validationArgs, options, jsonOptions)) + return CommandExitCodes.UsageError; if (TryWriteUnsupportedOptionError( invocationContext, validationArgs, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchFindAlternative.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchFindAlternative.cs new file mode 100644 index 000000000..968afa6ae --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchFindAlternative.cs @@ -0,0 +1,395 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private const int SearchFindAlternativeDiagnosticItemLimit = 32; + + private static readonly HashSet SearchFindAlternativeMappableOptions = + new(StringComparer.Ordinal) + { + "--", + "--all", + "--allow-partial", + "--allow-unknown-lang", + "--count", + "--data-dir", + "--db", + "--exact", + "--exclude-path", + "--exclude-tests", + "--format", + "--immutable", + "--include-generated", + "--json", + "--lang", + "--limit", + "--max-json-bytes", + "--max-line-width", + "--max-results", + "--no-progress", + "--path", + "--profile", + "--query", + "--quiet", + "--read-only", + "--regex", + "--silent", + "--slow-query-ms", + "--snippet-lines", + "--strict-not-found", + "--top", + "--verbose", + "-q", + }; + + private static readonly HashSet SearchFindAlternativeOutputFormats = + new(StringComparer.Ordinal) + { + "compact", + "count", + "csv", + "json", + "lsp", + "qf", + "sarif", + "text", + "tsv", + }; + + private static bool TryWriteSearchFindAlternativeError( + string[] cmdArgs, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions) + { + if (!options.Regex && !options.All) + return false; + + var optionNames = CollectSearchFindAlternativeOptionNames(cmdArgs); + var acceptedSearchOptions = CliFlagSchema + .GetAcceptedFlagNamesForCommand("search") + .ToHashSet(StringComparer.Ordinal); + if (optionNames.Any(name => + name is not "--regex" and not "--all" + && !acceptedSearchOptions.Contains(name))) + { + // Keep the existing typo/unknown-option recovery for malformed invocations. + return false; + } + + var nonEquivalentOptions = optionNames + .Where(name => !SearchFindAlternativeMappableOptions.Contains(name)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Take(SearchFindAlternativeDiagnosticItemLimit) + .ToList(); + var blockers = new List(); + + if (options.ParseError != null) + blockers.Add("one or more search option values are invalid and cannot be normalized safely"); + if (string.IsNullOrWhiteSpace(options.Query)) + blockers.Add("search did not receive one non-empty query to replay"); + if (options.Query is { Length: > QueryLimits.MaxQueryLength }) + blockers.Add($"find query exceeds the maximum {QueryLimits.MaxQueryLength} characters"); + if (options.ExtraNames.Count > 0) + blockers.Add("extra positional query text cannot be represented as one find query"); + if (options.PathPatterns.Count > 0 && options.All) + blockers.Add("find accepts either --path filters or --all, not both"); + if (options.SnippetLinesExplicit && options.SnippetLines <= 0) + blockers.Add("find requires --snippet-lines to be a positive integer"); + if (!SearchFindAlternativeOutputFormats.Contains(options.OutputFormat)) + nonEquivalentOptions.Add($"--format {options.OutputFormat}"); + if (options.JsonOutputFormatExplicit + && !string.Equals(options.JsonOutputFormat, JsonOutputFormatNdjson, StringComparison.OrdinalIgnoreCase)) + { + nonEquivalentOptions.Add($"--json={options.JsonOutputFormat}"); + } + + var usesFindAll = options.PathPatterns.Count == 0; + if (usesFindAll + && !options.CountOnly + && options.OutputFormat is not OutputFormatText and not OutputFormatJson) + { + blockers.Add($"find --all cannot preserve row output format {options.OutputFormat}"); + } + if (options.OutputFormat == OutputFormatCompact && options.SnippetLinesExplicit) + blockers.Add("find --format compact cannot be combined with --snippet-lines"); + + nonEquivalentOptions = nonEquivalentOptions + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Take(SearchFindAlternativeDiagnosticItemLimit) + .ToList(); + + List? argv = null; + if (blockers.Count == 0 && nonEquivalentOptions.Count == 0) + { + argv = BuildSearchFindAlternativeArgv(optionNames, options, usesFindAll); + if (argv.Any(argument => argument.Any(char.IsControl))) + { + blockers.Add("the query or an option value contains control characters that are unsafe in a one-line replay suggestion"); + argv = null; + } + } + + var reason = BuildSearchFindAlternativeReason(argv, nonEquivalentOptions, blockers); + var additionalProperties = BuildSearchFindAlternativeJson( + argv, + reason, + nonEquivalentOptions, + blockers); + var triggeringOptions = options.Regex && options.All + ? "--regex and --all are" + : options.Regex + ? "--regex is" + : "--all is"; + var hint = argv != null + ? $"Run this equivalent find scan (displayed only; not executed): {RenderSearchFindAlternativeForCurrentShell(argv)}" + : $"Use `find` for literal or regular-expression file scans, but no exact command was generated: {reason}"; + + WriteSearchFindAlternativeError( + ProgramRunner.ContainsJsonOutputFlag(cmdArgs), + jsonOptions, + options.MaxJsonBytes, + $"{triggeringOptions} not supported for search.", + hint, + GetUsageLineOrThrow("search"), + additionalProperties); + return true; + } + + private static List CollectSearchFindAlternativeOptionNames(string[] cmdArgs) + { + var names = new List(); + for (var i = 0; i < cmdArgs.Length; i++) + { + var arg = cmdArgs[i]; + if (arg == "--") + { + names.Add(arg); + if (i + 1 < cmdArgs.Length) + i++; + continue; + } + + if (!arg.StartsWith("-", StringComparison.Ordinal)) + continue; + + var hasInlineValue = TrySplitInlineOptionValue(arg, out var inlineOptionName); + var normalizedName = inlineOptionName ?? arg; + names.Add(normalizedName); + if (!hasInlineValue + && ValueTakingOptions.Contains(normalizedName) + && i + 1 < cmdArgs.Length) + { + i++; + } + } + + return names; + } + + private static List BuildSearchFindAlternativeArgv( + IReadOnlyCollection explicitOptionNames, + QueryCommandOptions options, + bool usesFindAll) + { + var argv = new List + { + "cdidx", + "find", + "--query", + options.Query!, + }; + + if (usesFindAll) + { + argv.Add("--all"); + } + else + { + foreach (var path in options.PathPatterns) + AddSearchFindAlternativeOption(argv, "--path", path); + } + + if (options.Regex) + argv.Add("--regex"); + if (options.Exact) + argv.Add("--exact"); + if (options.Lang != null) + AddSearchFindAlternativeOption(argv, "--lang", options.Lang); + if (options.AllowUnknownLang) + argv.Add("--allow-unknown-lang"); + foreach (var excludePath in options.ExcludePaths) + AddSearchFindAlternativeOption(argv, "--exclude-path", excludePath); + if (options.ExcludeTests) + argv.Add("--exclude-tests"); + if (options.IncludeGenerated) + argv.Add("--include-generated"); + if (options.LimitExplicit) + AddSearchFindAlternativeOption(argv, "--limit", options.Limit); + if (options.SnippetLinesExplicit) + AddSearchFindAlternativeOption(argv, "--snippet-lines", options.SnippetLines); + if (explicitOptionNames.Contains("--max-line-width")) + AddSearchFindAlternativeOption(argv, "--max-line-width", options.MaxLineWidth); + var structuredCount = options.OutputFormat == OutputFormatCount + && explicitOptionNames.Contains("--format"); + if (options.CountOnly && !structuredCount) + argv.Add("--count"); + if (options.StrictNotFound) + argv.Add("--strict-not-found"); + if (options.AllowPartial) + argv.Add("--allow-partial"); + + if (structuredCount) + AddSearchFindAlternativeOption(argv, "--format", OutputFormatCount); + else if (options.OutputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatCount) + AddSearchFindAlternativeOption(argv, "--format", options.OutputFormat); + if (options.OutputFormat == OutputFormatJson + || explicitOptionNames.Contains("--json")) + argv.Add("--json"); + if (options.MaxJsonBytes.HasValue) + AddSearchFindAlternativeOption(argv, "--max-json-bytes", options.MaxJsonBytes.Value); + + if (explicitOptionNames.Contains("--data-dir") && options.DataDir != null) + AddSearchFindAlternativeOption(argv, "--data-dir", options.DataDir); + if (options.DbPathExplicit) + AddSearchFindAlternativeOption(argv, "--db", options.DbPath); + if (options.ReadOnly) + argv.Add("--read-only"); + if (options.Profile) + argv.Add("--profile"); + if (options.Verbose) + argv.Add("--verbose"); + if (options.SlowQueryMs.HasValue) + AddSearchFindAlternativeOption(argv, "--slow-query-ms", options.SlowQueryMs.Value); + if (explicitOptionNames.Contains("--quiet") + || explicitOptionNames.Contains("-q") + || explicitOptionNames.Contains("--silent")) + argv.Add("--quiet"); + if (explicitOptionNames.Contains("--no-progress")) + argv.Add("--no-progress"); + + return argv; + } + + private static void WriteSearchFindAlternativeError( + bool json, + JsonSerializerOptions jsonOptions, + int? maxJsonBytes, + string message, + string hint, + string usage, + JsonObject additionalProperties) + { + if (!json) + { + CommandErrorWriter.Write( + message, + hint, + usage, + CommandErrorCodes.UsageError); + return; + } + + var payload = CommandErrorWriter.BuildJsonPayload( + jsonOptions, + message, + CommandExitCodes.UsageError, + hint, + usage, + CommandErrorCodes.UsageError, + category: "usage", + command: "search", + additionalJsonProperties: additionalProperties); + var serialized = payload.ToJsonString(jsonOptions); + if (maxJsonBytes.HasValue + && Encoding.UTF8.GetByteCount(serialized) + Encoding.UTF8.GetByteCount(Environment.NewLine) + > maxJsonBytes.Value) + { + CommandErrorWriter.Write( + $"--max-json-bytes {maxJsonBytes.Value} is too small for search recovery error JSON output.", + "Increase --max-json-bytes to receive the structured find alternative.", + errorCode: CommandErrorCodes.UsageError); + return; + } + + CommandErrorWriter.WriteStdout(serialized); + } + + private static void AddSearchFindAlternativeOption(List argv, string name, string value) + { + argv.Add(name); + argv.Add(value); + } + + private static void AddSearchFindAlternativeOption(List argv, string name, int value) + => AddSearchFindAlternativeOption(argv, name, value.ToString(CultureInfo.InvariantCulture)); + + private static string BuildSearchFindAlternativeReason( + IReadOnlyList? argv, + IReadOnlyList nonEquivalentOptions, + IReadOnlyList blockers) + { + if (argv != null) + { + return "find performs literal and regular-expression file scans; the query, scope, and every representable option were preserved in argv. The alternative is displayed only and was not executed."; + } + + var parts = new List(); + if (nonEquivalentOptions.Count > 0) + parts.Add($"these search options have no safe find mapping: {string.Join(", ", nonEquivalentOptions)}"); + parts.AddRange(blockers); + return parts.Count == 0 + ? "the invocation cannot be represented as one equivalent find command" + : string.Join("; ", parts); + } + + private static JsonObject BuildSearchFindAlternativeJson( + IReadOnlyList? argv, + string reason, + IReadOnlyList nonEquivalentOptions, + IReadOnlyList blockers) + { + JsonObject? alternativeCommand = null; + if (argv != null) + { + alternativeCommand = new JsonObject + { + ["argv"] = ToSearchFindAlternativeJsonArray(argv), + ["posix_sh"] = ExcerptRecoveryCommandFormatter.RenderDisplayCommand(argv, RecoveryCommandShell.PosixSh), + ["powershell"] = ExcerptRecoveryCommandFormatter.RenderDisplayCommand(argv, RecoveryCommandShell.PowerShell), + ["display_only"] = true, + ["executed"] = false, + }; + } + + return new JsonObject + { + ["alternative_command"] = alternativeCommand, + ["alternative_reason"] = reason, + ["non_equivalent_options"] = ToSearchFindAlternativeJsonArray(nonEquivalentOptions), + ["alternative_blockers"] = ToSearchFindAlternativeJsonArray(blockers), + ["automatic_execution"] = false, + }; + } + + private static JsonArray ToSearchFindAlternativeJsonArray(IEnumerable values) + { + var result = new JsonArray(); + foreach (var value in values) + result.Add(value); + return result; + } + + private static string RenderSearchFindAlternativeForCurrentShell(IReadOnlyList argv) + => ExcerptRecoveryCommandFormatter.RenderDisplayCommand( + argv, + OperatingSystem.IsWindows() + ? RecoveryCommandShell.PowerShell + : RecoveryCommandShell.PosixSh); +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchIssue4906Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchIssue4906Tests.cs new file mode 100644 index 000000000..acfc8fdcc --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchIssue4906Tests.cs @@ -0,0 +1,352 @@ +using System.Text.Json; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Theory] + [InlineData("Widget.*", "--regex")] + [InlineData("Widget", "--all")] + public void RunSearch_ScanOnlyFlagsPointHumanUsersToFindWithoutExecuting_Issue4906( + string query, + string scanFlag) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + ["search", query, scanFlag], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains($"{scanFlag} is not supported for search.", stderr, StringComparison.Ordinal); + Assert.Contains("cdidx find --query", stderr, StringComparison.Ordinal); + Assert.Contains("--all", stderr, StringComparison.Ordinal); + Assert.Contains("displayed only; not executed", stderr, StringComparison.Ordinal); + Assert.Contains("Usage: cdidx search", stderr, StringComparison.Ordinal); + Assert.DoesNotContain("--data-dir", stderr, StringComparison.Ordinal); + if (scanFlag == "--regex") + Assert.Contains("--regex", stderr, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_RegexJsonReturnsTypedShellSafeFindAlternative_Issue4906() + { + const string query = "a'b $value; .*"; + const string path = "src/space dir/**"; + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + [ + "search", + "--query", query, + "--regex", + "--path", path, + "--path", "tools/**", + "--lang", "cs", + "--exclude-path", "**/obj/**", + "--exclude-tests", + "--include-generated", + "--limit", "7", + "--snippet-lines", "3", + "--max-line-width", "120", + "--json", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal("error", root.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, root.GetProperty("error_code").GetString()); + Assert.Equal("search", root.GetProperty("command").GetString()); + Assert.False(root.GetProperty("automatic_execution").GetBoolean()); + Assert.Contains("not executed", root.GetProperty("alternative_reason").GetString(), StringComparison.Ordinal); + Assert.Empty(root.GetProperty("non_equivalent_options").EnumerateArray()); + Assert.Empty(root.GetProperty("alternative_blockers").EnumerateArray()); + + var alternative = root.GetProperty("alternative_command"); + Assert.True(alternative.GetProperty("display_only").GetBoolean()); + Assert.False(alternative.GetProperty("executed").GetBoolean()); + var argv = alternative + .GetProperty("argv") + .EnumerateArray() + .Select(item => item.GetString()!) + .ToArray(); + Assert.Equal(["cdidx", "find", "--query", query], argv.Take(4)); + Assert.Equal(1, argv.Count(value => value == "--regex")); + Assert.Equal("csharp", ValueAfterIssue4906(argv, "--lang")); + Assert.Equal("7", ValueAfterIssue4906(argv, "--limit")); + Assert.Equal("3", ValueAfterIssue4906(argv, "--snippet-lines")); + Assert.Equal("120", ValueAfterIssue4906(argv, "--max-line-width")); + Assert.Contains(path, argv); + Assert.Contains("tools/**", argv); + Assert.Contains("**/obj/**", argv); + Assert.Contains("--exclude-tests", argv); + Assert.Contains("--include-generated", argv); + Assert.Contains("--json", argv); + Assert.DoesNotContain("--all", argv); + Assert.DoesNotContain("--data-dir", argv); + + var posix = alternative.GetProperty("posix_sh").GetString()!; + var powershell = alternative.GetProperty("powershell").GetString()!; + Assert.Contains("'a'\\''b $value; .*'", posix, StringComparison.Ordinal); + Assert.Contains("'src/space dir/**'", posix, StringComparison.Ordinal); + Assert.Contains("'a''b $value; .*'", powershell, StringComparison.Ordinal); + Assert.Contains("'src/space dir/**'", powershell, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_StructuredFormatDoesNotInventJsonFlag_Issue4906() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + ["search", "TODO", "--regex", "--path", "src/**", "--format", "csv"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--format csv", stderr, StringComparison.Ordinal); + Assert.DoesNotContain("--format csv --json", stderr, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_FormatCountPreservesStructuredFindOutput_Issue4906() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + ["search", "TODO", "--regex", "--format", "count"], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains( + "cdidx find --query TODO --all --regex --format count", + stderr, + StringComparison.Ordinal); + Assert.DoesNotContain("--regex --count", stderr, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_OptionShapedQueryIsNotReinterpretedInFindAlternative_Issue4906() + { + var cases = new[] + { + new + { + Args = new[] { "search", "--query", "--regex", "--all", "--json" }, + Query = "--regex", + Scope = "--all", + ActualRegex = false, + }, + new + { + Args = new[] { "search", "--query", "--all", "--regex", "--path", "src/**", "--json" }, + Query = "--all", + Scope = "--path", + ActualRegex = true, + }, + }; + + foreach (var testCase in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(testCase.Args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var argv = document.RootElement + .GetProperty("alternative_command") + .GetProperty("argv") + .EnumerateArray() + .Select(item => item.GetString()!) + .ToArray(); + Assert.Equal(testCase.Query, ValueAfterIssue4906(argv, "--query")); + Assert.Contains(testCase.Scope, argv); + Assert.Equal(testCase.ActualRegex ? 1 : 0, argv.Skip(4).Count(value => value == "--regex")); + } + + var consumedOptionCases = new[] + { + new[] { "search", "--query", "--json", "--regex", "--path", "src/**" }, + new[] { "search", "--query", "--data-dir", "--regex", "--path", "src/**", "--json" }, + }; + foreach (var args in consumedOptionCases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var argv = document.RootElement + .GetProperty("alternative_command") + .GetProperty("argv") + .EnumerateArray() + .Select(item => item.GetString()!) + .ToArray(); + Assert.Equal(args[2], argv[3]); + Assert.Equal(1, argv.Count(value => value == args[2])); + } + } + + [Fact] + public void RunSearch_UnmappableOrUnsafeScanRequestsExplainWhyWithoutCommand_Issue4906() + { + var cases = new[] + { + new + { + Args = new[] + { + "search", "TODO", "--regex", "--path", "src/**", + "--group-by", "file", "--count", "--json", + }, + ExpectedOption = "--group-by", + ExpectedBlocker = string.Empty, + }, + new + { + Args = new[] { "search", "TODO", "--all", "--path", "src/**", "--json" }, + ExpectedOption = string.Empty, + ExpectedBlocker = "either --path filters or --all", + }, + new + { + Args = new[] { "search", "--query", "line1\nline2", "--regex", "--json" }, + ExpectedOption = string.Empty, + ExpectedBlocker = "control characters", + }, + }; + + foreach (var testCase in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(testCase.Args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal(JsonValueKind.Null, root.GetProperty("alternative_command").ValueKind); + Assert.False(root.GetProperty("automatic_execution").GetBoolean()); + if (testCase.ExpectedOption.Length > 0) + { + Assert.Contains( + root.GetProperty("non_equivalent_options").EnumerateArray(), + item => item.GetString() == testCase.ExpectedOption); + } + if (testCase.ExpectedBlocker.Length > 0) + { + Assert.Contains( + root.GetProperty("alternative_blockers").EnumerateArray(), + item => item.GetString()!.Contains(testCase.ExpectedBlocker, StringComparison.Ordinal)); + } + } + } + + [Fact] + public void RunSearch_FindAlternativeRejectsIncompatibleCompactSnippetOutput_Issue4906() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + [ + "search", "TODO", "--regex", "--path", "src/**", + "--format", "compact", "--snippet-lines", "3", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var error = document.RootElement.GetProperty("metadata").GetProperty("error"); + Assert.Equal(JsonValueKind.Null, error.GetProperty("alternative_command").ValueKind); + Assert.Contains( + error.GetProperty("alternative_blockers").EnumerateArray(), + item => item.GetString()!.Contains( + "compact cannot be combined with --snippet-lines", + StringComparison.Ordinal)); + } + + [Fact] + public void RunSearch_FindAlternativeHonorsJsonByteBudget_Issue4906() + { + const int maxJsonBytes = 200; + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run( + [ + "search", "TODO", "--regex", "--path", "src/**", + "--json", "--max-json-bytes", "200", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.True(System.Text.Encoding.UTF8.GetByteCount(stdout) <= maxJsonBytes); + Assert.Equal(string.Empty, stdout); + Assert.Contains("too small for search recovery error JSON output", stderr, StringComparison.Ordinal); + Assert.Contains("Increase --max-json-bytes", stderr, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_FindAlternativeRejectsFindValidationFailures_Issue4906() + { + var cases = new[] + { + new + { + Args = new[] + { + "search", "TODO", "--regex", "--path", "src/**", + "--snippet-lines", "0", "--json", + }, + ExpectedBlocker = "positive integer", + }, + new + { + Args = new[] + { + "search", new string('x', QueryLimits.MaxQueryLength + 1), + "--regex", "--json", + }, + ExpectedBlocker = $"maximum {QueryLimits.MaxQueryLength} characters", + }, + }; + + foreach (var testCase in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(testCase.Args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.Equal(JsonValueKind.Null, root.GetProperty("alternative_command").ValueKind); + Assert.Contains( + root.GetProperty("alternative_blockers").EnumerateArray(), + item => item.GetString()!.Contains(testCase.ExpectedBlocker, StringComparison.Ordinal)); + } + } + + private static string ValueAfterIssue4906(IReadOnlyList argv, string option) + { + var index = -1; + for (var i = 0; i < argv.Count; i++) + { + if (argv[i] == option) + { + index = i; + break; + } + } + Assert.InRange(index, 0, argv.Count - 2); + return argv[index + 1]; + } +}