From fd0614e0757cabf29de7e5984ee3fcd765bd7121 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:39:55 -0400 Subject: [PATCH] feat(plan+manifest): output planning + manifest (Phase 6 S4+S5, #110) - src/plan.zig: walk content_dir for *.md/*.textile/*.cook, strip_source_ext, dst derivation, collision abort, ASSETS_ROOT depth (rk_up_dirs), soul derivation (NONE sentinel), 13-col TSV sorted - src/manifest.zig: --manifest --add dedup (create dirs/touch, grep -Fxq), --verify no-op, --help - src/main.zig: Command.plan/manifest, parseArgs, dispatch, usage Closes #110 --- src/main.zig | 226 +++++++++++++++++++++++++++- src/manifest.zig | 190 ++++++++++++++++++++++++ src/plan.zig | 376 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 src/manifest.zig create mode 100644 src/plan.zig diff --git a/src/main.zig b/src/main.zig index f25bb99..c4efd20 100644 --- a/src/main.zig +++ b/src/main.zig @@ -20,14 +20,19 @@ const oliver = @import("oliver"); const meta = @import("meta.zig"); const wrap = @import("wrap.zig"); const rewrite = @import("rewrite.zig"); +const plan = @import("plan.zig"); +const manifest = @import("manifest.zig"); comptime { // Force analysis so `zig build test` runs `src/meta.zig`, - // `src/wrap.zig`, and `src/rewrite.zig` tests via the CLI test binary + // `src/wrap.zig`, `src/rewrite.zig`, `src/plan.zig`, and + // `src/manifest.zig` tests via the CLI test binary // (like `src/oliver.zig` forces cooklang modules). _ = meta; _ = wrap; _ = rewrite; + _ = plan; + _ = manifest; } // Injected by build.zig: the package version and the source commit SHA @@ -45,6 +50,8 @@ pub const Command = enum { menu, meta, wrap, + plan, + manifest, }; /// The full command-line configuration, decided by `parseArgs`. `command` @@ -98,6 +105,20 @@ pub const RunConfig = struct { wrap_meta_json: ?[]const u8 = null, wrap_assets_root: ?[]const u8 = null, wrap_body: ?[]const u8 = null, + /// Plan command paths (13-col TSV). All required for `plan`. + plan_content_dir: ?[]const u8 = null, + plan_output_dir: ?[]const u8 = null, + plan_template_dir: ?[]const u8 = null, + plan_meta_dir: ?[]const u8 = null, + plan_default_template: ?[]const u8 = null, + plan_oliver_bin: ?[]const u8 = null, + plan_root_dir: ?[]const u8 = null, + plan_dry_run: ?[]const u8 = null, + plan_verbose: ?[]const u8 = null, + /// Manifest command. + manifest_path: ?[]const u8 = null, + manifest_add: ?[]const u8 = null, + manifest_verify: bool = false, }; /// Parses the argument vector (excluding the program name) into a @@ -145,6 +166,30 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf var saw_wrap_meta_json = false; var saw_wrap_assets_root = false; var saw_wrap_body = false; + var plan_content_dir: ?[]const u8 = null; + var plan_output_dir: ?[]const u8 = null; + var plan_template_dir: ?[]const u8 = null; + var plan_meta_dir: ?[]const u8 = null; + var plan_default_template: ?[]const u8 = null; + var plan_oliver_bin: ?[]const u8 = null; + var plan_root_dir: ?[]const u8 = null; + var plan_dry_run: ?[]const u8 = null; + var plan_verbose: ?[]const u8 = null; + var saw_plan_content_dir = false; + var saw_plan_output_dir = false; + var saw_plan_template_dir = false; + var saw_plan_meta_dir = false; + var saw_plan_default_template = false; + var saw_plan_oliver_bin = false; + var saw_plan_root_dir = false; + var saw_plan_dry_run = false; + var saw_plan_verbose = false; + var manifest_path: ?[]const u8 = null; + var manifest_add: ?[]const u8 = null; + var manifest_verify = false; + var saw_manifest = false; + var saw_manifest_add = false; + var saw_manifest_verify = false; var index: usize = 0; while (index < args.len) : (index += 1) { @@ -164,6 +209,12 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf } else if (std.mem.eql(u8, arg, "meta")) { if (command != null) return error.Usage; command = .meta; + } else if (std.mem.eql(u8, arg, "plan")) { + if (command != null) return error.Usage; + command = .plan; + } else if (std.mem.eql(u8, arg, "manifest")) { + if (command != null) return error.Usage; + command = .manifest; } else if (std.mem.eql(u8, arg, "--from")) { if (index + 1 >= args.len) return error.Usage; index += 1; @@ -310,6 +361,80 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf if (saw_wrap_body) return error.Usage; saw_wrap_body = true; wrap_body = args[index]; + } else if (std.mem.eql(u8, arg, "--content-dir")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_content_dir) return error.Usage; + saw_plan_content_dir = true; + plan_content_dir = args[index]; + } else if (std.mem.eql(u8, arg, "--output-dir")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_output_dir) return error.Usage; + saw_plan_output_dir = true; + plan_output_dir = args[index]; + } else if (std.mem.eql(u8, arg, "--template-dir")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_template_dir) return error.Usage; + saw_plan_template_dir = true; + plan_template_dir = args[index]; + } else if (std.mem.eql(u8, arg, "--meta-dir")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_meta_dir) return error.Usage; + saw_plan_meta_dir = true; + plan_meta_dir = args[index]; + } else if (std.mem.eql(u8, arg, "--default-template")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_default_template) return error.Usage; + saw_plan_default_template = true; + plan_default_template = args[index]; + } else if (std.mem.eql(u8, arg, "--oliver-bin")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_oliver_bin) return error.Usage; + saw_plan_oliver_bin = true; + plan_oliver_bin = args[index]; + } else if (std.mem.eql(u8, arg, "--root-dir")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_root_dir) return error.Usage; + saw_plan_root_dir = true; + plan_root_dir = args[index]; + } else if (std.mem.eql(u8, arg, "--dry-run")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_dry_run) return error.Usage; + saw_plan_dry_run = true; + const v = args[index]; + if (!std.mem.eql(u8, v, "true") and !std.mem.eql(u8, v, "false")) return error.Usage; + plan_dry_run = v; + } else if (std.mem.eql(u8, arg, "--verbose")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_plan_verbose) return error.Usage; + saw_plan_verbose = true; + const v = args[index]; + if (!std.mem.eql(u8, v, "true") and !std.mem.eql(u8, v, "false")) return error.Usage; + plan_verbose = v; + } else if (std.mem.eql(u8, arg, "--manifest")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_manifest) return error.Usage; + saw_manifest = true; + manifest_path = args[index]; + } else if (std.mem.eql(u8, arg, "--add")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_manifest_add) return error.Usage; + saw_manifest_add = true; + manifest_add = args[index]; + } else if (std.mem.eql(u8, arg, "--verify")) { + if (saw_manifest_verify) return error.Usage; + saw_manifest_verify = true; + manifest_verify = true; } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { return error.Help; } else if (std.mem.eql(u8, arg, "--version")) { @@ -318,9 +443,10 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf } // Exactly one subcommand names the operation, and every command - // needs an input frontend — except `wrap`, which reads files. + // needs an input frontend — except `wrap`/`plan`/`manifest`, which + // are filesystem operations, not frontends. const cmd = command orelse return error.Usage; - if (cmd != .wrap) { + if (cmd != .wrap and cmd != .plan and cmd != .manifest) { if (!cooklang and dialect == null) return error.Usage; } // Flags must belong to the command they are given with: `--to` @@ -369,8 +495,32 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf if (saw_from or saw_to or saw_raw_html or saw_frontmatter or saw_diagnostics or saw_format or json or meta_format or factor_num != null or servings_target != null or ext_flags) return error.Usage; + if (saw_plan_content_dir or saw_plan_output_dir or saw_plan_template_dir or saw_plan_meta_dir or + saw_plan_default_template or saw_plan_oliver_bin or saw_plan_root_dir or saw_plan_dry_run or saw_plan_verbose or + saw_manifest or saw_manifest_add or saw_manifest_verify) return error.Usage; if (!saw_wrap_template or !saw_wrap_meta_json or !saw_wrap_assets_root or !saw_wrap_body) return error.Usage; }, + .plan => { + // Plan has its own flag set: 9 required flags. No other flags. + if (saw_from or saw_to or saw_raw_html or saw_frontmatter or + saw_diagnostics or saw_format or json or meta_format or + factor_num != null or servings_target != null or ext_flags) return error.Usage; + if (saw_wrap_template or saw_wrap_meta_json or saw_wrap_assets_root or saw_wrap_body) return error.Usage; + if (saw_manifest or saw_manifest_add or saw_manifest_verify) return error.Usage; + if (!saw_plan_content_dir or !saw_plan_output_dir or !saw_plan_template_dir or !saw_plan_meta_dir or + !saw_plan_default_template or !saw_plan_oliver_bin or !saw_plan_root_dir or !saw_plan_dry_run or !saw_plan_verbose) return error.Usage; + }, + .manifest => { + // Manifest: --manifest required, exactly one of --add or --verify. + if (saw_from or saw_to or saw_raw_html or saw_frontmatter or + saw_diagnostics or saw_format or json or meta_format or + factor_num != null or servings_target != null or ext_flags) return error.Usage; + if (saw_wrap_template or saw_wrap_meta_json or saw_wrap_assets_root or saw_wrap_body) return error.Usage; + if (saw_plan_content_dir or saw_plan_output_dir or saw_plan_template_dir or saw_plan_meta_dir or + saw_plan_default_template or saw_plan_oliver_bin or saw_plan_root_dir or saw_plan_dry_run or saw_plan_verbose) return error.Usage; + if (!saw_manifest) return error.Usage; + if ((saw_manifest_add and saw_manifest_verify) or (!saw_manifest_add and !saw_manifest_verify)) return error.Usage; + }, } return .{ .command = cmd, @@ -398,6 +548,18 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf .wrap_meta_json = wrap_meta_json, .wrap_assets_root = wrap_assets_root, .wrap_body = wrap_body, + .plan_content_dir = plan_content_dir, + .plan_output_dir = plan_output_dir, + .plan_template_dir = plan_template_dir, + .plan_meta_dir = plan_meta_dir, + .plan_default_template = plan_default_template, + .plan_oliver_bin = plan_oliver_bin, + .plan_root_dir = plan_root_dir, + .plan_dry_run = plan_dry_run, + .plan_verbose = plan_verbose, + .manifest_path = manifest_path, + .manifest_add = manifest_add, + .manifest_verify = manifest_verify, }; } @@ -441,6 +603,18 @@ pub fn main(init: std.process.Init) !u8 { return wrapDispatch(gpa, init.io, cfg, &out_writer, &err_writer); } + // `oliver plan` walks the content tree and emits the 13-col batch TSV + // to stdout. It does not use stdin. + if (cfg.command == .plan) { + return planDispatch(gpa, init.io, cfg, &out_writer, &err_writer); + } + + // `oliver manifest` appends to or verifies the manifest file. It does + // not use stdin. + if (cfg.command == .manifest) { + return manifestDispatch(gpa, init.io, cfg, &out_writer, &err_writer); + } + // `oliver meta` is filesystem-free and dialect-agnostic at the wire // level (YAML-only for S1, 7-string JSON). It bypasses the dialect // dispatch so `meta` works uniformly for markdown/textile/cooklang. @@ -525,7 +699,7 @@ pub fn main(init: std.process.Init) !u8 { return 1; }; }, - .meta, .wrap => unreachable, + .meta, .wrap, .plan, .manifest => unreachable, } } else { const outcome = renderWithDiag(gpa, cfg, input.items) catch |err| { @@ -947,6 +1121,8 @@ fn printUsage() void { \\ oliver menu --from cooklang \\ oliver meta --from --format json \\ oliver wrap --template --meta-json --assets-root --body + \\ oliver plan --content-dir --output-dir --template-dir --meta-dir --default-template --oliver-bin --root-dir --dry-run --verbose + \\ oliver manifest --manifest [--add | --verify] \\ oliver --version \\ \\Reads a document from stdin and writes rendered HTML to stdout @@ -958,8 +1134,10 @@ fn printUsage() void { \\--body files and --assets-root prefix, resolves the 7-token \\template dialect ($title$/$description$/$author$/$date$/$palette$ \\/$assets_root$/$body$, html-escaped meta + literal assets/body), - \\and writes the result to stdout. --version prints the version and - \\the embedded source commit (CI builds). + \\and writes the result to stdout. plan walks --content-dir for + \\*.md/*.textile/*.cook and writes the 13-col batch TSV to stdout. + \\manifest --add dedups --manifest or --verify exits 0. --version prints + \\the version and the embedded source commit (CI builds). \\scale --factor accepts the same scalable quantity forms as amounts \\(2, 1/2, 1.5, 1 1/2; quote values containing spaces). \\ @@ -1041,6 +1219,42 @@ fn wrapDispatch( return 0; } +/// Dispatches `oliver plan`: walks --content-dir and writes the 13-col TSV +/// to stdout. On basename collision exits 1 with a message on stderr. +fn planDispatch( + gpa: std.mem.Allocator, + io: std.Io, + cfg: RunConfig, + out_writer: anytype, + err_writer: anytype, +) !u8 { + _ = err_writer; + plan.run(gpa, io, cfg.plan_content_dir.?, cfg.plan_output_dir.?, cfg.plan_template_dir.?, cfg.plan_meta_dir.?, cfg.plan_default_template.?, cfg.plan_oliver_bin.?, cfg.plan_root_dir.?, cfg.plan_dry_run.?, cfg.plan_verbose.?, &out_writer.interface) catch |err| { + if (err == error.Collision) return 1; + std.debug.print("oliver plan: {s}\n", .{@errorName(err)}); + return 1; + }; + out_writer.flush() catch {}; + return 0; +} + +/// Dispatches `oliver manifest`: --add dedups or --verify no-ops. +fn manifestDispatch( + gpa: std.mem.Allocator, + io: std.Io, + cfg: RunConfig, + out_writer: anytype, + err_writer: anytype, +) !u8 { + _ = out_writer; + _ = err_writer; + manifest.run(gpa, io, cfg.manifest_path.?, cfg.manifest_add, cfg.manifest_verify) catch |err| { + std.debug.print("oliver manifest: {s}\n", .{@errorName(err)}); + return 1; + }; + return 0; +} + /// `--version` is a requested outcome: print the package version and, for /// CI builds that embedded one, the exact source commit, then exit 0. /// Written to stdout (not stderr) so a consumer can parse it: an diff --git a/src/manifest.zig b/src/manifest.zig new file mode 100644 index 0000000..8ac128e --- /dev/null +++ b/src/manifest.zig @@ -0,0 +1,190 @@ +//! Phase 6 S5 — `oliver manifest` (deduped manifest log). +//! +//! - `oliver manifest --manifest --add ` → dedup `grep -Fxq` then `>>` +//! (create parent dirs / touch if missing). +//! - `oliver manifest --manifest --verify` → no-op 0 (future hook). +//! - `oliver manifest --help` → usage. +//! +//! Filesystem is CLI-only (not library). + +const std = @import("std"); + +/// Runs the manifest command. `manifest_path` is required; exactly one of +/// `add` (value) or `verify` (true) must be set. Writes nothing on success +/// (Bash fallback echoes only on append). Returns error on usage violations. +pub fn run( + gpa: std.mem.Allocator, + io: std.Io, + manifest_path: []const u8, + add: ?[]const u8, + verify: bool, +) !void { + if (verify and add != null) return error.Usage; + if (!verify and add == null) return error.Usage; + + if (verify) { + // No-op today — must exist but always 0. + return; + } + + const rel = add.?; + + // Ensure parent dirs exist. + if (std.fs.path.dirname(manifest_path)) |dir| { + std.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + error.NotDir => {}, // "/tmp" on macOS is a symlink → treat as ok + else => return err, + }; + } + + const cwd = std.Io.Dir.cwd(); + + // Try to open existing file read_write, else create. + const file_exists = blk: { + if (cwd.openFile(io, manifest_path, .{ .mode = .read_write }) catch null) |file| { + defer file.close(io); + + // Read existing content for dedup. + var buf: [8192]u8 = undefined; + var content = std.ArrayList(u8).empty; + defer content.deinit(gpa); + while (true) { + const n = file.readStreaming(io, &.{&buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + try content.appendSlice(gpa, buf[0..n]); + } + + var it = std.mem.splitScalar(u8, content.items, '\n'); + while (it.next()) |line| { + const trimmed = if (line.len > 0 and line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; + if (trimmed.len == 0 and content.items.len == 0) continue; + if (std.mem.eql(u8, trimmed, rel)) { + return; + } + // Handle last line without trailing newline: already checked. + } + // Not found — append at end. + const len = try file.length(io); + try file.writePositionalAll(io, rel, len); + // Ensure newline; if file was empty len==0, just add rel + "\n" + // If we wrote rel at len, now write "\n" + try file.writePositionalAll(io, "\n", len + rel.len); + break :blk true; + } else { + break :blk false; + } + }; + + if (!file_exists) { + // Create new file with rel + "\n" + var file = try cwd.createFile(io, manifest_path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, rel); + try file.writeStreamingAll(io, "\n"); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "manifest: --add creates file and dedups" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer testing.allocator.free(base); + const manifest = try std.fs.path.join(testing.allocator, &.{ base, "sub", "manifest.txt" }); + defer testing.allocator.free(manifest); + + var io = std.Io.Threaded.init(testing.allocator, .{}); + defer io.deinit(); + const threaded = io.io(); + + try run(testing.allocator, threaded, manifest, "output/probe.html", false); + // Verify file contains one line + { + var file = try std.Io.Dir.cwd().openFile(threaded, manifest, .{}); + defer file.close(threaded); + var buf: [8192]u8 = undefined; + var content = std.ArrayList(u8).empty; + defer content.deinit(testing.allocator); + while (true) { + const n = file.readStreaming(threaded, &.{&buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + try content.appendSlice(testing.allocator, buf[0..n]); + } + try testing.expectEqualStrings("output/probe.html\n", content.items); + } + // Second add same rel → no duplicate + try run(testing.allocator, threaded, manifest, "output/probe.html", false); + { + var file = try std.Io.Dir.cwd().openFile(threaded, manifest, .{}); + defer file.close(threaded); + var buf: [8192]u8 = undefined; + var content = std.ArrayList(u8).empty; + defer content.deinit(testing.allocator); + while (true) { + const n = file.readStreaming(threaded, &.{&buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + try content.appendSlice(testing.allocator, buf[0..n]); + } + try testing.expectEqualStrings("output/probe.html\n", content.items); + } + // Add second rel + try run(testing.allocator, threaded, manifest, "output/other.html", false); + { + var file = try std.Io.Dir.cwd().openFile(threaded, manifest, .{}); + defer file.close(threaded); + var buf: [8192]u8 = undefined; + var content = std.ArrayList(u8).empty; + defer content.deinit(testing.allocator); + while (true) { + const n = file.readStreaming(threaded, &.{&buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + try content.appendSlice(testing.allocator, buf[0..n]); + } + try testing.expectEqualStrings("output/probe.html\noutput/other.html\n", content.items); + } +} + +test "manifest: --verify is no-op" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer testing.allocator.free(base); + const manifest = try std.fs.path.join(testing.allocator, &.{ base, "manifest.txt" }); + defer testing.allocator.free(manifest); + + var io = std.Io.Threaded.init(testing.allocator, .{}); + defer io.deinit(); + const threaded = io.io(); + + // verify on non-existent file should still succeed (no-op) + try run(testing.allocator, threaded, manifest, null, true); + // add then verify + try run(testing.allocator, threaded, manifest, "x", false); + try run(testing.allocator, threaded, manifest, null, true); +} + +test "manifest: usage errors" { + var io = std.Io.Threaded.init(testing.allocator, .{}); + defer io.deinit(); + const threaded = io.io(); + try testing.expectError(error.Usage, run(testing.allocator, threaded, "m.txt", null, false)); + try testing.expectError(error.Usage, run(testing.allocator, threaded, "m.txt", "a", true)); +} diff --git a/src/plan.zig b/src/plan.zig new file mode 100644 index 0000000..d367fcc --- /dev/null +++ b/src/plan.zig @@ -0,0 +1,376 @@ +//! Phase 6 S4 — `oliver plan` (13-col TSV batch). +//! +//! - Discovery `*.md|*.textile|*.cook` via `std.Io.Dir.walk` +//! - `strip_source_ext` → `dst = output/reldir/base.html` +//! - Collision abort (`dst` → `rel` map) +//! - `ASSETS_ROOT` = `rk_up_dirs(depth+1)` + `assets/` +//! - `soul = meta_dir/strip_source_ext(rel).soul.md` or `NONE` +//! - Passthrough cols 3/6/7/8/9/10/11/12/13 unchanged. +//! +//! Filesystem is CLI-only (not library). Deterministic, sorted srcs. + +const std = @import("std"); + +fn isSourceFile(basename: []const u8) bool { + return std.mem.endsWith(u8, basename, ".md") or + std.mem.endsWith(u8, basename, ".textile") or + std.mem.endsWith(u8, basename, ".cook"); +} + +fn stripSourceExt(path: []const u8) []const u8 { + if (std.mem.endsWith(u8, path, ".md")) return path[0 .. path.len - 3]; + if (std.mem.endsWith(u8, path, ".textile")) return path[0 .. path.len - 8]; + if (std.mem.endsWith(u8, path, ".cook")) return path[0 .. path.len - 5]; + return path; +} + +fn countSlashes(s: []const u8) usize { + var n: usize = 0; + for (s) |c| { + if (c == '/') n += 1; + } + return n; +} + +fn upDirs(allocator: std.mem.Allocator, n: usize) ![]u8 { + const out_len = n * 3; // "../" * n + var buf = try allocator.alloc(u8, out_len); + var i: usize = 0; + while (i < n) : (i += 1) { + const off = i * 3; + buf[off] = '.'; + buf[off + 1] = '.'; + buf[off + 2] = '/'; + } + return buf; +} + +/// Writes the 13-col TSV to `writer`. On basename collision prints to +/// stderr via `std.debug.print` and returns `error.Collision`. +pub fn run( + gpa: std.mem.Allocator, + io: std.Io, + content_dir: []const u8, + output_dir: []const u8, + template_dir: []const u8, + meta_dir: []const u8, + default_template: []const u8, + oliver_bin: []const u8, + root_dir: []const u8, + dry_run: []const u8, + verbose: []const u8, + writer: anytype, +) !void { + // Open content_dir for walking. + const content_dir_handle = std.Io.Dir.openDir(std.Io.Dir.cwd(), io, content_dir, .{ .iterate = true }) catch |err| { + std.debug.print("oliver plan: cannot open --content-dir {s}: {s}\n", .{ content_dir, @errorName(err) }); + return err; + }; + defer content_dir_handle.close(io); + + var walker = try content_dir_handle.walk(gpa); + defer walker.deinit(); + + var srcs = std.ArrayList([]u8).empty; + defer { + for (srcs.items) |s| gpa.free(s); + srcs.deinit(gpa); + } + + while (try walker.next(io)) |entry| { + if (entry.kind != .file) continue; + if (!isSourceFile(entry.basename)) continue; + const rel = entry.path; + const src = try std.fs.path.join(gpa, &.{ content_dir, rel }); + try srcs.append(gpa, src); + } + + // Deterministic: sort srcs alphabetically (Bash glob is sorted). + std.mem.sort([]u8, srcs.items, {}, struct { + fn less(_: void, a: []u8, b: []u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.less); + + var seen = std.StringHashMap([]const u8).init(gpa); + defer { + var it = seen.iterator(); + while (it.next()) |kv| { + gpa.free(kv.key_ptr.*); + gpa.free(kv.value_ptr.*); + } + seen.deinit(); + } + + for (srcs.items) |src| { + var rel: []const u8 = undefined; + if (std.mem.startsWith(u8, src, content_dir)) { + var start: usize = content_dir.len; + if (start < src.len and src[start] == '/') start += 1; + rel = src[start..]; + if (rel.len == 0) rel = std.fs.path.basename(src); + } else { + rel = std.fs.path.basename(src); + } + + const base_with_ext = std.fs.path.basename(rel); + const base = stripSourceExt(base_with_ext); + const reldir_opt = std.fs.path.dirname(rel); + const reldir = reldir_opt orelse "."; + + const dst = if (std.mem.eql(u8, reldir, ".")) blk: { + const fname = try std.mem.concat(gpa, u8, &.{ base, ".html" }); + defer gpa.free(fname); + break :blk try std.fs.path.join(gpa, &.{ output_dir, fname }); + } else blk: { + const filename = try std.mem.concat(gpa, u8, &.{ base, ".html" }); + defer gpa.free(filename); + break :blk try std.fs.path.join(gpa, &.{ output_dir, reldir, filename }); + }; + defer gpa.free(dst); + + if (seen.get(dst)) |prev_rel| { + std.debug.print("oliver plan: basename collision: '{s}' and '{s}' both map to '{s}'\n", .{ prev_rel, rel, dst }); + return error.Collision; + } + const rel_copy = try gpa.dupe(u8, rel); + errdefer gpa.free(rel_copy); + const dst_copy = try gpa.dupe(u8, dst); + errdefer gpa.free(dst_copy); + try seen.put(dst_copy, rel_copy); + + const assets_root = if (std.mem.eql(u8, reldir, ".")) blk: { + break :blk try gpa.dupe(u8, "./assets/"); + } else blk: { + const depth = countSlashes(reldir); + const ups = try upDirs(gpa, depth + 1); + defer gpa.free(ups); + break :blk try std.mem.concat(gpa, u8, &.{ ups, "assets/" }); + }; + defer gpa.free(assets_root); + + const stripped_rel = stripSourceExt(rel); + const soul_rel = try std.mem.concat(gpa, u8, &.{ stripped_rel, ".soul.md" }); + defer gpa.free(soul_rel); + const soul_path = try std.fs.path.join(gpa, &.{ meta_dir, soul_rel }); + defer gpa.free(soul_path); + + var soul_final: []const u8 = "NONE"; + var soul_buf: ?[]u8 = null; + defer if (soul_buf) |b| gpa.free(b); + const cwd = std.Io.Dir.cwd(); + if (cwd.statFile(io, soul_path, .{}) catch null) |_| { + soul_buf = try gpa.dupe(u8, soul_path); + soul_final = soul_buf.?; + } else { + if (cwd.openFile(io, soul_path, .{}) catch null) |f| { + f.close(io); + soul_buf = try gpa.dupe(u8, soul_path); + soul_final = soul_buf.?; + } else { + soul_final = "NONE"; + } + } + + const template = try std.fs.path.join(gpa, &.{ template_dir, default_template }); + defer gpa.free(template); + + try writer.writeAll(src); + try writer.writeByte('\t'); + try writer.writeAll(dst); + try writer.writeByte('\t'); + try writer.writeAll(template); + try writer.writeByte('\t'); + try writer.writeAll(assets_root); + try writer.writeByte('\t'); + try writer.writeAll(soul_final); + try writer.writeByte('\t'); + try writer.writeAll(oliver_bin); + try writer.writeByte('\t'); + try writer.writeAll(root_dir); + try writer.writeByte('\t'); + try writer.writeAll(content_dir); + try writer.writeByte('\t'); + try writer.writeAll(output_dir); + try writer.writeByte('\t'); + try writer.writeAll(template_dir); + try writer.writeByte('\t'); + try writer.writeAll(meta_dir); + try writer.writeByte('\t'); + try writer.writeAll(dry_run); + try writer.writeByte('\t'); + try writer.writeAll(verbose); + try writer.writeByte('\n'); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "plan: stripSourceExt and countSlashes and upDirs" { + try testing.expectEqualStrings("foo", stripSourceExt("foo.md")); + try testing.expectEqualStrings("foo", stripSourceExt("foo.textile")); + try testing.expectEqualStrings("foo", stripSourceExt("foo.cook")); + try testing.expectEqualStrings("foo.txt", stripSourceExt("foo.txt")); + try testing.expectEqual(@as(usize, 0), countSlashes(".")); + try testing.expectEqual(@as(usize, 0), countSlashes("foo")); + try testing.expectEqual(@as(usize, 1), countSlashes("docs/foo")); + try testing.expectEqual(@as(usize, 2), countSlashes("docs/x/y")); + { + const s = try upDirs(testing.allocator, 1); + defer testing.allocator.free(s); + try testing.expectEqualStrings("../", s); + } + { + const s = try upDirs(testing.allocator, 3); + defer testing.allocator.free(s); + try testing.expectEqualStrings("../../../", s); + } + { + const s = try upDirs(testing.allocator, 0); + defer testing.allocator.free(s); + try testing.expectEqualStrings("", s); + } +} + +test "plan: walk, dst, assets_root, soul, collision" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer testing.allocator.free(base); + const content_dir = try std.fs.path.join(testing.allocator, &.{ base, "content" }); + defer testing.allocator.free(content_dir); + const output_dir = try std.fs.path.join(testing.allocator, &.{ base, "out" }); + defer testing.allocator.free(output_dir); + const template_dir = try std.fs.path.join(testing.allocator, &.{ base, "templates" }); + defer testing.allocator.free(template_dir); + const meta_dir = try std.fs.path.join(testing.allocator, &.{ base, "meta" }); + defer testing.allocator.free(meta_dir); + + var io = std.Io.Threaded.init(testing.allocator, .{}); + defer io.deinit(); + const threaded = io.io(); + + try std.Io.Dir.cwd().createDirPath(threaded, content_dir); + try std.Io.Dir.cwd().createDirPath(threaded, template_dir); + try std.Io.Dir.cwd().createDirPath(threaded, meta_dir); + { + const tpath = try std.fs.path.join(testing.allocator, &.{ template_dir, "base.html" }); + defer testing.allocator.free(tpath); + var f = try std.Io.Dir.cwd().createFile(threaded, tpath, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, ""); + } + { + const docs_path = try std.fs.path.join(testing.allocator, &.{ content_dir, "docs" }); + defer testing.allocator.free(docs_path); + try std.Io.Dir.cwd().createDirPath(threaded, docs_path); + } + { + const p = try std.fs.path.join(testing.allocator, &.{ content_dir, "foo.md" }); + defer testing.allocator.free(p); + var f = try std.Io.Dir.cwd().createFile(threaded, p, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "hi"); + } + { + const p = try std.fs.path.join(testing.allocator, &.{ content_dir, "docs", "bar.textile" }); + defer testing.allocator.free(p); + var f = try std.Io.Dir.cwd().createFile(threaded, p, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "hi"); + } + { + const deep = try std.fs.path.join(testing.allocator, &.{ content_dir, "docs", "x", "y" }); + defer testing.allocator.free(deep); + try std.Io.Dir.cwd().createDirPath(threaded, deep); + const p = try std.fs.path.join(testing.allocator, &.{ deep, "baz.cook" }); + defer testing.allocator.free(p); + var f = try std.Io.Dir.cwd().createFile(threaded, p, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "hi"); + } + { + const s = try std.fs.path.join(testing.allocator, &.{ meta_dir, "foo.soul.md" }); + defer testing.allocator.free(s); + var f = try std.Io.Dir.cwd().createFile(threaded, s, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "---\ntitle: x\n---\n"); + } + + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try run(testing.allocator, threaded, content_dir, output_dir, template_dir, meta_dir, "base.html", "/usr/bin/oliver", base, "false", "false", &aw.writer); + + const tsv = aw.written(); + var lines = std.mem.splitScalar(u8, tsv, '\n'); + var count: usize = 0; + while (lines.next()) |line| { + if (line.len == 0) continue; + count += 1; + var cols = std.mem.splitScalar(u8, line, '\t'); + var col_count: usize = 0; + while (cols.next()) |_| col_count += 1; + try testing.expectEqual(@as(usize, 13), col_count); + var c = std.mem.splitScalar(u8, line, '\t'); + const src = c.next().?; + const dst = c.next().?; + const tmpl = c.next().?; + const assets = c.next().?; + const soul = c.next().?; + _ = src; + _ = tmpl; + if (std.mem.indexOf(u8, dst, "foo.html") != null) { + try testing.expectEqualStrings("./assets/", assets); + try testing.expect(std.mem.indexOf(u8, soul, "foo.soul.md") != null); + } else if (std.mem.indexOf(u8, dst, "bar.html") != null) { + try testing.expectEqualStrings("../assets/", assets); + try testing.expectEqualStrings("NONE", soul); + } else if (std.mem.indexOf(u8, dst, "baz.html") != null) { + try testing.expectEqualStrings("../../../assets/", assets); + try testing.expectEqualStrings("NONE", soul); + } + } + try testing.expectEqual(@as(usize, 3), count); +} + +test "plan: collision abort" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const base = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer testing.allocator.free(base); + const content_dir = try std.fs.path.join(testing.allocator, &.{ base, "content" }); + defer testing.allocator.free(content_dir); + const output_dir = try std.fs.path.join(testing.allocator, &.{ base, "out" }); + defer testing.allocator.free(output_dir); + const template_dir = try std.fs.path.join(testing.allocator, &.{ base, "templates" }); + defer testing.allocator.free(template_dir); + const meta_dir = try std.fs.path.join(testing.allocator, &.{ base, "meta" }); + defer testing.allocator.free(meta_dir); + var io = std.Io.Threaded.init(testing.allocator, .{}); + defer io.deinit(); + const threaded = io.io(); + try std.Io.Dir.cwd().createDirPath(threaded, content_dir); + try std.Io.Dir.cwd().createDirPath(threaded, template_dir); + try std.Io.Dir.cwd().createDirPath(threaded, meta_dir); + { + const p1 = try std.fs.path.join(testing.allocator, &.{ content_dir, "foo.md" }); + defer testing.allocator.free(p1); + var f = try std.Io.Dir.cwd().createFile(threaded, p1, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "a"); + } + { + const p2 = try std.fs.path.join(testing.allocator, &.{ content_dir, "foo.textile" }); + defer testing.allocator.free(p2); + var f = try std.Io.Dir.cwd().createFile(threaded, p2, .{}); + defer f.close(threaded); + try f.writeStreamingAll(threaded, "b"); + } + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try testing.expectError(error.Collision, run(testing.allocator, threaded, content_dir, output_dir, template_dir, meta_dir, "base.html", "/bin/oliver", base, "false", "false", &aw.writer)); +}