diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6385fdbd5b..cc630e55d8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -86,6 +86,15 @@ cdidx search AuthService --db /artifacts/codeindex.db --immutable Mutating commands such as `index`, `backfill-fold`, `optimize`, and `vacuum` require writable storage and reject read-only database opens. +For CI jobs that want to publish a reusable index artifact, run +`cdidx export codeindex.cdidx.zip` after indexing and upload that archive. A +consumer can run `cdidx import codeindex.cdidx.zip --db ` before query +commands. Use `--prune-paths` on import when the archive comes from another +checkout and the restored DB should advertise the current workspace root. The +archive contains `manifest.json` plus `codeindex.db`; import validates the +embedded SQLite file as a CodeIndex database before replacing the destination +DB. + Use `cdidx db checkpoint ` to take a filesystem snapshot of `codeindex.db` plus existing WAL/SHM sidecars before risky maintenance, and use `cdidx db restore ` to roll back. Checkpoints live next to the DB under diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 9d7317faf2..96e332eb4c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -286,6 +286,8 @@ sections below show examples and option details for the most common workflows. | Diagnostics | `db --integrity-check` | Run SQLite `PRAGMA integrity_check` against the DB | -- | | Diagnostics | `report --output ` | Build a redacted bug-report bundle | -- | | Feedback | `suggestions` | List, inspect, and export local suggestion history | -- | +| Portability | `export ctags` | Write a native `tags` file for Vim, Emacs, Sublime, and other ctags consumers | -- | +| Portability | `export` / `import` | Share a built CodeIndex database as a portable archive | -- | | MCP | `mcp` | Start the MCP server for AI tools | server transport | | Legal | `license` | Show the license and commercial-use summary | -- | @@ -307,6 +309,31 @@ cdidx search authenticate --json # ndjson stream, one result per line cdidx search authenticate --json=array # single JSON array ``` +## Editor and index portability + +Use `cdidx export ctags` when an editor wants the traditional ctags file format +instead of querying `cdidx` directly: + +```bash +cdidx export ctags --output tags +cdidx export ctags --db .cdidx/codeindex.db --output .tags +``` + +Use `cdidx export ` to package the current `codeindex.db` with a +manifest, and `cdidx import ` to restore it on another checkout or CI +job: + +```bash +cdidx export codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip --db /tmp/codeindex.db --prune-paths +``` + +The archive path is intended for trusted CodeIndex databases. Import validates +that the embedded SQLite file is a CodeIndex DB before replacing the destination +database. `--prune-paths` rewrites the imported `indexed_project_root` metadata +to the current checkout. + ## Flag compatibility and migrations `--exact` remains accepted for compatibility, but new usage should prefer the @@ -2354,6 +2381,8 @@ cdidx index . --quiet | Diagnostics | `db --integrity-check` | DB に対して SQLite `PRAGMA integrity_check` を実行 | -- | | Diagnostics | `report --output ` | redact 済み bug-report bundle を作成 | -- | | Feedback | `suggestions` | local suggestion history を list / inspect / export | -- | +| Portability | `export ctags` | Vim、Emacs、Sublime など ctags consumer 向けに `tags` file を出力 | -- | +| Portability | `export` / `import` | build 済み CodeIndex database を portable archive として共有 | -- | | MCP | `mcp` | AI tools 向け MCP server を起動 | server transport | | Legal | `license` | license と commercial-use summary を表示 | -- | @@ -2375,6 +2404,29 @@ cdidx search authenticate --json # ndjson stream、1 行 1 result cdidx search authenticate --json=array # 単一 JSON array ``` +## Editor / index portability + +Editor が `cdidx` を直接 query するのではなく従来の ctags file を読む場合は、 +`cdidx export ctags` を使います。 + +```bash +cdidx export ctags --output tags +cdidx export ctags --db .cdidx/codeindex.db --output .tags +``` + +`cdidx export ` は現在の `codeindex.db` と manifest を archive 化します。 +別 checkout や CI job では `cdidx import ` で復元できます。 + +```bash +cdidx export codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip --db /tmp/codeindex.db --prune-paths +``` + +archive は信頼できる CodeIndex database の共有向けです。Import は埋め込まれた +SQLite file が CodeIndex DB であることを検証してから destination database を置き換えます。 +`--prune-paths` は import した `indexed_project_root` metadata を現在の checkout に書き換えます。 + ## フラグ互換性と移行 `--exact` は互換性のため引き続き受け付けますが、新しい使い方ではコマンド系統に diff --git a/changelog.d/unreleased/1649.added.md b/changelog.d/unreleased/1649.added.md new file mode 100644 index 0000000000..a457344487 --- /dev/null +++ b/changelog.d/unreleased/1649.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1649 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - USER_GUIDE.md +--- + +## English + +- **Added ctags export (#1649)** — `cdidx export ctags` now writes editor-native `tags` files from indexed symbols, with `--output` and `--db` options. + +## 日本語 + +- **ctags export を追加しました (#1649)** — `cdidx export ctags` が indexed symbols から editor native の `tags` file を出力し、`--output` と `--db` options に対応しました。 diff --git a/changelog.d/unreleased/1726.added.md b/changelog.d/unreleased/1726.added.md new file mode 100644 index 0000000000..509272628d --- /dev/null +++ b/changelog.d/unreleased/1726.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 1726 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Added portable index archive export/import (#1726)** — `cdidx export ` and `cdidx import ` now package and restore CodeIndex databases with a manifest for CI or cross-machine reuse, including `--prune-paths` on import. + +## 日本語 + +- **portable index archive の export/import を追加しました (#1726)** — `cdidx export ` / `cdidx import ` で manifest 付きの CodeIndex database を CI や別 machine で再利用でき、import 時の `--prune-paths` に対応しました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 9b4cb30f8a..be3a5f1d2f 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -103,6 +103,9 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ]"), + ("export", "cdidx export [--db ] [--json]"), + ("export", "cdidx export ctags [--output ] [--db ]"), + ("import", "cdidx import [--db ] [--prune-paths] [--json]"), ("languages", "cdidx languages [--json]"), ("batch", "cdidx batch [--db ] # reads JSON string arrays from stdin, one query command per line"), ("mcp", "cdidx mcp [--db ]"), @@ -741,6 +744,8 @@ public static void PrintUsageBrief(bool showBanner = true) Console.WriteLine(" deps Show file-level dependency edges from the reference graph"); Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); + Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); + Console.WriteLine(" import Import a portable CodeIndex archive"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); Console.WriteLine(" lsp Start LSP server over stdio (for LSP-native editors)"); @@ -851,6 +856,8 @@ private static void PrintCommandSummary() Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); Console.WriteLine(" suggestions List, inspect, and export local suggestion history"); + Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); + Console.WriteLine(" import Import a portable CodeIndex archive"); Console.WriteLine(" languages List supported languages and their capabilities"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); @@ -965,6 +972,9 @@ private static void PrintExamples() Console.WriteLine(" Update DB from files changed between two refs"); Console.WriteLine(" cdidx index ./myproject --files src/app.cs Update specific files"); Console.WriteLine(" cdidx index ./myproject --watch Run an initial scan, then keep the index live as files change (Ctrl+C to stop)"); + Console.WriteLine(" cdidx export ctags --output tags Export editor tags for Vim, Emacs, and Sublime"); + Console.WriteLine(" cdidx export codeindex.cdidx.zip Export a portable CodeIndex archive"); + Console.WriteLine(" cdidx import codeindex.cdidx.zip Import a portable CodeIndex archive"); Console.WriteLine(" cdidx search \"authenticate\" Full-text search"); Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs new file mode 100644 index 0000000000..05b5150906 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -0,0 +1,388 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static class ExportImportCommandRunner +{ + private const string ManifestEntryName = "manifest.json"; + private const string DatabaseEntryName = "codeindex.db"; + private static readonly DateTimeOffset DeterministicZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static int RunExport(string[] args, JsonSerializerOptions jsonOptions, string appVersion) + { + if (args.Length > 0 && args[0] == "ctags") + return RunExportCtags(args[1..]); + + return RunExportArchive(args, jsonOptions, appVersion); + } + + public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) + { + string? archivePath = null; + string? dbPath = null; + var wantsJson = false; + var prunePaths = false; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--prune-paths") + { + prunePaths = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx import --db `.", "cdidx import [--db ] [--json]"); + dbPath = dbValue; + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return WriteError($"unknown import option `{arg}`.", "use `cdidx import [--db ]`.", "cdidx import [--db ] [--prune-paths] [--json]"); + + if (archivePath != null) + return WriteError($"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", "cdidx import [--db ] [--json]"); + archivePath = arg; + } + + if (string.IsNullOrWhiteSpace(archivePath)) + return WriteError("import requires an archive path.", "pass an archive produced by `cdidx export `.", "cdidx import [--db ] [--prune-paths] [--json]"); + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); + var dbDirectory = Path.GetDirectoryName(fullDbPath); + if (string.IsNullOrWhiteSpace(dbDirectory)) + return WriteError($"could not resolve destination DB directory for `{dbPath}`.", "pass an explicit `--db `.", "cdidx import [--db ] [--json]"); + + var tempPath = Path.Combine(dbDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); + try + { + Directory.CreateDirectory(dbDirectory); + using (var archive = ZipFile.OpenRead(archivePath)) + { + if (archive.GetEntry(ManifestEntryName) == null) + return WriteError("archive is missing manifest.json.", "use an archive produced by `cdidx export `.", "cdidx import [--db ] [--json]"); + + var dbEntry = archive.GetEntry(DatabaseEntryName); + if (dbEntry == null) + return WriteError("archive is missing codeindex.db.", "use an archive produced by `cdidx export `.", "cdidx import [--db ] [--json]"); + + dbEntry.ExtractToFile(tempPath, overwrite: true); + } + + if (!DbContext.TryValidateExistingCodeIndexDb(tempPath, out var validationMessage, out _)) + return WriteError($"archive database is invalid: {validationMessage}.", "re-export from a compatible CodeIndex database.", "cdidx import [--db ] [--prune-paths] [--json]"); + SqliteConnection.ClearAllPools(); + + if (prunePaths) + { + RewriteImportedProjectRoot(tempPath, Environment.CurrentDirectory); + SqliteConnection.ClearAllPools(); + } + + DeleteSqliteSidecars(fullDbPath); + File.Move(tempPath, fullDbPath, overwrite: true); + DeleteSqliteSidecars(fullDbPath); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize(new ImportResult("1", fullDbPath, prunePaths), jsonOptions)); + } + else + { + Console.WriteLine($"Imported CodeIndex database to {fullDbPath}"); + } + return CommandExitCodes.Success; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or SqliteException) + { + return WriteError($"import failed: {ex.Message}", "check the archive path and destination database permissions.", "cdidx import [--db ] [--prune-paths] [--json]"); + } + finally + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + } + } + + private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOptions, string appVersion) + { + string? outputPath = null; + string? dbPath = null; + var wantsJson = false; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx export --db `.", "cdidx export [--db ] [--json]"); + dbPath = dbValue; + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return WriteError($"unknown export option `{arg}`.", "use `cdidx export [--db ]` or `cdidx export ctags`.", "cdidx export [--db ] [--json]"); + + if (outputPath != null) + return WriteError($"export accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", "cdidx export [--db ] [--json]"); + outputPath = arg; + } + + if (string.IsNullOrWhiteSpace(outputPath)) + return WriteError("export requires an output archive path.", "pass a destination such as `codeindex.cdidx.zip`, or use `cdidx export ctags`.", "cdidx export [--db ] [--json]"); + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + if (!DbContext.TryValidateExistingCodeIndexDb(normalizedDbPath, out var validationMessage, out _)) + return WriteError(validationMessage, "run `cdidx index ` first or pass `--db `.", "cdidx export [--db ] [--json]"); + + var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); + var fullOutputPath = Path.GetFullPath(outputPath); + if (IsSamePath(fullOutputPath, fullSourceDbPath) + || IsSamePath(fullOutputPath, fullSourceDbPath + "-wal") + || IsSamePath(fullOutputPath, fullSourceDbPath + "-shm")) + { + return WriteError("export archive path must not be the source database or a SQLite sidecar.", "choose a separate archive path, for example `codeindex.cdidx.zip`.", "cdidx export [--db ] [--json]"); + } + + var snapshotPath = Path.Combine(Path.GetTempPath(), $"codeindex-export-{Guid.NewGuid():N}.db"); + try + { + var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + CreateDatabaseSnapshot(normalizedDbPath, snapshotPath); + ExportManifest manifest; + using (var snapshotConnection = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath))) + { + snapshotConnection.Open(); + manifest = BuildManifest(snapshotConnection, appVersion); + } + SqliteConnection.ClearAllPools(); + manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath) }; + if (File.Exists(outputPath)) + File.Delete(outputPath); + + using (var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create)) + { + AddTextEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, jsonOptions)); + var dbEntry = archive.CreateEntry(DatabaseEntryName, CompressionLevel.SmallestSize); + dbEntry.LastWriteTime = DeterministicZipTimestamp; + using var source = File.OpenRead(snapshotPath); + using var target = dbEntry.Open(); + source.CopyTo(target); + } + + if (wantsJson) + Console.WriteLine(JsonSerializer.Serialize(new ExportArchiveResult("1", Path.GetFullPath(outputPath), fullSourceDbPath), jsonOptions)); + else + Console.WriteLine($"Exported CodeIndex archive to {outputPath}"); + return CommandExitCodes.Success; + } + catch (Exception ex) + { + return WriteError($"export failed: {ex.Message}", "check the database and output archive paths.", "cdidx export [--db ] [--json]"); + } + finally + { + try { if (File.Exists(snapshotPath)) File.Delete(snapshotPath); } catch { } + try { DeleteSqliteSidecars(snapshotPath); } catch { } + } + } + + private static int RunExportCtags(string[] args) + { + var outputPath = "tags"; + string? dbPath = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (TryReadValueOption(args, ref i, "--output", arg, out var outputValue, out var outputError)) + { + if (outputError != null) + return WriteError(outputError, "use `cdidx export ctags --output tags`.", "cdidx export ctags [--output ] [--db ]"); + outputPath = outputValue!; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx export ctags --db `.", "cdidx export ctags [--output ] [--db ]"); + dbPath = dbValue; + continue; + } + + return WriteError($"unknown ctags export option `{arg}`.", "use `--output ` or `--db `.", "cdidx export ctags [--output ] [--db ]"); + } + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + if (!DbContext.TryValidateExistingCodeIndexDb(normalizedDbPath, out var validationMessage, out _)) + return WriteError(validationMessage, "run `cdidx index ` first or pass `--db `.", "cdidx export ctags [--output ] [--db ]"); + + try + { + using var db = new DbContext(normalizedDbPath); + db.TryMigrateForRead(); + var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + using var writer = new StreamWriter(outputPath, append: false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/"); + writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/"); + + using var cmd = db.Connection.CreateCommand(); + cmd.CommandText = @" + SELECT s.name, f.path, COALESCE(s.start_line, s.line, 1), s.kind + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.name IS NOT NULL AND s.name != '' + ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var name = SanitizeCtagsField(reader.GetString(0)); + var path = SanitizeCtagsField(reader.GetString(1)); + var line = Math.Max(1, reader.GetInt32(2)); + var kind = SanitizeCtagsField(reader.GetString(3)); + writer.WriteLine($"{name}\t{path}\t{line};\"\tkind:{kind}\tline:{line}"); + } + + Console.WriteLine($"Exported ctags to {outputPath}"); + return CommandExitCodes.Success; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SqliteException) + { + return WriteError($"ctags export failed: {ex.Message}", "check the database and output paths.", "cdidx export ctags [--output ] [--db ]"); + } + } + + private static ExportManifest BuildManifest(SqliteConnection connection, string appVersion) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA user_version"; + var userVersion = Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'indexed_project_root' LIMIT 1"; + var projectRoot = cmd.ExecuteScalar() as string; + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'indexed_head_sha' LIMIT 1"; + var indexedHead = cmd.ExecuteScalar() as string; + return new ExportManifest("1", appVersion, userVersion, projectRoot, indexedHead, string.Empty); + } + + private static void AddTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); + entry.LastWriteTime = DeterministicZipTimestamp; + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(content); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static void RewriteImportedProjectRoot(string dbPath, string projectRoot) + { + using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO codeindex_meta(key, value) + VALUES ('indexed_project_root', @projectRoot) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + cmd.Parameters.AddWithValue("@projectRoot", Path.GetFullPath(projectRoot)); + cmd.ExecuteNonQuery(); + } + + private static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotPath) + { + using var source = new SqliteConnection(CreateUnpooledConnectionString(sourceDbPath)); + using var destination = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + } + + private static string CreateUnpooledConnectionString(string dbPath) + => new SqliteConnectionStringBuilder { DataSource = dbPath, Pooling = false }.ConnectionString; + + private static void DeleteSqliteSidecars(string dbPath) + { + TryDeleteFile(dbPath + "-wal"); + TryDeleteFile(dbPath + "-shm"); + } + + private static void TryDeleteFile(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + + private static bool IsSamePath(string left, string right) + => string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string SanitizeCtagsField(string value) + => value.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' '); + + private static bool TryReadValueOption(string[] args, ref int index, string optionName, string arg, out string? value, out string? error) + { + value = null; + error = null; + if (arg == optionName) + { + if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) + { + error = $"{optionName} requires a non-empty value."; + return true; + } + value = args[++index]; + return true; + } + + var prefix = optionName + "="; + if (arg.StartsWith(prefix, StringComparison.Ordinal)) + { + value = arg[prefix.Length..]; + if (string.IsNullOrWhiteSpace(value)) + error = $"{optionName} requires a non-empty value."; + return true; + } + + return false; + } + + private static int WriteError(string message, string hint, string usage) + => CommandErrorWriter.Write(message, CommandExitCodes.UsageError, hint, usage); + + internal sealed record ExportManifest(string FormatVersion, string CdidxVersion, int UserVersion, string? ProjectRoot, string? IndexedHeadSha, string DatabaseSha256); + internal sealed record ExportArchiveResult(string ApiVersion, string ArchivePath, string DbPath); + internal sealed record ImportResult(string ApiVersion, string DbPath, bool PrunedPaths); +} diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 7903bec6af..7361e75a4e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -365,6 +365,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(DiffSummaryOnlyJsonResult))] [JsonSerializable(typeof(DiffSummaryJsonResult))] [JsonSerializable(typeof(ExactZeroHintResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ExportArchiveResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ExportManifest))] [JsonSerializable(typeof(ExcerptSemanticToken))] [JsonSerializable(typeof(FileDependencyResult))] [JsonSerializable(typeof(FileExcerptResult))] @@ -387,6 +389,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(IndexUpdateJsonResult))] [JsonSerializable(typeof(IndexUpdateSummaryJsonResult))] [JsonSerializable(typeof(IndexWatchEventJsonResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ImportResult))] [JsonSerializable(typeof(HookCommandJsonResult))] [JsonSerializable(typeof(JsonStreamDoneResult))] [JsonSerializable(typeof(LanguageEntryJsonResult))] diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 3ebbc8b2ac..a871131042 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -267,6 +267,8 @@ internal static int Run( { "upgrade" => RunUpgrade(subArgs, jsonOptions, appVersion), "index" => IndexCommandRunner.Run(subArgs, jsonOptions), + "export" => ExportImportCommandRunner.RunExport(subArgs, jsonOptions, appVersion), + "import" => ExportImportCommandRunner.RunImport(subArgs, jsonOptions), "diff" => DiffCommandRunner.Run(subArgs, jsonOptions), "hooks" => HookCommandRunner.Run(subArgs, jsonOptions), "backfill-fold" => IndexCommandRunner.RunBackfillFold(subArgs, jsonOptions), diff --git a/src/CodeIndex/CodeIndex.csproj b/src/CodeIndex/CodeIndex.csproj index e7eab15775..2e41fa6074 100644 --- a/src/CodeIndex/CodeIndex.csproj +++ b/src/CodeIndex/CodeIndex.csproj @@ -7,9 +7,9 @@ CodeIndex enable enable - true - true - true + true + true + true false true true diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 94bcd08a63..867497435f 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using CodeIndex.Database; using CodeIndex.Models; using Microsoft.Data.Sqlite; using System.Text.Json; @@ -290,6 +291,8 @@ public void Completions_OptionLikeShellTokenReturnsUsageError() [InlineData("deps", "cdidx deps")] [InlineData("map", "cdidx map")] [InlineData("status", "cdidx status")] + [InlineData("export", "cdidx export ")] + [InlineData("import", "cdidx import ")] [InlineData("doctor", "cdidx doctor")] [InlineData("completions", "cdidx completions ")] [InlineData("license", "cdidx license")] @@ -307,6 +310,110 @@ public void SubcommandHelp_PrintsCommandSpecificUsage(string command, string exp Assert.DoesNotContain("██████╗", stdout); } + [Fact] + public void ExportCtags_WritesTagsFileFromIndexedSymbols() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_ctags"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var tagsPath = Path.Combine(projectRoot, "tags"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess(["export", "ctags", "--db", dbPath, "--output", tagsPath]); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("Exported ctags", stdout); + var tags = File.ReadAllText(tagsPath); + Assert.Contains("!_TAG_FILE_FORMAT\t2", tags); + Assert.Contains("App\tsrc/app.cs\t1;\"", tags); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ExportImportArchive_RestoresCodeIndexDatabase() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_archive"); + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); + + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath]); + + Assert.True(exportExit == 0, exportStderr); + Assert.Equal(string.Empty, exportStderr); + Assert.True(importExit == 0, importStderr); + Assert.Equal(string.Empty, importStderr); + Assert.Contains("Imported CodeIndex database", importStdout); + Assert.True(File.Exists(importedDbPath)); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(importedDbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ExportArchive_RejectsSourceDatabaseAsOutput() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_same_db"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + + var (exitCode, _, stderr) = RunCliInSubprocess(["export", dbPath, "--db", dbPath]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("must not be the source database", stderr); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(dbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ImportArchive_RemovesStaleDestinationSidecars() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_sidecars"); + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var destinationDbPath = Path.Combine(projectRoot, "destination", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationDbPath)!); + File.WriteAllText(destinationDbPath, "old"); + File.WriteAllText(destinationDbPath + "-wal", "old wal"); + File.WriteAllText(destinationDbPath + "-shm", "old shm"); + + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (importExit, _, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", destinationDbPath]); + + Assert.True(exportExit == 0, exportStderr); + Assert.True(importExit == 0, importStderr); + Assert.False(File.Exists(destinationDbPath + "-wal")); + Assert.False(File.Exists(destinationDbPath + "-shm")); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(destinationDbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Doctor_PrintsRedactedEnvironmentSummary() {