//! 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 { 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 { 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 = (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}"); } /// Dates across two years, one per month, so nothing collides. fn dated_csv(scratch: &Scratch) -> PathBuf { let csv = scratch.path("dated.csv"); std::fs::write( &csv, "Date,Revenue\n2024-01-15,1\n2024-04-02,10\n2025-01-03,100\n", ) .unwrap(); csv } /// improvise-8jk6.3, headless: `--extract` builds ONE hierarchical category by /// default, as the wizard does, and it is written with its `>` lines. #[test] fn a_headless_date_import_is_one_hierarchical_category() { let scratch = Scratch::new("date_hierarchy"); let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &[ "--extract", "Date:Year", "--extract", "Date:Quarter", "--extract", "Date:Month", ], ); assert!(out.status.success(), "{}", stderr_of(&out)); let doc = std::fs::read_to_string(&out_path).unwrap(); assert!(!doc.contains("Date_Month"), "{doc}"); assert!(doc.contains("- |2024-01|[|2024-Q1|]"), "{doc}"); assert!(doc.contains("> |2024-Q1|[|2024|]"), "{doc}"); assert!(!doc.contains("expanded"), "it opens folded: {doc}"); } /// The flat split stays available headless, as `s` makes it in the wizard. #[test] fn a_headless_date_import_splits_when_told_to() { let scratch = Scratch::new("date_split"); let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &["--extract", "Date:Month", "--split-date", "Date"], ); assert!(out.status.success(), "{}", stderr_of(&out)); let doc = std::fs::read_to_string(&out_path).unwrap(); assert!(doc.contains("## Category: Date_Month"), "{doc}"); assert!(doc.contains("2024-01-15"), "the raw dates stay: {doc}"); } /// improvise-8jk6.3 review: a `--split-date` that names nothing splittable /// said nothing and exited 0. #[test] fn split_date_naming_an_unknown_or_non_date_field_is_refused() { let scratch = Scratch::new("split_date_unknown"); for field in ["Nope", "Revenue"] { let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &["--extract", "Date:Month", "--split-date", field], ); assert!(!out.status.success(), "{field}: {}", stderr_of(&out)); let err = stderr_of(&out); assert!(err.contains("--split-date") && err.contains(field), "{err}"); } } /// improvise-8jk6.3 review: `--axis Date_Month:column` names a category the /// hierarchical import no longer builds, and was dropped without a word. #[test] fn an_axis_naming_a_category_the_import_did_not_build_is_refused() { let scratch = Scratch::new("axis_unknown"); let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &["--extract", "Date:Month", "--axis", "Date_Month:column"], ); assert!(!out.status.success(), "{}", stderr_of(&out)); let err = stderr_of(&out); assert!( err.contains("Date_Month") && err.contains("--split-date"), "{err}" ); } /// `--time` on a column with no date format makes it a time category without /// dates, so there is nothing to split. #[test] fn split_date_on_a_time_field_with_no_date_format_is_refused() { let scratch = Scratch::new("split_date_no_format"); let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &["--time", "Revenue", "--split-date", "Revenue"], ); assert!(!out.status.success(), "{}", stderr_of(&out)); assert!( stderr_of(&out).contains("--split-date"), "{}", stderr_of(&out) ); } /// A name that is no date part gets the categories that exist, never the date /// hint (the README's old `--axis Measure:column` was this). #[test] fn an_absent_axis_category_that_is_no_date_part_names_what_was_built() { let scratch = Scratch::new("axis_unknown_plain"); let out_path = scratch.path("out.improv"); let out = run_import( &dated_csv(&scratch), &out_path, &["--axis", "Measure:column"], ); assert!(!out.status.success(), "{}", stderr_of(&out)); let err = stderr_of(&out); assert!( err.contains("'Measure'") && err.contains("_Measure"), "{err}" ); assert!(!err.contains("--split-date"), "no date hint: {err}"); } /// The README's import recipe runs as printed. #[test] fn the_readmes_import_recipe_runs() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let readme = std::fs::read_to_string(root.join("README.md")).unwrap(); let start = readme.find("improvise import examples/demo.csv").unwrap(); let recipe = readme[start..] .split("```") .next() .unwrap() .replace("\\\n", " "); let mut args: Vec = vec![]; for (i, part) in recipe.split('"').enumerate() { match i % 2 { 1 => args.push(part.to_string()), _ => args.extend(part.split_whitespace().map(String::from)), } } let at = args.iter().position(|a| a == "-o").unwrap(); args.truncate(at); let scratch = Scratch::new("readme_recipe"); let out = Command::new(bin()) .arg("import") .arg(root.join("examples/demo.csv")) .args(args.iter().skip(3)) .arg("-o") .arg(scratch.path("out.improv")) .output() .unwrap(); assert!(out.status.success(), "{}", stderr_of(&out)); } /// 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 --no-wizard -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}" ); }