From 115f691599df57b2e33bb40319e4d6c00f6ada27 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 14:42:44 +0900 Subject: [PATCH 1/4] Fix workspace manifest bounds (#3216) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/3216.fixed.md | 17 +++++++ src/CodeIndex/Cli/WorkspaceManifest.cs | 8 ++++ .../WorkspaceCommandRunnerTests.cs | 48 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3216.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e0cc867639..c1aded3793 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -138,7 +138,7 @@ ownership boundaries so behavior changes remain reviewable and testable. ### Workspaces -`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, and 1024 members. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` is a plain file name that 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.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` is a plain file name that 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. diff --git a/changelog.d/unreleased/3216.fixed.md b/changelog.d/unreleased/3216.fixed.md new file mode 100644 index 0000000000..253e6190f8 --- /dev/null +++ b/changelog.d/unreleased/3216.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3216 +affected: + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace manifests now reject overlong member paths and default DB names (#3216)** - manifest loading now bounds member path strings and `default_db_name` before resolving paths or materializing DB locations. + +## 日本語 + +- **Workspace manifest が長すぎる member path と default DB 名を拒否するようになりました (#3216)** - manifest 読み込み時に path 解決や DB 位置の materialize 前に member path 文字列と `default_db_name` の長さを制限します。 diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 864526d9ab..5fafa38fec 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -29,6 +29,8 @@ internal static class WorkspaceManifestLoader internal const int MaxManifestBytes = 64 * 1024; internal const int MaxManifestDepth = 16; internal const int MaxManifestMembers = 1024; + internal const int MaxManifestMemberPathChars = 4096; + internal const int MaxDefaultDbNameChars = 255; internal static WorkspaceManifest? Find(string startingDirectory) { @@ -94,6 +96,9 @@ internal static WorkspaceManifest Load(string path) private static string ValidateDefaultDbName(string dbName) { + if (dbName.Length > MaxDefaultDbNameChars) + throw new InvalidDataException($"Workspace manifest default_db_name exceeds the {MaxDefaultDbNameChars} character limit."); + if (string.IsNullOrWhiteSpace(dbName) || dbName is "." or ".." || Path.IsPathRooted(dbName) @@ -123,6 +128,9 @@ private static IReadOnlyList ReadMembers(JsonElement element) if (string.IsNullOrWhiteSpace(value)) continue; + if (value.Length > MaxManifestMemberPathChars) + throw new InvalidDataException($"Workspace manifest member path exceeds the {MaxManifestMemberPathChars} character limit."); + if (members.Count >= MaxManifestMembers) throw new InvalidDataException($"Workspace manifest members exceed the {MaxManifestMembers} member limit."); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 23543035d5..2e734d28fd 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -106,6 +106,30 @@ public void WorkspaceManifestLoader_Load_RejectsTooManyMembers() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsOverlongMemberPath() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_member_length"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var member = new string('a', WorkspaceManifestLoader.MaxManifestMemberPathChars + 1); + File.WriteAllText(manifestPath, $$""" + { + "members": [{{JsonSerializer.Serialize(member)}}] + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains($"{WorkspaceManifestLoader.MaxManifestMemberPathChars} character limit", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsRootedMemberPath() { @@ -153,6 +177,30 @@ public void WorkspaceManifestLoader_Load_RejectsEscapingMemberPath() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsOverlongDefaultDbName() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_db_name_length"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var dbName = new string('a', WorkspaceManifestLoader.MaxDefaultDbNameChars + 1); + File.WriteAllText(manifestPath, $$""" + { + "default_db_name": {{JsonSerializer.Serialize(dbName)}} + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains($"{WorkspaceManifestLoader.MaxDefaultDbNameChars} character limit", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsAbsoluteDefaultDbName() { From 6605a07d41952989eb23ebb217b609a140a2edd4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 14:44:27 +0900 Subject: [PATCH 2/4] Reject unknown workspace index strategies (#3041) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/3041.fixed.md | 17 +++++++++++++ src/CodeIndex/Cli/WorkspaceManifest.cs | 13 +++++++++- .../WorkspaceCommandRunnerTests.cs | 24 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3041.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index c1aded3793..29d648bddf 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -138,7 +138,7 @@ ownership boundaries so behavior changes remain reviewable and testable. ### Workspaces -`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` is a plain file name that 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.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that 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. diff --git a/changelog.d/unreleased/3041.fixed.md b/changelog.d/unreleased/3041.fixed.md new file mode 100644 index 0000000000..e1d8b63e2a --- /dev/null +++ b/changelog.d/unreleased/3041.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3041 +affected: + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace manifests now reject unknown `index_strategy` values (#3041)** - typos such as `singel` now fail manifest validation instead of silently using per-member DB behavior. + +## 日本語 + +- **Workspace manifest が未知の `index_strategy` 値を拒否するようになりました (#3041)** - `singel` のような typo は per-member DB 動作へ黙って落ちず、manifest 検証エラーになります。 diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 5fafa38fec..4a30d620cd 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -73,7 +73,7 @@ internal static WorkspaceManifest Load(string path) }); var element = document.RootElement; - var strategy = ReadString(element, "index_strategy") ?? "per_member"; + var strategy = ValidateIndexStrategy(ReadString(element, "index_strategy") ?? "per_member"); var dbName = ValidateDefaultDbName(ReadString(element, "default_db_name") ?? "codeindex.db"); var rawMembers = ReadMembers(element); @@ -94,6 +94,17 @@ internal static WorkspaceManifest Load(string path) ? value.GetString() : null; + private static string ValidateIndexStrategy(string strategy) + { + if (string.Equals(strategy, "per_member", StringComparison.OrdinalIgnoreCase) + || string.Equals(strategy, "single", StringComparison.OrdinalIgnoreCase)) + { + return strategy; + } + + throw new InvalidDataException($"Workspace manifest index_strategy must be 'per_member' or 'single': {strategy}"); + } + private static string ValidateDefaultDbName(string dbName) { if (dbName.Length > MaxDefaultDbNameChars) diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 2e734d28fd..e327d1ec0c 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -278,6 +278,30 @@ public void WorkspaceManifestLoader_Load_AcceptsUtf8BomManifest() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsUnknownIndexStrategy() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_index_strategy"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + File.WriteAllText(manifestPath, """ + { + "members": ["src/A"], + "index_strategy": "singel" + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains("index_strategy must be 'per_member' or 'single'", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceErrors_HonorJsonFlag() { From 855ce666277e9dbf7da52ea75774f513af76662a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 14:46:04 +0900 Subject: [PATCH 3/4] Reject missing workspace members (#3217) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/3217.fixed.md | 17 ++++++++++ src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 2 ++ .../WorkspaceCommandRunnerTests.cs | 33 +++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3217.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 29d648bddf..3ced18e70e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -140,7 +140,7 @@ ownership boundaries so behavior changes remain reviewable and testable. `cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that 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. +`cdidx workspace use ` writes an existing manifest member or `default` workspace to the per-user config directory and rejects missing manifest members. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. ### Observability diff --git a/changelog.d/unreleased/3217.fixed.md b/changelog.d/unreleased/3217.fixed.md new file mode 100644 index 0000000000..493fe317dc --- /dev/null +++ b/changelog.d/unreleased/3217.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3217 +affected: + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`workspace use` now rejects missing manifest members (#3217)** - selecting a listed member that is absent on disk now fails with a usage error instead of persisting an active workspace pointing at the missing root. + +## 日本語 + +- **`workspace use` が存在しない manifest member を拒否するようになりました (#3217)** - manifest にある member がディスク上に存在しない場合、missing root を指す active workspace を保存せず usage error を返します。 diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index fee297141e..363620342c 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -74,6 +74,8 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio : manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)); if (manifest != null && member == null && !useDefault) 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."); + if (member is { Exists: false }) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member is missing on disk.", CommandExitCodes.UsageError, "create the missing member directory or run `cdidx workspace list` and choose an existing member."); 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 e327d1ec0c..0554037f22 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -603,6 +603,39 @@ public void WorkspaceUse_RejectsUnknownManifestMember() } } + [Fact] + public void WorkspaceUse_RejectsMissingManifestMember() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing_config"); + try + { + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/Missing"] }"""); + 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", "Missing"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace member is missing on disk", stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUse_RejectsNamedWorkspaceWithoutManifest() { From 956308d3d99c97a6c632d2a5cccfee3353a2608c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 14:48:06 +0900 Subject: [PATCH 4/4] Reject ambiguous workspace members (#3165) --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/3165.fixed.md | 17 +++++++++ src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 38 ++++++++++++++++--- .../WorkspaceCommandRunnerTests.cs | 37 ++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3165.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3ced18e70e..d8b56bf77a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -140,7 +140,7 @@ ownership boundaries so behavior changes remain reviewable and testable. `cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that 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 an existing manifest member or `default` workspace to the per-user config directory and rejects missing manifest members. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. +`cdidx workspace use ` writes an existing manifest member or `default` workspace to the per-user config directory, rejects missing manifest members, and rejects ambiguous member directory names. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. ### Observability diff --git a/changelog.d/unreleased/3165.fixed.md b/changelog.d/unreleased/3165.fixed.md new file mode 100644 index 0000000000..e037c000d0 --- /dev/null +++ b/changelog.d/unreleased/3165.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3165 +affected: + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`workspace use` now rejects ambiguous same-basename members (#3165)** - when multiple manifest members share the requested directory name, the command now reports a bounded candidate list instead of selecting the first match. + +## 日本語 + +- **`workspace use` が同じ basename の曖昧な member を拒否するようになりました (#3165)** - 要求された directory name に一致する manifest member が複数ある場合、先頭を選ばず bounded な候補一覧を含むエラーを返します。 diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 363620342c..bdad8798e2 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -4,6 +4,9 @@ namespace CodeIndex.Cli; internal static class WorkspaceCommandRunner { + private const int MaxAmbiguousMemberCandidates = 5; + private const int MaxAmbiguousMemberPathChars = 160; + internal static int Run(string[] args, JsonSerializerOptions jsonOptions) { var json = args.Contains("--json", StringComparer.Ordinal); @@ -69,11 +72,22 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio 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)); - if (manifest != null && member == null && !useDefault) - 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."); + WorkspaceMember? member = null; + if (manifest != null && !useDefault) + { + var matches = manifest.Members + .Where(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)) + .Take(MaxAmbiguousMemberCandidates + 1) + .ToArray(); + + if (matches.Length == 0) + 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."); + if (matches.Length > 1) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member name is ambiguous.", CommandExitCodes.UsageError, $"matching members: {FormatAmbiguousMemberCandidates(matches)}. Use unique member directory names in the workspace manifest."); + + member = matches[0]; + } + if (member is { Exists: false }) return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member is missing on disk.", CommandExitCodes.UsageError, "create the missing member directory or run `cdidx workspace list` and choose an existing member."); @@ -87,4 +101,18 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio Console.WriteLine($"Active workspace set to {state.Name}: {state.DbPath}"); return CommandExitCodes.Success; } + + private static string FormatAmbiguousMemberCandidates(IReadOnlyList matches) + { + var candidates = matches + .Take(MaxAmbiguousMemberCandidates) + .Select(member => TruncateAmbiguousMemberPath(member.Path)); + var suffix = matches.Count > MaxAmbiguousMemberCandidates ? ", ..." : string.Empty; + return string.Join(", ", candidates) + suffix; + } + + private static string TruncateAmbiguousMemberPath(string path) + => path.Length <= MaxAmbiguousMemberPathChars + ? path + : path[..(MaxAmbiguousMemberPathChars - 3)] + "..."; } diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 0554037f22..b1df9d03c5 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -636,6 +636,43 @@ public void WorkspaceUse_RejectsMissingManifestMember() } } + [Fact] + public void WorkspaceUse_RejectsAmbiguousSameBasenameMembers() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous_config"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "App")); + Directory.CreateDirectory(Path.Combine(root, "tests", "App")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/App", "tests/App"] }"""); + 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", "App"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace member name is ambiguous", stderr); + Assert.Contains(Path.Combine("src", "App"), stderr); + Assert.Contains(Path.Combine("tests", "App"), stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUse_RejectsNamedWorkspaceWithoutManifest() {