diff --git a/.claude/skills/drift/SKILL.md b/.claude/skills/drift/SKILL.md index 1bd07c6..e6ca1f6 100644 --- a/.claude/skills/drift/SKILL.md +++ b/.claude/skills/drift/SKILL.md @@ -139,6 +139,24 @@ origin = "github:your-org/your-repo" sig = "a1b2c3d4e5f6a7b8" ``` +A consumer repo with a local checkout of the origin repo can map it and have those anchors actually checked instead of skipped — against the sibling checkout's files, with blame from its history: + +```bash +drift check --repo github:your-org/your-repo=../your-repo +``` + +Or persistently in `.drift/config.toml` (flag wins over config for the same origin): + +```toml +version = 1 + +[[repos]] +origin = "github:your-org/your-repo" +path = "../your-repo" +``` + +If a mapped path doesn't exist on disk, the anchor is skipped with reason `mapped repo missing` rather than failing the check. + ## Staleness `drift check` reads bindings from `drift.lock` and exits 1 if any anchor is stale or markdown link is broken. Use `drift check --changed ` to scope checking to affected docs — useful in CI when you know which files changed. For supported languages (TypeScript-family files including TS/TSX/JS/JSX, Python, Rust, Go, Zig, Java), comparison is syntax-aware — formatting-only changes won’t trigger staleness. For changed anchors, stale reports include best-effort git context for the target file (author, commit, committer date, subject) so you can see what changed. diff --git a/docs/CLI.md b/docs/CLI.md index 5a936d7..8baf3b7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -7,7 +7,7 @@ Check all docs for staleness. The primary command. Exits 1 if any anchor is stale or any link is broken. `drift lint` is an alias. Markdown files under directories with their own `drift.lock` are skipped — they belong to a nested scope. ``` -drift check [--format text|json] [--changed ] [--silent] +drift check [--format text|json] [--changed ] [--silent] [--repo =]... ``` Reads bindings from `drift.lock`, recomputes content signatures for each target, and compares against the stored `sig` values. Reports stale anchors with reasons. @@ -47,6 +47,24 @@ vendor/shared-skill.md Anchors with an `origin` field that doesn't match the current repo are skipped — they reference files in a different repository. +The `--repo =` flag (repeatable) maps a foreign origin to a local checkout so those anchors are checked instead of skipped. The origin must be in the normalized `github:owner/repo` form; the path is the checkout's root directory, resolved relative to the current working directory. Fingerprints and blame for mapped anchors compute against the mapped checkout. If the mapped directory does not exist on disk the anchors are skipped with reason `mapped_repo_missing` rather than reported stale, so a teammate without the sibling checkout is not broken. + +``` +$ drift check --repo github:acme/server=../server +``` + +The same mappings can live in `.drift/config.toml` as `[[repos]]` tables, so they don't have to be repeated on every invocation: + +```toml +version = 1 + +[[repos]] +origin = "github:acme/server" +path = "../server" +``` + +Config paths resolve against the lockfile root (checkout-location-independent), while `--repo` paths resolve against the current working directory. When a flag and a config entry map the same origin, the flag wins. + ## drift status Show all docs and their anchors without checking staleness. Reads bindings from `drift.lock`. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7af7c49..c2e51d6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -160,3 +160,9 @@ We use tree-sitter for link extraction rather than regex because: - Tree-sitter markdown's `section` node provides heading-to-body grouping, which regex cannot reliably determine The two-parser architecture requires two passes per file: block grammar first (producing `inline` node ranges), then inline grammar on those ranges. This adds build complexity (two grammar C sources, two `ts.Language` instances) but is how the grammar is designed — block and inline are separate grammars with separate node types. + +## 15. Repo mappings merge with CLI-wins precedence + +Foreign-origin checkout mappings come from two sources: repeated `--repo =` flags and `[[repos]]` tables in `.drift/config.toml`. Both feed one merged map; when both define the same origin, the flag entry wins. This follows the usual configuration layering rule — the most explicit, most recent instruction (typed on the command line for this run) overrides the persistent default (checked into config) — and makes one-off experiments cheap: point an origin at a scratch checkout without editing shared config. + +Path resolution differs by source on purpose. Config paths resolve against the lockfile root so a committed mapping like `path = "../server"` works for every teammate regardless of where the repo is cloned or which subdirectory `drift check` runs from. Flag paths resolve against the cwd, matching how every other command-line path argument behaves. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f2607f7..596355c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -249,20 +249,23 @@ Format rules: - Lines starting with `#` are comments, blank lines ignored; inline comments and general TOML tables are outside the lockfile subset - Discovery: walk up from cwd until `drift.lock` is found -### .drift/config.yaml +### .drift/config.toml Optional project-level settings. The `.drift/` directory exists only for configuration (scan globs, VCS backend override, etc.). -```yaml -# .drift/config.yaml (optional) -scan: - include: - - "docs/**/*.md" - - "*.md" - exclude: - - "node_modules/**" - - "vendor/**" -vcs: auto # auto | git | jj +The config reuses the lockfile's TOML subset: a mandatory `version = 1` header, `[[array-of-tables]]` blocks, bare keys, and single-line basic strings with the same escapes. Blank lines and full-line `#` comments are ignored; unknown keys or tables are hard errors with a line number, matching lockfile strictness. + +```toml +# .drift/config.toml (optional) +version = 1 + +[[repos]] +origin = "github:acme/server" +path = "../server" ``` +Each `[[repos]]` table maps a foreign binding origin to a local checkout, with exactly two keys: `origin` (normalized `github:owner/repo` form, same validation as `--repo` flag specs) and `path` (the checkout's root directory). Relative paths resolve against the lockfile root — not the cwd — so the mapping works no matter where in the checkout `drift check` runs. Unknown keys inside `[[repos]]` are hard errors. + +`--repo` flags and `[[repos]]` entries feed the same origin map; when both define the same origin, the CLI flag wins. Flag paths resolve against the cwd, as usual for command-line paths. + If no config exists, drift scans all `*.md` and `**/*.md` files and auto-detects the VCS. diff --git a/docs/check-json-schema.md b/docs/check-json-schema.md index 47ebd7f..b586b76 100644 --- a/docs/check-json-schema.md +++ b/docs/check-json-schema.md @@ -156,7 +156,8 @@ Each entry represents a relative markdown link extracted from the doc via tree-s | `symbol_not_found` | A `file#Symbol` anchor's symbol is no longer present. | | `fingerprint_unavailable` | A `@sig:` anchor could not be re-fingerprinted (e.g. unknown language). | | `baseline_unavailable` | Reserved — historical baseline could not be retrieved. (Not currently emitted; held for forward compatibility.) | -| `origin_mismatch` | The doc's `origin:` does not match the current repo identity. Anchors are skipped. | +| `origin_mismatch` | The doc's `origin:` does not match the current repo identity and no `--repo` mapping covers it. Anchors are skipped. | +| `mapped_repo_missing` | A `--repo` mapping covers the origin but the mapped directory does not exist on disk. Anchors are skipped. | | `link_target_not_found` | A plain markdown link in the doc points to a file that doesn't exist. | `reason.message` strings are stable in `v1` and asserted by tests. Changing one is a diff --git a/drift.lock b/drift.lock index 3726b0f..72be931 100644 --- a/drift.lock +++ b/drift.lock @@ -4,7 +4,7 @@ version = 1 doc = ".claude/skills/drift/SKILL.md" target = "src/main.zig" origin = "github:fiberplane/drift" -sig = "3faa73e2bb344a79" +sig = "94225f5ed5a3ca5b" [[bindings]] doc = ".claude/skills/drift/SKILL.md" @@ -20,7 +20,7 @@ sig = "2dccb33f6b790afa" [[bindings]] doc = "CLAUDE.md" target = "src/main.zig" -sig = "3faa73e2bb344a79" +sig = "94225f5ed5a3ca5b" [[bindings]] doc = "docs/CLI.md" @@ -30,7 +30,7 @@ sig = "3ae8f4ee2c85d8d8" [[bindings]] doc = "docs/CLI.md" target = "src/commands/lint.zig" -sig = "fe7bd5a687f3e917" +sig = "3afc7404179936ee" [[bindings]] doc = "docs/CLI.md" @@ -55,12 +55,12 @@ sig = "82d9da38ea486f36" [[bindings]] doc = "docs/DESIGN.md" target = "src/lockfile.zig" -sig = "55bc77a2853cb654" +sig = "851c526f05362849" [[bindings]] doc = "docs/DESIGN.md" target = "src/main.zig" -sig = "3faa73e2bb344a79" +sig = "94225f5ed5a3ca5b" [[bindings]] doc = "docs/DESIGN.md" diff --git a/src/Config.zig b/src/Config.zig new file mode 100644 index 0000000..ce47dbb --- /dev/null +++ b/src/Config.zig @@ -0,0 +1,313 @@ +//! Project configuration loaded from `/.drift/config.toml`. +//! +//! The config reuses the lockfile's TOML subset (docs/DECISIONS.md §11): +//! a mandatory `version = 1` header, bare keys, single-line basic strings, +//! full-line comments. A missing file yields `Config.default`; unknown keys +//! or tables are hard errors, matching lockfile strictness. + +const std = @import("std"); +const repo_map = @import("repo_map.zig"); +const toml = @import("toml.zig"); + +const Config = @This(); + +/// Schema version from the mandatory `version = 1` header. +version: u32, +/// True when `.drift/config.toml` was found on disk. +exists: bool, +/// Origin → checkout-path mappings from `[[repos]]` tables. Paths are kept +/// verbatim; `repo_map.RepoMap.buildMerged` resolves them against the +/// lockfile root. +repos: []const repo_map.Entry, + +pub const default: Config = .{ .version = 1, .exists = false, .repos = &.{} }; + +pub const max_config_bytes = 64 * 1024; + +pub const LoadError = error{ + ConfigRepoInvalid, + ConfigSyntax, + ConfigUnknownKey, + ConfigUnknownTable, + ConfigUnreadable, + ConfigVersionMissing, + ConfigVersionUnsupported, + OutOfMemory, +}; + +/// Identifies the offending line when `load` or `parse` fails. 1-based; +/// 0 when no line has been read (empty file). +pub const Diagnostics = struct { + line_number: usize = 0, +}; + +/// Reads and parses `/.drift/config.toml`. A missing file is not +/// an error: the default Config is returned. All allocations come from +/// `arena_allocator` and are freed by arena teardown, not by the caller. +pub fn load(io: std.Io, arena_allocator: std.mem.Allocator, root_path: []const u8, diagnostics: *Diagnostics) LoadError!Config { + const config_path = std.Io.Dir.path.join(arena_allocator, &.{ root_path, ".drift", "config.toml" }) catch return error.OutOfMemory; + + const content = readFileAt(io, arena_allocator, config_path) catch |err| switch (err) { + error.FileNotFound => return .default, + error.OutOfMemory => return error.OutOfMemory, + else => return error.ConfigUnreadable, + }; + + return parse(arena_allocator, content, diagnostics); +} + +/// A `[[repos]]` table under construction: exactly the keys `origin` and +/// `path`, both mandatory. `header_line` points diagnostics at the table +/// header when a key is missing. +const PendingRepo = struct { + origin: ?[]const u8 = null, + path: ?[]const u8 = null, + header_line: usize, +}; + +/// Parses config content: the mandatory `version = 1` header followed by zero +/// or more `[[repos]]` tables. Unknown keys and tables are hard errors. +/// Parsed strings live in `arena_allocator` storage. +pub fn parse(arena_allocator: std.mem.Allocator, content: []const u8, diagnostics: *Diagnostics) LoadError!Config { + var lines: toml.LineIterator = .init(content); + var version: ?u32 = null; + var repos: std.ArrayList(repo_map.Entry) = .empty; + var pending: ?PendingRepo = null; + + while (lines.next()) |line| { + diagnostics.line_number = lines.line_number; + + if (toml.arrayTableName(line)) |table_name| { + if (!std.mem.eql(u8, table_name, "repos")) return error.ConfigUnknownTable; + if (version == null) return error.ConfigVersionMissing; + if (pending) |p| try repos.append(arena_allocator, try parseFinishRepo(p, diagnostics)); + pending = .{ .header_line = lines.line_number }; + continue; + } + + const pair = toml.splitKeyValue(line) orelse return error.ConfigSyntax; + if (!toml.isValidBareKey(pair.key)) return error.ConfigSyntax; + + if (pending) |*repo| { + try parseRepoKey(arena_allocator, pair, repo); + continue; + } + + if (!std.mem.eql(u8, pair.key, "version")) return error.ConfigUnknownKey; + if (version != null) return error.ConfigSyntax; + const parsed = std.fmt.parseUnsigned(u32, pair.raw_value, 10) catch return error.ConfigSyntax; + if (parsed != 1) return error.ConfigVersionUnsupported; + version = parsed; + } + + if (pending) |p| try repos.append(arena_allocator, try parseFinishRepo(p, diagnostics)); + + return .{ + .version = version orelse return error.ConfigVersionMissing, + .exists = true, + .repos = repos.items, + }; +} + +fn parseRepoKey(arena_allocator: std.mem.Allocator, pair: toml.KeyValue, repo: *PendingRepo) LoadError!void { + const value = toml.parseString(arena_allocator, pair.raw_value) catch |err| switch (err) { + error.InvalidString => return error.ConfigSyntax, + error.OutOfMemory => return error.OutOfMemory, + }; + + if (std.mem.eql(u8, pair.key, "origin")) { + if (repo.origin != null) return error.ConfigSyntax; + repo.origin = value; + } else if (std.mem.eql(u8, pair.key, "path")) { + if (repo.path != null) return error.ConfigSyntax; + repo.path = value; + } else { + return error.ConfigUnknownKey; + } +} + +/// Completes a `[[repos]]` table, enforcing that both keys are present and +/// that the entry passes the same shape validation as `--repo` flag specs +/// (`repo_map.validate`). Missing keys point diagnostics at the table header. +fn parseFinishRepo(pending: PendingRepo, diagnostics: *Diagnostics) LoadError!repo_map.Entry { + const entry: repo_map.Entry = .{ + .origin = pending.origin orelse { + diagnostics.line_number = pending.header_line; + return error.ConfigRepoInvalid; + }, + .path = pending.path orelse { + diagnostics.line_number = pending.header_line; + return error.ConfigRepoInvalid; + }, + }; + repo_map.validate(entry) catch { + diagnostics.line_number = pending.header_line; + return error.ConfigRepoInvalid; + }; + return entry; +} + +fn readFileAt(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const file = if (std.Io.Dir.path.isAbsolute(path)) + try std.Io.Dir.openFileAbsolute(io, path, .{}) + else + try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + var file_reader = file.reader(io, &.{}); + return try file_reader.interface.allocRemaining(allocator, .limited(max_config_bytes)); +} + +test "load returns default Config when file is missing" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const root_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root_path); + + var diagnostics: Diagnostics = .{}; + const config = try load(io, arena.allocator(), root_path, &diagnostics); + try std.testing.expect(!config.exists); + try std.testing.expectEqual(@as(u32, 1), config.version); +} + +test "load reads version header with comments and blank lines" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(io, ".drift"); + try tmp.dir.writeFile(io, .{ + .sub_path = ".drift/config.toml", + .data = "# drift config\n\nversion = 1\n", + }); + + const root_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root_path); + + var diagnostics: Diagnostics = .{}; + const config = try load(io, arena.allocator(), root_path, &diagnostics); + try std.testing.expect(config.exists); + try std.testing.expectEqual(@as(u32, 1), config.version); +} + +/// Test helper: parse with a throwaway arena, returning only the error (if +/// any). Keeps error-path tests leak-free under std.testing.allocator. +fn testParseError(content: []const u8, diagnostics: *Diagnostics) ?LoadError { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + _ = parse(arena.allocator(), content, diagnostics) catch |err| return err; + return null; +} + +test "parse requires the version header" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual(@as(?LoadError, error.ConfigVersionMissing), testParseError("", &diagnostics)); + try std.testing.expectEqual(@as(?LoadError, error.ConfigVersionMissing), testParseError("# comments only\n\n", &diagnostics)); + try std.testing.expectEqual(@as(?LoadError, error.ConfigVersionMissing), testParseError("[[repos]]\norigin = \"github:acme/server\"\npath = \"../server\"\n", &diagnostics)); +} + +test "parse rejects unsupported versions and duplicate headers" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual(@as(?LoadError, error.ConfigVersionUnsupported), testParseError("version = 2\n", &diagnostics)); + try std.testing.expectEqual(@as(?LoadError, error.ConfigSyntax), testParseError("version = 1\nversion = 1\n", &diagnostics)); + try std.testing.expectEqual(@as(?LoadError, error.ConfigSyntax), testParseError("version = one\n", &diagnostics)); +} + +test "parse rejects unknown keys and tables with line numbers" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual(@as(?LoadError, error.ConfigUnknownKey), testParseError("version = 1\n\nname = \"drift\"\n", &diagnostics)); + try std.testing.expectEqual(@as(usize, 3), diagnostics.line_number); + + diagnostics = .{}; + try std.testing.expectEqual(@as(?LoadError, error.ConfigUnknownTable), testParseError("version = 1\n[[remotes]]\n", &diagnostics)); + try std.testing.expectEqual(@as(usize, 2), diagnostics.line_number); +} + +test "parse reports the line number of malformed lines" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual(@as(?LoadError, error.ConfigSyntax), testParseError("# header\nversion = 1\nnot a key value\n", &diagnostics)); + try std.testing.expectEqual(@as(usize, 3), diagnostics.line_number); +} + +test "parse reads [[repos]] tables with origin and path" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + var diagnostics: Diagnostics = .{}; + const config = try parse(arena.allocator(), + \\version = 1 + \\ + \\[[repos]] + \\origin = "github:acme/server" + \\path = "../server" + \\ + \\[[repos]] + \\path = "/checkouts/client" + \\origin = "github:acme/client" + \\ + , &diagnostics); + + try std.testing.expectEqual(@as(usize, 2), config.repos.len); + try std.testing.expectEqualStrings("github:acme/server", config.repos[0].origin); + try std.testing.expectEqualStrings("../server", config.repos[0].path); + try std.testing.expectEqualStrings("github:acme/client", config.repos[1].origin); + try std.testing.expectEqualStrings("/checkouts/client", config.repos[1].path); +} + +test "parse rejects unknown keys inside [[repos]]" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual( + @as(?LoadError, error.ConfigUnknownKey), + testParseError("version = 1\n[[repos]]\norigin = \"github:acme/server\"\nbranch = \"main\"\npath = \"../server\"\n", &diagnostics), + ); + try std.testing.expectEqual(@as(usize, 4), diagnostics.line_number); +} + +test "parse rejects [[repos]] entries missing origin or path" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual( + @as(?LoadError, error.ConfigRepoInvalid), + testParseError("version = 1\n\n[[repos]]\npath = \"../server\"\n", &diagnostics), + ); + try std.testing.expectEqual(@as(usize, 3), diagnostics.line_number); + + diagnostics = .{}; + try std.testing.expectEqual( + @as(?LoadError, error.ConfigRepoInvalid), + testParseError("version = 1\n\n[[repos]]\norigin = \"github:acme/server\"\n", &diagnostics), + ); + try std.testing.expectEqual(@as(usize, 3), diagnostics.line_number); +} + +test "parse applies the same origin validation as --repo specs" { + var diagnostics: Diagnostics = .{}; + const invalid_origins = [_][]const u8{ + "acme/server", // missing github: prefix + "github:acme", // no owner/repo split + "github:acme/server.git", // .git suffix never survives normalization + "gitlab:acme/server", // unsupported forge + }; + for (invalid_origins) |origin| { + var content_buf: [256]u8 = undefined; + const content = try std.fmt.bufPrint(&content_buf, "version = 1\n[[repos]]\norigin = \"{s}\"\npath = \"../server\"\n", .{origin}); + try std.testing.expectEqual(@as(?LoadError, error.ConfigRepoInvalid), testParseError(content, &diagnostics)); + } +} + +test "parse rejects duplicate keys inside a [[repos]] table" { + var diagnostics: Diagnostics = .{}; + try std.testing.expectEqual( + @as(?LoadError, error.ConfigSyntax), + testParseError("version = 1\n[[repos]]\norigin = \"github:acme/server\"\norigin = \"github:acme/other\"\npath = \"../server\"\n", &diagnostics), + ); + try std.testing.expectEqual(@as(usize, 4), diagnostics.line_number); +} diff --git a/src/commands/lint.zig b/src/commands/lint.zig index ddc79c1..0ae7f07 100644 --- a/src/commands/lint.zig +++ b/src/commands/lint.zig @@ -2,8 +2,10 @@ const std = @import("std"); const build_options = @import("build_options"); const drift_check_v1 = @import("payload"); const CommandContext = @import("../context.zig").CommandContext; +const Config = @import("../Config.zig"); const lockfile = @import("../lockfile.zig"); const markdown = @import("../markdown.zig"); +const repo_map = @import("../repo_map.zig"); const symbols = @import("../symbols.zig"); const target = @import("../target.zig"); const vcs = @import("../vcs.zig"); @@ -59,6 +61,7 @@ const ReasonCode = enum { fingerprint_unavailable, baseline_unavailable, origin_mismatch, + mapped_repo_missing, link_target_not_found, }; @@ -72,6 +75,7 @@ fn reasonMessage(code: ReasonCode) []const u8 { .fingerprint_unavailable => "cannot compute fingerprint", .baseline_unavailable => "baseline unavailable", .origin_mismatch => "origin mismatch", + .mapped_repo_missing => "mapped repo missing", .link_target_not_found => "link target not found", }; } @@ -102,6 +106,10 @@ fn linkResultStr(r: LinkResult) []const u8 { const JsonAnchorRow = struct { blame_storage: ?vcs.BlameInfo, + /// Root the anchor was checked against: the scope root, or the mapped + /// sibling checkout when the binding's origin was resolved via `--repo`. + /// Blame queries in phase 2 run with this directory as cwd. + effective_root: []const u8, wire: drift_check_v1.Anchor, }; @@ -175,8 +183,9 @@ pub fn run( stderr_w: *std.Io.Writer, format: Format, changed_path: ?[]const u8, + repo_entries: []const repo_map.Entry, ) !RunStatus { - const result = try compute(ctx, stderr_w, changed_path); + const result = try compute(ctx, stderr_w, changed_path, repo_entries); switch (format) { .text => try renderText(stdout_w, &result), .json => try renderJson(ctx.run_arena, stdout_w, &result), @@ -194,24 +203,42 @@ pub fn compute( ctx: CommandContext, stderr_w: *std.Io.Writer, changed_path: ?[]const u8, + repo_entries: []const repo_map.Entry, ) !CheckResult { const cwd_path = try std.Io.Dir.cwd().realPathFileAlloc(ctx.io, ".", ctx.run_arena); const lf = try lockfile.discover(ctx.io, ctx.run_arena, ctx.scratch(), cwd_path); ctx.resetScratch(); - // Kick off the `git remote get-url origin` query concurrently with the - // `git ls-files` run inside `discoverDocGroups`. Both are independent and - // both shell out to git, so the two subprocesses overlap. + // Kick off the `git remote get-url origin` query and the config read + // concurrently with the `git ls-files` run inside `discoverDocGroups`. + // All three are independent, so the subprocesses and the file read overlap. var identity_future = ctx.io.async(vcs.getRepoIdentity, .{ ctx.io, ctx.run_arena, ctx.run_arena, cwd_path }); defer _ = identity_future.cancel(ctx.io); + var config_diagnostics: Config.Diagnostics = .{}; + var config_future = ctx.io.async(Config.load, .{ ctx.io, ctx.run_arena, lf.root_path, &config_diagnostics }); + defer if (config_future.cancel(ctx.io)) |_| {} else |_| {}; + var doc_groups = try discoverDocGroups(ctx.io, ctx.run_arena, lf.root_path, lf.bindings.items); defer { for (doc_groups.items) |*doc| doc.bindings.deinit(ctx.run_arena); doc_groups.deinit(ctx.run_arena); } + const config = config_future.await(ctx.io) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + reportConfigError(stderr_w, err, config_diagnostics.line_number); + return error.LintCheckFailed; + }, + }; + + // Built once and shared read-only across the per-doc tasks below. Flag + // entries win over config entries on duplicate origins; flag paths resolve + // against cwd, config paths against the lockfile root (docs/CLI.md). + const mapped_repos = try repo_map.RepoMap.buildMerged(ctx.run_arena, repo_entries, cwd_path, config.repos, lf.root_path); + const detected_vcs = vcs.detectVcs(); const repo_identity = identity_future.await(ctx.io); @@ -257,6 +284,7 @@ pub fn compute( doc, detected_vcs, repo_identity, + &mapped_repos, &results[i], }); } @@ -274,6 +302,27 @@ pub fn compute( return result; } +/// Prints a human-readable diagnostic for a `.drift/config.toml` load failure. +/// The caller turns the failure into `error.LintCheckFailed` so `main` exits 1 +/// without printing a second, less specific message. +fn reportConfigError(stderr_w: *std.Io.Writer, err: Config.LoadError, line_number: usize) void { + const detail: []const u8 = switch (err) { + error.ConfigRepoInvalid => "invalid [[repos]] entry: need origin = \"github:owner/repo\" and a non-empty path", + error.ConfigSyntax => "syntax error", + error.ConfigUnknownKey => "unknown key", + error.ConfigUnknownTable => "unknown table", + error.ConfigUnreadable => "file not readable", + error.ConfigVersionMissing => "missing 'version = 1' header", + error.ConfigVersionUnsupported => "unsupported config version", + error.OutOfMemory => "out of memory", + }; + if (line_number > 0) { + stderr_w.print("error: .drift/config.toml:{d}: {s}\n", .{ line_number, detail }) catch {}; + } else { + stderr_w.print("error: .drift/config.toml: {s}\n", .{detail}) catch {}; + } +} + /// Write the text report for an already-computed result. Safe to call more /// than once (e.g. to stdout under normal conditions and to stderr when /// `--silent` runs fail). @@ -301,9 +350,10 @@ fn checkOneDoc( doc: DocGroup, detected_vcs: vcs.VcsKind, repo_identity: ?[]const u8, + mapped_repos: *const repo_map.RepoMap, out: *?DocCheckResult, ) void { - checkOneDocInner(io, run_arena, root_path, doc, detected_vcs, repo_identity, out) catch |err| { + checkOneDocInner(io, run_arena, root_path, doc, detected_vcs, repo_identity, mapped_repos, out) catch |err| { // Persist the failure into the result slot so the main thread can // surface it as `error.LintCheckFailed` after `group.await`. const msg = std.fmt.allocPrint(run_arena, "error checking doc {s}: {s}\n", .{ doc.path, @errorName(err) }) catch "error checking doc (out of memory)\n"; @@ -325,6 +375,7 @@ fn checkOneDocInner( doc: DocGroup, detected_vcs: vcs.VcsKind, repo_identity: ?[]const u8, + mapped_repos: *const repo_map.RepoMap, out: *?DocCheckResult, ) !void { var task_scratch = std.heap.ArenaAllocator.init(run_arena); @@ -359,12 +410,25 @@ fn checkOneDocInner( const parsed = target.parse(binding.target); const origin = binding.fieldValue("origin"); + var effective_root = root_path; const outcome = blk: { if (origin) |o| { const is_local = if (repo_identity) |ri| std.mem.eql(u8, o, ri) else false; - if (!is_local) break :blk AnchorOutcome{ .result = .skip, .reason_code = .origin_mismatch }; + if (!is_local) { + // A foreign origin is checkable when `--repo` maps it to a + // local sibling checkout that exists on disk. Fingerprints + // (and phase-2 blame) then compute against that checkout. + const mapped_root = mapped_repos.resolve(o) orelse + break :blk AnchorOutcome{ .result = .skip, .reason_code = .origin_mismatch }; + if (!pathExists(io, mapped_root)) { + // Mapped but absent (e.g. a teammate without the + // checkout): skip instead of reporting stale. + break :blk AnchorOutcome{ .result = .skip, .reason_code = .mapped_repo_missing }; + } + effective_root = mapped_root; + } } - break :blk checkBinding(task_ctx, root_path, binding, parsed, &file_cache) catch |err| { + break :blk checkBinding(task_ctx, effective_root, binding, parsed, &file_cache) catch |err| { const msg = try std.fmt.allocPrint(run_arena, "error checking {s}: {s}\n", .{ binding.target, @errorName(err) }); doc_result.error_message = msg; out.* = doc_result; @@ -372,7 +436,7 @@ fn checkOneDocInner( }; }; - try doc_result.anchors.append(run_arena, jsonAnchorFromOutcome(binding.target, binding.fieldValue("sig"), parsed, outcome)); + try doc_result.anchors.append(run_arena, jsonAnchorFromOutcome(binding.target, binding.fieldValue("sig"), parsed, outcome, effective_root)); switch (outcome.result) { .fresh => fresh_count += 1, .stale => stale_count += 1, @@ -398,7 +462,7 @@ fn checkOneDocInner( if (!std.mem.eql(u8, reason.code, @tagName(ReasonCode.changed_after_baseline))) continue; const future = io.async(vcs.getLatestBlameInfo, .{ - io, run_arena, run_arena, root_path, row.wire.path, detected_vcs, + io, run_arena, run_arena, row.effective_root, row.wire.path, detected_vcs, }); blame_jobs.append(run_arena, .{ .row_index = i, .future = future }) catch { // If we can't record the future, at least drain it so it doesn't leak. @@ -744,10 +808,11 @@ fn driftReason(code: ReasonCode) ?drift_check_v1.Reason { return .{ .code = @tagName(code), .message = reasonMessage(code) }; } -fn jsonAnchorFromOutcome(raw_target: []const u8, sig: ?[]const u8, parsed: target.ParsedTarget, outcome: AnchorOutcome) JsonAnchorRow { +fn jsonAnchorFromOutcome(raw_target: []const u8, sig: ?[]const u8, parsed: target.ParsedTarget, outcome: AnchorOutcome, effective_root: []const u8) JsonAnchorRow { const blame_storage = outcome.blame; return .{ .blame_storage = blame_storage, + .effective_root = effective_root, .wire = .{ .identity = parsed.identity, .raw = raw_target, @@ -815,7 +880,13 @@ fn textEmitAnchor(w: *std.Io.Writer, origin: ?[]const u8, row: drift_check_v1.An } if (std.mem.eql(u8, row.result, "skip")) { - if (origin) |o| { + const is_origin_mismatch = if (row.reason) |reason| + std.mem.eql(u8, reason.code, @tagName(ReasonCode.origin_mismatch)) + else + true; + if (!is_origin_mismatch) { + w.print(" SKIP {s} ({s})\n", .{ row.raw, row.reason.?.message }) catch {}; + } else if (origin) |o| { w.print(" SKIP {s} (origin: {s})\n", .{ row.raw, o }) catch {}; } else { w.print(" SKIP {s}\n", .{row.raw}) catch {}; diff --git a/src/lockfile.zig b/src/lockfile.zig index fe202d1..ac2c243 100644 --- a/src/lockfile.zig +++ b/src/lockfile.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const toml = @import("toml.zig"); pub const MetadataField = struct { key: []const u8, @@ -141,11 +142,9 @@ pub fn parseInto(allocator: std.mem.Allocator, content: []const u8, bindings: *s // Compatibility reader for pre-TOML lockfiles. All writes use V1 TOML, so // old files are upgraded on the next mutation. - var lines = std.mem.splitScalar(u8, content, '\n'); + var lines: toml.LineIterator = .init(content); while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (trimmed.len == 0 or trimmed[0] == '#') continue; - try bindings.append(allocator, try parseLine(allocator, trimmed)); + try bindings.append(allocator, try parseLine(allocator, line)); } } @@ -199,31 +198,11 @@ fn lessThanMetadataByKey(_: void, a: MetadataField, b: MetadataField) bool { return std.mem.order(u8, a.key, b.key) == .lt; } -fn isValidTomlBareKey(key: []const u8) bool { - if (key.len == 0) return false; - for (key) |c| switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '_', '-' => {}, - else => return false, - }; - return true; -} - fn writeTomlString(writer: *std.Io.Writer, value: []const u8) !void { - if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidMetadataField; - - try writer.writeByte('"'); - for (value) |c| switch (c) { - '\x08' => try writer.writeAll("\\b"), - '\t' => try writer.writeAll("\\t"), - '\n' => try writer.writeAll("\\n"), - '\x0c' => try writer.writeAll("\\f"), - '\r' => try writer.writeAll("\\r"), - '"' => try writer.writeAll("\\\""), - '\\' => try writer.writeAll("\\\\"), - 0x00...0x07, 0x0b, 0x0e...0x1f, 0x7f => return error.InvalidMetadataField, - else => try writer.writeByte(c), + toml.writeString(writer, value) catch |err| switch (err) { + error.InvalidString => return error.InvalidMetadataField, + else => |e| return e, }; - try writer.writeByte('"'); } fn renderTomlBindingToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, binding: Binding) !void { @@ -239,7 +218,7 @@ fn renderTomlBindingToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, defer scratch.free(sorted); std.mem.sort(MetadataField, sorted, {}, lessThanMetadataByKey); for (sorted) |field| { - if (!isValidTomlBareKey(field.key) or std.mem.eql(u8, field.key, "doc") or std.mem.eql(u8, field.key, "target") or std.mem.eql(u8, field.key, "version")) return error.InvalidMetadataField; + if (!toml.isValidBareKey(field.key) or std.mem.eql(u8, field.key, "doc") or std.mem.eql(u8, field.key, "target") or std.mem.eql(u8, field.key, "version")) return error.InvalidMetadataField; try writer.print("{s} = ", .{field.key}); try writeTomlString(writer, field.value); try writer.writeByte('\n'); @@ -293,18 +272,16 @@ pub fn writeFile(io: std.Io, lockfile: *const Lockfile, scratch: std.mem.Allocat } fn containsTomlSyntax(content: []const u8) bool { - var lines = std.mem.splitScalar(u8, content, '\n'); + var lines: toml.LineIterator = .init(content); while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (std.mem.eql(u8, trimmed, "[[bindings]]") or isTopLevelVersionLine(trimmed)) return true; + if (std.mem.eql(u8, line, "[[bindings]]") or isTopLevelVersionLine(line)) return true; } return false; } fn isTopLevelVersionLine(line: []const u8) bool { - const equals = std.mem.findScalar(u8, line, '=') orelse return false; - const key = std.mem.trim(u8, line[0..equals], " \t"); - return std.mem.eql(u8, key, "version"); + const pair = toml.splitKeyValue(line) orelse return false; + return std.mem.eql(u8, pair.key, "version"); } const PendingBinding = struct { @@ -337,12 +314,9 @@ fn parseTomlInto(allocator: std.mem.Allocator, content: []const u8, bindings: *s errdefer if (pending) |*p| p.deinit(allocator); var version_seen = false; - var lines = std.mem.splitScalar(u8, content, '\n'); + var lines: toml.LineIterator = .init(content); while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r"); - if (trimmed.len == 0 or trimmed[0] == '#') continue; - - if (std.mem.eql(u8, trimmed, "[[bindings]]")) { + if (std.mem.eql(u8, line, "[[bindings]]")) { if (!version_seen) return error.InvalidBindingLine; if (pending) |*p| try bindings.append(allocator, try p.finish()); pending = .{}; @@ -350,15 +324,15 @@ fn parseTomlInto(allocator: std.mem.Allocator, content: []const u8, bindings: *s } if (pending == null) { - if (isTopLevelVersionLine(trimmed)) { + if (isTopLevelVersionLine(line)) { if (version_seen) return error.InvalidBindingLine; - try parseLockfileVersion(trimmed); + try parseLockfileVersion(line); version_seen = true; continue; } return error.InvalidBindingLine; } - try parseTomlFieldInto(allocator, trimmed, &pending.?); + try parseTomlFieldInto(allocator, line, &pending.?); } if (!version_seen) return error.InvalidBindingLine; @@ -366,19 +340,17 @@ fn parseTomlInto(allocator: std.mem.Allocator, content: []const u8, bindings: *s } fn parseLockfileVersion(line: []const u8) !void { - const equals = std.mem.findScalar(u8, line, '=') orelse return error.InvalidBindingLine; - const raw_value = std.mem.trim(u8, line[equals + 1 ..], " \t"); - const version = std.fmt.parseUnsigned(u32, raw_value, 10) catch return error.InvalidBindingLine; + const pair = toml.splitKeyValue(line) orelse return error.InvalidBindingLine; + const version = std.fmt.parseUnsigned(u32, pair.raw_value, 10) catch return error.InvalidBindingLine; if (version != 1) return error.UnsupportedLockfileVersion; } fn parseTomlFieldInto(allocator: std.mem.Allocator, line: []const u8, pending: *PendingBinding) !void { - const equals = std.mem.findScalar(u8, line, '=') orelse return error.InvalidMetadataField; - const key = std.mem.trim(u8, line[0..equals], " \t"); - const raw_value = std.mem.trim(u8, line[equals + 1 ..], " \t"); - if (!isValidTomlBareKey(key) or std.mem.eql(u8, key, "version")) return error.InvalidMetadataField; + const pair = toml.splitKeyValue(line) orelse return error.InvalidMetadataField; + const key = pair.key; + if (!toml.isValidBareKey(key) or std.mem.eql(u8, key, "version")) return error.InvalidMetadataField; - const value = try parseTomlString(allocator, raw_value); + const value = try parseTomlString(allocator, pair.raw_value); errdefer allocator.free(value); if (std.mem.eql(u8, key, "doc")) { @@ -401,37 +373,10 @@ fn parseTomlFieldInto(allocator: std.mem.Allocator, line: []const u8, pending: * } fn parseTomlString(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { - if (raw.len < 2 or raw[0] != '"' or raw[raw.len - 1] != '"') return error.InvalidMetadataField; - - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(allocator); - - var i: usize = 1; - while (i < raw.len - 1) : (i += 1) { - const c = raw[i]; - if (c == '\\') { - i += 1; - if (i >= raw.len - 1) return error.InvalidMetadataField; - switch (raw[i]) { - 'b' => try out.append(allocator, '\x08'), - 't' => try out.append(allocator, '\t'), - 'n' => try out.append(allocator, '\n'), - 'f' => try out.append(allocator, '\x0c'), - 'r' => try out.append(allocator, '\r'), - '"' => try out.append(allocator, '"'), - '\\' => try out.append(allocator, '\\'), - else => return error.InvalidMetadataField, - } - } else { - if (c == '"' or c == '\n' or c == '\r' or c < 0x20 or c == 0x7f) return error.InvalidMetadataField; - try out.append(allocator, c); - } - } - - const value = try out.toOwnedSlice(allocator); - errdefer allocator.free(value); - if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidMetadataField; - return value; + return toml.parseString(allocator, raw) catch |err| switch (err) { + error.InvalidString => error.InvalidMetadataField, + else => |e| e, + }; } fn parseLine(allocator: std.mem.Allocator, line: []const u8) !Binding { diff --git a/src/main.zig b/src/main.zig index 273dfb2..c7f9bdb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,6 +3,7 @@ const build_options = @import("build_options"); const clap = @import("clap"); const CommandContext = @import("context.zig").CommandContext; +const repo_map = @import("repo_map.zig"); const lint = @import("commands/lint.zig"); const status = @import("commands/status.zig"); const link = @import("commands/link.zig"); @@ -52,9 +53,36 @@ const check_params = clap.parseParamsComptime( \\--format \\--changed \\--silent + \\--repo ... \\ ); +const check_usage = "usage: drift check [--format text|json] [--changed ] [--silent] [--repo =]...\n"; + +/// Parses each repeated `--repo =` value into a repo_map.Entry, +/// exiting with a usage error on the first malformed spec. +fn parseRepoSpecs( + run_alloc: std.mem.Allocator, + specs: []const []const u8, + stderr_w: *std.Io.Writer, +) []const repo_map.Entry { + const entries = run_alloc.alloc(repo_map.Entry, specs.len) catch { + fatal(stderr_w, "error: out of memory\n", .{}); + }; + for (specs, entries) |spec, *entry| { + entry.* = repo_map.parseSpec(spec) catch |err| { + const detail: []const u8 = switch (err) { + error.MissingSeparator => "missing '=' separator", + error.EmptyOrigin => "empty origin", + error.EmptyPath => "empty path", + error.InvalidOrigin => "origin must look like github:owner/repo", + }; + fatal(stderr_w, "error: invalid --repo spec '{s}': {s} (expected =, e.g. github:acme/server=../server)\n", .{ spec, detail }); + }; + } + return entries; +} + fn parseFormat(maybe_value: ?[]const u8, stderr_w: *std.Io.Writer) lint.Format { const value = maybe_value orelse return .text; if (std.mem.eql(u8, value, "json")) return .json; @@ -161,7 +189,7 @@ pub fn main(init: std.process.Init) !void { var sub = parseExOrReport(&check_params, clap.parsers.default, gpa, &diag, io, &stderr_w.interface, &iter, clap_parse_all); defer sub.deinit(); if (iter.next()) |_| { - fatal(&stderr_w.interface, "usage: drift check [--format text|json] [--changed ] [--silent]\n", .{}); + fatal(&stderr_w.interface, check_usage, .{}); } const format = parseFormat(sub.args.format, &stderr_w.interface); const silent = sub.args.silent != 0; @@ -170,7 +198,8 @@ pub fn main(init: std.process.Init) !void { var scratch_arena = std.heap.ArenaAllocator.init(gpa); defer scratch_arena.deinit(); const ctx = CommandContext{ .io = io, .run_arena = run_arena.allocator(), .scratch_arena = &scratch_arena }; - const result = lint.compute(ctx, &stderr_w.interface, sub.args.changed) catch |err| switch (err) { + const repo_entries = parseRepoSpecs(ctx.run_arena, sub.args.repo, &stderr_w.interface); + const result = lint.compute(ctx, &stderr_w.interface, sub.args.changed, repo_entries) catch |err| switch (err) { error.LintCheckFailed => { stdout_w.interface.flush() catch {}; stderr_w.interface.flush() catch {}; @@ -306,7 +335,7 @@ fn printUsage(w: *std.Io.Writer) void { \\Usage: drift [options] \\ \\Commands: - \\ check Check all docs for staleness [--format text|json] [--changed ] [--silent] + \\ check Check all docs for staleness [--format text|json] [--changed ] [--silent] [--repo =]... \\ status Show all docs and their anchors [--format text|json] \\ link Add anchors to a doc [--doc-is-still-accurate] \\ unlink Remove anchors from a doc diff --git a/src/payload/drift_check_schema_gen.zig b/src/payload/drift_check_schema_gen.zig index 9380c70..16ef026 100644 --- a/src/payload/drift_check_schema_gen.zig +++ b/src/payload/drift_check_schema_gen.zig @@ -21,7 +21,7 @@ fn buildDocument(a: std.mem.Allocator) !json.Value { try root.put(a, "$schema", .{ .string = "https://json-schema.org/draft/2020-12/schema" }); try root.put(a, "$id", .{ .string = "drift.check.v1.json" }); try root.put(a, "title", .{ .string = "drift check / lint JSON output" }); - try root.put(a, "description", .{ .string = + try root.put(a, "description", .{ .string = \\Wire format emitted by `drift check --format json` and `drift lint --format json`. Match top-level `schema_version` to "drift.check.v1". Unknown properties may appear in future drift versions; consumers should ignore them. See docs/check-json-schema.md. }); try root.put(a, "type", .{ .string = "object" }); @@ -134,7 +134,7 @@ fn defSummary(a: std.mem.Allocator) !json.ObjectMap { var result = json.ObjectMap.empty; try result.put(a, "type", .{ .string = "string" }); try result.put(a, "enum", try stringArray(a, &.{ "pass", "fail" })); - try result.put(a, "description", .{ .string = + try result.put(a, "description", .{ .string = \\fail iff any anchor is stale or any link is broken; mirrors process exit code (0 pass, 1 fail). }); try props.put(a, "result", .{ .object = result }); @@ -142,7 +142,7 @@ fn defSummary(a: std.mem.Allocator) !json.ObjectMap { var vs = json.ObjectMap.empty; try vs.put(a, "type", .{ .string = "string" }); try vs.put(a, "enum", try stringArray(a, &.{ "none", "partial", "full" })); - try vs.put(a, "description", .{ .string = + try vs.put(a, "description", .{ .string = \\Coverage of verification: none = all docs skipped; partial = mix; full = nothing skipped (including zero docs). }); try props.put(a, "verification_state", .{ .object = vs }); diff --git a/src/repo_map.zig b/src/repo_map.zig new file mode 100644 index 0000000..0852522 --- /dev/null +++ b/src/repo_map.zig @@ -0,0 +1,260 @@ +//! Mapping from foreign binding origins (`github:owner/repo`) to local +//! sibling checkouts, populated from repeated `--repo =` flags +//! and `[[repos]]` entries in `.drift/config.toml`. Lets `drift check` verify +//! anchors whose origin does not match the current repo identity by computing +//! fingerprints against the mapped checkout. + +const std = @import("std"); + +pub const Entry = struct { + origin: []const u8, + path: []const u8, +}; + +pub const ValidateError = error{ + EmptyOrigin, + EmptyPath, + InvalidOrigin, +}; + +pub const ParseSpecError = ValidateError || error{MissingSeparator}; + +/// Shared shape validation for repo mappings, regardless of source (`--repo` +/// flag specs and `[[repos]]` config entries). The origin must already be in +/// the normalized `github:owner/repo` shape produced by +/// `vcs.normalizeGitHubUrl` / `vcs.getRepoIdentity`. +pub fn validate(entry: Entry) ValidateError!void { + if (entry.origin.len == 0) return error.EmptyOrigin; + if (entry.path.len == 0) return error.EmptyPath; + if (!isNormalizedOrigin(entry.origin)) return error.InvalidOrigin; +} + +/// Parses a `--repo` spec of the form `github:owner/repo=../server`. The +/// first '=' is the separator — origins never contain '=', paths may. +pub fn parseSpec(spec: []const u8) ParseSpecError!Entry { + const separator = std.mem.findScalar(u8, spec, '=') orelse return error.MissingSeparator; + const entry: Entry = .{ + .origin = spec[0..separator], + .path = spec[separator + 1 ..], + }; + try validate(entry); + return entry; +} + +/// True when `origin` matches the normalized repo identity shape +/// (`github:owner/repo`): non-empty owner and repo, exactly one '/', no +/// `.git` suffix and no trailing slash — mirroring `vcs.normalizeGitHubUrl`. +fn isNormalizedOrigin(origin: []const u8) bool { + const prefix = "github:"; + if (!std.mem.startsWith(u8, origin, prefix)) return false; + + const rest = origin[prefix.len..]; + const slash = std.mem.findScalar(u8, rest, '/') orelse return false; + const owner = rest[0..slash]; + const repo = rest[slash + 1 ..]; + + if (owner.len == 0 or repo.len == 0) return false; + if (std.mem.findScalar(u8, repo, '/') != null) return false; + if (std.mem.endsWith(u8, repo, ".git")) return false; + return true; +} + +/// Immutable origin → local-checkout map, built once per run and shared +/// (read-only) across the per-doc check tasks. +pub const RepoMap = struct { + entries: []const Entry, + + pub const empty: RepoMap = .{ .entries = &.{} }; + + /// Copies `source_entries` into `arena_allocator`-backed storage, + /// normalizing each path to absolute. Relative paths resolve against + /// `base_path` (the caller's cwd for flag-provided specs). + pub fn build( + arena_allocator: std.mem.Allocator, + source_entries: []const Entry, + base_path: []const u8, + ) error{OutOfMemory}!RepoMap { + const entries = try arena_allocator.alloc(Entry, source_entries.len); + for (source_entries, entries) |source, *entry| { + entry.* = try buildEntry(arena_allocator, source, base_path); + } + return .{ .entries = entries }; + } + + /// Combines flag entries and config entries into one map with CLI-wins + /// precedence: a config entry whose origin is already mapped by a flag is + /// dropped. Flag paths resolve against `flag_base_path` (the caller's + /// cwd); config paths resolve against `config_base_path` (the lockfile + /// root), so config mappings are checkout-location-independent. + pub fn buildMerged( + arena_allocator: std.mem.Allocator, + flag_entries: []const Entry, + flag_base_path: []const u8, + config_entries: []const Entry, + config_base_path: []const u8, + ) error{OutOfMemory}!RepoMap { + var entries = try std.ArrayList(Entry).initCapacity(arena_allocator, flag_entries.len + config_entries.len); + for (flag_entries) |source| { + entries.appendAssumeCapacity(try buildEntry(arena_allocator, source, flag_base_path)); + } + for (config_entries) |source| { + if (buildMergedContainsOrigin(entries.items, source.origin)) continue; + entries.appendAssumeCapacity(try buildEntry(arena_allocator, source, config_base_path)); + } + return .{ .entries = entries.items }; + } + + fn buildMergedContainsOrigin(entries: []const Entry, origin: []const u8) bool { + for (entries) |entry| { + if (std.mem.eql(u8, entry.origin, origin)) return true; + } + return false; + } + + fn buildEntry( + arena_allocator: std.mem.Allocator, + source: Entry, + base_path: []const u8, + ) error{OutOfMemory}!Entry { + return .{ + .origin = try arena_allocator.dupe(u8, source.origin), + .path = try std.Io.Dir.path.resolve(arena_allocator, &.{ base_path, source.path }), + }; + } + + /// Returns the mapped absolute checkout path for `origin`, or null when + /// the origin is unmapped. Linear scan — maps hold a handful of entries. + pub fn resolve(self: *const RepoMap, origin: []const u8) ?[]const u8 { + for (self.entries) |entry| { + if (std.mem.eql(u8, entry.origin, origin)) return entry.path; + } + return null; + } +}; + +// --- unit tests --- + +test "parseSpec splits at the first equals" { + const entry = try parseSpec("github:acme/server=../server"); + try std.testing.expectEqualStrings("github:acme/server", entry.origin); + try std.testing.expectEqualStrings("../server", entry.path); +} + +test "parseSpec keeps equals signs inside the path" { + const entry = try parseSpec("github:acme/server=/tmp/check=outs/server"); + try std.testing.expectEqualStrings("github:acme/server", entry.origin); + try std.testing.expectEqualStrings("/tmp/check=outs/server", entry.path); +} + +test "parseSpec rejects specs without a separator" { + try std.testing.expectError(error.MissingSeparator, parseSpec("github:acme/server")); + try std.testing.expectError(error.MissingSeparator, parseSpec("")); +} + +test "parseSpec rejects empty origin and empty path" { + try std.testing.expectError(error.EmptyOrigin, parseSpec("=../server")); + try std.testing.expectError(error.EmptyPath, parseSpec("github:acme/server=")); +} + +test "parseSpec rejects origins outside the normalized shape" { + const invalid_origins = [_][]const u8{ + "acme/server=../server", // missing github: prefix + "github:acme=../server", // no owner/repo split + "github:/server=../server", // empty owner + "github:acme/=../server", // empty repo + "github:acme/server/extra=../server", // extra path segment + "github:acme/server.git=../server", // .git suffix never survives normalization + "gitlab:acme/server=../server", // unsupported forge + }; + for (invalid_origins) |spec| { + try std.testing.expectError(error.InvalidOrigin, parseSpec(spec)); + } +} + +test "RepoMap.build normalizes relative paths against the base directory" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const source_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "../server" }, + .{ .origin = "github:acme/client", .path = "/checkouts/client" }, + }; + const map = try RepoMap.build(arena.allocator(), &source_entries, "/work/drift"); + + try std.testing.expectEqualStrings("/work/server", map.resolve("github:acme/server").?); + try std.testing.expectEqualStrings("/checkouts/client", map.resolve("github:acme/client").?); +} + +test "RepoMap.buildMerged prefers flag entries over config entries for the same origin" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const flag_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "../server" }, + }; + const config_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "checkouts/wrong-server" }, + .{ .origin = "github:acme/client", .path = "../client" }, + }; + const map = try RepoMap.buildMerged(arena.allocator(), &flag_entries, "/cwd/drift", &config_entries, "/root/drift"); + + try std.testing.expectEqual(@as(usize, 2), map.entries.len); + try std.testing.expectEqualStrings("/cwd/server", map.resolve("github:acme/server").?); + try std.testing.expectEqualStrings("/root/client", map.resolve("github:acme/client").?); +} + +test "RepoMap.buildMerged resolves config paths against the config base, flag paths against the flag base" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const flag_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "../server" }, + }; + const config_entries = [_]Entry{ + .{ .origin = "github:acme/client", .path = "../client" }, + }; + // The caller's cwd is a subdirectory of the lockfile root: flag paths + // follow the cwd, config paths stay anchored to the root. + const map = try RepoMap.buildMerged(arena.allocator(), &flag_entries, "/work/drift/sub", &config_entries, "/work/drift"); + + try std.testing.expectEqualStrings("/work/drift/server", map.resolve("github:acme/server").?); + try std.testing.expectEqualStrings("/work/client", map.resolve("github:acme/client").?); +} + +test "RepoMap.buildMerged with no flag entries keeps all config entries" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const config_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "/checkouts/server" }, + }; + const map = try RepoMap.buildMerged(arena.allocator(), &.{}, "/cwd", &config_entries, "/root"); + + try std.testing.expectEqual(@as(usize, 1), map.entries.len); + try std.testing.expectEqualStrings("/checkouts/server", map.resolve("github:acme/server").?); +} + +test "validate accepts normalized entries and rejects malformed ones" { + try validate(.{ .origin = "github:acme/server", .path = "../server" }); + try std.testing.expectError(error.EmptyOrigin, validate(.{ .origin = "", .path = "../server" })); + try std.testing.expectError(error.EmptyPath, validate(.{ .origin = "github:acme/server", .path = "" })); + try std.testing.expectError(error.InvalidOrigin, validate(.{ .origin = "gitlab:acme/server", .path = "../server" })); +} + +test "RepoMap.resolve returns null for unmapped origins" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + const source_entries = [_]Entry{ + .{ .origin = "github:acme/server", .path = "../server" }, + }; + const map = try RepoMap.build(arena.allocator(), &source_entries, "/work/drift"); + + try std.testing.expect(map.resolve("github:acme/other") == null); + try std.testing.expect(RepoMap.empty.resolve("github:acme/server") == null); +} diff --git a/src/toml.zig b/src/toml.zig new file mode 100644 index 0000000..26604c0 --- /dev/null +++ b/src/toml.zig @@ -0,0 +1,211 @@ +//! Shared helpers for drift's TOML subset (docs/DECISIONS.md §11). +//! +//! The dialect is intentionally small: bare keys (`[A-Za-z0-9_-]+`), +//! single-line basic strings with the common escapes (`\b \t \n \f \r \" \\`), +//! `[[array-of-tables]]` headers, full-line `#` comments, and blank lines. +//! Inline comments, dotted keys, multiline strings, and other general TOML +//! features are outside the subset. Consumers: src/lockfile.zig, src/Config.zig. + +const std = @import("std"); + +pub const StringError = error{InvalidString}; + +/// Yields trimmed, non-blank, non-comment lines while tracking 1-based line +/// numbers for parser diagnostics. +pub const LineIterator = struct { + split: std.mem.SplitIterator(u8, .scalar), + line_number: usize, + + pub fn init(content: []const u8) LineIterator { + return .{ + .split = std.mem.splitScalar(u8, content, '\n'), + .line_number = 0, + }; + } + + /// Returns the next line trimmed of spaces, tabs, and carriage returns, + /// skipping blank lines and full-line `#` comments. `line_number` reports + /// the position of the returned line in the original content. + pub fn next(self: *LineIterator) ?[]const u8 { + while (self.split.next()) |line| { + self.line_number += 1; + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + return trimmed; + } + return null; + } +}; + +pub const KeyValue = struct { + key: []const u8, + raw_value: []const u8, +}; + +/// Splits `key = value` at the first '=', trimming spaces and tabs around both +/// parts. Returns null when the line contains no '='. Does not validate the +/// key or decode the value. +pub fn splitKeyValue(line: []const u8) ?KeyValue { + const equals = std.mem.findScalar(u8, line, '=') orelse return null; + return .{ + .key = std.mem.trim(u8, line[0..equals], " \t"), + .raw_value = std.mem.trim(u8, line[equals + 1 ..], " \t"), + }; +} + +/// Returns the table name when `line` is an `[[name]]` array-of-tables header +/// with a valid bare-key name; null otherwise. Whitespace inside the brackets +/// is outside the subset. +pub fn arrayTableName(line: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, line, "[[") or !std.mem.endsWith(u8, line, "]]")) return null; + if (line.len < 4) return null; + const name = line[2 .. line.len - 2]; + if (!isValidBareKey(name)) return null; + return name; +} + +/// Bare keys are `[A-Za-z0-9_-]+`. +pub fn isValidBareKey(key: []const u8) bool { + if (key.len == 0) return false; + for (key) |c| switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '_', '-' => {}, + else => return false, + }; + return true; +} + +/// Decodes a single-line TOML basic string (`"..."`), resolving the supported +/// escapes. Rejects control characters, raw newlines, invalid UTF-8, and +/// escapes outside the subset. Caller owns the returned slice. +pub fn parseString(allocator: std.mem.Allocator, raw: []const u8) (StringError || std.mem.Allocator.Error)![]const u8 { + if (raw.len < 2 or raw[0] != '"' or raw[raw.len - 1] != '"') return error.InvalidString; + + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + + var i: usize = 1; + while (i < raw.len - 1) : (i += 1) { + const c = raw[i]; + if (c == '\\') { + i += 1; + if (i >= raw.len - 1) return error.InvalidString; + switch (raw[i]) { + 'b' => try out.append(allocator, '\x08'), + 't' => try out.append(allocator, '\t'), + 'n' => try out.append(allocator, '\n'), + 'f' => try out.append(allocator, '\x0c'), + 'r' => try out.append(allocator, '\r'), + '"' => try out.append(allocator, '"'), + '\\' => try out.append(allocator, '\\'), + else => return error.InvalidString, + } + } else { + if (c == '"' or c == '\n' or c == '\r' or c < 0x20 or c == 0x7f) return error.InvalidString; + try out.append(allocator, c); + } + } + + const value = try out.toOwnedSlice(allocator); + errdefer allocator.free(value); + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidString; + return value; +} + +/// Encodes `value` as a single-line TOML basic string, escaping the supported +/// control characters. Rejects invalid UTF-8 and control characters that have +/// no escape in the subset. +pub fn writeString(writer: *std.Io.Writer, value: []const u8) (StringError || std.Io.Writer.Error)!void { + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidString; + + try writer.writeByte('"'); + for (value) |c| switch (c) { + '\x08' => try writer.writeAll("\\b"), + '\t' => try writer.writeAll("\\t"), + '\n' => try writer.writeAll("\\n"), + '\x0c' => try writer.writeAll("\\f"), + '\r' => try writer.writeAll("\\r"), + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + 0x00...0x07, 0x0b, 0x0e...0x1f, 0x7f => return error.InvalidString, + else => try writer.writeByte(c), + }; + try writer.writeByte('"'); +} + +test "LineIterator skips blanks and comments while tracking line numbers" { + var lines: LineIterator = .init("# header\n\nversion = 1\r\n # indented comment\n[[bindings]]\n"); + + try std.testing.expectEqualStrings("version = 1", lines.next().?); + try std.testing.expectEqual(@as(usize, 3), lines.line_number); + try std.testing.expectEqualStrings("[[bindings]]", lines.next().?); + try std.testing.expectEqual(@as(usize, 5), lines.line_number); + try std.testing.expectEqual(@as(?[]const u8, null), lines.next()); +} + +test "LineIterator yields nothing for empty or comment-only content" { + var empty: LineIterator = .init(""); + try std.testing.expectEqual(@as(?[]const u8, null), empty.next()); + + var comments: LineIterator = .init("# a\n \n\t\n# b\n"); + try std.testing.expectEqual(@as(?[]const u8, null), comments.next()); +} + +test "splitKeyValue splits at first equals and trims" { + const pair = splitKeyValue("key = \"a = b\"").?; + try std.testing.expectEqualStrings("key", pair.key); + try std.testing.expectEqualStrings("\"a = b\"", pair.raw_value); + + try std.testing.expectEqual(@as(?KeyValue, null), splitKeyValue("[[bindings]]")); +} + +test "arrayTableName recognizes valid headers only" { + try std.testing.expectEqualStrings("bindings", arrayTableName("[[bindings]]").?); + try std.testing.expectEqualStrings("repos", arrayTableName("[[repos]]").?); + try std.testing.expectEqual(@as(?[]const u8, null), arrayTableName("[bindings]")); + try std.testing.expectEqual(@as(?[]const u8, null), arrayTableName("[[]]")); + try std.testing.expectEqual(@as(?[]const u8, null), arrayTableName("[[ bindings ]]")); + try std.testing.expectEqual(@as(?[]const u8, null), arrayTableName("[[a.b]]")); +} + +test "isValidBareKey accepts the bare-key alphabet only" { + try std.testing.expect(isValidBareKey("sig")); + try std.testing.expect(isValidBareKey("a-b_C9")); + try std.testing.expect(!isValidBareKey("")); + try std.testing.expect(!isValidBareKey("a.b")); + try std.testing.expect(!isValidBareKey("a b")); +} + +test "parseString and writeString round-trip every supported escape" { + const allocator = std.testing.allocator; + const original = "quote\" slash\\ tab\t line\n carriage\r backspace\x08 formfeed\x0c plain"; + + var encoded: std.Io.Writer.Allocating = .init(allocator); + defer encoded.deinit(); + try writeString(&encoded.writer, original); + + const decoded = try parseString(allocator, encoded.written()); + defer allocator.free(decoded); + try std.testing.expectEqualStrings(original, decoded); +} + +test "parseString rejects strings outside the subset" { + const allocator = std.testing.allocator; + const cases = [_][]const u8{ + "unquoted", + "\"unterminated", + "\"trailing\\\"", + "\"bad\\u1234\"", + "\"raw\nnewline\"", + "\"\xff\"", + "\"", + }; + for (cases) |raw| { + try std.testing.expectError(error.InvalidString, parseString(allocator, raw)); + } +} + +test "writeString rejects unescapable control characters and invalid UTF-8" { + var discard: std.Io.Writer.Discarding = .init(&.{}); + try std.testing.expectError(error.InvalidString, writeString(&discard.writer, "nul\x00")); + try std.testing.expectError(error.InvalidString, writeString(&discard.writer, "bad\xff")); +} diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 5080bcd..55dd9b4 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -823,6 +823,266 @@ test "check from root skips docs in nested drift.lock scope" { try helpers.expectNotContains(result.stdout, "BROKEN"); } +const foreign_origin = "github:acme/server"; + +/// Two temp git repos for `--repo` tests: `server` holds the real target file, +/// `local` holds a doc bound to that target with a foreign `origin` field and +/// the sig recorded from the server checkout. +const ForeignRepoPair = struct { + server: helpers.TempRepo, + local: helpers.TempRepo, + + fn init(allocator: std.mem.Allocator) !ForeignRepoPair { + var server = try helpers.TempRepo.init(allocator); + errdefer server.cleanup(); + + try server.writeFile("src/server.ts", "export function handle() { return 1; }\n"); + try server.writeFile("docs/doc.md", "# Server Doc\n"); + try server.commit("add server source and doc"); + + // Link in the server repo to obtain the real sig for the target. + const link_result = try server.runDrift(&.{ "link", "docs/doc.md", "src/server.ts" }); + defer link_result.deinit(allocator); + try helpers.expectExitCode(link_result.term, 0); + + const server_lock = try server.readFile("drift.lock"); + defer allocator.free(server_lock); + const sig = try extractSigValue(server_lock); + + var local = try helpers.TempRepo.init(allocator); + errdefer local.cleanup(); + + try local.writeFile("docs/doc.md", "# Local Doc\n"); + const local_lock = try std.fmt.allocPrint( + allocator, + "version = 1\n\n[[bindings]]\n" ++ + "doc = \"docs/doc.md\"\n" ++ + "target = \"src/server.ts\"\n" ++ + "origin = \"" ++ foreign_origin ++ "\"\n" ++ + "sig = \"{s}\"\n", + .{sig}, + ); + defer allocator.free(local_lock); + try local.writeFile("drift.lock", local_lock); + try local.commit("add foreign-origin binding"); + + return .{ .server = server, .local = local }; + } + + fn repoSpec(self: *ForeignRepoPair, allocator: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(allocator, foreign_origin ++ "={s}", .{self.server.abs_path}); + } + + fn cleanup(self: *ForeignRepoPair) void { + self.local.cleanup(); + self.server.cleanup(); + } +}; + +fn extractSigValue(lock_content: []const u8) ![]const u8 { + const marker = "sig = \""; + const start = (std.mem.find(u8, lock_content, marker) orelse return error.TestUnexpectedResult) + marker.len; + const end = std.mem.findScalarPos(u8, lock_content, start, '"') orelse return error.TestUnexpectedResult; + return lock_content[start..end]; +} + +test "check without --repo skips foreign-origin anchors" { + const allocator = std.testing.allocator; + var pair = try ForeignRepoPair.init(allocator); + defer pair.cleanup(); + + const result = try pair.local.runDrift(&.{"check"}); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "SKIP"); + try helpers.expectContains(result.stdout, "origin: " ++ foreign_origin); +} + +test "check --repo resolves foreign-origin anchors against the mapped checkout" { + const allocator = std.testing.allocator; + var pair = try ForeignRepoPair.init(allocator); + defer pair.cleanup(); + + const spec = try pair.repoSpec(allocator); + defer allocator.free(spec); + + // Server checkout unchanged: the anchor is fresh. + { + const result = try pair.local.runDrift(&.{ "check", "--repo", spec }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "ok"); + try helpers.expectNotContains(result.stdout, "SKIP"); + try helpers.expectNotContains(result.stdout, "STALE"); + } + + // Server file drifts: the anchor goes stale, with blame from the mapped repo. + try pair.server.writeFile("src/server.ts", "export function handle() { return 2; }\n"); + try pair.server.commit("change server handler"); + + { + const result = try pair.local.runDrift(&.{ "check", "--repo", spec }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try helpers.expectContains(result.stdout, "STALE"); + try helpers.expectContains(result.stdout, "changed after doc"); + try helpers.expectContains(result.stdout, "change server handler"); + } +} + +test "check --repo reports mapped_repo_missing when the mapped path does not exist" { + const allocator = std.testing.allocator; + var pair = try ForeignRepoPair.init(allocator); + defer pair.cleanup(); + + const spec = try std.fmt.allocPrint( + allocator, + foreign_origin ++ "={s}/no-such-checkout", + .{pair.server.abs_path}, + ); + defer allocator.free(spec); + + const result = try pair.local.runDrift(&.{ "check", "--repo", spec, "--format", "json" }); + defer result.deinit(allocator); + try helpers.expectExitCode(result.term, 0); + try helpers.validateDriftCheckJson(allocator, result.stdout); + + const Payload = struct { + summary: struct { result: []const u8, docs_skipped: u32, anchors_skipped: u32 }, + docs: []const struct { + result: []const u8, + anchors: []const struct { + result: []const u8, + reason: ?struct { code: []const u8 }, + }, + }, + }; + + var parsed = try std.json.parseFromSlice(Payload, allocator, result.stdout, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("pass", parsed.value.summary.result); + try std.testing.expectEqual(@as(u32, 1), parsed.value.summary.docs_skipped); + try std.testing.expectEqual(@as(u32, 1), parsed.value.summary.anchors_skipped); + try std.testing.expectEqualStrings("skip", parsed.value.docs[0].result); + try std.testing.expectEqualStrings("skip", parsed.value.docs[0].anchors[0].result); + try std.testing.expectEqualStrings("mapped_repo_missing", parsed.value.docs[0].anchors[0].reason.?.code); +} + +test "check resolves foreign-origin anchors via [[repos]] in .drift/config.toml" { + const allocator = std.testing.allocator; + var pair = try ForeignRepoPair.init(allocator); + defer pair.cleanup(); + + // Map the origin via config with a path relative to the lockfile root. + const relative_server = try std.Io.Dir.path.relative(allocator, "", null, pair.local.abs_path, pair.server.abs_path); + defer allocator.free(relative_server); + + const config_content = try std.fmt.allocPrint( + allocator, + "version = 1\n\n[[repos]]\norigin = \"" ++ foreign_origin ++ "\"\npath = \"{s}\"\n", + .{relative_server}, + ); + defer allocator.free(config_content); + try pair.local.writeFile(".drift/config.toml", config_content); + + // No --repo flag, and cwd is a subdirectory: the config path must resolve + // against the lockfile root, not the cwd, or the mapping breaks here. + { + const result = try pair.local.runDriftFromSubdir("docs", &.{"check"}); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "ok"); + try helpers.expectNotContains(result.stdout, "SKIP"); + try helpers.expectNotContains(result.stdout, "STALE"); + } + + // Server file drifts: the config-mapped anchor goes stale. + try pair.server.writeFile("src/server.ts", "export function handle() { return 2; }\n"); + try pair.server.commit("change server handler"); + + { + const result = try pair.local.runDrift(&.{"check"}); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try helpers.expectContains(result.stdout, "STALE"); + try helpers.expectContains(result.stdout, "changed after doc"); + } +} + +test "check --repo overrides a [[repos]] config mapping for the same origin" { + const allocator = std.testing.allocator; + var pair = try ForeignRepoPair.init(allocator); + defer pair.cleanup(); + + // Config maps the origin to a wrong (nonexistent) checkout path. + try pair.local.writeFile( + ".drift/config.toml", + "version = 1\n\n[[repos]]\norigin = \"" ++ foreign_origin ++ "\"\npath = \"no-such-checkout\"\n", + ); + + // Without the flag the wrong config mapping is used: skip, repo missing. + { + const result = try pair.local.runDrift(&.{"check"}); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "SKIP"); + try helpers.expectContains(result.stdout, "mapped repo missing"); + } + + // The flag overrides the config entry for the same origin: anchor checks. + const spec = try pair.repoSpec(allocator); + defer allocator.free(spec); + + { + const result = try pair.local.runDrift(&.{ "check", "--repo", spec }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + try helpers.expectContains(result.stdout, "ok"); + try helpers.expectNotContains(result.stdout, "SKIP"); + try helpers.expectNotContains(result.stdout, "STALE"); + } +} + +test "check fails with a config diagnostic when [[repos]] is malformed" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/doc.md", "# Doc\n"); + try repo.writeFile(".drift/config.toml", "version = 1\n\n[[repos]]\npath = \"../server\"\n"); + try repo.commit("add doc and malformed config"); + + const result = try repo.runDrift(&.{"check"}); + defer result.deinit(allocator); + + try std.testing.expect(result.exitCode() != 0); + try helpers.expectContains(result.stderr, ".drift/config.toml:3"); + try helpers.expectContains(result.stderr, "[[repos]]"); +} + +test "check --repo rejects malformed specs with a usage error" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("docs/doc.md", "# Doc\n"); + try repo.commit("add doc"); + + const result = try repo.runDrift(&.{ "check", "--repo", "not-a-spec" }); + defer result.deinit(allocator); + + try std.testing.expect(result.exitCode() != 0); + try helpers.expectContains(result.stderr, "invalid --repo spec"); +} + test "check from nested subdir with its own drift.lock only checks that scope" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator); diff --git a/tests.zig b/tests.zig index 73cb030..966802a 100644 --- a/tests.zig +++ b/tests.zig @@ -1,6 +1,9 @@ test { _ = @import("src/main.zig"); _ = @import("src/lockfile.zig"); + _ = @import("src/toml.zig"); + _ = @import("src/Config.zig"); + _ = @import("src/repo_map.zig"); _ = @import("test/payload_validate_test.zig"); // Integration tests