From c900d76cc6c93672680b00478521e87a603ad7ae Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:04:38 +0900 Subject: [PATCH 1/8] Add workspace config commands for issues #1708 #1709 #1710 --- DEVELOPER_GUIDE.md | 6 ++ README.md | 6 +- changelog.d/unreleased/1708.added.md | 13 +++ changelog.d/unreleased/1709.added.md | 13 +++ changelog.d/unreleased/1710.added.md | 13 +++ examples/cdidx.workspace.json | 12 +++ src/CodeIndex/Cli/ActiveWorkspace.cs | 58 +++++++++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 26 +++++ src/CodeIndex/Cli/ConsoleUi.cs | 4 + src/CodeIndex/Cli/DbPathResolver.cs | 5 + src/CodeIndex/Cli/JsonOutputContracts.cs | 6 ++ src/CodeIndex/Cli/ProgramRunner.cs | 4 + src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 81 ++++++++++++++++ src/CodeIndex/Cli/WorkspaceManifest.cs | 95 +++++++++++++++++++ .../WorkspaceCommandRunnerTests.cs | 56 +++++++++++ 15 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/1708.added.md create mode 100644 changelog.d/unreleased/1709.added.md create mode 100644 changelog.d/unreleased/1710.added.md create mode 100644 examples/cdidx.workspace.json create mode 100644 src/CodeIndex/Cli/ActiveWorkspace.cs create mode 100644 src/CodeIndex/Cli/WorkspaceCommandRunner.cs create mode 100644 src/CodeIndex/Cli/WorkspaceManifest.cs create mode 100644 tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index aff2f7ebd5..273ffcd1ae 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -89,6 +89,12 @@ The lock files for projects with zero direct `PackageReference` entries (e.g. `t | DTOs | `Models/FileRecord.cs`, `Models/ChunkRecord.cs`, `Models/SymbolRecord.cs`, `Models/ReferenceRecord.cs` | Records shared by indexing, storage, query, and MCP layers. | | Tests | `tests/CodeIndex.Tests/*Tests.cs`, `TestProjectHelper.cs`, `TestConsoleLock.cs` | Focused unit/integration coverage for chunking, extraction, DB reads/writes, CLI behavior, MCP behavior, git helpers, and shared test harness utilities. | +### Workspaces + +`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. The supported schema is additive: `members` is an array of member paths relative to the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths. + +`cdidx workspace use ` writes the active workspace to the per-user config directory. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. + ### Indexing pipeline ``` diff --git a/README.md b/README.md index 123e70a65f..cd38212edd 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Output controls: | Need | Option | |---|---| | Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date-stamped log files. Use `--log-format text|json`, `--log-retain-count `, `--log-max-size-mb `, or the matching `CDIDX_LOG_*` environment variables to make lifecycle logs JSONL-friendly and rotate them for aggregation. | -| Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file. | +| Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file and `cdidx config show` to inspect precedence. | +| Workspaces | Use `cdidx.workspace.json` or `.cdidx-workspace.json` to declare monorepo members, `cdidx workspace list` to inspect them, and `cdidx workspace use ` / `cdidx workspace current` for a persisted active workspace. | | ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. | | Color and terminal capability | `--color auto` emits ANSI only for capable interactive terminals; `TERM=dumb`, `CI=true`, missing Unix terminal hints, `NO_COLOR`, or `CLICOLOR=0` disable ANSI/progress control sequences. `--palette basic|256|truecolor` can override the `COLORTERM` / `TERM` color-depth detection. | | UTF-8 JSON pipelines | CLI `--json` output is written as UTF-8 without a BOM and never includes ANSI escape sequences, even when color is forced for human output. | @@ -311,7 +312,8 @@ cdidx mcp | 目的 | option / 動作 | |---|---| | POSIX の persistent stderr log を owner-only にする | global tool stderr log は開くたびに `0600` 権限へ補正され、既存の日付付き log file も同じ扱いになります。 | -| checked-in configuration | repository 既定値には `.cdidx/config.json` を使えます。例: `search.limit`、`search.snippet_lines`、`search.max_line_width`。検出された file は `cdidx validate-config` で検証できます。 | +| checked-in configuration | repository 既定値には `.cdidx/config.json` を使えます。例: `search.limit`、`search.snippet_lines`、`search.max_line_width`。検出された file は `cdidx validate-config` で検証でき、`cdidx config show` で優先順位を確認できます。 | +| workspaces | monorepo member は `cdidx.workspace.json` または `.cdidx-workspace.json` で宣言し、`cdidx workspace list` で確認できます。`cdidx workspace use ` / `cdidx workspace current` は永続 active workspace を扱います。 | | ASCII-only 端末で崩れない表示にする | `--ascii`、`CDIDX_ASCII=1`、`NO_UNICODE`、`TERM=dumb`、accessibility 系の環境変数、非 UTF-8 locale を使います。スピナーは pipe、slash、dash、backslash の frame、進捗バーは `#` / `-` になり、幅が非常に狭い端末では percentage-only になります。 | | color と端末 capability | `--color auto` は対応する interactive terminal でだけ ANSI を出力します。`TERM=dumb`、`CI=true`、Unix で端末 hint が無い場合、`NO_COLOR`、`CLICOLOR=0` では ANSI / progress 制御シーケンスを抑止します。`--palette basic|256|truecolor` で `COLORTERM` / `TERM` による color-depth 判定を上書きできます。 | | UTF-8 JSON pipeline | CLI の `--json` 出力は BOM なし UTF-8 で書き出され、human output 向けに色を強制していても ANSI escape sequence を含みません。 | diff --git a/changelog.d/unreleased/1708.added.md b/changelog.d/unreleased/1708.added.md new file mode 100644 index 0000000000..1c2b2683b1 --- /dev/null +++ b/changelog.d/unreleased/1708.added.md @@ -0,0 +1,13 @@ +--- +category: added +issues: + - 1708 +--- + +## English + +- Added JSON workspace manifests (`cdidx.workspace.json` / `.cdidx-workspace.json`) and `cdidx workspace list/status` for monorepo member topology. + +## 日本語 + +- monorepo member 構成を宣言する JSON workspace manifest (`cdidx.workspace.json` / `.cdidx-workspace.json`) と `cdidx workspace list/status` を追加しました。 diff --git a/changelog.d/unreleased/1709.added.md b/changelog.d/unreleased/1709.added.md new file mode 100644 index 0000000000..21c46e3d0a --- /dev/null +++ b/changelog.d/unreleased/1709.added.md @@ -0,0 +1,13 @@ +--- +category: added +issues: + - 1709 +--- + +## English + +- Added `cdidx config show` to report discovered workspace config, active workspace state, and config precedence. + +## 日本語 + +- 検出された workspace config、active workspace state、config 優先順位を表示する `cdidx config show` を追加しました。 diff --git a/changelog.d/unreleased/1710.added.md b/changelog.d/unreleased/1710.added.md new file mode 100644 index 0000000000..3cb119e01b --- /dev/null +++ b/changelog.d/unreleased/1710.added.md @@ -0,0 +1,13 @@ +--- +category: added +issues: + - 1710 +--- + +## English + +- Added persisted active workspace state with `cdidx workspace use/current` and query DB resolution after explicit flags and environment settings. + +## 日本語 + +- `cdidx workspace use/current` による永続 active workspace state と、明示 flag / 環境設定の後に使われる query DB 解決を追加しました。 diff --git a/examples/cdidx.workspace.json b/examples/cdidx.workspace.json new file mode 100644 index 0000000000..83043f1ba0 --- /dev/null +++ b/examples/cdidx.workspace.json @@ -0,0 +1,12 @@ +{ + "members": [ + "src/CodeIndex", + "tests/CodeIndex.Tests" + ], + "index_strategy": "per_member", + "default_db_name": "codeindex.db", + "shared_ignores": [ + "bin/", + "obj/" + ] +} diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs new file mode 100644 index 0000000000..9ac7a4519c --- /dev/null +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal sealed record ActiveWorkspaceState(string Name, string Root, string DbPath); + +internal static class ActiveWorkspace +{ + internal const string EnvironmentVariable = "CDIDX_ACTIVE_WORKSPACE"; + + internal static string StatePath + { + get + { + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var root = string.IsNullOrWhiteSpace(configHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config") + : configHome; + return Path.Combine(root, "cdidx", "active.json"); + } + } + + internal static ActiveWorkspaceState? Load() + { + var envPath = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (!string.IsNullOrWhiteSpace(envPath)) + return new ActiveWorkspaceState("env", Path.GetDirectoryName(Path.GetFullPath(envPath)) ?? Environment.CurrentDirectory, Path.GetFullPath(envPath)); + + var path = StatePath; + if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + var name = ReadString(root, "name") ?? "default"; + var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; + var dbPath = ReadString(root, "db_path"); + if (string.IsNullOrWhiteSpace(dbPath)) + return null; + return new ActiveWorkspaceState(name, Path.GetFullPath(workspaceRoot), Path.GetFullPath(dbPath)); + } + + internal static void Save(ActiveWorkspaceState state) + { + Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!); + var payload = new Dictionary + { + ["name"] = state.Name, + ["root"] = Path.GetFullPath(state.Root), + ["db_path"] = Path.GetFullPath(state.DbPath), + }; + File.WriteAllText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; +} diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index f8a493f88c..4b2fc10d6d 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -344,6 +344,32 @@ internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } + internal static int RunShow(string[] args, JsonSerializerOptions jsonOptions) + { + var json = args.Contains("--json", StringComparer.Ordinal); + args = args.Where(a => a != "--json").ToArray(); + if (args.Length > 0) + return CommandErrorWriter.Write("config show does not accept positional arguments.", CommandExitCodes.UsageError, "run `cdidx config show` from the workspace whose config should be shown."); + + var path = FindConfigFile(Environment.CurrentDirectory); + var active = ActiveWorkspace.Load(); + var payload = new ConfigShowJsonResult( + path, + active, + ["cli", "env", "config_file", "active_workspace", "cwd_default"], + [ProjectConfigRelativePath, FileName]); + if (json) + Console.WriteLine(JsonSerializer.Serialize(payload, jsonOptions)); + else + { + Console.WriteLine($"Config path : {path ?? "(none)"}"); + Console.WriteLine($"Active workspace : {(active == null ? "(none)" : active.Name + " -> " + active.DbPath)}"); + Console.WriteLine("Precedence : CLI > env > config file > active workspace > CWD default"); + } + + return CommandExitCodes.Success; + } + private static bool ValidateOptionalObject(JsonElement root, string key, IReadOnlyList knownKeys, string path, out string? error) { error = null; diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d9b935293e..8cd995d40a 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -82,6 +82,8 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--verbose]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), + ("workspace", "cdidx workspace [name] [--json]"), + ("config", "cdidx config show [--json]"), ("validate-config", "cdidx validate-config"), ("db", "cdidx db --integrity-check [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), @@ -744,6 +746,8 @@ private static void PrintCommandSummary() Console.WriteLine(" inspect Bundle definition, graph, and nearby symbol context"); Console.WriteLine(" outline Show a file outline ordered by line, start column, kind, and name"); Console.WriteLine(" status Show database statistics; add --check for freshness, --config for effective config, --explain for readiness, or --log-path for logs"); + Console.WriteLine(" workspace List manifest members and manage the active workspace"); + Console.WriteLine(" config show Show resolved workspace config and precedence"); Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 96f7126989..757f077e0e 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -14,6 +14,7 @@ public static class DbPathResolver public const string DataDirEnvironmentVariable = "CDIDX_DATA_DIR"; public const string DataDirSourceFlag = "flag"; public const string DataDirSourceEnv = "env"; + public const string DataDirSourceActiveWorkspace = "active_workspace"; public const string DataDirSourceXdg = "xdg"; public const string DataDirSourceWorkspace = "workspace"; @@ -51,6 +52,10 @@ internal static DbPathResolution ResolveDataDir(string workspacePath, string? ex if (!string.IsNullOrWhiteSpace(environmentDataDir)) return BuildDataDirResolution(environmentDataDir, DataDirSourceEnv); + var active = ActiveWorkspace.Load(); + if (active != null) + return new DbPathResolution(active.DbPath, Path.GetDirectoryName(active.DbPath), DataDirSourceActiveWorkspace); + if (!string.IsNullOrWhiteSpace(xdgDataHome)) { return BuildDataDirResolution(BuildXdgDataDir(xdgDataHome, fullWorkspacePath), DataDirSourceXdg); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 4de679feff..fe7c6ee35f 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -268,12 +268,15 @@ internal sealed record VersionInfoJsonResult( PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(BackfillFoldJsonResult))] +[JsonSerializable(typeof(ActiveWorkspaceJsonResult))] +[JsonSerializable(typeof(ActiveWorkspaceState))] [JsonSerializable(typeof(CalleeResult))] [JsonSerializable(typeof(CallerResult))] [JsonSerializable(typeof(CliJsonMessage))] [JsonSerializable(typeof(CompactSearchResult))] [JsonSerializable(typeof(CompactSearchResult[]))] [JsonSerializable(typeof(CommandErrorJsonResult))] +[JsonSerializable(typeof(ConfigShowJsonResult))] [JsonSerializable(typeof(DbIntegrityCheckJsonResult))] [JsonSerializable(typeof(DefinitionResult))] [JsonSerializable(typeof(Dictionary))] @@ -356,6 +359,9 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(CodeIndex.Models.UpdateCheckResult))] [JsonSerializable(typeof(VacuumResult))] [JsonSerializable(typeof(VersionInfoJsonResult))] +[JsonSerializable(typeof(WorkspaceListJsonResult))] +[JsonSerializable(typeof(WorkspaceManifest))] +[JsonSerializable(typeof(WorkspaceMember))] internal partial class CliJsonSerializerContext : JsonSerializerContext; internal static class CliJsonSerializerContextFactory diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 5523efde4f..5449af3572 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -252,6 +252,10 @@ internal static int Run( "optimize" => IndexCommandRunner.RunOptimizeFts(subArgs, jsonOptions), "vacuum" => QueryCommandRunner.RunVacuum(subArgs, jsonOptions), "validate-config" => CdidxConfigFile.RunValidate(subArgs, jsonOptions), + "config" => subArgs.Length > 0 && subArgs[0] == "show" + ? CdidxConfigFile.RunShow(subArgs[1..], jsonOptions) + : ShowError(args, "Unknown config command: use `cdidx config show`."), + "workspace" => WorkspaceCommandRunner.Run(subArgs, jsonOptions), "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), _ when IsProjectPathArg(commandName) diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs new file mode 100644 index 0000000000..974367e00b --- /dev/null +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +namespace CodeIndex.Cli; + +internal static class WorkspaceCommandRunner +{ + internal static int Run(string[] args, JsonSerializerOptions jsonOptions) + { + var json = args.Contains("--json", StringComparer.Ordinal); + args = args.Where(a => a != "--json").ToArray(); + if (args.Length == 0) + return List(json, jsonOptions); + + return args[0] switch + { + "list" => List(json, jsonOptions), + "status" => List(json, jsonOptions), + "current" => Current(json, jsonOptions), + "use" => Use(args[1..], json, jsonOptions), + _ => CommandErrorWriter.Write("Unknown workspace command.", CommandExitCodes.UsageError, "use `cdidx workspace list`, `cdidx workspace use `, or `cdidx workspace current`.") + }; + } + + private static int List(bool json, JsonSerializerOptions jsonOptions) + { + var manifest = WorkspaceManifestLoader.Find(Environment.CurrentDirectory); + if (manifest == null) + { + if (json) + Console.WriteLine(JsonSerializer.Serialize(new WorkspaceListJsonResult(null, Array.Empty()), jsonOptions)); + else + Console.WriteLine("No cdidx.workspace.json or .cdidx-workspace.json found."); + return CommandExitCodes.Success; + } + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(new WorkspaceListJsonResult(manifest, manifest.Members), jsonOptions)); + return CommandExitCodes.Success; + } + + Console.WriteLine($"Manifest : {manifest.Path}"); + Console.WriteLine($"Strategy : {manifest.IndexStrategy}"); + foreach (var member in manifest.Members) + Console.WriteLine($" {(member.Exists ? "ok" : "missing")} {member.Path} -> {member.DbPath}"); + return CommandExitCodes.Success; + } + + private static int Current(bool json, JsonSerializerOptions jsonOptions) + { + var state = ActiveWorkspace.Load(); + if (json) + Console.WriteLine(JsonSerializer.Serialize(new ActiveWorkspaceJsonResult(state, null), jsonOptions)); + else if (state == null) + Console.WriteLine("No active workspace set."); + else + Console.WriteLine($"{state.Name}: {state.Root} -> {state.DbPath}"); + return CommandExitCodes.Success; + } + + private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptions) + { + if (args.Length != 1) + return CommandErrorWriter.Write("workspace use requires a name.", CommandExitCodes.UsageError, "run `cdidx workspace use ` from a manifest member or pass `default`."); + + var name = args[0]; + var manifest = WorkspaceManifestLoader.Find(Environment.CurrentDirectory); + var member = manifest?.Members.FirstOrDefault(m => + string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "default", StringComparison.OrdinalIgnoreCase)); + var root = member?.Path ?? Environment.CurrentDirectory; + var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null); + var state = new ActiveWorkspaceState(name, root, dbPath); + ActiveWorkspace.Save(state); + if (json) + Console.WriteLine(JsonSerializer.Serialize(new ActiveWorkspaceJsonResult(state, ActiveWorkspace.StatePath), jsonOptions)); + else + Console.WriteLine($"Active workspace set to {state.Name}: {state.DbPath}"); + return CommandExitCodes.Success; + } +} diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs new file mode 100644 index 0000000000..569f91744d --- /dev/null +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal sealed record WorkspaceMember(string Path, string DbPath, bool Exists); + +internal sealed record WorkspaceManifest( + string Path, + string Root, + string IndexStrategy, + string DefaultDbName, + IReadOnlyList Members); + +internal sealed record WorkspaceListJsonResult(WorkspaceManifest? Manifest, IReadOnlyList Members); + +internal sealed record ActiveWorkspaceJsonResult(ActiveWorkspaceState? ActiveWorkspace, string? Path); + +internal sealed record ConfigShowJsonResult( + string? ConfigPath, + ActiveWorkspaceState? ActiveWorkspace, + IReadOnlyList Precedence, + IReadOnlyList SupportedFiles); + +internal static class WorkspaceManifestLoader +{ + internal const string FileName = "cdidx.workspace.json"; + internal const string DotFileName = ".cdidx-workspace.json"; + + internal static WorkspaceManifest? Find(string startingDirectory) + { + DirectoryInfo? current; + try + { + current = new DirectoryInfo(Path.GetFullPath(startingDirectory)); + } + catch + { + return null; + } + + while (current is not null) + { + foreach (var name in new[] { DotFileName, FileName }) + { + var candidate = Path.Combine(current.FullName, name); + if (File.Exists(LongPath.EnsureWindowsPrefix(candidate))) + return Load(candidate); + } + + current = current.Parent; + } + + return null; + } + + internal static WorkspaceManifest Load(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory; + using var document = JsonDocument.Parse(File.ReadAllText(fullPath), new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + var element = document.RootElement; + var strategy = ReadString(element, "index_strategy") ?? "per_member"; + var dbName = ReadString(element, "default_db_name") ?? "codeindex.db"; + var rawMembers = element.TryGetProperty("members", out var membersElement) && membersElement.ValueKind == JsonValueKind.Array + ? membersElement.EnumerateArray() + .Where(m => m.ValueKind == JsonValueKind.String) + .Select(m => m.GetString()) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Select(m => m!) + .ToArray() + : Array.Empty(); + + var members = rawMembers.Select(member => + { + var fullMember = Path.GetFullPath(Path.Combine(root, member)); + var dbPath = string.Equals(strategy, "single", StringComparison.OrdinalIgnoreCase) + ? Path.Combine(root, ".cdidx", dbName) + : Path.Combine(fullMember, ".cdidx", dbName); + return new WorkspaceMember(fullMember, dbPath, Directory.Exists(LongPath.EnsureWindowsPrefix(fullMember))); + }).ToArray(); + + return new WorkspaceManifest(fullPath, root, strategy, dbName, members); + } + + private static string? ReadString(JsonElement element, string name) + => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs new file mode 100644 index 0000000000..0d57df513b --- /dev/null +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -0,0 +1,56 @@ +using CodeIndex.Cli; +using System.Text.Json; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public class WorkspaceCommandRunnerTests +{ + private readonly JsonSerializerOptions _jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + + [Fact] + public void WorkspaceList_ReadsManifestMembers() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "A")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """ + { + "members": ["src/A"], + "index_strategy": "per_member", + "default_db_name": "index.db" + } + """); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = Path.Combine(root, "src", "A"); + var (exitCode, stdout, _) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["list", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("cdidx.workspace.json", stdout); + Assert.Contains("index.db", stdout); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void ConfigShow_PrintsPrecedence() + { + var (exitCode, stdout, _) = ConsoleCapture.Capture(() => CdidxConfigFile.RunShow(["--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("precedence", stdout); + Assert.Contains("active_workspace", stdout); + } +} From aec369ddb555d60c919f4e8ddcd8c15123d73575 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:09:35 +0900 Subject: [PATCH 2/8] Fix active workspace query resolution for issue #1710 --- src/CodeIndex/Cli/DbPathResolver.cs | 8 +++--- .../WorkspaceCommandRunnerTests.cs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 757f077e0e..f730b3921d 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -52,10 +52,6 @@ internal static DbPathResolution ResolveDataDir(string workspacePath, string? ex if (!string.IsNullOrWhiteSpace(environmentDataDir)) return BuildDataDirResolution(environmentDataDir, DataDirSourceEnv); - var active = ActiveWorkspace.Load(); - if (active != null) - return new DbPathResolution(active.DbPath, Path.GetDirectoryName(active.DbPath), DataDirSourceActiveWorkspace); - if (!string.IsNullOrWhiteSpace(xdgDataHome)) { return BuildDataDirResolution(BuildXdgDataDir(xdgDataHome, fullWorkspacePath), DataDirSourceXdg); @@ -73,6 +69,10 @@ internal static DbPathResolution ResolveDataDirForQuery(string workspacePath, st if (!string.IsNullOrWhiteSpace(environmentDataDir)) return BuildDataDirResolution(environmentDataDir, DataDirSourceEnv); + var active = ActiveWorkspace.Load(); + if (active != null) + return new DbPathResolution(active.DbPath, Path.GetDirectoryName(active.DbPath), DataDirSourceActiveWorkspace); + if (!string.IsNullOrWhiteSpace(xdgDataHome)) { var ancestorXdgDataDir = TryResolveOutermostAncestorXdgDataDir(fullWorkspacePath, xdgDataHome); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 0d57df513b..0d8b02f751 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -53,4 +53,30 @@ public void ConfigShow_PrintsPrecedence() Assert.Contains("precedence", stdout); Assert.Contains("active_workspace", stdout); } + + [Fact] + public void ActiveWorkspace_AffectsQueryResolutionButNotIndexResolution() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_project"); + var activeRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_state"); + var activeDb = Path.Combine(activeRoot, ".cdidx", "codeindex.db"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, activeDb); + + var query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + var index = DbPathResolver.ResolveForIndex(projectRoot, explicitDbPath: null, explicitDataDir: null); + + Assert.Equal(Path.GetFullPath(activeDb), query.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceActiveWorkspace, query.DataDirSource); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), index.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, index.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(activeRoot); + } + } } From 772c66dcb32161f0a82a94d7021e8f120790ba9f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:13:33 +0900 Subject: [PATCH 3/8] Reject unknown workspace member for issue #1710 --- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 3 ++ .../WorkspaceCommandRunnerTests.cs | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 974367e00b..5cfcf0e080 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -68,6 +68,9 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio var member = manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase) || string.Equals(name, "default", StringComparison.OrdinalIgnoreCase)); + if (manifest != null && member == null && !string.Equals(name, "default", StringComparison.OrdinalIgnoreCase)) + return CommandErrorWriter.Write("workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); + var root = member?.Path ?? Environment.CurrentDirectory; var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null); var state = new ActiveWorkspaceState(name, root, dbPath); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 0d8b02f751..c998f24a48 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -79,4 +79,38 @@ public void ActiveWorkspace_AffectsQueryResolutionButNotIndexResolution() TestProjectHelper.DeleteDirectory(activeRoot); } } + + [Fact] + public void WorkspaceUse_RejectsUnknownManifestMember() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_unknown"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_config"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "A")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/A"] }"""); + using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "typo"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace member was not found", stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } } From 5124fa422213084577eb159a449fbc94ac6dc176 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:19:02 +0900 Subject: [PATCH 4/8] Stabilize workspace config tests for issue #1709 --- .../WorkspaceCommandRunnerTests.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index c998f24a48..44f7223d62 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -47,11 +47,27 @@ public void WorkspaceList_ReadsManifestMembers() [Fact] public void ConfigShow_PrintsPrecedence() { - var (exitCode, stdout, _) = ConsoleCapture.Capture(() => CdidxConfigFile.RunShow(["--json"], _jsonOptions)); + var configHome = TestProjectHelper.CreateTempProject("cdidx_config_show_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains("precedence", stdout); - Assert.Contains("active_workspace", stdout); + var (exitCode, stdout, _) = ConsoleCapture.Capture(() => CdidxConfigFile.RunShow(["--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + Assert.False(root.TryGetProperty("active_workspace", out _)); + Assert.Contains( + root.GetProperty("precedence").EnumerateArray(), + item => item.GetString() == "active_workspace"); + } + finally + { + TestProjectHelper.DeleteDirectory(configHome); + } } [Fact] From 582323b65011e70bdb8ce44e20f1a97307f146ec Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:23:52 +0900 Subject: [PATCH 5/8] Harden active workspace handling for issue #1710 --- src/CodeIndex/Cli/ActiveWorkspace.cs | 28 ++++---- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 9 +-- .../WorkspaceCommandRunnerTests.cs | 64 +++++++++++++++++++ 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index 9ac7a4519c..88f4674d31 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -31,25 +31,27 @@ internal static string StatePath if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) return null; - using var document = JsonDocument.Parse(File.ReadAllText(path)); - var root = document.RootElement; - var name = ReadString(root, "name") ?? "default"; - var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; - var dbPath = ReadString(root, "db_path"); - if (string.IsNullOrWhiteSpace(dbPath)) + try + { + using var document = JsonDocument.Parse(File.ReadAllText(LongPath.EnsureWindowsPrefix(path))); + var root = document.RootElement; + var name = ReadString(root, "name") ?? "default"; + var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; + var dbPath = ReadString(root, "db_path"); + if (string.IsNullOrWhiteSpace(dbPath)) + return null; + return new ActiveWorkspaceState(name, Path.GetFullPath(workspaceRoot), Path.GetFullPath(dbPath)); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { return null; - return new ActiveWorkspaceState(name, Path.GetFullPath(workspaceRoot), Path.GetFullPath(dbPath)); + } } internal static void Save(ActiveWorkspaceState state) { Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!); - var payload = new Dictionary - { - ["name"] = state.Name, - ["root"] = Path.GetFullPath(state.Root), - ["db_path"] = Path.GetFullPath(state.DbPath), - }; + var payload = new ActiveWorkspaceState(state.Name, Path.GetFullPath(state.Root), Path.GetFullPath(state.DbPath)); File.WriteAllText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); } diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 5cfcf0e080..690fccce2e 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -65,10 +65,11 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio var name = args[0]; var manifest = WorkspaceManifestLoader.Find(Environment.CurrentDirectory); - var member = manifest?.Members.FirstOrDefault(m => - string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, "default", StringComparison.OrdinalIgnoreCase)); - if (manifest != null && member == null && !string.Equals(name, "default", StringComparison.OrdinalIgnoreCase)) + var useDefault = string.Equals(name, "default", StringComparison.OrdinalIgnoreCase); + var member = useDefault + ? null + : manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)); + if (manifest != null && member == null && !useDefault) return CommandErrorWriter.Write("workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); var root = member?.Path ?? Environment.CurrentDirectory; diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 44f7223d62..3e8e946f8e 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -96,6 +96,31 @@ public void ActiveWorkspace_AffectsQueryResolutionButNotIndexResolution() } } + [Fact] + public void MalformedActiveWorkspaceState_DoesNotOverrideQueryResolution() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_malformed_project"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_malformed_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + File.WriteAllText(ActiveWorkspace.StatePath, "{"); + + var query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUse_RejectsUnknownManifestMember() { @@ -129,4 +154,43 @@ public void WorkspaceUse_RejectsUnknownManifestMember() TestProjectHelper.DeleteDirectory(configHome); } } + + [Fact] + public void WorkspaceUseDefault_DoesNotSelectFirstManifestMember() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_default"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_default_config"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "A")); + Directory.CreateDirectory(Path.Combine(root, "src", "B")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/A", "src/B"] }"""); + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, _) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "default"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + var state = ActiveWorkspace.Load(); + Assert.NotNull(state); + var expectedRoot = Path.GetFullPath(Environment.CurrentDirectory); + Assert.Equal(expectedRoot, state.Root); + Assert.Equal(Path.GetFullPath(Path.Combine(expectedRoot, ".cdidx", "codeindex.db")), state.DbPath); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } } From 1ae6c0f24491d307e403cab56dbe51298a598eb1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:29:13 +0900 Subject: [PATCH 6/8] Honor JSON errors for workspace commands #1709 #1710 --- src/CodeIndex/Cli/ProgramRunner.cs | 7 +++- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 6 ++-- .../WorkspaceCommandRunnerTests.cs | 34 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 5449af3572..e372f1f608 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -254,7 +254,12 @@ internal static int Run( "validate-config" => CdidxConfigFile.RunValidate(subArgs, jsonOptions), "config" => subArgs.Length > 0 && subArgs[0] == "show" ? CdidxConfigFile.RunShow(subArgs[1..], jsonOptions) - : ShowError(args, "Unknown config command: use `cdidx config show`."), + : CommandErrorWriter.WriteJsonOrHuman( + ContainsJsonOutputFlag(subArgs), + jsonOptions, + "Unknown config command: use `cdidx config show`.", + CommandExitCodes.UsageError, + "use `cdidx config show`."), "workspace" => WorkspaceCommandRunner.Run(subArgs, jsonOptions), "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 690fccce2e..240e7cd726 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -17,7 +17,7 @@ internal static int Run(string[] args, JsonSerializerOptions jsonOptions) "status" => List(json, jsonOptions), "current" => Current(json, jsonOptions), "use" => Use(args[1..], json, jsonOptions), - _ => CommandErrorWriter.Write("Unknown workspace command.", CommandExitCodes.UsageError, "use `cdidx workspace list`, `cdidx workspace use `, or `cdidx workspace current`.") + _ => CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "Unknown workspace command.", CommandExitCodes.UsageError, "use `cdidx workspace list`, `cdidx workspace use `, or `cdidx workspace current`.") }; } @@ -61,7 +61,7 @@ private static int Current(bool json, JsonSerializerOptions jsonOptions) private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptions) { if (args.Length != 1) - return CommandErrorWriter.Write("workspace use requires a name.", CommandExitCodes.UsageError, "run `cdidx workspace use ` from a manifest member or pass `default`."); + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace use requires a name.", CommandExitCodes.UsageError, "run `cdidx workspace use ` from a manifest member or pass `default`."); var name = args[0]; var manifest = WorkspaceManifestLoader.Find(Environment.CurrentDirectory); @@ -70,7 +70,7 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio ? null : manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)); if (manifest != null && member == null && !useDefault) - return CommandErrorWriter.Write("workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); var root = member?.Path ?? Environment.CurrentDirectory; var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 3e8e946f8e..625273faee 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -44,6 +44,40 @@ public void WorkspaceList_ReadsManifestMembers() } } + [Fact] + public void WorkspaceErrors_HonorJsonFlag() + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["nope", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("\"status\":\"error\"", stdout); + Assert.Contains("Unknown workspace command", stdout); + Assert.DoesNotContain("Unknown workspace command", stderr); + } + + [Fact] + public void ConfigErrors_HonorJsonFlag() + { + var configHome = TestProjectHelper.CreateTempProject("cdidx_config_error_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => ProgramRunner.Run(["config", "nope", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("\"status\":\"error\"", stdout); + Assert.Contains("Unknown config command", stdout); + Assert.DoesNotContain("Unknown config command", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void ConfigShow_PrintsPrecedence() { From a7e846887e2ce18ee497ad54bc40427ed86cfef1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:45:24 +0900 Subject: [PATCH 7/8] Reject workspace names without manifest #1710 --- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 3 ++ .../WorkspaceCommandRunnerTests.cs | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 240e7cd726..fee297141e 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -66,6 +66,9 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio var name = args[0]; var manifest = WorkspaceManifestLoader.Find(Environment.CurrentDirectory); var useDefault = string.Equals(name, "default", StringComparison.OrdinalIgnoreCase); + if (manifest == null && !useDefault) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace manifest was not found.", CommandExitCodes.UsageError, "run `cdidx workspace use ` from a manifest member or pass `default`."); + var member = useDefault ? null : manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 625273faee..6b8eb65cb9 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -189,6 +189,38 @@ public void WorkspaceUse_RejectsUnknownManifestMember() } } + [Fact] + public void WorkspaceUse_RejectsNamedWorkspaceWithoutManifest() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_no_manifest"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_no_manifest_config"); + try + { + using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "typo"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace manifest was not found", stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUseDefault_DoesNotSelectFirstManifestMember() { From a88d7c1b142d02ac5d0dc3baa43179aa650f00a7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:50:18 +0900 Subject: [PATCH 8/8] Restore extractor docs after workspace merge #1709 --- DEVELOPER_GUIDE.md | 48 +++++++++++++ README.md | 12 ++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 2 +- src/CodeIndex/Cli/ProgramRunner.cs | 69 +++++++++++++++++++ .../WorkspaceCommandRunnerTests.cs | 11 +++ 5 files changed, 141 insertions(+), 1 deletion(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7d4589fd81..09c890a065 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -3059,6 +3059,54 @@ Cloud セッションは開発ループの中で `dotnet build` にフォール - ドキュメント(README, CHANGELOG)は前半英語、後半日本語の構成。 - 不要な本番パッケージは入れない。test-only package は、テストハーネスの改善に明確に寄与し、`tests/CodeIndex.Tests/` に閉じる限り許容されるが、本番依存ルールを緩めるものではない。 +## Custom Language Extraction + +Downstream users can add lightweight language support without rebuilding +`cdidx`: + +- extension aliases are read from `~/.config/cdidx/langmap.yaml` and the first + workspace ancestor `.cdidx-langmap.yaml`; workspace entries override user + entries; +- regex-backed symbol patterns are read from `.cdidx/patterns/*.yaml` and + `~/.config/cdidx/patterns/*.yaml`; +- `cdidx test-extractor --language --file --json` runs symbol + extraction without building an index, and `--expect-symbols ` compares + the extracted JSON to a fixture. + +Minimal examples: + +```yaml +# .cdidx-langmap.yaml +entries: + - extension: ".kts.in" + language: "kotlin" +``` + +```yaml +# .cdidx/patterns/toydsl.yaml +language: "toydsl" +extensions: + - extension: ".toy" +patterns: + - kind: "class" + regex: "^entity (?\\w+)" +``` + +Each configured regex should expose a named `name` capture. If it does not, +`cdidx` uses the full match text as the symbol name. Invalid sidecar files are +ignored so a broken local experiment does not prevent indexing. + +## カスタム言語抽出 + +下流ユーザーは `cdidx` を再ビルドせずに軽量な言語対応を追加できます。 + +- 拡張子 alias は `~/.config/cdidx/langmap.yaml` と、最初に見つかった workspace + 祖先の `.cdidx-langmap.yaml` から読み込まれ、workspace 側が user 側を上書きします。 +- regex ベースのシンボルパターンは `.cdidx/patterns/*.yaml` と + `~/.config/cdidx/patterns/*.yaml` から読み込まれます。 +- `cdidx test-extractor --language --file --json` は index を作らずに + symbol extraction だけを実行し、`--expect-symbols ` で fixture JSON と比較できます。 + ## SQLite reader のデバッグ `Database/DbDebug.cs` は `ExecuteTrackedReader` / `TrackedRead` の最後に流れた SQL、パラメーター、行ごとの状態を記録し、ループ途中で `SqliteException` が発生した場合に再現に十分な文脈を stderr へダンプする。インデックス済みのソースバイトが想定外の経路に漏れないよう、ダンプ経路はゲート制御されている: diff --git a/README.md b/README.md index e866b0b25c..75b71cf6a3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ cdidx validate cdidx mcp ``` +Custom language loops can stay out of tree: put extension aliases in +`.cdidx-langmap.yaml`, put regex symbol patterns in `.cdidx/patterns/*.yaml`, +and run `cdidx test-extractor --language --file --json` to test +an extractor fixture without building a full index. See +[Custom Language Extraction](DEVELOPER_GUIDE.md#custom-language-extraction). + After the first command, use these cues and follow-up commands: | Situation | What to expect or run | @@ -300,6 +306,12 @@ cdidx validate cdidx mcp ``` +カスタム言語の開発ループは out-of-tree で回せます。拡張子 alias は +`.cdidx-langmap.yaml`、regex シンボルパターンは `.cdidx/patterns/*.yaml` に置き、 +`cdidx test-extractor --language --file --json` で full index を作らずに +extractor fixture を確認できます。詳細は +[Custom Language Extraction](DEVELOPER_GUIDE.md#custom-language-extraction) を参照してください。 + 初回実行後は、次の見方と追加コマンドをよく使います。 | 状況 | 見るもの / 使うもの | diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 4b2fc10d6d..63cdc387be 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -349,7 +349,7 @@ internal static int RunShow(string[] args, JsonSerializerOptions jsonOptions) var json = args.Contains("--json", StringComparer.Ordinal); args = args.Where(a => a != "--json").ToArray(); if (args.Length > 0) - return CommandErrorWriter.Write("config show does not accept positional arguments.", CommandExitCodes.UsageError, "run `cdidx config show` from the workspace whose config should be shown."); + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "config show does not accept positional arguments.", CommandExitCodes.UsageError, "run `cdidx config show` from the workspace whose config should be shown."); var path = FindConfigFile(Environment.CurrentDirectory); var active = ActiveWorkspace.Load(); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index abee62da86..5c150682c7 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -263,6 +263,7 @@ internal static int Run( "workspace" => WorkspaceCommandRunner.Run(subArgs, jsonOptions), "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), + "test-extractor" => RunTestExtractor(subArgs, jsonOptions), _ when IsProjectPathArg(commandName) => IndexCommandRunner.Run(args, jsonOptions), _ => ShowError(args, $"Unknown command: {commandName}") @@ -332,6 +333,74 @@ private static bool IsWindowsDrivePath(string arg) => && arg[1] == ':' && ((arg[0] >= 'A' && arg[0] <= 'Z') || (arg[0] >= 'a' && arg[0] <= 'z')); + private static int RunTestExtractor(string[] args, JsonSerializerOptions jsonOptions) + { + string? language = null; + string? file = null; + string? expect = null; + var json = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (TryConsumeInlineOrNext(args, ref i, arg, "--language", out var value)) + language = value; + else if (TryConsumeInlineOrNext(args, ref i, arg, "--file", out value)) + file = value; + else if (TryConsumeInlineOrNext(args, ref i, arg, "--expect-symbols", out value) || TryConsumeInlineOrNext(args, ref i, arg, "--expect", out value)) + expect = value; + else if (arg == "--json") + json = true; + else + return CommandErrorWriter.Write($"Unknown test-extractor argument: {arg}", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); + } + + if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(file)) + return CommandErrorWriter.Write("test-extractor requires --language and --file.", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); + if (!File.Exists(file)) + return CommandErrorWriter.Write($"File not found: {file}", CommandExitCodes.NotFound); + + var source = File.ReadAllText(file); + var symbols = Indexer.SymbolExtractor.Extract(1, language, source, file); + if (expect != null) + { + var expected = File.ReadAllText(expect); + var actual = JsonSerializer.Serialize(symbols); + if (!JsonEquivalent(expected, actual)) + { + Console.Error.WriteLine("Expected symbols did not match extracted symbols."); + Console.Error.WriteLine(actual); + return CommandExitCodes.InvalidArgument; + } + } + + if (json || expect == null) + Console.WriteLine(JsonSerializer.Serialize(symbols)); + return CommandExitCodes.Success; + } + + private static bool TryConsumeInlineOrNext(string[] args, ref int index, string arg, string flag, out string value) + { + value = string.Empty; + if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) + { + value = arg[(flag.Length + 1)..]; + return true; + } + + if (arg != flag || index + 1 >= args.Length) + return false; + + value = args[++index]; + return true; + } + + private static bool JsonEquivalent(string expected, string actual) + { + using var expectedDoc = JsonDocument.Parse(expected); + using var actualDoc = JsonDocument.Parse(actual); + return JsonSerializer.Serialize(expectedDoc.RootElement) == JsonSerializer.Serialize(actualDoc.RootElement); + } + internal static void EnsureRedirectedStdoutUsesUtf8() { if (!Console.IsOutputRedirected || Console.Out is StringWriter || Console.Out.GetType().Assembly != typeof(Console).Assembly) diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 6b8eb65cb9..9f77f36276 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -78,6 +78,17 @@ public void ConfigErrors_HonorJsonFlag() } } + [Fact] + public void ConfigShowErrors_HonorJsonFlag() + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => CdidxConfigFile.RunShow(["extra", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("\"status\":\"error\"", stdout); + Assert.Contains("config show does not accept positional arguments", stdout); + Assert.DoesNotContain("config show does not accept positional arguments", stderr); + } + [Fact] public void ConfigShow_PrintsPrecedence() {