diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a1161cca37..689959cbd7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -116,6 +116,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. + ### Observability CodeIndex exposes an opt-in `ActivitySource` named `CodeIndex`. MCP JSON-RPC frames create `mcp.request` server spans and SQLite commands routed through tracked database helpers create `db.query` spans. MCP callers can pass W3C trace context as `params._meta.traceparent`; when present, the MCP span uses that trace as its parent. No exporter dependency is bundled, so spans are emitted only when the host process installs an OpenTelemetry/Diagnostics listener. diff --git a/README.md b/README.md index 91c44dbe9d..75b71cf6a3 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,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. | @@ -326,7 +327,8 @@ extractor fixture を確認できます。詳細は | 目的 | 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..88f4674d31 --- /dev/null +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -0,0 +1,60 @@ +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; + + 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; + } + } + + internal static void Save(ActiveWorkspaceState state) + { + Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!); + var payload = new ActiveWorkspaceState(state.Name, Path.GetFullPath(state.Root), 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..63cdc387be 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.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(); + 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 4cf0ba5369..5d258891bc 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..f730b3921d 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"; @@ -68,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/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index d1f56bb050..e05f782cb7 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -270,12 +270,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))] @@ -359,6 +362,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 85d6b7509e..5c150682c7 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -252,6 +252,15 @@ 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) + : 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), "test-extractor" => RunTestExtractor(subArgs, jsonOptions), diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs new file mode 100644 index 0000000000..fee297141e --- /dev/null +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -0,0 +1,88 @@ +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.WriteJsonOrHuman(json, jsonOptions, "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.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); + 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)); + 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."); + + 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..9f77f36276 --- /dev/null +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -0,0 +1,273 @@ +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 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 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() + { + 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); + + 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] + 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); + } + } + + [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() + { + 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); + } + } + + [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() + { + 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); + } + } +}