Files
improvise/tests/headless_cli.rs
T
Claude (agent-lpzu) bb505f8faf feat(import): give each merged file a view filtered to it (improvise-lpzu)
A CSV merge now records each file's columns, and the build adds one view
per file: File on Filter at that file's item, its own categories seated
as a fresh import seats them, the rest on None, and other files'
measures hidden. The merged Default view stays the initial view. The
headless and wizard paths both carry the sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 20:48:16 -07:00

711 lines
24 KiB
Rust

//! The `improvise cmd` / `improvise script` contract, pinned at the process
//! boundary. Why a spawned binary: `context/test-evidence/cli.md`.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_improvise")
}
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("improv_cli_{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch directory");
Self(dir)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn run_cmd(args: &[&str], file: &Path) -> Output {
Command::new(bin())
.arg("cmd")
.args(args)
.arg("-f")
.arg(file)
.output()
.expect("the improvise binary runs")
}
fn stderr_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
fn stdout_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).to_string()
}
/// A document with one category, so `add-item` has somewhere legitimate to go.
fn seeded(scratch: &Scratch, name: &str) -> PathBuf {
let doc = scratch.path(name);
let out = run_cmd(&["add-category Region"], &doc);
assert!(doc.exists(), "the seed run wrote a document: {out:?}");
doc
}
#[test]
fn a_refused_command_says_what_went_wrong() {
let scratch = Scratch::new("refused");
let doc = seeded(&scratch, "m.improv");
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
let said = stderr_of(&out);
assert!(
!said.trim().is_empty(),
"a refused command said nothing at all; stdout={:?}",
stdout_of(&out)
);
assert!(
said.contains("Nonexistent"),
"the refusal does not name the category that does not exist: {said:?}"
);
}
/// The rival this separates from: a fix that reports on the happy path and
/// stays silent on the refusal. `context/test-evidence/cli.md`.
#[test]
fn a_command_with_nothing_to_say_says_nothing() {
let scratch = Scratch::new("quiet");
let doc = scratch.path("m.improv");
let out = run_cmd(&["add-category Region"], &doc);
assert!(
stderr_of(&out).trim().is_empty(),
"a command that succeeded with nothing to report spoke anyway: {:?}",
stderr_of(&out)
);
assert!(
out.status.success(),
"a command that succeeded did not exit 0"
);
}
#[test]
fn an_aborted_batch_exits_non_zero_and_says_why() {
let scratch = Scratch::new("abort");
let doc = seeded(&scratch, "m.improv");
let bad = scratch.path("no-such-dir").join("out.improv");
let out = run_cmd(&[&format!("w {}", bad.display())], &doc);
let said = stderr_of(&out);
assert!(
!said.trim().is_empty(),
"a failed save said nothing: stdout={:?}",
stdout_of(&out)
);
assert!(
!out.status.success(),
"a failed save exited 0; stderr={said:?}"
);
}
#[test]
fn a_parse_error_still_exits_non_zero() {
let scratch = Scratch::new("parse");
let doc = seeded(&scratch, "m.improv");
let out = run_cmd(&["no-such-command-at-all"], &doc);
assert!(!out.status.success(), "an unparseable line exited 0");
assert!(
stderr_of(&out).contains("no-such-command-at-all"),
"the parse error does not name the word typed: {:?}",
stderr_of(&out)
);
}
/// Diagnostics go to stderr so that stdout stays free for a data output, and
/// so `2>/dev/null` is the whole suppression story. `context/code/cli.md`.
#[test]
fn what_a_command_says_goes_to_stderr_and_stdout_stays_empty() {
let scratch = Scratch::new("stream");
let doc = seeded(&scratch, "m.improv");
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
assert!(
stdout_of(&out).trim().is_empty(),
"a diagnostic reached stdout: {:?}",
stdout_of(&out)
);
assert!(!stderr_of(&out).trim().is_empty(), "stderr was empty too");
}
#[test]
fn a_script_reports_exactly_as_cmd_does() {
let scratch = Scratch::new("script");
let doc = seeded(&scratch, "m.improv");
let script = scratch.path("s.txt");
std::fs::write(&script, "# a comment\nadd-item Nonexistent Thing\n").expect("a script file");
let out = Command::new(bin())
.arg("script")
.arg(&script)
.arg("-f")
.arg(&doc)
.output()
.expect("the improvise binary runs");
assert!(
stderr_of(&out).contains("Nonexistent"),
"`script` swallowed the refusal `cmd` reports: {:?}",
stderr_of(&out)
);
}
/// A run that changed nothing must leave the bytes alone: a fixture edited by
/// hand between runs is otherwise silently normalised. `context/code/cli.md`.
#[test]
fn a_run_that_changes_nothing_does_not_rewrite_the_document() {
let scratch = Scratch::new("noop");
let doc = seeded(&scratch, "m.improv");
let before = std::fs::read(&doc).expect("the seeded document");
let out = run_cmd(&["move-cursor down"], &doc);
let after = std::fs::read(&doc).expect("the document after a view-only command");
assert_eq!(
before,
after,
"a view-only command rewrote the document; stderr={:?}",
stderr_of(&out)
);
}
/// The reported instance: `1e20` is expanded to its twenty digits by a run that
/// was asked to change nothing at all.
#[test]
fn a_no_op_run_leaves_an_unnormalised_literal_alone() {
let scratch = Scratch::new("literal");
let doc = scratch.path("m.improv");
std::fs::write(
&doc,
"v2025-04-09\n# Big\nInitial View: Default\n\n\
## View: Default\nRegion: row\n_Measure: column\n\n\
## Category: Region\n- North\n\n\
## Category: _Measure\n- Score\n\n\
## Data\nRegion=North, _Measure=Score = 1e20\n",
)
.expect("a hand-written document");
let before = std::fs::read_to_string(&doc).expect("the document");
assert!(before.contains("1e20"), "the fixture itself is wrong");
let out = run_cmd(&["move-cursor down"], &doc);
let after = std::fs::read_to_string(&doc).expect("the document after");
assert!(
after.contains("1e20"),
"a no-op run normalised the literal; stderr={:?}, after={after:?}",
stderr_of(&out)
);
}
#[test]
fn the_cmd_help_does_not_advertise_a_language_it_refuses() {
for sub in ["cmd", "script"] {
let out = Command::new(bin())
.args([sub, "--help"])
.output()
.expect("the improvise binary runs");
let text = format!("{}{}", stdout_of(&out), stderr_of(&out));
assert!(
!text.contains("JSON") && !text.contains("json"),
"`{sub} --help` still advertises JSON, which it refuses: {text}"
);
}
}
/// The language it does accept, demonstrated end to end so the help above has
/// something true to point at.
#[test]
fn the_language_the_help_names_is_the_one_it_accepts() {
let scratch = Scratch::new("lang");
let doc = seeded(&scratch, "m.improv");
let out = run_cmd(&["add-item Region East"], &doc);
assert!(out.status.success(), "the command line form was refused");
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(text.contains("East"), "the item was not added: {text}");
let json = run_cmd(&[r#"{"cmd":"add-item","args":["Region","West"]}"#], &doc);
assert!(
!json.status.success(),
"the JSON form the help used to advertise was accepted after all"
);
}
/// The rival this separates from: making any emitted status a non-zero exit.
/// `fit-measure` reports its reproducing seed the same way — `context/code/cli.md`.
#[test]
fn a_command_that_succeeds_while_reporting_still_exits_zero() {
let scratch = Scratch::new("reports");
let doc = seeded(&scratch, "m.improv");
let save = format!("w {}", doc.display());
let out = run_cmd(&["add-item Region East", &save], &doc);
assert!(
!stderr_of(&out).trim().is_empty(),
"a successful save reported nothing, so this test proves nothing"
);
assert!(
out.status.success(),
"a successful command that reported was treated as a failure: {:?}",
stderr_of(&out)
);
}
/// A run that did change the document still writes it, even when the last thing
/// it did was save somewhere else and clear the dirty flag.
#[test]
fn a_run_that_changed_the_document_still_writes_it() {
let scratch = Scratch::new("elsewhere");
let doc = seeded(&scratch, "m.improv");
let other = scratch.path("other.improv");
let save = format!("w {}", other.display());
run_cmd(&["add-item Region East", &save], &doc);
let text = std::fs::read_to_string(&doc).expect("the -f document");
assert!(
text.contains("East"),
"the -f document lost an edit because `:w` elsewhere cleared the flag: {text}"
);
}
/// A document whose row axis carries three items, so a cursor move has
/// somewhere to go and the three landing places are distinguishable.
fn three_rows(scratch: &Scratch, name: &str) -> PathBuf {
let doc = scratch.path(name);
run_cmd(&["add-category Region"], &doc);
run_cmd(&["add-items Region North South East"], &doc);
doc
}
fn hidden_line(doc: &Path) -> String {
std::fs::read_to_string(doc)
.expect("the document")
.lines()
.find(|l| l.starts_with("hidden:"))
.unwrap_or("(nothing hidden)")
.to_string()
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_script_can_move_the_cursor_and_the_next_command_sees_it() {
let scratch = Scratch::new("cursor");
let moved = three_rows(&scratch, "moved.improv");
let still = three_rows(&scratch, "still.improv");
run_cmd(&["move-selection 2 0 . hide-selected-row-item"], &moved);
run_cmd(&["hide-selected-row-item"], &still);
assert_eq!(
hidden_line(&still),
"hidden: Region/North",
"without a move the cursor is at the origin; if this changes, the other \
half of this test stops meaning anything"
);
assert_eq!(
hidden_line(&moved),
"hidden: Region/East",
"`move-selection 2 0` must move the cursor two rows down before the \
next command reads it (improvise-6uva)"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_script_cursor_move_survives_the_line_that_made_it() {
let scratch = Scratch::new("cursor-lines");
let doc = three_rows(&scratch, "lines.improv");
run_cmd(&["move-selection 1 0", "hide-selected-row-item"], &doc);
assert_eq!(hidden_line(&doc), "hidden: Region/South");
}
fn cell_lines(doc: &Path) -> Vec<String> {
std::fs::read_to_string(doc)
.expect("the document")
.lines()
.filter(|l| l.contains(" = "))
.map(|l| l.trim().to_string())
.collect()
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn an_explicit_coordinate_still_beats_the_cursor() {
let scratch = Scratch::new("explicit");
let doc = scratch.path("cells.improv");
run_cmd(&["add-category Region"], &doc);
run_cmd(&["add-items Region North South East"], &doc);
run_cmd(
&[
"set-cell 1 Region/North",
"set-cell 2 Region/South",
"set-cell 3 Region/East",
],
&doc,
);
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
// The cursor is at the origin, which is North. Clearing East by name must
// clear East, not whatever the cursor happens to be sitting on.
run_cmd(&["clear-cell Region/East"], &doc);
let left = cell_lines(&doc);
assert!(
left.iter().any(|l| l.contains("North")),
"the cursor cell was cleared instead of the named one: {left:?}"
);
assert!(
!left.iter().any(|l| l.contains("East")),
"the named cell survived: {left:?}"
);
}
/// North=1, South=2, East=3 on the row axis; the cursor starts on North.
fn three_cells(scratch: &Scratch, name: &str) -> PathBuf {
let doc = three_rows(scratch, name);
run_cmd(
&[
"set-cell 1 Region/North",
"set-cell 2 Region/South",
"set-cell 3 Region/East",
],
&doc,
);
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
doc
}
fn value_of(doc: &Path, item: &str) -> Option<String> {
cell_lines(doc)
.into_iter()
.find(|l| l.contains(&format!("Region={item}")))
.and_then(|l| l.rsplit(" = ").next().map(str::to_string))
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() {
let scratch = Scratch::new("commit-explicit");
let doc = three_cells(&scratch, "commit.improv");
let out = run_cmd(
&["move-selection 1 0 . commit-cell-edit editing 5 Region/East"],
&doc,
);
assert!(out.status.success(), "the line failed: {}", stderr_of(&out));
let cells = cell_lines(&doc);
assert_eq!(value_of(&doc, "East").as_deref(), Some("5"), "{cells:?}");
assert_eq!(
value_of(&doc, "South").as_deref(),
Some("2"),
"the cell under the cursor was touched: {cells:?}"
);
assert_eq!(
value_of(&doc, "North").as_deref(),
Some("1"),
"the origin cell was touched: {cells:?}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_line_whose_gate_passes_runs_whole() {
let scratch = Scratch::new("commit-atomic");
let doc = scratch.path("atomic.improv");
let out = run_cmd(
&["add-category Foo . commit-cell-edit editing 5 Foo/x"],
&doc,
);
assert!(
!stderr_of(&out).contains("Parse error"),
"a line the gate accepted was refused part-way: {}",
stderr_of(&out)
);
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
text.lines()
.any(|l| l.contains("Foo=x") && l.ends_with("= 5")),
"the commit did not land: {text}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_malformed_commit_line_is_refused_whole() {
let scratch = Scratch::new("commit-malformed");
let doc = seeded(&scratch, "malformed.improv");
let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5"], &doc);
assert!(!out.status.success(), "a malformed line exited 0");
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
!text.contains("Foo"),
"the first half of a refused line landed: {text}"
);
}
/// Runs `improvise cmd` with no controlling terminal and the given `LINES`,
/// so the only size the process can discover is the one handed to it here.
#[cfg(unix)]
fn run_cmd_in_a_terminal_of(rows: u16, cols: u16, args: &[&str], file: &Path) -> Output {
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(bin());
cmd.arg("cmd")
.args(args)
.arg("-f")
.arg(file)
.env("TERM", "xterm")
.env("LINES", rows.to_string())
.env("COLUMNS", cols.to_string())
.stdin(std::process::Stdio::null());
// SAFETY: `setsid` is async-signal-safe, and leaving the runner's terminal
// is what makes `LINES` the only size the child can find.
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
cmd.output().expect("the improvise binary runs")
}
/// Evidence: `context/test-evidence/cli.md`.
#[cfg(unix)]
#[test]
fn a_script_writes_the_same_record_whatever_terminal_ran_it() {
let scratch = Scratch::new("extent");
let items: Vec<String> = (1..=60).map(|i| format!("R{i:02}")).collect();
let seed = scratch.path("seed.improv");
run_cmd(&["add-category Region"], &seed);
run_cmd(&[&format!("add-items Region {}", items.join(" "))], &seed);
let script = ["page-scroll 1 . hide-selected-row-item"];
let mut hidden = Vec::new();
for (rows, cols) in [(24, 80), (60, 200)] {
let doc = scratch.path(&format!("{rows}x{cols}.improv"));
std::fs::copy(&seed, &doc).expect("a copy of the seed");
let out = run_cmd_in_a_terminal_of(rows, cols, &script, &doc);
assert!(out.status.success(), "{}", stderr_of(&out));
hidden.push(hidden_line(&doc));
}
assert_ne!(
hidden[0], "hidden: Region/R01",
"the cursor never moved, so the page size was never read"
);
assert!(
hidden[0].starts_with("hidden: Region/R"),
"the script hid nothing, so agreement proves nothing: {hidden:?}"
);
assert_eq!(
hidden[0], hidden[1],
"the same script hid a different record at a different terminal size"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_commit_whose_advance_has_nowhere_to_go_still_exits_zero() {
let scratch = Scratch::new("commit-tail");
let doc = three_cells(&scratch, "tail.improv");
let out = run_cmd(
&["move-selection 2 0 . commit-cell-edit editing 9 Region/South"],
&doc,
);
assert_eq!(
value_of(&doc, "South").as_deref(),
Some("9"),
"the write did not land"
);
assert!(
out.status.success(),
"the write landed and was saved, yet the run exited non-zero: {:?}",
stderr_of(&out)
);
let empty = scratch.path("empty.improv");
let out = run_cmd(
&["add-category Foo . commit-cell-edit editing 5 Foo/x"],
&empty,
);
assert!(
out.status.success(),
"a commit on an empty grid landed and exited non-zero: {:?}",
stderr_of(&out)
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_refusal_that_only_writes_a_status_exits_non_zero() {
let scratch = Scratch::new("refusal-exit");
let doc = seeded(&scratch, "m.improv");
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
assert!(
stderr_of(&out).contains("Nonexistent"),
"the refusal was not reported, so its exit code proves nothing: {:?}",
stderr_of(&out)
);
assert!(
!out.status.success(),
"a refused command exited 0: {:?}",
stderr_of(&out)
);
}
/// Two regions, East repeated: records #1 and #3 share every coordinate.
fn colliding_csv(scratch: &Scratch) -> PathBuf {
let csv = scratch.path("dups.csv");
std::fs::write(&csv, "Region,Revenue\nEast,10\nWest,5\nEast,20\n").unwrap();
csv
}
fn run_import(csv: &Path, out: &Path, extra: &[&str]) -> Output {
Command::new(bin())
.arg("import")
.arg(csv)
.arg("--no-wizard")
.args(extra)
.arg("-o")
.arg(out)
.output()
.expect("the improvise binary runs")
}
/// improvise-cgqt: nobody is there to ask, so an unanswered collision stops
/// the import and names the records. `context/test-evidence/cli.md`.
#[test]
fn a_headless_import_with_duplicates_refuses_by_default_and_names_them() {
let scratch = Scratch::new("dup_refuse");
let out_path = scratch.path("out.improv");
let out = run_import(&colliding_csv(&scratch), &out_path, &[]);
let err = stderr_of(&out);
assert!(!out.status.success(), "{err}");
assert!(err.contains("2 records (#1, #3): Region=East"), "{err}");
assert!(err.contains("--on-duplicate"), "names the way out: {err}");
assert!(!out_path.exists(), "nothing half-imported is written");
}
#[test]
fn a_headless_import_sums_duplicates_when_told_to() {
let scratch = Scratch::new("dup_sum");
let out_path = scratch.path("out.improv");
let out = run_import(
&colliding_csv(&scratch),
&out_path,
&["--on-duplicate", "sum"],
);
assert!(out.status.success(), "{}", stderr_of(&out));
let doc = std::fs::read_to_string(&out_path).unwrap();
assert!(doc.contains("= 30"), "{doc}");
assert!(!doc.contains("Occurrence"), "{doc}");
}
#[test]
fn a_headless_import_keeps_duplicates_separate_when_told_to() {
let scratch = Scratch::new("dup_separate");
let out_path = scratch.path("out.improv");
let out = run_import(
&colliding_csv(&scratch),
&out_path,
&["--on-duplicate", "separate"],
);
assert!(out.status.success(), "{}", stderr_of(&out));
let doc = std::fs::read_to_string(&out_path).unwrap();
assert!(doc.contains("## Category: Occurrence"), "{doc}");
assert!(doc.contains("= 10") && doc.contains("= 20"), "{doc}");
assert!(!doc.contains("= 30"), "{doc}");
}
/// code-maat's reports: three schemas, a stray note, and room for the output.
fn code_maat_folder(scratch: &Scratch) -> PathBuf {
let dir = scratch.path("maat");
std::fs::create_dir_all(&dir).unwrap();
for (name, body) in [
(
"coupling.csv",
"entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\n",
),
("age.csv", "entity,age-months\nsrc/a.rs,2\nsrc/b.rs,9\n"),
(
"abs-churn.csv",
"date,added,deleted,commits\n2026-01-02,10,3,2\n",
),
("README.txt", "not a report\n"),
] {
std::fs::write(dir.join(name), body).unwrap();
}
dir
}
/// improvise-lpzu: a folder argument is its `*.csv` files, merged sparsely and
/// in name order. Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte() {
let scratch = Scratch::new("folder_import");
let dir = code_maat_folder(&scratch);
let out_path = dir.join("maat.improv");
let first = run_import(&dir, &out_path, &[]);
assert!(first.status.success(), "{}", stderr_of(&first));
let doc = std::fs::read_to_string(&out_path).unwrap();
for line in [
"File=coupling, _Measure=degree, coupled=|src/b.rs|, entity=|src/a.rs| = 80",
"File=age, _Measure=age-months, entity=|src/b.rs| = 9",
"File=abs-churn, _Measure=added, date=|2026-01-02| = 10",
] {
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
}
assert!(!doc.contains("README"), "{doc}");
let views: Vec<&str> = doc.lines().filter(|l| l.starts_with("## View: ")).collect();
assert_eq!(
views,
[
"## View: Default",
"## View: abs-churn",
"## View: age",
"## View: coupling"
]
);
assert!(doc.lines().any(|l| l == "File: filter, coupling"), "{doc}");
assert!(doc.contains("Initial View: Default"), "{doc}");
// The output now sits in the folder; a second run must not read it.
let second = run_import(&dir, &out_path, &[]);
assert!(second.status.success(), "{}", stderr_of(&second));
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), doc);
}
/// improvise-lpzu: a file and a folder together are one merge, and a folder
/// with nothing to import is named. Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_headless_import_takes_files_and_folders_together_and_refuses_an_empty_folder() {
let scratch = Scratch::new("folder_mixed");
let dir = code_maat_folder(&scratch);
let extra = scratch.path("extra.csv");
std::fs::write(&extra, "entity,age-months\nsrc/z.rs,4\n").unwrap();
let out_path = scratch.path("out.improv");
let out = Command::new(bin())
.args(["import", "--no-wizard", "-o"])
.arg(&out_path)
.arg(&extra)
.arg(&dir)
.output()
.unwrap();
assert!(out.status.success(), "{}", stderr_of(&out));
let doc = std::fs::read_to_string(&out_path).unwrap();
assert!(
doc.contains("File=extra, _Measure=age-months, entity=|src/z.rs| = 4"),
"{doc}"
);
assert!(doc.contains("File=coupling"), "{doc}");
let empty = scratch.path("empty");
std::fs::create_dir_all(&empty).unwrap();
let refused = run_import(&empty, &scratch.path("never.improv"), &[]);
assert!(!refused.status.success());
let err = stderr_of(&refused);
assert!(
err.contains(&format!("no CSV files in folder {}", empty.display())),
"{err}"
);
assert!(!scratch.path("never.improv").exists());
}