diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a72ed269d..de051816b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1348,6 +1348,8 @@ Current stable codes and triggers: | Memory tracing | `index --json --memory-trace` adds a `memory_timeline` block to the CLI index result and persists peak working-set MB into `last_index_run`; dry-run results also emit live `start`, `snapshot`, `scan`, and `finalize` samples but never persist run metadata. `index --dry-run --rebuild` bypasses destructive confirmation because it does not delete or rewrite the index. `CDIDX_MEM_WARN_MB=` prints a warning when the sampled working set crosses that threshold. | | Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. | +`vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics. + ### Data directory resolution When `--db ` is omitted, cdidx resolves the SQLite location from a data directory and appends `codeindex.db`. The precedence chain is: @@ -5228,6 +5230,8 @@ apply 時は `PRAGMA optimize` を実行します。 | memory tracing | `index --json --memory-trace` は CLI index 結果に `memory_timeline` block を追加し、peak working-set MB を `last_index_run` に保存します。dry-run 結果も live な `start`、`snapshot`、`scan`、`finalize` sample を返しますが、run metadata は保存しません。`index --dry-run --rebuild` は index を削除も rewrite もしないため destructive confirmation を bypass します。`CDIDX_MEM_WARN_MB=` は sampled working set がしきい値を超えたときに warning を出します。 | | newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 | +`vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 + ### データディレクトリ解決 `--db ` が省略された場合、cdidx は data directory を解決し、その下に `codeindex.db` を置く。優先順位は次のとおりです。 diff --git a/changelog.d/unreleased/5123.fixed.md b/changelog.d/unreleased/5123.fixed.md new file mode 100644 index 000000000..cac745aba --- /dev/null +++ b/changelog.d/unreleased/5123.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 5123 +affected: + - src/CodeIndex/PathUriNormalizer.cs + - src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`vacuum --dry-run` now accepts single-slash local file URIs (#5123)** — Unix `file:/absolute/path` and Windows `file:/C:/absolute/path` spellings are canonicalized while preserving the original query string and ignoring URI fragments, and validation reads the same snapshot used for metrics, including explicit `immutable=1` semantics. + +## 日本語 + +- **`vacuum --dry-run` が single-slash のローカル file URI を受け付けるようになりました (#5123)** — Unix の `file:/absolute/path` と Windows の `file:/C:/absolute/path` を、元の query string を維持して URI fragment を無視しつつ canonicalize し、validation と metric 収集で同じ snapshot を読むため、明示的な `immutable=1` semantics も維持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs index e28cd5034..a78f9b662 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Maintenance.cs @@ -54,8 +54,17 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions, MaintenanceDatabaseFailureKind.NotWritable)); } + var queryOnlyDbPath = options.DryRun + ? StripFileUriFragment(options.DbPath) + : options.DbPath; + if (options.DryRun + && TryCanonicalizeSingleSlashFileUri(queryOnlyDbPath, out var canonicalDbUri)) + { + queryOnlyDbPath = canonicalDbUri; + } + if (!DbContext.TryValidateExistingCodeIndexDb( - options.DbPath, + options.DryRun ? queryOnlyDbPath : options.DbPath, requireWritable: !options.DryRun, requireSupportedUserVersion: false, out _, @@ -82,7 +91,7 @@ public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions, DbContext.VacuumGenerationWitness? vacuumGenerationWitness; using (var db = DbContext.CreateUnpooled( options.DryRun ? DbOpenIntent.QueryOnly : DbOpenIntent.Repair, - options.DbPath, + options.DryRun ? queryOnlyDbPath : options.DbPath, cancellationToken)) { db.SuppressPlannerStatisticsMaintenanceOnClose(); @@ -163,4 +172,34 @@ private static void WriteVacuumByteTransition(string label, long? before, long? if (before.HasValue && after.HasValue) Console.WriteLine(ConsoleUi.FormatSummaryLine(label, $"{before.Value:N0} -> {after.Value:N0} bytes")); } + + private static bool TryCanonicalizeSingleSlashFileUri(string originalDbUri, out string canonicalDbUri) + { + canonicalDbUri = originalDbUri; + if (!originalDbUri.StartsWith("file:/", StringComparison.OrdinalIgnoreCase) + || originalDbUri.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!DbPathResolver.TryNormalizeDbPath(originalDbUri, out var normalizedDbPath, out _) + || SqliteFileUri.StartsWithFileScheme(normalizedDbPath)) + { + return false; + } + + var queryIndex = originalDbUri.IndexOf('?', StringComparison.Ordinal); + var querySuffix = queryIndex >= 0 ? originalDbUri[queryIndex..] : string.Empty; + canonicalDbUri = CodeIndex.FileUriPolicy.PathToFileUri(Path.GetFullPath(normalizedDbPath)) + querySuffix; + return true; + } + + private static string StripFileUriFragment(string dbPath) + { + if (!SqliteFileUri.StartsWithFileScheme(dbPath)) + return dbPath; + + var fragmentIndex = dbPath.IndexOf('#', StringComparison.Ordinal); + return fragmentIndex >= 0 ? dbPath[..fragmentIndex] : dbPath; + } } diff --git a/src/CodeIndex/PathUriNormalizer.cs b/src/CodeIndex/PathUriNormalizer.cs index 6710e5283..ab4317584 100644 --- a/src/CodeIndex/PathUriNormalizer.cs +++ b/src/CodeIndex/PathUriNormalizer.cs @@ -82,6 +82,8 @@ internal static bool TryNormalizeFileUriPath(string fileUri, out string normaliz var relativePath = Uri.UnescapeDataString(pathText["file:".Length..]); if (string.IsNullOrWhiteSpace(relativePath)) return true; + if (OperatingSystem.IsWindows() && HasSingleSlashWindowsDrivePrefix(relativePath)) + relativePath = relativePath[1..]; normalizedPath = Path.GetFullPath(relativePath); return true; } @@ -126,6 +128,13 @@ internal static bool HasWindowsDrivePrefix(string path) && path[1] == ':' && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')); + private static bool HasSingleSlashWindowsDrivePrefix(string path) + => path.Length >= 4 + && path[0] is '/' or '\\' + && path[2] == ':' + && path[3] is '/' or '\\' + && ((path[1] >= 'A' && path[1] <= 'Z') || (path[1] >= 'a' && path[1] <= 'z')); + private static string StripQuery(string uri) { var query = uri.IndexOf('?'); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 3e0f6d350..2dd3a0298 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1076,6 +1076,91 @@ INSERT INTO vacuum_payload (payload) DbWriter.DefaultFtsOptimizeIncrementalWriteThreshold, ftsOptimization.GetProperty("observed_writes").GetInt64()); Assert.Equal("current", ftsOptimization.GetProperty("state").GetString()); + + using (var walDb = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + walDb.SuppressPlannerStatisticsMaintenanceOnClose(); + walDb.CheckpointWalTruncate(); + new DbWriter(walDb).SetMeta(DbWriter.FtsIncrementalWritesSinceOptimizeMetaKey, "0"); + } + + var canonicalDbUri = new Uri(dbPath).AbsoluteUri; + Assert.StartsWith("file:///", canonicalDbUri, StringComparison.OrdinalIgnoreCase); + var singleSlashDbUri = "file:/" + canonicalDbUri["file:///".Length..]; + foreach (var (canonicalUri, singleSlashUri, expectedFtsState) in new[] + { + (canonicalDbUri + "?immutable=1", singleSlashDbUri + "?immutable=1", "stale"), + (canonicalDbUri + "?immutable=1#ignored", singleSlashDbUri + "?immutable=1#ignored", "stale"), + (canonicalDbUri + "#ignored", singleSlashDbUri + "#ignored", "current"), + (canonicalDbUri, singleSlashDbUri, "current"), + }) + { + var (canonicalExitCode, canonicalStdout, canonicalStderr) = CaptureVacuum( + ["--db", canonicalUri, "--dry-run", "--json"]); + var (uriExitCode, uriStdout, uriStderr) = CaptureVacuum( + ["--db", singleSlashUri, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, canonicalExitCode); + Assert.Equal(string.Empty, canonicalStderr); + Assert.Equal(CommandExitCodes.Success, uriExitCode); + Assert.Equal(string.Empty, uriStderr); + using var canonicalDocument = ParseJsonOutput(canonicalStdout); + using var uriDocument = ParseJsonOutput(uriStdout); + var uriRoot = uriDocument.RootElement; + Assert.Equal("dry_run", uriRoot.GetProperty("status").GetString()); + Assert.True(uriRoot.GetProperty("dry_run").GetBoolean()); + var canonicalFtsOptimization = canonicalDocument.RootElement + .GetProperty("maintenance_guidance") + .GetProperty("fts_optimization"); + Assert.Equal( + expectedFtsState, + canonicalFtsOptimization.GetProperty("state").GetString()); + var uriFtsOptimization = uriRoot + .GetProperty("maintenance_guidance") + .GetProperty("fts_optimization"); + Assert.Equal( + canonicalFtsOptimization.GetProperty("recommended").GetBoolean(), + uriFtsOptimization.GetProperty("recommended").GetBoolean()); + Assert.Equal( + canonicalFtsOptimization.GetProperty("action").GetString(), + uriFtsOptimization.GetProperty("action").GetString()); + Assert.Equal( + canonicalFtsOptimization.GetProperty("reason").GetString(), + uriFtsOptimization.GetProperty("reason").GetString()); + Assert.Equal( + canonicalFtsOptimization.GetProperty("threshold_writes").GetInt32(), + uriFtsOptimization.GetProperty("threshold_writes").GetInt32()); + Assert.Equal( + canonicalFtsOptimization.GetProperty("observed_writes").GetInt64(), + uriFtsOptimization.GetProperty("observed_writes").GetInt64()); + Assert.Equal( + canonicalFtsOptimization.GetProperty("state").GetString(), + uriFtsOptimization.GetProperty("state").GetString()); + } + + using (var invalidWalDb = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + invalidWalDb.SuppressPlannerStatisticsMaintenanceOnClose(); + invalidWalDb.CheckpointWalTruncate(); + using var command = invalidWalDb.Connection.CreateCommand(); + command.CommandText = "PRAGMA application_id=0"; + command.ExecuteNonQuery(); + } + + foreach (var immutableUri in new[] + { + canonicalDbUri + "?immutable=1", + singleSlashDbUri + "?immutable=1", + }) + { + var (immutableExitCode, immutableStdout, immutableStderr) = CaptureVacuum( + ["--db", immutableUri, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, immutableExitCode); + Assert.Equal(string.Empty, immutableStderr); + using var immutableDocument = ParseJsonOutput(immutableStdout); + Assert.Equal("dry_run", immutableDocument.RootElement.GetProperty("status").GetString()); + } } [Fact]