From b102cbb40407893e3cd0e09b409d823ea752fce7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 22:37:36 +0900 Subject: [PATCH] Add quiet mode for query stderr (#1805) --- README.md | 8 ++ changelog.d/unreleased/1805.added.md | 20 ++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/ConsoleUi.cs | 2 + src/CodeIndex/Cli/ProgramRunner.cs | 130 +++++++++++++++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 3 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- tests/CodeIndex.Tests/ProgramCliTests.cs | 72 ++++++++++++- 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/1805.added.md diff --git a/README.md b/README.md index ab2e378ae8..4cbc944cc1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ Terminals that request ASCII-only output with `--ascii`, `CDIDX_ASCII=1`, spinner frames as `|` / `/` / `-` / `\` and progress bars with `#` / `-` instead of Unicode glyphs. Very narrow Unicode-capable terminals show a percentage-only progress line so the display does not wrap. +For script-friendly query pipelines, pass `--quiet`, `-q`, `--silent`, or set +`CDIDX_QUIET=1` to suppress informational stderr output such as zero-result +hints, summaries, warnings, notes, and verbose diagnostics while preserving +error lines. `--quiet` takes precedence over `--verbose` for stderr text. Use `cdidx` when a repository will be searched repeatedly from terminals, scripts, CI, or AI tools. Use `rg` when you only need a one-off text scan. @@ -290,6 +294,10 @@ POSIX 環境では、persistent global tool stderr log は開くたびに所有 または非 UTF-8 locale により ASCII-only 出力が要求されている端末では、スピナーは `|` / `/` / `-` / `\`、進捗バーは `#` / `-` で描画されます。Unicode を利用できる端末でも 幅が非常に狭い場合は、折り返しを避けるため percentage-only の進捗行を表示します。 +スクリプト向けの query pipeline では、`--quiet`、`-q`、`--silent`、または +`CDIDX_QUIET=1` により、0件時のヒント、summary、warning、note、verbose 診断などの +informational stderr 出力を抑制し、error 行だけを残せます。stderr text については +`--quiet` が `--verbose` より優先されます。 ターミナル、スクリプト、CI、AI ツールから同じリポジトリを繰り返し検索する 場合は `cdidx` が向いています。1回限りのテキスト検索には `rg` が向いています。 diff --git a/changelog.d/unreleased/1805.added.md b/changelog.d/unreleased/1805.added.md new file mode 100644 index 0000000000..60f8ebc108 --- /dev/null +++ b/changelog.d/unreleased/1805.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 1805 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - README.md + - tests/CodeIndex.Tests/ProgramCliTests.cs +--- + +## English + +- **Global quiet mode for script-friendly query pipelines (#1805)** — `--quiet`, `-q`, `--silent`, and `CDIDX_QUIET=1` now suppress informational stderr text while preserving error lines. + +## 日本語 + +- **スクリプト向け query pipeline 用の global quiet mode を追加しました (#1805)** — `--quiet`、`-q`、`--silent`、`CDIDX_QUIET=1` により、error 行を残したまま informational stderr text を抑制できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index db167b5220..eb2640ad68 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -180,6 +180,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", Commands = Set(DbPathCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) }, new() { Name = "--json", Description = "JSON output; search also accepts --json=array for a single JSON array", Commands = Set(JsonCommands) }, + new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) }, + new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) }, new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", Commands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) }, new() { Name = "--slow-query-ms", ValuePlaceholder = "", Description = "Log profiled SQL statements at or above this millisecond threshold", Commands = Set(ProfileCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a740852551..2bf4cce6d8 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -656,6 +656,7 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" --dry-run Scan files without writing to the database"); Console.WriteLine(" --force Bypass the per-database index lock; only use when no other cdidx index is active"); Console.WriteLine(" --json Output results as JSON (for AI/machine use)"); + Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)"); Console.WriteLine(" --duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms"); Console.WriteLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); Console.WriteLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)"); @@ -687,6 +688,7 @@ public static void PrintUsage(bool showBanner = true) Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); Console.WriteLine(" --json Output as JSON (search streams ndjson by default; use search --json=array for one array)"); Console.WriteLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); + Console.WriteLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); Console.WriteLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); Console.WriteLine(" --slow-query-ms Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); Console.WriteLine(" --limit , --top Max results to return (default: 20)"); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index bad92cfacb..973999fc9b 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Globalization; using System.Net; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using CodeIndex.Database; @@ -10,6 +11,8 @@ namespace CodeIndex.Cli; internal static class ProgramRunner { + internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; + internal static int Run( string[] args, JsonSerializerOptions? jsonOptions = null, @@ -38,6 +41,9 @@ internal static int Run( GlobalToolLog.Info($"config_file_loaded path={configResult.Path}"); jsonOptions ??= CreateDefaultJsonOptions(); + var quiet = TryConsumeQuietFlag(ref args) || IsTruthyEnvironmentVariable(QuietEnvironmentVariable); + using var quietScope = quiet ? QuietStderrScope.Start() : null; + if (!TryConsumeColorFlag(ref args, out var colorError)) { CommandErrorWriter.Write(StripErrorPrefix(colorError), "use one of `auto`, `always`, `never`."); @@ -241,6 +247,51 @@ _ when IsProjectPathArg(commandName) internal static bool IsProjectPathArg(string arg) => !arg.StartsWith('-') && (Directory.Exists(arg) || arg.Contains('/') || arg.Contains('\\') || arg == "."); + internal static bool TryConsumeQuietFlag(ref string[] args) + { + if (args.Length == 0) + return false; + + var kept = new List(args.Length); + var quiet = false; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (arg is "--quiet" or "-q" or "--silent") + { + quiet = true; + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + return quiet; + } + + private static bool IsTruthyEnvironmentVariable(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return value != null + && !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase); + } + internal static int MapCodeIndexExceptionExitCode(string code) => code switch { CommandErrorCodes.DbNotFound => CommandExitCodes.NotFound, @@ -257,6 +308,85 @@ internal static bool IsProjectPathArg(string arg) => _ => CommandExitCodes.DatabaseError, }; + private sealed class QuietStderrScope : IDisposable + { + private readonly TextWriter _originalError; + + private QuietStderrScope(TextWriter originalError) + { + _originalError = originalError; + } + + public static QuietStderrScope Start() + { + var originalError = Console.Error; + Console.SetError(new ErrorOnlyTextWriter(originalError)); + return new QuietStderrScope(originalError); + } + + public void Dispose() + { + Console.Error.Flush(); + Console.SetError(_originalError); + } + } + + private sealed class ErrorOnlyTextWriter(TextWriter inner) : TextWriter + { + private readonly StringBuilder _lineBuffer = new(); + + public override Encoding Encoding => inner.Encoding; + + public override void Write(char value) + { + if (value == '\r') + return; + + if (value == '\n') + { + FlushBufferedLine(); + return; + } + + _lineBuffer.Append(value); + } + + public override void Write(string? value) + { + if (value == null) + return; + + foreach (var ch in value) + Write(ch); + } + + public override void WriteLine(string? value) + { + Write(value); + FlushBufferedLine(); + } + + public override void Flush() + { + FlushBufferedLine(); + inner.Flush(); + } + + private void FlushBufferedLine() + { + if (_lineBuffer.Length == 0) + return; + + var line = _lineBuffer.ToString(); + _lineBuffer.Clear(); + if (IsErrorLine(line)) + inner.WriteLine(line); + } + + private static bool IsErrorLine(string line) + => line.StartsWith("Error", StringComparison.Ordinal); + } + internal static bool TryConsumeColorFlag(ref string[] args, out string error) { error = string.Empty; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 9710ca3b14..54c80a1239 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -162,6 +162,9 @@ private sealed record StatusReadinessField( "--version", "-V", "--verbose", + "--quiet", + "-q", + "--silent", "--by-bucket", "--group-by-name", "--with-paths", diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 87e5b15f68..3a5f32e101 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -680,7 +680,7 @@ public void PrintCompletions_ReportFlagSetsMatchAcrossShells() var expected = new SortedSet(StringComparer.Ordinal) { - "db", "json", "output", "log-lines", "no-log", "include-args", + "db", "json", "quiet", "silent", "output", "log-lines", "no-log", "include-args", }; Assert.Equal(expected, bashReport); Assert.Equal(expected, zshReport); diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 4d258685f1..33baeb58ff 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -90,6 +90,66 @@ public void Symbols_NameHelpLikeValueReturnsUsageError() Assert.DoesNotContain("██████╗", stderr); } + [Theory] + [InlineData("--quiet")] + [InlineData("-q")] + [InlineData("--silent")] + public void QueryQuietFlag_SuppressesInformationalStderrOnZeroResults(string quietFlag) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_program_quiet_zero"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess([quietFlag, "search", "definitely_missing_query", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.NotFound, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Equal(string.Empty, stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void QueryQuietEnvironment_SuppressesVerboseStderr() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_program_quiet_env"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess( + ["search", "definitely_missing_query", "--verbose", "--db", dbPath], + new Dictionary { [ProgramRunner.QuietEnvironmentVariable] = "1" }); + + Assert.Equal(CommandExitCodes.NotFound, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Equal(string.Empty, stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void QueryQuietFlag_PreservesErrorLines() + { + var missingDbPath = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess(["--quiet", "search", "Run", "--db", missingDbPath]); + + Assert.NotEqual(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains($"Error [{CommandErrorCodes.DbNotFound}]:", stderr); + Assert.DoesNotContain("Hint:", stderr); + } + [Fact] public void Completions_HelpLikeValueReturnsCompletionsError() { @@ -293,7 +353,7 @@ public void Suggestions_ExportMarkdownIncludesFilteredSuggestions() Assert.DoesNotContain("Add parser support", stdout); } - private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(string[] args) + private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(string[] args, IReadOnlyDictionary? environment = null) { var psi = new System.Diagnostics.ProcessStartInfo { @@ -308,6 +368,16 @@ private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(s psi.ArgumentList.Add(GetBuiltCliDllPath()); foreach (var arg in args) psi.ArgumentList.Add(arg); + if (environment != null) + { + foreach (var (key, value) in environment) + { + if (value == null) + psi.Environment.Remove(key); + else + psi.Environment[key] = value; + } + } using var process = System.Diagnostics.Process.Start(psi) ?? throw new InvalidOperationException("Failed to start cdidx subprocess / cdidx サブプロセスの起動に失敗");