Skip to content

Batch Export Paths Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [x]) syntax for tracking.

Goal: Add opt-in directory preservation and deterministic filename templates.

Architecture: Resolve all destinations in the existing planner before workers start. Keep path validation and alias checks separate from template parsing; carry resolved options into existing worker snapshots.

Tech Stack: Rust 2021, Rust 1.90, Cargo and the existing public Rich APIs.

Spec: Approved design.

Global Constraints

Read the release index and AGENTS.md before execution. Preserve default output; keep new library behaviour in rich-ext/rich-art. Do not add required Renderable methods or fields to ConsoleOptions/ImageOptions. No implicit ambient probes, source-file reads or global logger installation. Rust 1.90, Rust 2021 and unsafe_code = "deny" apply. Before each commit run cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, and env -u NO_COLOR cargo test; commit only after all pass. Publication is a separate handoff.

Review Focus

  1. Equal basenames in separate input directories retain distinct paths (E2).
  2. Escaped braces, non-UTF8 names and malformed tokens have explicit outcomes (E1).
  3. Symlink/hardlink aliases cannot overwrite source inputs (E2).
  4. Dry-run under missing parents previews directories without creating them (E2).
  5. Serial/parallel execution and collision suffixing use the same planned indices (E2).

E1: Filename template grammar

Files: Create crates/rich-cli/src/batch_paths.rs, crates/rich-cli/tests/batch_paths.rs; modify crates/rich-cli/src/main.rs to declare module and docs/cli.md.

Interfaces: Internal FilenameTemplate::parse(source:&str)->Result<Self,PathPlanError> and expand(&self,tokens:&FilenameTokens<'_>)->Result<std::ffi::OsString,PathPlanError>. FilenameTokens has stem:&OsStr,input_ext:&OsStr,output_ext:&str,index:usize (one-based, reject0). PathPlanError variants InvalidTemplate, InvalidLeaf, OutsideRoot, InputAlias, InvalidParent with owned descriptive strings. Preserve OsStr tokens without lossy replacement; template literals/output_ext are UTF-8. Unit tests for private grammar live in batch_paths.rs; integration tests invoke the binary once E2 wires flags.

  • [x] Add unit tests for {index}-{stem}.{output_ext} producing 1-report.html, {stem}.{{draft}}.{output_ext} producing report.{draft}.svg, empty/unknown/unbalanced tokens, ., .., slash, backslash, drive-qualified and platform-invalid names. Assert a non-UTF8 Unix stem retains raw bytes.
let t = FilenameTemplate::parse("{index}-{stem}.{output_ext}").unwrap();
let args = FilenameTokens { stem: std::ffi::OsStr::new("report"),
    input_ext: std::ffi::OsStr::new("txt"), output_ext: "html", index: 1 };
assert_eq!(t.expand(&args).unwrap(), std::ffi::OsString::from("1-report.html"));
assert!(FilenameTemplate::parse("{unknown}").is_err());
  • [x] Run env -u NO_COLOR cargo test -p rs-rich-cli batch_paths; expect missing grammar API.
  • [x] Parse with a character iterator into Literal/Stem/InputExt/OutputExt/Index tokens; double braces append a literal brace. Reject unmatched singles. Append OsStr tokens into OsString without conversion, then validate resulting single leaf using platform path components plus explicit separator/drive checks. Do not append an extension after expansion.
let mut leaf = std::ffi::OsString::new();
leaf.push(args.index.to_string());
leaf.push("-");
leaf.push(args.stem);
leaf.push(".");
leaf.push(args.output_ext);

This demonstrates lossless concatenation; production expansion dispatches parsed tokens in order. Windows reserved names/trailing dots/spaces are rejected on Windows. Null bytes rejected everywhere. - [x] Run grammar tests, global gates; commit feat: parse deterministic batch filename templates.

E2: Planner/config/worker integration

Files: Modify CLI src/main.rs, src/batch.rs, src/batch_paths.rs, src/config.rs; create crates/rich-cli/tests/batch_paths.rs if E1 did not need it yet; modify docs/cli.md, docs/PORTING.md, scripts/test_batch_v9.py.

Interfaces: CLI flags --batch-preserve-dirs, --batch-input-root PATH, --batch-name-template TEMPLATE; config keys batch_preserve_dirs, batch_input_root, batch_name_template. Internal BatchPathOptions { preserve_dirs:bool,input_root:Option<PathBuf>,template:Option<FilenameTemplate> } carried in worker settings. PlannedDestination { path:PathBuf,create_parents:Vec<PathBuf> }; plan_destination(input:&Path,output_root:&Path,output_ext:&str,index:usize,options:&BatchPathOptions)->Result<PlannedDestination,PathPlanError>. Existing batch collision policy stays authoritative after expansion. URLs may use legacy flat export but preservation rejects them before path conversion.

  • [x] Create temp inputs root/a/report.txt and root/b/report.txt; dry-run with preserve dirs and template must plan out/a/1-report.html and out/b/2-report.html, while out remains absent. Invoke command through env!("CARGO_BIN_EXE_rich") using existing batch_v9.rs harness conventions.
assert!(output.status.success());
assert!(report.contains("a/1-report.html"));
assert!(report.contains("b/2-report.html"));
assert!(!output_root.exists());

Add all existing collision policies, mixed HTML/SVG, empty/multiple inputs, outside-root input, symlink ancestor escape, hardlink output=input, invalid parent regular file, stable index when an earlier item is skipped, and config overridden by flags. Windows compares Path components rather than slash strings. Compare outputs and ordered reports at worker counts1 and4; assert legacy flat dry-run fixtures unchanged. - [x] Run env -u NO_COLOR cargo test -p rs-rich-cli --test batch_paths; expect unrecognised flags or wrong destinations. - [x] Canonicalise explicit input root and input, require containment, retain relative parent components. Expand leaf per destination extension, join under destination root, canonicalise nearest existing ancestor, reject aliases against every source and planned output, then apply existing collision policy. Record missing parents in a deduplicated ordered list. Dry-run serialises this plan only; execution creates validated parents once before workers, then repeats alias checks at write time. Keep completed outputs/new parents on cancellation. No race-free filesystem guarantee.

let relative = canonical_input.strip_prefix(&canonical_root)
    .map_err(|_| PathPlanError::OutsideRoot(input.display().to_string()))?;
let parent = relative.parent().unwrap_or_else(|| std::path::Path::new(""));
let destination = output_root.join(parent).join(leaf);
  • [x] Run batch_paths, batch_v8, batch_v9, config tests and Python batch tests with existing harness usage; run global gates. Commit feat: preserve batch directories and apply export templates.