8fa504fbdc
#321 added its own cli.md section 13, so this PR's sections become 14 (pipe quoting, surplus arguments) and 15 (names the document lacks), with every citation renumbered. The stale descriptions of the deleted unknown_* helpers in cli.md, command.md and the test evidence now describe RequireKnown, and section 15 says a refused name stops the rest of a `:` line but not a headless one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
980 lines
34 KiB
Rust
980 lines
34 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)
|
|
);
|
|
}
|
|
|
|
/// improvise-31oe.2 at the process boundary, with a control that exits 0.
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_unknown_category_axis_or_item_exits_non_zero_and_names_it() {
|
|
let scratch = Scratch::new("unknown_names");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["add-item Region East"], &doc);
|
|
assert!(out.status.success(), "the seed item: {}", stderr_of(&out));
|
|
for (line, missing) in [
|
|
("set-axis Regoin row", "Regoin"),
|
|
("set-page Region Nowhere", "Nowhere"),
|
|
("set-axis Region sideways", "sideways"),
|
|
] {
|
|
let out = run_cmd(&[line], &doc);
|
|
let said = stderr_of(&out);
|
|
assert!(!out.status.success(), "`{line}` exited 0; stderr={said:?}");
|
|
assert!(
|
|
said.contains(missing),
|
|
"`{line}` did not name {missing}: {said:?}"
|
|
);
|
|
}
|
|
let out = run_cmd(&["set-page Region East"], &doc);
|
|
assert!(out.status.success(), "the control: {}", stderr_of(&out));
|
|
}
|
|
|
|
/// improvise-31oe.3: one pipe-quoted argument, and a surplus one refused.
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_pipe_quoted_name_reaches_the_document_whole() {
|
|
let scratch = Scratch::new("pipes");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["create-view |By area|"], &doc);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let text = std::fs::read_to_string(&doc).expect("the document");
|
|
assert!(text.contains("## View: By area\n"), "{text}");
|
|
assert!(
|
|
!text.contains("|By"),
|
|
"a pipe reached the view name: {text}"
|
|
);
|
|
|
|
let out = run_cmd(&["create-view By area"], &doc);
|
|
let said = stderr_of(&out);
|
|
assert!(
|
|
!out.status.success(),
|
|
"a surplus argument exited 0: {said:?}"
|
|
);
|
|
assert!(
|
|
said.contains("\"area\""),
|
|
"the refusal does not name it: {said:?}"
|
|
);
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
/// Runs `import <source> --no-wizard <extra> -o out.improv` on a source it writes.
|
|
fn import_of(scratch: &Scratch, name: &str, body: &str, extra: &[&str]) -> (Output, PathBuf) {
|
|
let source = scratch.path(name);
|
|
std::fs::write(&source, body).unwrap();
|
|
let out_path = scratch.path("out.improv");
|
|
(run_import(&source, &out_path, extra), out_path)
|
|
}
|
|
|
|
/// improvise-8jk6.4: a forced category's values are item names, so they keep
|
|
/// their source text. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_csv_column_forced_to_category_keeps_its_text_verbatim() {
|
|
let scratch = Scratch::new("forced_category_text");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"commits.csv",
|
|
"commit,lines\n00565796,3\n220944e2,5\n",
|
|
&["--category", "commit", "--measure", "lines"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(
|
|
doc.lines().any(|l| l == "- |00565796|, |220944e2|"),
|
|
"the items are the source text:\n{doc}"
|
|
);
|
|
for line in [
|
|
"_Measure=lines, commit=|00565796| = 3",
|
|
"_Measure=lines, commit=|220944e2| = 5",
|
|
] {
|
|
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
|
|
}
|
|
}
|
|
|
|
/// improvise-8jk6.4: JSON has no source text for a number, so a forced
|
|
/// category names it as serde_json writes it. Evidence file.
|
|
#[test]
|
|
fn a_json_number_forced_to_category_is_named_as_json_writes_it() {
|
|
let scratch = Scratch::new("forced_category_json");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"commits.json",
|
|
r#"[{"commit": 42, "n": 1}, {"commit": 1.5, "n": 2}, {"commit": 1e3, "n": 3}]"#,
|
|
&["--category", "commit", "--measure", "n"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(
|
|
doc.lines().any(|l| l == "- |42|, |1.5|, |1000.0|"),
|
|
"the items are the numbers' JSON text:\n{doc}"
|
|
);
|
|
}
|
|
|
|
/// improvise-8jk6.5: a formula the user named and that does not parse is a
|
|
/// refusal, not a silent drop. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing() {
|
|
let scratch = Scratch::new("bad_formula");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"f.csv",
|
|
"a,x\nk,1\n",
|
|
&[
|
|
"--measure",
|
|
"x",
|
|
"--formula",
|
|
"Y = x +",
|
|
"--formula",
|
|
"Z = x",
|
|
"--formula",
|
|
"W = * 2",
|
|
],
|
|
);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "a dropped formula exited 0: {err}");
|
|
assert!(err.contains("'Y = x +'"), "names the formula: {err}");
|
|
assert!(err.contains("'W = * 2'"), "names every bad one: {err}");
|
|
assert!(err.contains("expected"), "carries the parse error: {err}");
|
|
assert!(!err.contains("'Z = x'"), "names only the bad one: {err}");
|
|
assert!(!out_path.exists(), "nothing is written");
|
|
}
|
|
|
|
/// improvise-8jk6.6: an import that kept no record says why, and exits
|
|
/// non-zero writing nothing. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_that_keeps_no_record_refuses_and_says_why() {
|
|
let scratch = Scratch::new("kept_none");
|
|
let (out, out_path) = import_of(&scratch, "r.json", r#"[1, 2, "three"]"#, &[]);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("all 3 records were skipped as not JSON objects"),
|
|
"says why: {err}"
|
|
);
|
|
assert!(!out_path.exists(), "nothing is written");
|
|
}
|
|
|
|
/// improvise-8jk6.6: a partial skip warns with a count and exits 0, a choice
|
|
/// put to the user. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_that_skips_some_records_warns_with_a_count_and_still_writes() {
|
|
let scratch = Scratch::new("kept_some");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"p.json",
|
|
r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#,
|
|
&[],
|
|
);
|
|
let err = stderr_of(&out);
|
|
assert!(out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("warning: Skipped 1 of 3 records that were not JSON objects"),
|
|
"{err}"
|
|
);
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}");
|
|
}
|
|
|
|
/// A JSON array with one stray scalar among its records.
|
|
const ONE_STRAY: &str = r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#;
|
|
|
|
/// improvise-8jk6.6, the user's ruling: `--strict` makes any skip a refusal
|
|
/// that writes nothing. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_strict_import_that_skips_a_record_refuses_and_writes_nothing() {
|
|
let scratch = Scratch::new("strict_skip");
|
|
let (out, out_path) = import_of(&scratch, "p.json", ONE_STRAY, &["--strict"]);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("--strict: Skipped 1 of 3 records that were not JSON objects"),
|
|
"{err}"
|
|
);
|
|
assert!(!out_path.exists(), "a strict refusal writes nothing");
|
|
}
|
|
|
|
/// The control: `--strict` refuses a skip, not every import.
|
|
#[test]
|
|
fn a_strict_import_that_skips_nothing_still_writes() {
|
|
let scratch = Scratch::new("strict_clean");
|
|
let body = r#"[{"a": "k", "x": 1}, {"a": "m", "x": 4}]"#;
|
|
let (out, out_path) = import_of(&scratch, "p.json", body, &["--strict"]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}");
|
|
}
|
|
|
|
/// improvise-8jk6.17 (F3): a CSV decimal measure is its number, not dropped
|
|
/// text. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_csv_decimal_measure_imports_as_its_number() {
|
|
let scratch = Scratch::new("csv_decimal");
|
|
let body = "Region,Revenue\nEast,1000.50\nWest,-2.25\n";
|
|
let (out, out_path) = import_of(&scratch, "f.csv", body, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
for line in [
|
|
"Region=East, _Measure=Revenue = 1000.5",
|
|
"Region=West, _Measure=Revenue = -2.25",
|
|
] {
|
|
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
|
|
}
|
|
}
|
|
|
|
/// improvise-8jk6.17 (F1): `import-json` reads a CSV as CSV, so its numeric
|
|
/// column is a measure. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn import_json_on_a_csv_reads_its_numeric_column_as_a_measure() {
|
|
let scratch = Scratch::new("import_json_csv");
|
|
let csv = scratch.path("f.csv");
|
|
std::fs::write(&csv, "Region,Revenue\nEast,1000.50\nWest,7\n").unwrap();
|
|
let doc = scratch.path("m.improv");
|
|
let out = run_cmd(&[&format!("import-json {}", csv.display())], &doc);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let text = std::fs::read_to_string(&doc).unwrap();
|
|
assert!(!text.contains("## Category: Revenue"), "{text}");
|
|
for line in [
|
|
"Region=East, _Measure=Revenue = 1000.5",
|
|
"Region=West, _Measure=Revenue = 7",
|
|
] {
|
|
assert!(
|
|
text.lines().any(|l| l == line),
|
|
"{line} missing from\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-lpzu, the user's ruling: a folder always merges, so even one CSV
|
|
/// keeps its file identity. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not() {
|
|
let scratch = Scratch::new("folder_single");
|
|
let dir = scratch.path("one");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let csv = dir.join("age.csv");
|
|
std::fs::write(&csv, "entity,age-months\nsrc/a.rs,2\n").unwrap();
|
|
|
|
let from_folder = scratch.path("folder.improv");
|
|
let out = run_import(&dir, &from_folder, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&from_folder).unwrap();
|
|
assert!(doc.contains("## Category: File\n- age\n"), "{doc}");
|
|
assert!(doc.lines().any(|l| l == "File: filter, age"), "{doc}");
|
|
assert!(
|
|
doc.contains("File=age, _Measure=age-months, entity=|src/a.rs| = 2"),
|
|
"{doc}"
|
|
);
|
|
|
|
let from_file = scratch.path("file.improv");
|
|
let out = run_import(&csv, &from_file, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&from_file).unwrap();
|
|
assert!(
|
|
!doc.contains("File"),
|
|
"a lone file keeps today's shape: {doc}"
|
|
);
|
|
}
|