diff --git a/changelog.d/unreleased/5210.fixed.md b/changelog.d/unreleased/5210.fixed.md new file mode 100644 index 000000000..d65799043 --- /dev/null +++ b/changelog.d/unreleased/5210.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 5210 +affected: + - src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5210Tests.cs +--- + +## English + +- **Explicit search modes now keep global progress control parseable (#5210)** — `search --recipe`, child recipe selectors, explicit queries, and named-query batches now accept `--no-progress` before or after their arguments without treating it as a positional query, while plain option-like ad-hoc queries remain unchanged. + +## 日本語 + +- **明示的な search モードでグローバルな進捗制御を正しく解析するようになりました (#5210)** — `search --recipe`、recipe の child selector、明示的な query、named-query batch では、引数の前後にある `--no-progress` を位置引数 query と誤認せず受け付けるようになり、通常の option-like ad-hoc query の挙動は維持されます。 diff --git a/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs index 8cc9d5387..33fbf0688 100644 --- a/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs +++ b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs @@ -178,6 +178,9 @@ private static QueryCommandTokenRole GetQueryCommandTokenRole(string commandName return QueryCommandTokenRole.CommandOptionValue; } + if (HasExplicitSearchQueryMode(commandName, subArgs, withValues, flagOnly)) + return QueryCommandTokenRole.None; + if (!CommandAcceptsQueryLiteral(commandName)) return QueryCommandTokenRole.None; @@ -225,6 +228,41 @@ private static QueryCommandTokenRole GetQueryCommandTokenRole(string commandName return QueryCommandTokenRole.FirstQueryLiteral; } + private static bool HasExplicitSearchQueryMode( + string commandName, + string[] subArgs, + IReadOnlySet withValues, + IReadOnlySet flagOnly) + { + if (!string.Equals(commandName, "search", StringComparison.Ordinal)) + return false; + + for (var i = 0; i < subArgs.Length; i++) + { + var arg = subArgs[i]; + if (arg == "--") + break; + + if (TryGetInlineOptionName(arg, out var topLevelInlineName) + && TopLevelValueOptionNames.Contains(topLevelInlineName)) + continue; + if (TopLevelValueOptionNames.Contains(arg)) + { + if (i + 1 < subArgs.Length) + i++; + continue; + } + + var normalizedArg = NormalizeCommandOptionToken(arg, withValues, flagOnly, out var hasInlineValue); + if (normalizedArg is "--query" or "--recipe" or "--named-query") + return true; + if (!hasInlineValue && withValues.Contains(normalizedArg) && i + 1 < subArgs.Length) + i++; + } + + return false; + } + private static bool IsInspectPathLineMode(string commandName, string[] subArgs) { if (!string.Equals(commandName, "inspect", StringComparison.Ordinal)) diff --git a/tests/CodeIndex.Tests/MetricsSinkTests.cs b/tests/CodeIndex.Tests/MetricsSinkTests.cs index 1323d6117..d9c3bb6a7 100644 --- a/tests/CodeIndex.Tests/MetricsSinkTests.cs +++ b/tests/CodeIndex.Tests/MetricsSinkTests.cs @@ -62,6 +62,18 @@ public void TryConsumeMetricsFlag_AfterDoubleDash_PreservesQueryEscape() Assert.Equal(new[] { "search", "--", "--metrics=foo" }, args); } + [Fact] + public void TryConsumeMetricsFlag_MissingValueBeforeRecipe_PreservesSearchArguments_Issue5210() + { + var args = new[] { "search", "--metrics", "--recipe", "risky-code", "--json" }; + var expected = args.ToArray(); + + Assert.True(ProgramRunner.TryConsumeMetricsFlag(ref args, out var path, out var error)); + Assert.Null(path); + Assert.Empty(error); + Assert.Equal(expected, args); + } + [Fact] public void TryParseLanguageFromArgs_ReturnsValueWhenPresent() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5210Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5210Tests.cs new file mode 100644 index 000000000..3f8411efe --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5210Tests.cs @@ -0,0 +1,97 @@ +using CodeIndex.Cli; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class QueryCommandRunnerIssue5210Tests +{ + [Fact] + public void RunSearch_RecipeAndNamedQueryConsumeNoProgressAsGlobalFlag_Issue5210() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_global_flag_5210"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Issue5210.cs", + "csharp", + "public static class Issue5210 { public const string Needle = \"Issue5210Needle\"; }\n"); + + var recipeArgs = new[] + { + new[] + { + "search", "--no-progress", + "--recipe", "phrase-risk-patterns/obsolete-production-code", + "--json=ndjson", "--results-only", "--limit", "1", + "--db", dbPath, "--max-json-bytes", "1048576", + }, + new[] + { + "search", + "--recipe", "phrase-risk-patterns/obsolete-production-code", + "--json=ndjson", "--results-only", "--limit", "1", + "--db", dbPath, "--max-json-bytes", "1048576", + "--no-progress", + }, + new[] + { + "search", + "--recipe", "phrase-risk-patterns/obsolete-production-code", + "--json=ndjson", "--results-only", "--limit", "1", + "--db", dbPath, "--max-json-bytes", "1048576", + "--quiet", + }, + }; + + foreach (var args in recipeArgs) + { + var (exitCode, _, stderr) = CaptureConsole(() => + ProgramRunner.Run(args, JsonOptions, "1.44.3-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + } + + var (namedExitCode, _, namedStderr) = CaptureConsole(() => ProgramRunner.Run( + [ + "search", "--named-query", "needle=Issue5210Needle", + "--db", dbPath, "--json=ndjson", "--results-only", "--limit", "1", + "--no-progress", + ], + JsonOptions, + "1.44.3-test")); + + Assert.Equal(CommandExitCodes.Success, namedExitCode); + Assert.Equal(string.Empty, namedStderr); + } + + [Fact] + public void RunSearch_NoProgressRemainsAvailableAsOptionLikeAdHocQuery_Issue5210() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_literal_flag_5210"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Issue5210.cs", + "csharp", + "// --no-progress remains searchable as literal query text.\n"); + + var commands = new[] + { + new[] { "search", "--db", dbPath, "--json", "--count", "--no-progress" }, + new[] { "search", "--query", "--no-progress", "--db", dbPath, "--json", "--count" }, + }; + + foreach (var args in commands) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => + ProgramRunner.Run(args, JsonOptions, "1.44.3-test")); + using var result = ParseJsonOutput(stdout); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("--no-progress", result.RootElement.GetProperty("query").GetString()); + } + } +}