From 902563a2cbcf2ea1136dd681a294d436a657e19e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 13 May 2026 08:51:09 +1000 Subject: [PATCH 01/25] Add run and resume subcommands to command-line --- src/main.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/main.rs b/src/main.rs index b6dba84..654e5d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,6 +170,35 @@ fn main() { .help("The file containing the Technique you want to render."), ), ) + .subcommand( + Command::new("run") + .about("Interactively work through a Technique procedure.") + .long_about("Walk through the steps of a Technique procedure interactively, \ + prompting you at each step and recording results locally. \ + When a Technique document is instantiated as a running procedure \ + it is allocated a unique identifier. That identifier can be used with \ + `technique resume` to continue an interrupted workflow.") + .arg( + Arg::new("filename") + .required(true) + .help("The file containing the Technique document to run."), + ) + .arg( + Arg::new("arguments") + .num_args(0..) + .action(ArgAction::Append) + .help("Values here, if any, will be bound as the entry procedure's parameters."), + ), + ) + .subcommand( + Command::new("resume") + .about("Resume an interrupted procedure.") + .arg( + Arg::new("id") + .required(true) + .help("The identifier of the run to continue. Can be written as `000007` or just `7`."), + ), + ) .subcommand( Command::new("language") .about("Language Server Protocol integration for editors and IDEs.") @@ -470,6 +499,24 @@ fn main() { _ => panic!("Unrecognized --output value"), } } + Some(("run", submatches)) => { + let filename = submatches + .get_one::("filename") + .unwrap(); + + debug!(filename); + + todo!(); + } + Some(("resume", submatches)) => { + let id = submatches + .get_one::("id") + .unwrap(); + + debug!(id); + + todo!(); + } Some(("language", _)) => { debug!("Starting Language Server"); From 83349ac878e6e3f5d456de7b101471f95051d0a0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 13 May 2026 09:01:35 +1000 Subject: [PATCH 02/25] Error messages for run failures --- src/lib.rs | 1 + src/problem/format.rs | 10 +++++++-- src/problem/messages.rs | 45 +++++++++++++++++++++++++++++++++++++++-- src/runner/runner.rs | 22 ++++++++++++++++++++ src/runner/state.rs | 11 ++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/runner/runner.rs create mode 100644 src/runner/state.rs diff --git a/src/lib.rs b/src/lib.rs index 4920ebf..49b482e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub mod language; pub mod parsing; pub mod program; pub(crate) mod regex; +pub mod runner; pub mod templating; pub mod translation; diff --git a/src/problem/format.rs b/src/problem/format.rs index 58b41ce..d3a6b1d 100644 --- a/src/problem/format.rs +++ b/src/problem/format.rs @@ -1,8 +1,8 @@ -use super::messages::{generate_error_message, generate_translation_error}; +use super::messages::{generate_error_message, generate_runner_error, generate_translation_error}; use owo_colors::OwoColorize; use std::path::Path; use technique::{ - formatting::Render, language::LoadingError, parsing::ParsingError, + formatting::Render, language::LoadingError, parsing::ParsingError, runner::RunnerError, translation::TranslationError, }; @@ -119,6 +119,12 @@ pub fn concise_translation_error<'i>( ) } +/// Format a runner error with concise single-line output. +pub fn concise_runner_error(error: &RunnerError, renderer: &impl Render) -> String { + let (problem, _) = generate_runner_error(error, renderer); + format!("{}: {}", "error".bright_red(), problem.bold()) +} + /// Format a LoadingError with concise single-line output pub fn concise_loading_error<'i>(error: &LoadingError<'i>) -> String { format!( diff --git a/src/problem/messages.rs b/src/problem/messages.rs index fc3afc2..40a888e 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1,6 +1,7 @@ use crate::problem::Present; use technique::{ - formatting::Render, language::*, parsing::ParsingError, translation::TranslationError, + formatting::Render, language::*, parsing::ParsingError, runner::RunnerError, + translation::TranslationError, }; /// Generate problem and detail messages for parsing errors using AST construction @@ -1018,7 +1019,8 @@ Hyphens, underscores, spaces, or subscripts are not valid in unit symbols. } } -/// Generate problem and detail messages for translation errors. +/// Generate problem and detail messages for errors occuring during the +/// translation phase. pub fn generate_translation_error<'i>( error: &TranslationError<'i>, _renderer: &dyn Render, @@ -1048,3 +1050,42 @@ pub fn generate_translation_error<'i>( ), } } + +/// Generate problem and detail messages for errors occuring when a proceure +/// is being evaluated by the runner. +pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (String, String) { + match error { + RunnerError::NoSuchRun(id) => ( + format!("No such run '{:06}'", id.0), + "The directory for this run identifier was not found in the local state store.".to_string(), + ), + RunnerError::StoreError { path, error } => ( + format!("I/O error with local state store at {}", path.display()), + format!("{}", error), + ), + RunnerError::MalformedRecord { run, .. } => ( + format!("Malformed record for run '{:06}'", run.0), + "The PFFTT state file for this run could not be parsed.".to_string(), + ), + RunnerError::ManifestMissing(id) => ( + format!("Manifest missing in run '{:06}'", id.0), + "The state file is present but its first tablet (the manifest) is missing or malformed.".to_string(), + ), + RunnerError::InvalidRunId(text) => ( + format!("Invalid run identifier '{}'", text), + "Run identifiers are integer values, conventionally rendered as six zero-padded digits.".to_string(), + ), + RunnerError::MissingEntryProcedure => ( + "No entry procedure".to_string(), + "The document has neither procedure declarations nor top-level steps so the runner can't start its walk.".to_string(), + ), + RunnerError::UnboundVariable(name) => ( + format!("Unbound variable '{}'", name), + "The procedure has not yet supplied a value for this variable!".to_string(), + ), + RunnerError::UserQuit => ( + "Interrupted".to_string(), + "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), + ), + } +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs new file mode 100644 index 0000000..4bb0a13 --- /dev/null +++ b/src/runner/runner.rs @@ -0,0 +1,22 @@ +//! Interactive walker over a translated Program. + +use std::io; +use std::path::PathBuf; + +use super::state::{RecordError, RunId}; + +/// Anything that can go wrong while preparing or running a Technique. +/// Variants are populated as the implementing steps land; the formatter +/// in `crate::problem` knows how to render each one. +#[allow(dead_code)] +#[derive(Debug)] +pub enum RunnerError { + NoSuchRun(RunId), + StoreError { path: PathBuf, error: io::Error }, + MalformedRecord { run: RunId, error: RecordError }, + ManifestMissing(RunId), + InvalidRunId(String), + MissingEntryProcedure, + UnboundVariable(String), + UserQuit, +} diff --git a/src/runner/state.rs b/src/runner/state.rs new file mode 100644 index 0000000..80af454 --- /dev/null +++ b/src/runner/state.rs @@ -0,0 +1,11 @@ +//! On-disk state store and run identifiers. + +/// Monotonic identifier for a run. Conventionally rendered and stored as a +/// six-digit zero-padded string. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub struct RunId(pub u32); + +/// Errors arising from parsing or writing PFFTT files. Variants are added +/// as the parser and writer land. +#[derive(Debug)] +pub enum RecordError {} From 523eee5396255ec141f1d7311f1469620dac6574 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 13 May 2026 09:03:20 +1000 Subject: [PATCH 03/25] Stubs for runner code --- src/runner/manifest.rs | 2 ++ src/runner/mod.rs | 13 +++++++++++++ src/runner/path.rs | 1 + src/runner/prompt.rs | 1 + src/runner/value.rs | 2 ++ 5 files changed, 19 insertions(+) create mode 100644 src/runner/manifest.rs create mode 100644 src/runner/mod.rs create mode 100644 src/runner/path.rs create mode 100644 src/runner/prompt.rs create mode 100644 src/runner/value.rs diff --git a/src/runner/manifest.rs b/src/runner/manifest.rs new file mode 100644 index 0000000..ff5ad4f --- /dev/null +++ b/src/runner/manifest.rs @@ -0,0 +1,2 @@ +//! PFFTT manifest — the first tablet of a run's state file. Captures the +//! source document URL and the run's start time. diff --git a/src/runner/mod.rs b/src/runner/mod.rs new file mode 100644 index 0000000..45aa211 --- /dev/null +++ b/src/runner/mod.rs @@ -0,0 +1,13 @@ +//! Interactive runner that walks a translated Program step-by-step, +//! prompting the operator and recording each completed step to a state store +//! so a run can be resumed after interruption. + +mod manifest; +mod path; +mod prompt; +mod runner; +mod state; +mod value; + +pub use runner::RunnerError; +pub use state::{RecordError, RunId}; diff --git a/src/runner/path.rs b/src/runner/path.rs new file mode 100644 index 0000000..0b80da7 --- /dev/null +++ b/src/runner/path.rs @@ -0,0 +1 @@ +//! Fully-qualified path rendering for steps as the walker descends. diff --git a/src/runner/prompt.rs b/src/runner/prompt.rs new file mode 100644 index 0000000..587e3dd --- /dev/null +++ b/src/runner/prompt.rs @@ -0,0 +1 @@ +//! Prompt trait, console implementation, and test mock. diff --git a/src/runner/value.rs b/src/runner/value.rs new file mode 100644 index 0000000..de5fc7d --- /dev/null +++ b/src/runner/value.rs @@ -0,0 +1,2 @@ +//! Runtime values and the strict reducer that evaluates value-bearing +//! Operations into Values for description rendering and binding. From b11a33c09287a5407ece8370cae2429134ce209c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 14 May 2026 11:40:09 +1000 Subject: [PATCH 04/25] Add RunId parse and render --- src/runner/checks/state.rs | 59 ++++++++++++++++++++++++++++++++++++++ src/runner/state.rs | 15 ++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/runner/checks/state.rs diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs new file mode 100644 index 0000000..0c07063 --- /dev/null +++ b/src/runner/checks/state.rs @@ -0,0 +1,59 @@ +use crate::runner::runner::RunnerError; +use crate::runner::state::RunId; + +#[test] +fn run_id_parse_padded() { + let id = RunId::parse("000007").expect("parse padded"); + assert_eq!(id, RunId(7)); +} + +#[test] +fn run_id_parse_unpadded() { + let id = RunId::parse("7").expect("parse unpadded"); + assert_eq!(id, RunId(7)); +} + +#[test] +fn run_id_parse_large() { + let id = RunId::parse("123456").expect("parse large"); + assert_eq!(id, RunId(123456)); +} + +#[test] +fn run_id_parse_rejects_empty() { + match RunId::parse("") { + Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, ""), + other => panic!("expected InvalidRunId, got {:?}", other), + } +} + +#[test] +fn run_id_parse_rejects_alphabetic() { + match RunId::parse("abc") { + Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, "abc"), + other => panic!("expected InvalidRunId, got {:?}", other), + } +} + +#[test] +fn run_id_parse_rejects_negative() { + match RunId::parse("-1") { + Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, "-1"), + other => panic!("expected InvalidRunId, got {:?}", other), + } +} + +#[test] +fn run_id_render_pads_to_six() { + assert_eq!(RunId(7).render(), "000007"); + assert_eq!(RunId(0).render(), "000000"); + assert_eq!(RunId(142).render(), "000142"); + assert_eq!(RunId(15003).render(), "015003"); + assert_eq!(RunId(123456).render(), "123456"); +} + +#[test] +fn run_id_render_wider_than_six_unpadded() { + // Six digits is the convention but larger values render naturally. + assert_eq!(RunId(1_234_567).render(), "1234567"); +} diff --git a/src/runner/state.rs b/src/runner/state.rs index 80af454..b5b403e 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -5,6 +5,21 @@ #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub struct RunId(pub u32); +impl RunId { + /// Parse a run identifier. Both unpadded (`7`) and zero-padded + /// (`000007`) decimal forms are accepted. + pub fn parse(text: &str) -> Result { + text.parse::() + .map(RunId) + .map_err(|_| RunnerError::InvalidRunId(text.to_string())) + } + + /// Render as a six-digit zero-padded decimal string. + pub fn render(self) -> String { + format!("{:06}", self.0) + } +} + /// Errors arising from parsing or writing PFFTT files. Variants are added /// as the parser and writer land. #[derive(Debug)] From b2c2b6fe3ecc2b5811af7e630a7e535e75d35ba5 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 14 May 2026 11:46:34 +1000 Subject: [PATCH 05/25] Implement basic state store --- src/runner/checks/state.rs | 35 ++++++++++++- src/runner/state.rs | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 0c07063..1fd8aed 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -1,5 +1,5 @@ use crate::runner::runner::RunnerError; -use crate::runner::state::RunId; +use crate::runner::state::{RunId, Store}; #[test] fn run_id_parse_padded() { @@ -57,3 +57,36 @@ fn run_id_render_wider_than_six_unpadded() { // Six digits is the convention but larger values render naturally. assert_eq!(RunId(1_234_567).render(), "1234567"); } + +#[test] +fn store_allocate_assigns_monotonic_ids() { + let base = std::env::temp_dir().join("technique-allocate-monotonic"); + let _ = std::fs::remove_dir_all(&base); + + let store = Store::new(base.clone()); + let (first, _) = store + .allocate() + .expect("first"); + let (second, _) = store + .allocate() + .expect("second"); + assert_eq!(first, RunId(1)); + assert_eq!(second, RunId(2)); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn store_allocate_resumes_from_existing_max() { + let base = std::env::temp_dir().join("technique-allocate-resume"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("000007")).unwrap(); + + let store = Store::new(base.clone()); + let (id, _) = store + .allocate() + .expect("allocate"); + assert_eq!(id, RunId(8)); + + let _ = std::fs::remove_dir_all(&base); +} diff --git a/src/runner/state.rs b/src/runner/state.rs index b5b403e..c1d2dba 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -1,5 +1,10 @@ //! On-disk state store and run identifiers. +use std::io; +use std::path::PathBuf; + +use super::runner::RunnerError; + /// Monotonic identifier for a run. Conventionally rendered and stored as a /// six-digit zero-padded string. #[derive(Copy, Clone, Eq, PartialEq, Debug)] @@ -24,3 +29,98 @@ impl RunId { /// as the parser and writer land. #[derive(Debug)] pub enum RecordError {} + +/// On-disk store of runs, rooted at some base directory (conventionally +/// `.store/` relative to the operator's current directory). +#[allow(dead_code)] +pub struct Store { + base: PathBuf, +} + +// Cap the number of times the allocator retries when another process has +// taken the identifier we just computed. The race window is small; a +// handful of retries is more than enough in practice. +#[allow(dead_code)] +const ALLOCATE_RETRIES: usize = 4; + +#[allow(dead_code)] +impl Store { + /// Build a handle to a store rooted at `base`. No I/O happens here; the + /// directory is created on the first call to `allocate`. + pub fn new(base: PathBuf) -> Self { + Store { base } + } + + /// Allocate a new run identifier and create its directory. Returns the + /// identifier and the path of the new directory. + pub fn allocate(&self) -> Result<(RunId, PathBuf), RunnerError> { + // Make sure the store root exists before scanning for siblings. + if let Err(error) = std::fs::create_dir_all(&self.base) { + return Err(RunnerError::StoreError { + path: self + .base + .clone(), + error, + }); + } + + for _ in 0..ALLOCATE_RETRIES { + let next = self.next_identifier()?; + let path = self + .base + .join(next.render()); + match std::fs::create_dir(&path) { + Ok(()) => return Ok((next, path)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(RunnerError::StoreError { path, error }), + } + } + + Err(RunnerError::StoreError { + path: self + .base + .clone(), + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "exhausted retries allocating a run identifier", + ), + }) + } + + // Scan the store for the highest existing run identifier and return + // one more. Entries whose names are not valid decimal integers are + // ignored, which keeps the allocator robust against editor scratch + // files left in `.store/`. + fn next_identifier(&self) -> Result { + let mut max: u32 = 0; + let entries = std::fs::read_dir(&self.base).map_err(|error| RunnerError::StoreError { + path: self + .base + .clone(), + error, + })?; + for entry in entries { + let entry = entry.map_err(|error| RunnerError::StoreError { + path: self + .base + .clone(), + error, + })?; + if let Some(name) = entry + .file_name() + .to_str() + { + if let Ok(n) = name.parse::() { + if n > max { + max = n; + } + } + } + } + Ok(RunId(max + 1)) + } +} + +#[cfg(test)] +#[path = "checks/state.rs"] +mod check; From 76f780f02759f205662fb3d6b2f65e9aec7cd14e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 15 May 2026 19:02:16 +1000 Subject: [PATCH 06/25] Write and parse PFFTT lines --- src/runner/checks/state.rs | 200 ++++++++++++++++++++++++++- src/runner/manifest.rs | 9 ++ src/runner/state.rs | 269 ++++++++++++++++++++++++++++++++++++- 3 files changed, 472 insertions(+), 6 deletions(-) diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 1fd8aed..40427e4 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -1,5 +1,9 @@ +use std::path::{Path, PathBuf}; + use crate::runner::runner::RunnerError; -use crate::runner::state::{RunId, Store}; +use crate::runner::state::{ + format_record, parse_manifest, parse_record, Outcome, Record, RunId, Store, +}; #[test] fn run_id_parse_padded() { @@ -90,3 +94,197 @@ fn store_allocate_resumes_from_existing_max() { let _ = std::fs::remove_dir_all(&base); } + +#[test] +fn manifest_round_trip_through_create_and_open() { + let base = std::env::temp_dir().join("technique-manifest-roundtrip"); + let _ = std::fs::remove_dir_all(&base); + + let document = PathBuf::from("/somewhere/NetworkProbe.tq"); + let started = "2026-05-14T12:34:56Z".to_string(); + + let store = Store::new(base.clone()); + let (id, _, written) = store + .create(&document, started.clone()) + .expect("create"); + let (read, completed, _) = store + .open(id) + .expect("open"); + + assert_eq!(written, read); + assert_eq!(read.document, document); + assert_eq!(read.started, started); + assert!(completed.is_empty()); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn open_replays_three_result_paths() { + let base = std::env::temp_dir().join("technique-replay-three"); + let _ = std::fs::remove_dir_all(&base); + + let run_dir = base.join("000001"); + std::fs::create_dir_all(&run_dir).unwrap(); + let mut file = String::new(); + file.push_str("[ document = file:///foo/Test.tq, started = 2026-05-14T12:00:00Z ]\n"); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + path: "test:1".to_string(), + outcome: Outcome::Done(None), + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + path: "test:2".to_string(), + outcome: Outcome::Skipped, + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + path: "test:3".to_string(), + outcome: Outcome::Failed(None), + })); + std::fs::write(run_dir.join("Test.pfftt"), file).unwrap(); + + let store = Store::new(base.clone()); + let (manifest, completed, _) = store + .open(RunId(1)) + .expect("open"); + + assert_eq!(manifest.document, Path::new("/foo/Test.tq")); + assert_eq!(completed.len(), 3); + assert!(completed.contains("test:1")); + assert!(completed.contains("test:2")); + assert!(completed.contains("test:3")); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn open_missing_run_returns_no_such_run() { + let base = std::env::temp_dir().join("technique-no-such-run"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + + let store = Store::new(base.clone()); + match store.open(RunId(42)) { + Err(RunnerError::NoSuchRun(id)) => assert_eq!(id, RunId(42)), + other => panic!("expected NoSuchRun, got {:?}", other), + } + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn create_writes_pfftt_file_with_expected_content() { + let base = std::env::temp_dir().join("technique-create-bytes"); + let _ = std::fs::remove_dir_all(&base); + + let document = PathBuf::from("/somewhere/NetworkProbe.tq"); + let started = "2026-05-14T12:34:56Z".to_string(); + + let store = Store::new(base.clone()); + let (_, run_dir, _) = store + .create(&document, started) + .expect("create"); + + let pfftt = run_dir.join("NetworkProbe.pfftt"); + let on_disk = std::fs::read_to_string(&pfftt).expect("read pfftt"); + assert_eq!( + on_disk, + "[ document = file:///somewhere/NetworkProbe.tq, started = 2026-05-14T12:34:56Z ]\n" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn format_record_produces_expected_text() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Done(None), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]\n" + ); +} + +#[test] +fn format_record_done_with_result_emits_sibling_field() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "lookup:1".to_string(), + outcome: Outcome::Done(Some("\"penguin\"".to_string())), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = lookup:1, outcome = Done, result = \"penguin\" ]\n" + ); +} + +#[test] +fn format_record_failed_with_reason_emits_sibling_field() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "ping:1".to_string(), + outcome: Outcome::Failed(Some("\"network unplugged\"".to_string())), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed, reason = \"network unplugged\" ]\n" + ); +} + +#[test] +fn parse_manifest_reads_expected_text() { + let line = "[ document = file:///foo/bar.tq, started = 2026-05-14T01:02:03Z ]"; + let manifest = parse_manifest(line).expect("parse"); + assert_eq!(manifest.document, Path::new("/foo/bar.tq")); + assert_eq!(manifest.started, "2026-05-14T01:02:03Z"); +} + +#[test] +fn parse_record_yields_expected_fields() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]"; + let record = parse_record(line).expect("parse"); + assert_eq!( + record, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "make_coffee:2".to_string(), + outcome: Outcome::Done(None), + } + ); +} + +#[test] +fn parse_record_done_with_result_folds_into_variant() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = lookup:1, outcome = Done, result = \"penguin\" ]"; + let record = parse_record(line).expect("parse"); + assert_eq!( + record.outcome, + Outcome::Done(Some("\"penguin\"".to_string())) + ); +} + +#[test] +fn parse_record_failed_with_reason_folds_into_variant() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed, reason = \"network unplugged\" ]"; + let record = parse_record(line).expect("parse"); + assert_eq!( + record.outcome, + Outcome::Failed(Some("\"network unplugged\"".to_string())) + ); +} + +#[test] +fn parse_record_rejects_unknown_outcome() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = x:1, outcome = Maybe ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::UnknownOutcome(text)) => { + assert_eq!(text, "Maybe"); + } + other => panic!("expected UnknownOutcome, got {:?}", other), + } +} diff --git a/src/runner/manifest.rs b/src/runner/manifest.rs index ff5ad4f..e695779 100644 --- a/src/runner/manifest.rs +++ b/src/runner/manifest.rs @@ -1,2 +1,11 @@ //! PFFTT manifest — the first tablet of a run's state file. Captures the //! source document URL and the run's start time. + +use std::path::PathBuf; + +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Manifest { + pub document: PathBuf, + pub started: String, +} diff --git a/src/runner/state.rs b/src/runner/state.rs index c1d2dba..173e07f 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -1,8 +1,10 @@ //! On-disk state store and run identifiers. +use std::collections::HashSet; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use super::manifest::Manifest; use super::runner::RunnerError; /// Monotonic identifier for a run. Conventionally rendered and stored as a @@ -25,10 +27,48 @@ impl RunId { } } -/// Errors arising from parsing or writing PFFTT files. Variants are added -/// as the parser and writer land. -#[derive(Debug)] -pub enum RecordError {} +/// Errors raised if a PFFTT file is malformed or invalid. +#[derive(Debug, Eq, PartialEq)] +pub enum RecordError { + MalformedTablet, + MissingField(&'static str), + UnknownOutcome(String), +} + +/// One Result, recorded on disk in the form of a tablet. +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Record { + pub recorded: String, + pub path: String, + pub outcome: Outcome, +} + +/// Outcome of executing a step. + +/// The on-disk PFFTT format keeps `result` and `reason` as sibling fields of +/// `outcome` so these can be grepped for directly without needing to +/// destructure this type's constructors. +/// +/// It also facilitates future combinations (e.g. partial result accompanying +/// a failure) that we currently don't need, but can accomodate in the future +/// without changing the on disk format. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Outcome { + Done(Option), + Skipped, + Failed(Option), +} + +impl Outcome { + fn as_str(&self) -> &'static str { + match self { + Outcome::Done(_) => "Done", + Outcome::Skipped => "Skipped", + Outcome::Failed(_) => "Failed", + } + } +} /// On-disk store of runs, rooted at some base directory (conventionally /// `.store/` relative to the operator's current directory). @@ -87,6 +127,64 @@ impl Store { }) } + /// Allocate a new run, write its manifest, and return the resulting run + /// identifier and start state. The PFFTT file is named after the source + /// document's basename (e.g. `NetworkProbe.pfftt`). + pub fn create( + &self, + document: &Path, + started: String, + ) -> Result<(RunId, PathBuf, Manifest), RunnerError> { + let (id, run_dir) = self.allocate()?; + let manifest = Manifest { + document: document.to_path_buf(), + started, + }; + let pfftt = construct_state_path(&run_dir, document); + let text = format_manifest(&manifest); + std::fs::write(&pfftt, text) + .map_err(|error| RunnerError::StoreError { path: pfftt, error })?; + Ok((id, run_dir, manifest)) + } + + /// Open an existing run. Parses the manifest and replays Result tablets + /// into a set of completed step paths. + pub fn open(&self, id: RunId) -> Result<(Manifest, HashSet, PathBuf), RunnerError> { + let run_dir = self + .base + .join(id.render()); + if !run_dir.is_dir() { + return Err(RunnerError::NoSuchRun(id)); + } + let pfftt = find_pfftt_file(&run_dir, id)?; + let content = std::fs::read_to_string(&pfftt).map_err(|error| RunnerError::StoreError { + path: pfftt.clone(), + error, + })?; + + let mut tablets = content + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }); + + let manifest = match tablets.next() { + Some(line) => parse_manifest(line) + .map_err(|error| RunnerError::MalformedRecord { run: id, error })?, + None => return Err(RunnerError::ManifestMissing(id)), + }; + + let mut completed = HashSet::new(); + for line in tablets { + let record = parse_record(line) + .map_err(|error| RunnerError::MalformedRecord { run: id, error })?; + completed.insert(record.path); + } + Ok((manifest, completed, run_dir)) + } + // Scan the store for the highest existing run identifier and return // one more. Entries whose names are not valid decimal integers are // ignored, which keeps the allocator robust against editor scratch @@ -121,6 +219,167 @@ impl Store { } } +// Compute the on-disk PFFTT file path for a run, named after the source +// document's stem. +fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { + let stem = document + .file_stem() + .map(|s| s.to_os_string()) + .unwrap_or_default(); + let mut name = PathBuf::from(stem); + name.set_extension("pfftt"); + run_dir.join(name) +} + +// Locate the single `*.pfftt` file in a run directory. +fn find_pfftt_file(run_dir: &Path, id: RunId) -> Result { + let entries = std::fs::read_dir(run_dir).map_err(|error| RunnerError::StoreError { + path: run_dir.to_path_buf(), + error, + })?; + for entry in entries { + let entry = entry.map_err(|error| RunnerError::StoreError { + path: run_dir.to_path_buf(), + error, + })?; + let path = entry.path(); + if path + .extension() + .and_then(|s| s.to_str()) + == Some("pfftt") + { + return Ok(path); + } + } + Err(RunnerError::ManifestMissing(id)) +} + +// Serialize a manifest as a single PFFTT tablet line. The trailing +// newline is part of the on-disk shape — every tablet occupies its own +// line. +pub(crate) fn format_manifest(m: &Manifest) -> String { + format!( + "[ document = file://{}, started = {} ]\n", + m.document + .display(), + m.started, + ) +} + +// Parse a manifest line into a Manifest. The `document` field is stored +// as a `file://` URL; the prefix is stripped on read. +pub(crate) fn parse_manifest(line: &str) -> Result { + let entries = parse_tablet(line)?; + let document = entries + .iter() + .find(|(k, _)| *k == "document") + .map(|(_, v)| *v) + .ok_or(RecordError::MissingField("document"))?; + let started = entries + .iter() + .find(|(k, _)| *k == "started") + .map(|(_, v)| *v) + .ok_or(RecordError::MissingField("started"))?; + let document = document + .strip_prefix("file://") + .unwrap_or(document); + Ok(Manifest { + document: PathBuf::from(document), + started: started.to_string(), + }) +} + +// Serialize a Result tablet from a Record. The `outcome` field carries +// the bare discriminator; `result` and `reason` are sibling fields +// emitted only when the corresponding Outcome variant carries a payload. +#[allow(dead_code)] +pub(crate) fn format_record(record: &Record) -> String { + let mut text = format!( + "[ recorded = {}, path = {}, outcome = {}", + record.recorded, + record.path, + record + .outcome + .as_str(), + ); + match &record.outcome { + Outcome::Done(Some(result)) => { + text.push_str(", result = "); + text.push_str(result); + } + Outcome::Failed(Some(reason)) => { + text.push_str(", reason = "); + text.push_str(reason); + } + _ => {} + } + text.push_str(" ]\n"); + text +} + +// Parse a Result tablet line into a Record. Sibling fields `result` and +// `reason` are folded into the Outcome variant they belong to. +pub(crate) fn parse_record(line: &str) -> Result { + let entries = Entries(parse_tablet(line)?); + let recorded = entries.required("recorded")?; + let path = entries.required("path")?; + let keyword = entries.required("outcome")?; + let result = entries.optional("result"); + let reason = entries.optional("reason"); + let outcome = match keyword { + "Done" => Outcome::Done(result.map(str::to_string)), + "Skipped" => Outcome::Skipped, + "Failed" => Outcome::Failed(reason.map(str::to_string)), + other => return Err(RecordError::UnknownOutcome(other.to_string())), + }; + Ok(Record { + recorded: recorded.to_string(), + path: path.to_string(), + outcome, + }) +} + +struct Entries<'a>(Vec<(&'a str, &'a str)>); + +impl<'a> Entries<'a> { + fn required(&self, name: &'static str) -> Result<&'a str, RecordError> { + self.optional(name) + .ok_or(RecordError::MissingField(name)) + } + + fn optional(&self, name: &'static str) -> Option<&'a str> { + self.0 + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| *v) + } +} + +// Split a `[ k = v, k = v, ... ]` line into (key, value) pairs. Permissive on +// whitespace; rejects anything without the bracketed envelope. +// +// TODO support nested tablets. +fn parse_tablet(line: &str) -> Result, RecordError> { + let trimmed = line.trim(); + let inner = trimmed + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or(RecordError::MalformedTablet)? + .trim(); + let mut entries = Vec::new(); + for entry in inner.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let (k, v) = entry + .split_once('=') + .ok_or(RecordError::MalformedTablet)?; + entries.push((k.trim(), v.trim())); + } + Ok(entries) +} + #[cfg(test)] #[path = "checks/state.rs"] mod check; From 466aea7c90cbb8af67bc87f3cffc33e0cd23e2e7 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 15 May 2026 22:19:42 +1000 Subject: [PATCH 07/25] Codify the Qualified Paths of steps --- src/runner/checks/path.rs | 105 +++++++++++++++++++++++++++++++++ src/runner/path.rs | 119 ++++++++++++++++++++++++++++++++++++++ src/runner/state.rs | 2 +- 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/runner/checks/path.rs diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs new file mode 100644 index 0000000..5a9166d --- /dev/null +++ b/src/runner/checks/path.rs @@ -0,0 +1,105 @@ +use crate::language::{Attribute, Identifier, Span}; +use crate::runner::path::{PathSegment, QualifiedPath}; + +#[test] +fn empty_stack_renders_empty_string() { + let stack = QualifiedPath::new(); + assert_eq!(stack.render(), ""); +} + +#[test] +fn single_section_renders_numeral() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Section("I")); + assert_eq!(stack.render(), "I"); +} + +#[test] +fn section_then_step_joins_with_slash() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Section("I")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "I/2"); +} + +#[test] +fn dependent_substep_chain() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "2/a"); +} + +#[test] +fn parallel_step_uses_dash_prefix() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::ParallelStep(3)); + assert_eq!(stack.render(), "-3"); +} + +#[test] +fn attribute_frame_composes_role_and_place() { + let frame = vec![ + Attribute::Role(Identifier::new("chef"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "@chef^kitchen/a"); +} + +#[test] +fn nested_attribute_frames_each_contribute_a_segment() { + let outer = vec![Attribute::Role(Identifier::new("team"), Span::default())]; + let inner = vec![ + Attribute::Role(Identifier::new("chef"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::Attributes(&outer)); + stack.push(PathSegment::Attributes(&inner)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "2/@team/@chef^kitchen/a"); +} + +#[test] +fn reset_attribute_suppresses_frame() { + let frame = vec![Attribute::Role(Identifier::new("*"), Span::default())]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "1/a"); +} + +#[test] +fn procedure_uses_colon_separator() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Procedure("make_coffee")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "make_coffee:2"); +} + +#[test] +fn procedure_descent_replaces_outer_prefix() { + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Procedure("outer")); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Procedure("inner")); + stack.push(PathSegment::DependentStep("2")); + assert_eq!(stack.render(), "inner:2"); +} + +#[test] +fn full_qualified_example_from_objective() { + // 2/@barista/a/-1 — dependent step 2, role @barista, substep a, first parallel sub-substep + let frame = vec![Attribute::Role(Identifier::new("barista"), Span::default())]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("2")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + stack.push(PathSegment::ParallelStep(1)); + assert_eq!(stack.render(), "2/@barista/a/-1"); +} diff --git a/src/runner/path.rs b/src/runner/path.rs index 0b80da7..1380c57 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -1 +1,120 @@ //! Fully-qualified path rendering for steps as the walker descends. + +use crate::language; + +/// One position in the structural path to a step. The runner pushes one +/// of these as it enters each containing scope, pops on exit. Strings +/// are borrowed from the source via the IR; the renderer materialises +/// an owned String only on demand. +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum PathSegment<'i> { + Section(&'i str), + DependentStep(&'i str), + ParallelStep(usize), + Attributes(&'i [language::Attribute<'i>]), + Procedure(&'i str), +} + +/// Absolute path from the document root to the walker's current +/// position. The runner pushes a segment on scope entry, pops on exit; +/// `render()` produces the document-rooted form (the tail of the +/// fully-qualified identifier — URL, file path, and run id are added by +/// outer layers). +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct QualifiedPath<'i> { + segments: Vec>, +} + +#[allow(dead_code)] +impl<'i> QualifiedPath<'i> { + pub fn new() -> Self { + QualifiedPath { + segments: Vec::new(), + } + } + + pub fn push(&mut self, segment: PathSegment<'i>) { + self.segments + .push(segment); + } + + pub fn pop(&mut self) -> Option> { + self.segments + .pop() + } + + /// Render the current path as a string. A `Procedure` segment opens + /// a fresh scope, so only segments after the last `Procedure` are + /// shown; the procedure's name becomes the prefix, joined to the + /// rest with `:`. Other segments are `/`-joined. An attribute frame + /// containing the `@*` reset role contributes nothing. + pub fn render(&self) -> String { + let mut prefix: Option<&str> = None; + let mut start: usize = 0; + for (i, segment) in self + .segments + .iter() + .enumerate() + { + if let PathSegment::Procedure(name) = segment { + prefix = Some(*name); + start = i + 1; + } + } + + let pieces: Vec = self.segments[start..] + .iter() + .filter_map(render_segment) + .collect(); + + match prefix { + Some(name) if pieces.is_empty() => name.to_string(), + Some(name) => format!("{}:{}", name, pieces.join("/")), + None => pieces.join("/"), + } + } +} + +fn render_segment(segment: &PathSegment) -> Option { + match segment { + PathSegment::Section(numeral) => Some(numeral.to_string()), + PathSegment::DependentStep(ordinal) => Some(ordinal.to_string()), + PathSegment::ParallelStep(idx) => Some(format!("-{}", idx)), + PathSegment::Attributes(frame) => render_attributes(frame), + PathSegment::Procedure(_) => None, + } +} + +fn render_attributes(frame: &[language::Attribute]) -> Option { + // The @* reset role makes the entire frame contribute nothing. + for attr in frame { + if let language::Attribute::Role(id, _) = attr { + if id.value == "*" { + return None; + } + } + } + if frame.is_empty() { + return None; + } + let mut text = String::new(); + for attr in frame { + match attr { + language::Attribute::Role(id, _) => { + text.push('@'); + text.push_str(id.value); + } + language::Attribute::Place(id, _) => { + text.push('^'); + text.push_str(id.value); + } + } + } + Some(text) +} + +#[cfg(test)] +#[path = "checks/path.rs"] +mod check; diff --git a/src/runner/state.rs b/src/runner/state.rs index 173e07f..76069bc 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -357,7 +357,7 @@ impl<'a> Entries<'a> { // Split a `[ k = v, k = v, ... ]` line into (key, value) pairs. Permissive on // whitespace; rejects anything without the bracketed envelope. -// +// // TODO support nested tablets. fn parse_tablet(line: &str) -> Result, RecordError> { let trimmed = line.trim(); From 86522235bf69c8e4107951f4c0ad857e1a401d68 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 15 May 2026 22:19:50 +1000 Subject: [PATCH 08/25] Fix warning --- .github/workflows/check.yaml | 2 ++ .github/workflows/release.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7823d1c..243aba3 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -2,6 +2,8 @@ name: Check on: pull_request: workflow_dispatch: +permissions: + contents: read jobs: build: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e3260f4..42ae54e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,6 +2,8 @@ name: Release on: release: types: [published] +permissions: + contents: read jobs: build: From 28be1afe648e78a1f57521feec7d8d35b93d0f04 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 15 May 2026 23:35:07 +1000 Subject: [PATCH 09/25] Introduce value:: module --- src/runner/checks/value.rs | 123 ++++++++++++++++++++++++++++++++ src/value/checks/types.rs | 42 +++++++++++ src/value/mod.rs | 8 +++ src/value/types.rs | 141 +++++++++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 src/runner/checks/value.rs create mode 100644 src/value/checks/types.rs create mode 100644 src/value/mod.rs create mode 100644 src/value/types.rs diff --git a/src/runner/checks/value.rs b/src/runner/checks/value.rs new file mode 100644 index 0000000..8705166 --- /dev/null +++ b/src/runner/checks/value.rs @@ -0,0 +1,123 @@ +use crate::language::{Identifier, Numeric as LangNumeric}; +use crate::program::{Entry, Fragment, Operation}; +use crate::runner::runner::RunnerError; +use crate::runner::value::{reduce, Environment}; +use crate::value::{Numeric, Value}; + +#[test] +fn unbound_variable_errors() { + let op = Operation::Variable(Identifier::new("missing")); + let mut env = Environment::new(); + match reduce(&op, &mut env) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + +#[test] +fn bound_variable_looks_up() { + let mut env = Environment::new(); + env.extend("name".to_string(), Value::Literali("World".to_string())); + let op = Operation::Variable(Identifier::new("name")); + let v = reduce(&op, &mut env).expect("reduce"); + assert_eq!(v, Value::Literali("World".to_string())); +} + +#[test] +fn number_reduces_to_quanticle() { + let op = Operation::Number(LangNumeric::Integral(42)); + let mut env = Environment::new(); + let v = reduce(&op, &mut env).expect("reduce"); + assert_eq!(v, Value::Quanticle(Numeric::Integral(42))); +} + +#[test] +fn string_fragments_interpolate_bound_variable() { + let mut env = Environment::new(); + env.extend("name".to_string(), Value::Literali("World".to_string())); + let op = Operation::String(vec![ + Fragment::Text("Hello, "), + Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), + Fragment::Text("!"), + ]); + let v = reduce(&op, &mut env).expect("reduce"); + assert_eq!(v, Value::Literali("Hello, World!".to_string())); +} + +#[test] +fn string_interpolation_propagates_unbound_error() { + let op = Operation::String(vec![ + Fragment::Text("hi "), + Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), + ]); + let mut env = Environment::new(); + match reduce(&op, &mut env) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + +#[test] +fn multiline_joins_with_newlines() { + let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); + let mut env = Environment::new(); + let v = reduce(&op, &mut env).expect("reduce"); + assert_eq!(v, Value::Literali("foo\nbar\nbaz".to_string())); +} + +#[test] +fn tablet_entries_reduce() { + let op = Operation::Tablet(vec![ + Entry { + label: "name", + value: Operation::String(vec![Fragment::Text("Kowalski")]), + }, + Entry { + label: "count", + value: Operation::Number(LangNumeric::Integral(7)), + }, + ]); + let mut env = Environment::new(); + let v = reduce(&op, &mut env).expect("reduce"); + assert_eq!( + v, + Value::Tabularum(vec![ + ("name".to_string(), Value::Literali("Kowalski".to_string())), + ("count".to_string(), Value::Quanticle(Numeric::Integral(7))), + ]) + ); +} + +#[test] +fn bind_extends_env_for_subsequent_lookup() { + let names = [Identifier::new("greeting")]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::String(vec![Fragment::Text("Hello")])), + }; + let lookup = Operation::Variable(Identifier::new("greeting")); + let seq = Operation::Sequence(vec![bind, lookup]); + let mut env = Environment::new(); + let v = reduce(&seq, &mut env).expect("reduce"); + assert_eq!(v, Value::Literali("Hello".to_string())); +} + +#[test] +fn sequence_returns_last_value() { + let seq = Operation::Sequence(vec![ + Operation::Number(LangNumeric::Integral(1)), + Operation::Number(LangNumeric::Integral(2)), + Operation::Number(LangNumeric::Integral(3)), + ]); + let mut env = Environment::new(); + let v = reduce(&seq, &mut env).expect("reduce"); + assert_eq!(v, Value::Quanticle(Numeric::Integral(3))); +} + +#[test] +fn empty_sequence_returns_unitus() { + let seq = Operation::Sequence(vec![]); + let mut env = Environment::new(); + let v = reduce(&seq, &mut env).expect("reduce"); + assert_eq!(v, Value::Unitus); +} diff --git a/src/value/checks/types.rs b/src/value/checks/types.rs new file mode 100644 index 0000000..6526483 --- /dev/null +++ b/src/value/checks/types.rs @@ -0,0 +1,42 @@ +use crate::value::{Numeric, Value}; + +#[test] +fn integral_renders_via_formatter() { + let v = Value::Quanticle(Numeric::Integral(42)); + assert_eq!(v.to_string(), "42"); +} + +#[test] +fn render_tabularum_formats_as_bracketed_pairs() { + let v = Value::Tabularum(vec![ + ("first".to_string(), Value::Literali("Anna".to_string())), + ("last".to_string(), Value::Literali("Kowalski".to_string())), + ]); + assert_eq!(v.to_string(), "[first = \"Anna\", last = \"Kowalski\"]"); +} + +#[test] +fn render_unitus_is_empty() { + assert_eq!(Value::Unitus.to_string(), ""); +} + +#[test] +fn render_futurae_is_braced() { + assert_eq!(Value::Futurae("name".to_string()).to_string(), "{name}"); +} + +#[test] +fn render_literali_is_quoted() { + let v = Value::Literali("just text".to_string()); + assert_eq!(v.to_string(), "\"just text\""); +} + +#[test] +fn render_parametriq_formats_as_bracketed_list() { + let v = Value::Parametriq(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + Value::Quanticle(Numeric::Integral(3)), + ]); + assert_eq!(v.to_string(), "[\"a\", \"b\", 3]"); +} diff --git a/src/value/mod.rs b/src/value/mod.rs new file mode 100644 index 0000000..08cf388 --- /dev/null +++ b/src/value/mod.rs @@ -0,0 +1,8 @@ +//! Runtime values. Owned mirror of the data that a translated Program +//! produces: the result of reducing value-bearing operations, the payload of +//! completed step Results in PFFTT files, and the eventual binding target for +//! CLI-supplied entry-procedure arguments. + +mod types; + +pub use types::*; diff --git a/src/value/types.rs b/src/value/types.rs new file mode 100644 index 0000000..29f936d --- /dev/null +++ b/src/value/types.rs @@ -0,0 +1,141 @@ +//! Values are the heart of the Technique language; they are the payload of +//! the results of executing steps. Values are produced at runtime by reducing +//! Operations, and are stored in PFFTT records on-disk. + +use std::fmt::{self, Display}; + +use crate::language; + +/// A value is the payload in the result of a step. +/// +/// Need names? Science names newly discovered creatures in Latin. I don't +/// speak Latin, but neither does anyone else so we can just make words up. +/// Yeay! (The lengths some people will go to in order to avoid qualified +/// imports is really impressive, isn't it?) +/// +/// `Value` is fully owned as results in PFFTT files stand alone irrespective +/// of a source document or live runtime environment. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Unitus, + Literali(String), + Quanticle(Numeric), + Tabularum(Vec<(String, Value)>), + Parametriq(Vec), + Futurae(String), +} + +/// Owned mirror of `language::Numeric`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Numeric { + Integral(i64), + Scientific(Quantity), +} + +/// Owned mirror of `language::Quantity`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Quantity { + pub mantissa: language::Decimal, + pub uncertainty: Option, + pub magnitude: Option, + pub symbol: String, +} + +impl From<&language::Numeric<'_>> for Numeric { + fn from(n: &language::Numeric<'_>) -> Self { + match n { + language::Numeric::Integral(i) => Numeric::Integral(*i), + language::Numeric::Scientific(q) => Numeric::Scientific(Quantity::from(q)), + } + } +} + +impl From<&language::Quantity<'_>> for Quantity { + fn from(q: &language::Quantity<'_>) -> Self { + Quantity { + mantissa: q.mantissa, + uncertainty: q.uncertainty, + magnitude: q.magnitude, + symbol: q + .symbol + .to_string(), + } + } +} + +/// Plain-text rendering of a Value, suitable for splicing into an +/// interpolated string fragment or showing the operator in a step +/// description prompt. Numeric formatting delegates to +/// `crate::formatting`'s number renderer so the operator sees the +/// same shape they would in source. +/// +/// Not intended for on-disk serialization. +impl Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Unitus => Ok(()), + Value::Literali(text) => write!(f, "\"{}\"", text), + Value::Quanticle(numeric) => write!(f, "{}", numeric), + Value::Tabularum(pairs) => { + f.write_str("[")?; + for (i, (label, value)) in pairs + .iter() + .enumerate() + { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{} = {}", label, value)?; + } + f.write_str("]") + } + Value::Parametriq(values) => { + f.write_str("[")?; + for (i, value) in values + .iter() + .enumerate() + { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{}", value)?; + } + f.write_str("]") + } + Value::Futurae(name) => write!(f, "{{{}}}", name), + } + } +} + +// Numeric rendering goes through `crate::formatting`'s number renderer. +// To call into it we briefly reconstruct a borrowed `language::Numeric` +impl Display for Numeric { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Numeric::Integral(i) => { + let n = language::Numeric::Integral(*i); + f.write_str(&crate::formatting::render_numeric( + &n, + &crate::formatting::Identity, + )) + } + Numeric::Scientific(q) => { + let qb = language::Quantity { + mantissa: q.mantissa, + uncertainty: q.uncertainty, + magnitude: q.magnitude, + symbol: &q.symbol, + }; + let n = language::Numeric::Scientific(qb); + f.write_str(&crate::formatting::render_numeric( + &n, + &crate::formatting::Identity, + )) + } + } + } +} + +#[cfg(test)] +#[path = "checks/types.rs"] +mod check; From fbe6e54344575340018a57e6ef3ea23a60d6028e Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 16:31:05 +1000 Subject: [PATCH 10/25] Evaluator to reduce Operations to Values! --- src/lib.rs | 1 + src/problem/messages.rs | 7 ++ src/runner/checks/evaluator.rs | 223 +++++++++++++++++++++++++++++++++ src/runner/checks/value.rs | 123 ------------------ src/runner/evaluator.rs | 139 ++++++++++++++++++++ src/runner/mod.rs | 2 +- src/runner/runner.rs | 1 + src/runner/value.rs | 2 - 8 files changed, 372 insertions(+), 126 deletions(-) create mode 100644 src/runner/checks/evaluator.rs delete mode 100644 src/runner/checks/value.rs create mode 100644 src/runner/evaluator.rs delete mode 100644 src/runner/value.rs diff --git a/src/lib.rs b/src/lib.rs index 49b482e..bdb74fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub(crate) mod regex; pub mod runner; pub mod templating; pub mod translation; +pub mod value; diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 40a888e..371bd61 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1083,6 +1083,13 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St format!("Unbound variable '{}'", name), "The procedure has not yet supplied a value for this variable!".to_string(), ), + RunnerError::BindArityMismatch { expected, actual } => ( + format!( + "Binding arity mismatch: {} names but {} values", + expected, actual + ), + "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + ), RunnerError::UserQuit => ( "Interrupted".to_string(), "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs new file mode 100644 index 0000000..2abbc12 --- /dev/null +++ b/src/runner/checks/evaluator.rs @@ -0,0 +1,223 @@ +use crate::language::{Identifier, Numeric as LangNumeric}; +use crate::program::{Entry, Fragment, Operation}; +use crate::runner::evaluator::{evaluate, Environment}; +use crate::runner::runner::RunnerError; +use crate::value; + +#[test] +fn unbound_variable_errors() { + let op = Operation::Variable(Identifier::new("missing")); + let mut env = Environment::new(); + match evaluate(&mut env, &op) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + +#[test] +fn bound_variable_looks_up() { + let mut env = Environment::new(); + env.extend( + "name".to_string(), + value::Value::Literali("World".to_string()), + ); + let op = Operation::Variable(Identifier::new("name")); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("World".to_string())); +} + +#[test] +fn number_evaluates_to_quanticle() { + let op = Operation::Number(LangNumeric::Integral(42)); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(42))); +} + +#[test] +fn string_fragments_interpolate_bound_variable() { + let mut env = Environment::new(); + env.extend( + "name".to_string(), + value::Value::Literali("World".to_string()), + ); + let op = Operation::String(vec![ + Fragment::Text("Hello, "), + Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), + Fragment::Text("!"), + ]); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("Hello, World!".to_string())); +} + +#[test] +fn string_interpolation_propagates_unbound_error() { + let op = Operation::String(vec![ + Fragment::Text("hi "), + Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), + ]); + let mut env = Environment::new(); + match evaluate(&mut env, &op) { + Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), + other => panic!("expected UnboundVariable, got {:?}", other), + } +} + +#[test] +fn multiline_joins_with_newlines() { + let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!(v, value::Value::Literali("foo\nbar\nbaz".to_string())); +} + +#[test] +fn tablet_entries_evaluate() { + let op = Operation::Tablet(vec![ + Entry { + label: "name", + value: Operation::String(vec![Fragment::Text("Kowalski")]), + }, + Entry { + label: "count", + value: Operation::Number(LangNumeric::Integral(7)), + }, + ]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &op).expect("evaluated"); + assert_eq!( + v, + value::Value::Tabularum(vec![ + ( + "name".to_string(), + value::Value::Literali("Kowalski".to_string()) + ), + ( + "count".to_string(), + value::Value::Quanticle(value::Numeric::Integral(7)) + ), + ]) + ); +} + +#[test] +fn bind_extends_env_for_subsequent_lookup() { + let names = [Identifier::new("greeting")]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::String(vec![Fragment::Text("Hello")])), + }; + let lookup = Operation::Variable(Identifier::new("greeting")); + let seq = Operation::Sequence(vec![bind, lookup]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Literali("Hello".to_string())); +} + +#[test] +fn sequence_returns_last_value() { + let seq = Operation::Sequence(vec![ + Operation::Number(LangNumeric::Integral(1)), + Operation::Number(LangNumeric::Integral(2)), + Operation::Number(LangNumeric::Integral(3)), + ]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(3))); +} + +#[test] +fn empty_sequence_returns_unitus() { + let seq = Operation::Sequence(vec![]); + let mut env = Environment::new(); + let v = evaluate(&mut env, &seq).expect("evaluated"); + assert_eq!(v, value::Value::Unitus); +} + +#[test] +fn multi_name_bind_destructures_parametriq() { + // Build a Parametriq of three values by reducing a wrapped construction. + // Simplest path: pre-stuff env with a Parametriq, then bind a tuple of + // names to a Variable that looks it up. + let mut env = Environment::new(); + env.extend( + "triple".to_string(), + value::Value::Parametriq(vec![ + value::Value::Literali("one".to_string()), + value::Value::Literali("two".to_string()), + value::Value::Quanticle(value::Numeric::Integral(3)), + ]), + ); + let names = [ + Identifier::new("a"), + Identifier::new("b"), + Identifier::new("c"), + ]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("triple"))), + }; + let result = evaluate(&mut env, &bind).expect("evaluated"); + assert_eq!(result, value::Value::Unitus); + assert_eq!( + env.lookup("a"), + Some(&value::Value::Literali("one".to_string())) + ); + assert_eq!( + env.lookup("b"), + Some(&value::Value::Literali("two".to_string())) + ); + assert_eq!( + env.lookup("c"), + Some(&value::Value::Quanticle(value::Numeric::Integral(3))) + ); +} + +#[test] +fn multi_name_bind_wrong_arity_errors() { + let mut env = Environment::new(); + env.extend( + "pair".to_string(), + value::Value::Parametriq(vec![ + value::Value::Literali("one".to_string()), + value::Value::Literali("two".to_string()), + ]), + ); + let names = [ + Identifier::new("a"), + Identifier::new("b"), + Identifier::new("c"), + ]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("pair"))), + }; + match evaluate(&mut env, &bind) { + Err(RunnerError::BindArityMismatch { expected, actual }) => { + assert_eq!(expected, 3); + assert_eq!(actual, 2); + } + other => panic!("expected BindArityMismatch, got {:?}", other), + } +} + +#[test] +fn multi_name_bind_non_parametriq_errors() { + let mut env = Environment::new(); + env.extend( + "scalar".to_string(), + value::Value::Literali("just one".to_string()), + ); + let names = [Identifier::new("a"), Identifier::new("b")]; + let bind = Operation::Bind { + names: &names, + value: Box::new(Operation::Variable(Identifier::new("scalar"))), + }; + match evaluate(&mut env, &bind) { + Err(RunnerError::BindArityMismatch { expected, actual }) => { + assert_eq!(expected, 2); + assert_eq!(actual, 1); + } + other => panic!("expected BindArityMismatch, got {:?}", other), + } +} diff --git a/src/runner/checks/value.rs b/src/runner/checks/value.rs deleted file mode 100644 index 8705166..0000000 --- a/src/runner/checks/value.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::language::{Identifier, Numeric as LangNumeric}; -use crate::program::{Entry, Fragment, Operation}; -use crate::runner::runner::RunnerError; -use crate::runner::value::{reduce, Environment}; -use crate::value::{Numeric, Value}; - -#[test] -fn unbound_variable_errors() { - let op = Operation::Variable(Identifier::new("missing")); - let mut env = Environment::new(); - match reduce(&op, &mut env) { - Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), - other => panic!("expected UnboundVariable, got {:?}", other), - } -} - -#[test] -fn bound_variable_looks_up() { - let mut env = Environment::new(); - env.extend("name".to_string(), Value::Literali("World".to_string())); - let op = Operation::Variable(Identifier::new("name")); - let v = reduce(&op, &mut env).expect("reduce"); - assert_eq!(v, Value::Literali("World".to_string())); -} - -#[test] -fn number_reduces_to_quanticle() { - let op = Operation::Number(LangNumeric::Integral(42)); - let mut env = Environment::new(); - let v = reduce(&op, &mut env).expect("reduce"); - assert_eq!(v, Value::Quanticle(Numeric::Integral(42))); -} - -#[test] -fn string_fragments_interpolate_bound_variable() { - let mut env = Environment::new(); - env.extend("name".to_string(), Value::Literali("World".to_string())); - let op = Operation::String(vec![ - Fragment::Text("Hello, "), - Fragment::Interpolation(Operation::Variable(Identifier::new("name"))), - Fragment::Text("!"), - ]); - let v = reduce(&op, &mut env).expect("reduce"); - assert_eq!(v, Value::Literali("Hello, World!".to_string())); -} - -#[test] -fn string_interpolation_propagates_unbound_error() { - let op = Operation::String(vec![ - Fragment::Text("hi "), - Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), - ]); - let mut env = Environment::new(); - match reduce(&op, &mut env) { - Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "nope"), - other => panic!("expected UnboundVariable, got {:?}", other), - } -} - -#[test] -fn multiline_joins_with_newlines() { - let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); - let mut env = Environment::new(); - let v = reduce(&op, &mut env).expect("reduce"); - assert_eq!(v, Value::Literali("foo\nbar\nbaz".to_string())); -} - -#[test] -fn tablet_entries_reduce() { - let op = Operation::Tablet(vec![ - Entry { - label: "name", - value: Operation::String(vec![Fragment::Text("Kowalski")]), - }, - Entry { - label: "count", - value: Operation::Number(LangNumeric::Integral(7)), - }, - ]); - let mut env = Environment::new(); - let v = reduce(&op, &mut env).expect("reduce"); - assert_eq!( - v, - Value::Tabularum(vec![ - ("name".to_string(), Value::Literali("Kowalski".to_string())), - ("count".to_string(), Value::Quanticle(Numeric::Integral(7))), - ]) - ); -} - -#[test] -fn bind_extends_env_for_subsequent_lookup() { - let names = [Identifier::new("greeting")]; - let bind = Operation::Bind { - names: &names, - value: Box::new(Operation::String(vec![Fragment::Text("Hello")])), - }; - let lookup = Operation::Variable(Identifier::new("greeting")); - let seq = Operation::Sequence(vec![bind, lookup]); - let mut env = Environment::new(); - let v = reduce(&seq, &mut env).expect("reduce"); - assert_eq!(v, Value::Literali("Hello".to_string())); -} - -#[test] -fn sequence_returns_last_value() { - let seq = Operation::Sequence(vec![ - Operation::Number(LangNumeric::Integral(1)), - Operation::Number(LangNumeric::Integral(2)), - Operation::Number(LangNumeric::Integral(3)), - ]); - let mut env = Environment::new(); - let v = reduce(&seq, &mut env).expect("reduce"); - assert_eq!(v, Value::Quanticle(Numeric::Integral(3))); -} - -#[test] -fn empty_sequence_returns_unitus() { - let seq = Operation::Sequence(vec![]); - let mut env = Environment::new(); - let v = reduce(&seq, &mut env).expect("reduce"); - assert_eq!(v, Value::Unitus); -} diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs new file mode 100644 index 0000000..7e6c30d --- /dev/null +++ b/src/runner/evaluator.rs @@ -0,0 +1,139 @@ +//! Variable bindings and the evaluator that turns value-bearing +//! Operations into Values for description rendering and binding. + +use std::collections::HashMap; + +use super::runner::RunnerError; +use crate::program::{Fragment, Operation}; +use crate::value::{Numeric, Value}; + +/// Variable bindings established by the walker as `Bind` operations +/// evaluate. Lookup is by identifier name. +#[allow(dead_code)] +#[derive(Debug, Default, Clone)] +pub struct Environment { + bindings: HashMap, +} + +#[allow(dead_code)] +impl Environment { + pub fn new() -> Self { + Environment { + bindings: HashMap::new(), + } + } + + pub fn lookup(&self, name: &str) -> Option<&Value> { + self.bindings + .get(name) + } + + pub fn extend(&mut self, name: String, value: Value) { + self.bindings + .insert(name, value); + } +} + +/// Evaluate an `Operation` to a `Value`. +/// +/// Fails with `UnboundVariable` etc if the operation cannot be resolved; +/// specifically at this point values of variables need to be known from the +/// `Environment` otherwise the `Operation` can't be evaluated. +/// +/// Non-value variants (Section / Step / Loop / Invoke / Execute) evaluate to +/// `Unitus` rather than failing — `evaluate` is only meant to be called on +/// value-bearing positions and that fallback keeps it total. +#[allow(dead_code)] +pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result { + match op { + Operation::Variable(id) => env + .lookup(id.value) + .cloned() + .ok_or_else(|| { + RunnerError::UnboundVariable( + id.value + .to_string(), + ) + }), + Operation::Number(n) => Ok(Value::Quanticle(Numeric::from(n))), + Operation::String(fragments) => { + let mut text = String::new(); + for fragment in fragments { + match fragment { + Fragment::Text(t) => text.push_str(t), + Fragment::Interpolation(inner) => match evaluate(env, inner)? { + Value::Literali(s) => text.push_str(&s), + other => text.push_str(&other.to_string()), + }, + } + } + Ok(Value::Literali(text)) + } + Operation::Multiline(_, lines) => Ok(Value::Literali(lines.join("\n"))), + Operation::Tablet(entries) => { + let mut pairs = Vec::with_capacity(entries.len()); + for entry in entries { + let v = evaluate(env, &entry.value)?; + pairs.push(( + entry + .label + .to_string(), + v, + )); + } + Ok(Value::Tabularum(pairs)) + } + Operation::Bind { names, value } => { + let v = evaluate(env, value)?; + match names.len() { + 1 => env.extend( + names[0] + .value + .to_string(), + v, + ), + n => { + let Value::Parametriq(values) = v else { + return Err(RunnerError::BindArityMismatch { + expected: n, + actual: 1, + }); + }; + if values.len() != n { + return Err(RunnerError::BindArityMismatch { + expected: n, + actual: values.len(), + }); + } + for (name, value) in names + .iter() + .zip(values) + { + env.extend( + name.value + .to_string(), + value, + ); + } + } + } + Ok(Value::Unitus) + } + Operation::Sequence(ops) => { + let mut last = Value::Unitus; + for child in ops { + last = evaluate(env, child)?; + } + Ok(last) + } + Operation::Section { .. } + | Operation::Step { .. } + | Operation::Loop { .. } + | Operation::Invoke(_) + | Operation::Execute(_) => Ok(Value::Unitus), + } +} + +#[cfg(test)] +#[path = "checks/evaluator.rs"] +mod check; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 45aa211..1bff19c 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -2,12 +2,12 @@ //! prompting the operator and recording each completed step to a state store //! so a run can be resumed after interruption. +mod evaluator; mod manifest; mod path; mod prompt; mod runner; mod state; -mod value; pub use runner::RunnerError; pub use state::{RecordError, RunId}; diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 4bb0a13..5949976 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -18,5 +18,6 @@ pub enum RunnerError { InvalidRunId(String), MissingEntryProcedure, UnboundVariable(String), + BindArityMismatch { expected: usize, actual: usize }, UserQuit, } diff --git a/src/runner/value.rs b/src/runner/value.rs deleted file mode 100644 index de5fc7d..0000000 --- a/src/runner/value.rs +++ /dev/null @@ -1,2 +0,0 @@ -//! Runtime values and the strict reducer that evaluates value-bearing -//! Operations into Values for description rendering and binding. From 335eaef50cb41a630bf81d212611380e12caa39d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 16:32:08 +1000 Subject: [PATCH 11/25] Preliminary code for interaction with user --- src/runner/checks/prompt.rs | 152 ++++++++++++++++++++++++++ src/runner/prompt.rs | 211 ++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/runner/checks/prompt.rs diff --git a/src/runner/checks/prompt.rs b/src/runner/checks/prompt.rs new file mode 100644 index 0000000..ba3f9cf --- /dev/null +++ b/src/runner/checks/prompt.rs @@ -0,0 +1,152 @@ +use std::io::Cursor; + +use crate::runner::prompt::{Console, Event, Mock, Prompt, UserInput}; +use crate::value::Value; + +#[test] +fn mock_returns_canned_answers_in_order() { + let mut p = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Skip, + UserInput::Quit, + ]); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(), UserInput::Skip); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn mock_records_step_and_ask_events() { + let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); + p.step("local_network:I/1", "Check the cable."); + let _ = p.ask(); + assert_eq!( + p.events(), + &[ + Event::Step { + qualified: "local_network:I/1".to_string(), + description: "Check the cable.".to_string(), + }, + Event::Ask, + ] + ); +} + +#[test] +fn mock_records_section_and_announce() { + let mut p = Mock::new(); + p.section("I", "Setup"); + p.announce("Calling helper"); + assert_eq!( + p.events(), + &[ + Event::Section { + qualified: "I".to_string(), + title: "Setup".to_string(), + }, + Event::Announce("Calling helper".to_string()), + ] + ); +} + +#[test] +#[should_panic(expected = "Mock::ask called with no canned answers remaining")] +fn mock_ask_without_answers_panics() { + let mut p = Mock::new(); + let _ = p.ask(); +} + +#[test] +fn console_done_input() { + let input = Cursor::new(b"d\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); +} + +#[test] +fn console_skip_input() { + let input = Cursor::new(b"s\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Skip); +} + +#[test] +fn console_fail_input() { + let input = Cursor::new(b"f\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Fail); +} + +#[test] +fn console_quit_input() { + let input = Cursor::new(b"q\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn console_uppercase_done_accepted() { + let input = Cursor::new(b"DONE\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); +} + +#[test] +fn console_leading_whitespace_tolerated() { + let input = Cursor::new(b" q\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn console_unrecognized_input_reprompts() { + let input = Cursor::new(b"x\nd\n"); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); + // Two prompts written: one for the rejected `x`, one for the + // accepted `d`. The prompt text contains "[d]one". + let written = String::from_utf8(output).expect("utf8"); + assert!( + written + .matches("[d]one") + .count() + >= 2 + ); +} + +#[test] +fn console_eof_returns_quit() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + assert_eq!(p.ask(), UserInput::Quit); +} + +#[test] +fn console_step_writes_fqn_and_description() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + p.step("local_network:I/1", "Check the cable."); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("local_network:I/1")); + assert!(written.contains("Check the cable.")); +} + +#[test] +fn console_section_writes_fqn_and_title() { + let input = Cursor::new(b""); + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(input, &mut output); + p.section("I", "Setup"); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("I")); + assert!(written.contains("Setup")); +} diff --git a/src/runner/prompt.rs b/src/runner/prompt.rs index 587e3dd..d354d8e 100644 --- a/src/runner/prompt.rs +++ b/src/runner/prompt.rs @@ -1 +1,212 @@ //! Prompt trait, console implementation, and test mock. +//! +//! The walker tells the prompt what to show (`step`, `section`, +//! `announce`) and then asks for the operator's verdict (`ask`). The +//! split lets a console implementation render output to stdout, then +//! read a line from stdin, while a test mock simply records what the +//! walker tried to show and returns canned responses. + +use std::io::{self, BufRead, Write}; + +use crate::value::Value; + +/// The person executing each step indicates a verdict on each prompt as +/// follows: +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum UserInput { + Done(Value), + Skip, + Fail, + Quit, +} + +/// What the walker uses to talk to the user executing the Technique. The two +/// initial implementations are for an interactive console, `Console`, and for +/// test cases, `Mock`. +#[allow(dead_code)] +pub trait Prompt { + /// Show the step's Qualified Name and rendered description. + /// The implementation displays them; it does not block waiting for + /// input — the walker calls `ask` for that separately. + fn step(&mut self, qualified: &str, description: &str); + + /// Announce entry to a Section, with its Qualified Name and title text. + fn section(&mut self, qualified: &str, title: &str); + + /// Surface an informational line — Loop body announcements, + /// Execute / Unresolved Invoke announce-only, resume diagnostics. + fn announce(&mut self, message: &str); + + /// Block until the operator answers the most recent `step` prompt. + fn ask(&mut self) -> UserInput; +} + +/// Interactive console prompt. Writes to stdout, reads line-buffered +/// stdin. `d` → Done, `s` → Skip, `f` → Fail, `q` → Quit; matched +/// case-insensitively on the first non-whitespace character. Anything +/// else re-prompts. +#[allow(dead_code)] +pub struct Console { + input: R, + output: W, +} + +#[allow(dead_code)] +impl Console, io::Stdout> { + /// Default console wired to process stdin (line-buffered) and + /// stdout. + pub fn new() -> Self { + Console { + input: io::BufReader::new(io::stdin()), + output: io::stdout(), + } + } +} + +#[allow(dead_code)] +impl Console { + /// Construct a console over arbitrary input and output handles. + /// Useful for end-to-end tests that exercise the actual rendering + /// and parsing paths without needing a TTY. + pub fn with_handles(input: R, output: W) -> Self { + Console { input, output } + } +} + +impl Prompt for Console { + fn step(&mut self, fqn: &str, description: &str) { + let _ = writeln!(self.output, " {}", fqn); + let _ = writeln!(self.output, "{}", description); + } + + fn section(&mut self, qualified: &str, title: &str) { + let _ = writeln!(self.output); + let _ = writeln!(self.output, "=== {} ===", qualified); + if !title.is_empty() { + let _ = writeln!(self.output, "{}", title); + } + } + + fn announce(&mut self, message: &str) { + let _ = writeln!(self.output, "{}", message); + } + + fn ask(&mut self) -> UserInput { + loop { + let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); + let _ = self + .output + .flush(); + let mut line = String::new(); + match self + .input + .read_line(&mut line) + { + Ok(0) => return UserInput::Quit, + Ok(_) => {} + Err(_) => return UserInput::Quit, + } + match line + .trim_start() + .chars() + .next() + .map(|c| c.to_ascii_lowercase()) + { + Some('d') => return UserInput::Done(Value::Unitus), + Some('s') => return UserInput::Skip, + Some('f') => return UserInput::Fail, + Some('q') => return UserInput::Quit, + _ => continue, + } + } + } +} + +/// Simulated prompt responses for test cases. Returns answers from a +/// pre-loaded queue and records every announcement / step / section call so a +/// test can assert what the walker tried to show. +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct Mock { + answers: std::collections::VecDeque, + events: Vec, +} + +/// One thing the walker showed (or attempted to show). Tests use this +/// to inspect ordering and content of the walker's user-facing output. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + Step { + qualified: String, + description: String, + }, + Section { + qualified: String, + title: String, + }, + Announce(String), + Ask, +} + +#[allow(dead_code)] +impl Mock { + /// Construct a Mock with no canned answers — useful for tests that + /// only inspect announcements and never reach an `ask` call. + pub fn new() -> Self { + Mock::default() + } + + /// Construct a Mock pre-loaded with the answers `ask()` will pop + /// in order. Tests that walk past `n` Step prompts need at least + /// `n` answers. + pub fn with_answers>(answers: I) -> Self { + Mock { + answers: answers + .into_iter() + .collect(), + events: Vec::new(), + } + } + + /// Snapshot the event log so far. Cheap clone for assertion in tests. + pub fn events(&self) -> &[Event] { + &self.events + } +} + +impl Prompt for Mock { + fn step(&mut self, fqn: &str, description: &str) { + self.events + .push(Event::Step { + qualified: fqn.to_string(), + description: description.to_string(), + }); + } + + fn section(&mut self, fqn: &str, title: &str) { + self.events + .push(Event::Section { + qualified: fqn.to_string(), + title: title.to_string(), + }); + } + + fn announce(&mut self, message: &str) { + self.events + .push(Event::Announce(message.to_string())); + } + + fn ask(&mut self) -> UserInput { + self.events + .push(Event::Ask); + self.answers + .pop_front() + .expect("Mock::ask called with no canned answers remaining") + } +} + +#[cfg(test)] +#[path = "checks/prompt.rs"] +mod check; From b9cf8ed042fc467a6c995aa08cf82314d89ee2ea Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 17:53:48 +1000 Subject: [PATCH 12/25] Appender for writing to PFFTT files --- src/runner/state.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/runner/state.rs b/src/runner/state.rs index 76069bc..b16c8d9 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -219,9 +219,9 @@ impl Store { } } -// Compute the on-disk PFFTT file path for a run, named after the source +// Compute the on-disk PFFTT file path for a run, named using the source // document's stem. -fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { +pub(crate) fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { let stem = document .file_stem() .map(|s| s.to_os_string()) @@ -231,6 +231,45 @@ fn construct_state_path(run_dir: &Path, document: &Path) -> PathBuf { run_dir.join(name) } +/// Append-only writer for a PFFTT file. This is used by the runner to record +/// a Result tablet for each completed Step. +#[allow(dead_code)] +pub struct Appender { + file: std::fs::File, + path: PathBuf, +} + +#[allow(dead_code)] +impl Appender { + /// Open the PFFTT file for append. The file must already exist (the + /// runner writes the manifest first via `Store::create`). + pub fn open(path: PathBuf) -> Result { + let file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .map_err(|error| RunnerError::StoreError { + path: path.clone(), + error, + })?; + Ok(Appender { file, path }) + } + + /// Append one Result tablet line. Flushes are left to the OS; on Quit + /// the runner drops the Appender, which closes the file. + pub fn append(&mut self, record: &Record) -> Result<(), RunnerError> { + use std::io::Write; + let text = format_record(record); + self.file + .write_all(text.as_bytes()) + .map_err(|error| RunnerError::StoreError { + path: self + .path + .clone(), + error, + }) + } +} + // Locate the single `*.pfftt` file in a run directory. fn find_pfftt_file(run_dir: &Path, id: RunId) -> Result { let entries = std::fs::read_dir(run_dir).map_err(|error| RunnerError::StoreError { From 92259afa6f1b3b60c4df3e36c7a3c8bacf976204 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 19:16:00 +1000 Subject: [PATCH 13/25] Add time dependency --- Cargo.lock | 53 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/runner/runner.rs | 31 +++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d672f2d..1ca2890 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "errno" version = "0.3.14" @@ -291,6 +300,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "once_cell" version = "1.21.4" @@ -315,6 +330,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -482,6 +503,7 @@ dependencies = [ "regex", "serde", "serde_json", + "time", "tinytemplate", "tracing", "tracing-subscriber", @@ -506,6 +528,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinytemplate" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 58856c4..03346d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } serde_json = "1.0" +time = { version = "0.3", features = [ "formatting" ] } tinytemplate = "1.2.1" tracing = "0.1.40" diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 5949976..d8044ef 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -3,7 +3,36 @@ use std::io; use std::path::PathBuf; -use super::state::{RecordError, RunId}; +use super::evaluator::Environment; +use super::path::{PathSegment, QualifiedPath}; +use super::prompt::{Prompt, UserInput}; +use super::state::{Appender, Outcome as RecordOutcome, Record, RecordError, RunId}; +use crate::program::{Operation, Ordinal, Program}; +use crate::value::Value; + +/// What executing an Operation (or evaluating a Step at any scale) +/// produced. `Done(Value)` is the natural success — for a leaf Step +/// the operator's recorded value, for a Sequence / Section / procedure +/// body the unit value once the whole subtree is finished. `Skipped` and +/// `Failed` are operator verdicts on individual Steps. `Quit` is a +/// control signal that propagates immediately up the call stack: +/// nothing is recorded, a `technique resume` would pick up where +/// this run paused. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Outcome { + Done(Value), + Skipped, + Failed(Failure), + Quit, +} + +/// Why a Step failed. +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum Failure { + Aborted(String), +} /// Anything that can go wrong while preparing or running a Technique. /// Variants are populated as the implementing steps land; the formatter From 8fe8369470a1a2891ac099bbabfb1d0a70818244 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 19:27:51 +1000 Subject: [PATCH 14/25] Walk sections and steps --- src/runner/checks/runner.rs | 347 +++++++++++++++++++++++++++++++ src/runner/runner.rs | 271 ++++++++++++++++++++++++ src/translation/checks/errors.rs | 2 +- 3 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 src/runner/checks/runner.rs diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs new file mode 100644 index 0000000..54f87ee --- /dev/null +++ b/src/runner/checks/runner.rs @@ -0,0 +1,347 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use crate::language; +use crate::program::{Operation, Ordinal, Program, Subroutine}; +use crate::runner::prompt::{Event, Mock, UserInput}; +use crate::runner::runner::{Outcome, Runner}; +use crate::runner::state::{Appender, Store}; +use crate::value::Value; + +// A small fixture builder. The Program borrows from its inputs, so the +// caller has to keep storage alive for the duration of the run. We +// return a (base, run_dir, appender) tuple so the test can clean up +// the temp directory afterwards. +struct StoreFixture { + base: PathBuf, + appender: Option, +} + +impl StoreFixture { + fn new(test_name: &str) -> Self { + let base = std::env::temp_dir().join(format!("technique-runner-{}", test_name)); + let _ = std::fs::remove_dir_all(&base); + let store = Store::new(base.clone()); + let document = PathBuf::from("/tmp/Test.tq"); + let (_, run_dir, _) = store + .create(&document, "2026-05-16T00:00:00Z".to_string()) + .expect("create"); + let pfftt = crate::runner::state::construct_state_path(&run_dir, &document); + let appender = Appender::open(pfftt).expect("open appender"); + StoreFixture { + base, + appender: Some(appender), + } + } + + fn take_appender(&mut self) -> Appender { + self.appender + .take() + .expect("appender") + } + + fn pfftt_contents(&self) -> String { + let entries = std::fs::read_dir(&self.base) + .expect("read base") + .next() + .expect("at least one run dir") + .expect("entry"); + for f in std::fs::read_dir(entries.path()).expect("read run dir") { + let f = f.expect("entry"); + if f.path() + .extension() + .and_then(|e| e.to_str()) + == Some("pfftt") + { + return std::fs::read_to_string(f.path()).expect("read pfftt"); + } + } + panic!("no pfftt file"); + } +} + +impl Drop for StoreFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + +fn empty_paragraphs() -> &'static [language::Paragraph<'static>] { + &[] +} + +fn step(ordinal: Ordinal<'static>, body: Operation<'static>) -> Operation<'static> { + Operation::Step { + ordinal, + attributes: Vec::new(), + description: empty_paragraphs(), + body: Box::new(body), + responses: Vec::new(), + } +} + +fn anonymous_with_body(body: Operation<'static>) -> Program<'static> { + let mut program = Program::new(); + let mut sub = Subroutine::anonymous(); + sub.body = body; + program + .subroutines + .push(sub); + program +} + +#[test] +fn single_step_prompts_and_records() { + let mut fixture = StoreFixture::new("single-step"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let outcome = runner + .run() + .expect("run"); + assert_eq!(outcome, Outcome::Done(Value::Unitus)); + + let prompt = runner.into_prompt(); + let events = prompt.events(); + // Exactly one Step event and one Ask event. + assert!(events + .iter() + .any(|e| matches!(e, Event::Step { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, Event::Ask))); + + let pfftt = fixture.pfftt_contents(); + assert!(pfftt.contains("path = 1")); + assert!(pfftt.contains("outcome = Done")); + assert!(pfftt.contains("result = ()")); +} + +#[test] +fn two_steps_prompted_in_source_order() { + let mut fixture = StoreFixture::new("two-steps"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["1", "2"]); +} + +#[test] +fn pre_completed_step_short_circuits() { + let mut fixture = StoreFixture::new("short-circuit"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + // Pre-mark step 1 completed; the walker should skip its prompt + // and only ask about step 2. + let mut completed = HashSet::new(); + completed.insert("1".to_string()); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), completed, prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["2"]); +} + +#[test] +fn quit_propagates_and_stops_walking() { + let mut fixture = StoreFixture::new("quit-propagates"); + let body = Operation::Sequence(vec![ + step(Ordinal::Dependent("1"), Operation::Sequence(vec![])), + step(Ordinal::Dependent("2"), Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Quit]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let outcome = runner + .run() + .expect("run"); + assert_eq!(outcome, Outcome::Quit); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + // Only the first Step was prompted; the second never fired. + assert_eq!(step_fqns, vec!["1"]); + + // Quit doesn't record an outcome — the PFFTT file contains the + // manifest tablet and nothing else. + let pfftt = fixture.pfftt_contents(); + assert_eq!( + pfftt + .matches("outcome =") + .count(), + 0 + ); +} + +#[test] +fn section_renders_path_segment() { + let mut fixture = StoreFixture::new("section-path"); + let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); + let body = Operation::Sequence(vec![Operation::Section { + numeral: "I", + title: None, + body: Box::new(Operation::Sequence(vec![inner])), + responses: Vec::new(), + }]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let events = prompt.events(); + let section_fqns: Vec<&str> = events + .iter() + .filter_map(|e| { + if let Event::Section { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + let step_fqns: Vec<&str> = events + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(section_fqns, vec!["I"]); + assert_eq!(step_fqns, vec!["I/1"]); +} + +#[test] +fn section_title_renders_from_paragraph() { + let mut fixture = StoreFixture::new("section-title"); + let title: &'static language::Paragraph<'static> = + Box::leak(Box::new(language::Paragraph::new(vec![ + language::Descriptive::Text("Setup"), + ]))); + let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); + let body = Operation::Sequence(vec![Operation::Section { + numeral: "I", + title: Some(title), + body: Box::new(Operation::Sequence(vec![inner])), + responses: Vec::new(), + }]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let section_title = prompt + .events() + .iter() + .find_map(|e| { + if let Event::Section { title, .. } = e { + Some(title.as_str()) + } else { + None + } + }) + .expect("section event"); + assert_eq!(section_title, "Setup"); +} + +#[test] +fn parallel_step_index_starts_at_one() { + let mut fixture = StoreFixture::new("parallel-index"); + let body = Operation::Sequence(vec![ + step(Ordinal::Parallel, Operation::Sequence(vec![])), + step(Ordinal::Parallel, Operation::Sequence(vec![])), + ]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(step_fqns, vec!["-1", "-2"]); +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs index d8044ef..b1ccca4 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -1,5 +1,6 @@ //! Interactive walker over a translated Program. +use std::collections::HashSet; use std::io; use std::path::PathBuf; @@ -50,3 +51,273 @@ pub enum RunnerError { BindArityMismatch { expected: usize, actual: usize }, UserQuit, } + +/// Execute a Technique interactively by walking the `Program` tree. Tracks +/// the position in the document via a `QaulifiedPath` stack, carries an +/// `Environment` with known result values. Maintains a set of +/// already-completed step FQNs, an append handle to write results, and the +/// prompt the operator interacts through. +#[allow(dead_code)] +pub struct Runner<'i, P: Prompt> { + program: &'i Program<'i>, + appender: Appender, + completed: HashSet, + prompt: P, + env: Environment, + path: QualifiedPath<'i>, +} + +#[allow(dead_code)] +impl<'i, P: Prompt> Runner<'i, P> { + pub fn with_pieces( + program: &'i Program<'i>, + appender: Appender, + completed: HashSet, + prompt: P, + ) -> Self { + Runner { + program, + appender, + completed, + prompt, + env: Environment::new(), + path: QualifiedPath::new(), + } + } + + /// Consume the runner and return the inner prompt. Tests use this + /// to assert on the Mock's event log after a run completes. + pub fn into_prompt(self) -> P { + self.prompt + } + + /// Walk the entry procedure top to bottom. Entry-procedure + /// selection here is `program.subroutines[0]` — the synthetic + /// anonymous wrapper if the document is top-level Steps, otherwise + /// the first declared procedure. + pub fn run(&mut self) -> Result { + let entry = self + .program + .subroutines + .first() + .ok_or(RunnerError::MissingEntryProcedure)?; + let name = entry + .name + .as_ref() + .map(|n| n.value); + if let Some(name) = name { + self.path + .push(PathSegment::Procedure(name)); + } + let outcome = self.walk(&entry.body)?; + if name.is_some() { + self.path + .pop(); + } + Ok(outcome) + } + + fn walk(&mut self, op: &'i Operation<'i>) -> Result { + match op { + Operation::Sequence(ops) => self.walk_sequence(ops), + Operation::Section { + numeral, + title, + body, + .. + } => self.walk_section(numeral, *title, body), + Operation::Step { .. } => { + // Dependent vs Parallel ordinal index needs the + // surrounding Sequence's parallel counter; a Step + // encountered outside a Sequence (i.e. as the entire + // body of a procedure) is treated as Dependent. + self.walk_step(op, 0) + } + // TODO + Operation::Loop { body, .. } => self.walk(body), + Operation::Invoke(_) | Operation::Execute(_) => Ok(Outcome::Done(Value::Unitus)), + Operation::Bind { .. } + | Operation::Variable(_) + | Operation::Number(_) + | Operation::String(_) + | Operation::Multiline(_, _) + | Operation::Tablet(_) => Ok(Outcome::Done(Value::Unitus)), + } + } + + fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { + let mut parallel_idx: usize = 0; + for op in ops { + let outcome = match op { + Operation::Step { ordinal, .. } => { + let index = match ordinal { + Ordinal::Parallel => { + parallel_idx += 1; + parallel_idx + } + Ordinal::Dependent(_) => 0, + }; + self.walk_step(op, index)? + } + _ => self.walk(op)?, + }; + if let Outcome::Quit = outcome { + return Ok(Outcome::Quit); + } + } + Ok(Outcome::Done(Value::Unitus)) + } + + fn walk_section( + &mut self, + numeral: &'i str, + title: Option<&'i crate::language::Paragraph<'i>>, + body: &'i Operation<'i>, + ) -> Result { + self.path + .push(PathSegment::Section(numeral)); + let qualified = self + .path + .render(); + let title_text = title + .map(render_paragraph) + .unwrap_or_default(); + self.prompt + .section(&qualified, &title_text); + let outcome = self.walk(body)?; + self.path + .pop(); + Ok(outcome) + } + + fn walk_step( + &mut self, + op: &'i Operation<'i>, + parallel_index: usize, + ) -> Result { + let Operation::Step { + ordinal, + attributes, + description: _description, + body, + .. + } = op + else { + unreachable!("walk_step called with non-Step operation"); + }; + + for frame in attributes { + self.path + .push(PathSegment::Attributes(frame)); + } + let segment = match ordinal { + Ordinal::Dependent(s) => PathSegment::DependentStep(s), + Ordinal::Parallel => PathSegment::ParallelStep(parallel_index), + }; + self.path + .push(segment); + let qualified = self + .path + .render(); + + let outcome = if self + .completed + .contains(&qualified) + { + // Resume short-circuit: don't descend into the body, don't + // re-prompt. The step's outcome was already recorded. + Outcome::Done(Value::Unitus) + } else { + let inner = self.walk(body)?; + if let Outcome::Quit = inner { + Outcome::Quit + } else { + self.prompt + .step(&qualified, ""); + let input = self + .prompt + .ask(); + let outcome = outcome_from(input); + if let Outcome::Quit = outcome { + // Quit: don't record this step; the run resumes + // at this step next time. + Outcome::Quit + } else { + let record = Record { + recorded: now_iso8601(), + path: qualified.clone(), + outcome: record_outcome(&outcome), + }; + self.appender + .append(&record)?; + self.completed + .insert(qualified.clone()); + outcome + } + } + }; + + self.path + .pop(); + for _ in attributes { + self.path + .pop(); + } + Ok(outcome) + } +} + +/// Lift a `UserInput` from the prompt into the runner's `Outcome`. +fn outcome_from(input: UserInput) -> Outcome { + match input { + UserInput::Done(value) => Outcome::Done(value), + UserInput::Skip => Outcome::Skipped, + UserInput::Fail => Outcome::Failed(Failure::Aborted("Failed".to_string())), + UserInput::Quit => Outcome::Quit, + } +} + +/// Render a `Paragraph` to plain text by concatenating each +/// `Descriptive`. Plain text comes through verbatim; code inlines +/// and invocations come through in their `{ ... }` / `(...)` +/// source forms. +fn render_paragraph(p: &crate::language::Paragraph<'_>) -> String { + let mut text = String::new(); + for descriptive in &p.0 { + text.push_str(&crate::formatting::render_descriptive( + descriptive, + &crate::formatting::Identity, + )); + } + text +} + +/// Project the runner's in-memory `Outcome` into the on-disk shape +/// the PFFTT writer expects. Done always serializes as `result = ()` +/// for now — rendered-value persistence is future work. Quit is +/// unreachable here: the caller filters it out before recording. +fn record_outcome(outcome: &Outcome) -> RecordOutcome { + match outcome { + Outcome::Done(_) => RecordOutcome::Done(Some("()".to_string())), + Outcome::Skipped => RecordOutcome::Skipped, + Outcome::Failed(Failure::Aborted(reason)) => { + RecordOutcome::Failed(Some(format!("\"{}\"", reason))) + } + Outcome::Quit => unreachable!("Quit is not recorded"), + } +} + +/// Current UTC time as an RFC3339 second-precision string, used for +/// the `recorded` field of every Result tablet. +fn now_iso8601() -> String { + use time::format_description::well_known::Rfc3339; + time::OffsetDateTime::now_utc() + .replace_nanosecond(0) + .unwrap() // Zero nanoseconds is always valid + .format(&Rfc3339) + .unwrap() // Rfc3339 formatting is infallible for a valid OffsetDateTime +} + +#[cfg(test)] +#[path = "checks/runner.rs"] +mod check; diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 2a566a2..3eb3741 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -137,7 +137,7 @@ fn description_after_multiple_body_elements() { // body elements, and a later Element::Description still trips the // guard. (A Steps-then-Description case is unreachable today because // the parser absorbs trailing prose into the previous step's - // description; see plans/PHASE-3.md.) + // description) let source = r#" % technique v1 From 164417ac926ac81cba9697286240dd7ab0614d7b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 22:06:48 +1000 Subject: [PATCH 15/25] Interpolate description variables --- src/program/types.rs | 10 +-- src/runner/checks/runner.rs | 66 +++++++++++++--- src/runner/runner.rs | 143 +++++++++++++++++++--------------- src/translation/translator.rs | 49 +++++++++++- 4 files changed, 184 insertions(+), 84 deletions(-) diff --git a/src/program/types.rs b/src/program/types.rs index dfb26f5..401a28f 100644 --- a/src/program/types.rs +++ b/src/program/types.rs @@ -40,7 +40,7 @@ pub struct Subroutine<'i> { /// then `None`, otherwise all procedures have names. pub name: Option>, pub title: Option<&'i str>, - pub description: &'i [language::Paragraph<'i>], + pub description: Vec>, pub parameters: Option<&'i [language::Identifier<'i>]>, pub signature: Option<&'i language::Signature<'i>>, pub body: Operation<'i>, @@ -55,7 +55,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: Some(name), title: None, - description: &[], + description: Vec::new(), parameters: None, signature: None, body: Operation::Sequence(Vec::new()), @@ -69,7 +69,7 @@ impl<'i> Subroutine<'i> { Subroutine { name: None, title: None, - description: &[], + description: Vec::new(), parameters: None, signature: None, body: Operation::Sequence(Vec::new()), @@ -94,14 +94,14 @@ pub enum Operation<'i> { Sequence(Vec>), Section { numeral: &'i str, - title: Option<&'i language::Paragraph<'i>>, + title: Option>>, body: Box>, responses: Vec<&'i language::Response<'i>>, }, Step { ordinal: Ordinal<'i>, attributes: Vec<&'i [language::Attribute<'i>]>, - description: &'i [language::Paragraph<'i>], + description: Vec>, body: Box>, responses: Vec<&'i language::Response<'i>>, }, diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 54f87ee..05eb911 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use std::path::PathBuf; -use crate::language; use crate::program::{Operation, Ordinal, Program, Subroutine}; use crate::runner::prompt::{Event, Mock, UserInput}; use crate::runner::runner::{Outcome, Runner}; @@ -66,15 +65,11 @@ impl Drop for StoreFixture { } } -fn empty_paragraphs() -> &'static [language::Paragraph<'static>] { - &[] -} - fn step(ordinal: Ordinal<'static>, body: Operation<'static>) -> Operation<'static> { Operation::Step { ordinal, attributes: Vec::new(), - description: empty_paragraphs(), + description: Vec::new(), body: Box::new(body), responses: Vec::new(), } @@ -278,15 +273,14 @@ fn section_renders_path_segment() { #[test] fn section_title_renders_from_paragraph() { + use crate::program::Fragment; + let mut fixture = StoreFixture::new("section-title"); - let title: &'static language::Paragraph<'static> = - Box::leak(Box::new(language::Paragraph::new(vec![ - language::Descriptive::Text("Setup"), - ]))); + let title = Operation::String(vec![Fragment::Text("Setup")]); let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); let body = Operation::Sequence(vec![Operation::Section { numeral: "I", - title: Some(title), + title: Some(Box::new(title)), body: Box::new(Operation::Sequence(vec![inner])), responses: Vec::new(), }]); @@ -345,3 +339,53 @@ fn parallel_step_index_starts_at_one() { .collect(); assert_eq!(step_fqns, vec!["-1", "-2"]); } + +#[test] +fn bind_in_body_interpolates_into_description() { + use crate::language::{Identifier as LangIdentifier, Numeric as LangNumeric}; + use crate::program::Fragment; + + let mut fixture = StoreFixture::new("bind-then-interpolate"); + + // Body: bind `answer` to 42. The description references `{answer}`, + // so when the prompt fires the operator should see "the answer is 42". + let names: &'static [LangIdentifier<'static>] = + Box::leak(Box::new([LangIdentifier::new("answer")])); + let bind = Operation::Bind { + names, + value: Box::new(Operation::Number(LangNumeric::Integral(42))), + }; + let description = vec![Operation::String(vec![ + Fragment::Text("the answer is "), + Fragment::Interpolation(Operation::Variable(LangIdentifier::new("answer"))), + ])]; + let the_step = Operation::Step { + ordinal: Ordinal::Dependent("1"), + attributes: Vec::new(), + description, + body: Box::new(bind), + responses: Vec::new(), + }; + let body = Operation::Sequence(vec![the_step]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let description_text = prompt + .events() + .iter() + .find_map(|e| { + if let Event::Step { description, .. } = e { + Some(description.as_str()) + } else { + None + } + }) + .expect("step event"); + assert_eq!(description_text, "the answer is 42"); +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs index b1ccca4..2172013 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -109,12 +109,12 @@ impl<'i, P: Prompt> Runner<'i, P> { self.path .push(PathSegment::Procedure(name)); } - let outcome = self.walk(&entry.body)?; + let result = self.walk(&entry.body); if name.is_some() { self.path .pop(); } - Ok(outcome) + result } fn walk(&mut self, op: &'i Operation<'i>) -> Result { @@ -125,7 +125,7 @@ impl<'i, P: Prompt> Runner<'i, P> { title, body, .. - } => self.walk_section(numeral, *title, body), + } => self.walk_section(numeral, title.as_deref(), body), Operation::Step { .. } => { // Dependent vs Parallel ordinal index needs the // surrounding Sequence's parallel counter; a Step @@ -141,7 +141,10 @@ impl<'i, P: Prompt> Runner<'i, P> { | Operation::Number(_) | Operation::String(_) | Operation::Multiline(_, _) - | Operation::Tablet(_) => Ok(Outcome::Done(Value::Unitus)), + | Operation::Tablet(_) => { + let value = super::evaluator::evaluate(&mut self.env, op)?; + Ok(Outcome::Done(value)) + } } } @@ -171,23 +174,35 @@ impl<'i, P: Prompt> Runner<'i, P> { fn walk_section( &mut self, numeral: &'i str, - title: Option<&'i crate::language::Paragraph<'i>>, + title: Option<&'i Operation<'i>>, body: &'i Operation<'i>, ) -> Result { self.path .push(PathSegment::Section(numeral)); + let result = self.execute_section(title, body); + self.path + .pop(); + result + } + + fn execute_section( + &mut self, + title: Option<&'i Operation<'i>>, + body: &'i Operation<'i>, + ) -> Result { let qualified = self .path .render(); - let title_text = title - .map(render_paragraph) - .unwrap_or_default(); + let title_text = match title { + Some(op) => match super::evaluator::evaluate(&mut self.env, op)? { + Value::Literali(s) => s, + other => other.to_string(), + }, + None => String::new(), + }; self.prompt .section(&qualified, &title_text); - let outcome = self.walk(body)?; - self.path - .pop(); - Ok(outcome) + self.walk(body) } fn walk_step( @@ -198,7 +213,7 @@ impl<'i, P: Prompt> Runner<'i, P> { let Operation::Step { ordinal, attributes, - description: _description, + description, body, .. } = op @@ -220,42 +235,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); - let outcome = if self - .completed - .contains(&qualified) - { - // Resume short-circuit: don't descend into the body, don't - // re-prompt. The step's outcome was already recorded. - Outcome::Done(Value::Unitus) - } else { - let inner = self.walk(body)?; - if let Outcome::Quit = inner { - Outcome::Quit - } else { - self.prompt - .step(&qualified, ""); - let input = self - .prompt - .ask(); - let outcome = outcome_from(input); - if let Outcome::Quit = outcome { - // Quit: don't record this step; the run resumes - // at this step next time. - Outcome::Quit - } else { - let record = Record { - recorded: now_iso8601(), - path: qualified.clone(), - outcome: record_outcome(&outcome), - }; - self.appender - .append(&record)?; - self.completed - .insert(qualified.clone()); - outcome - } - } - }; + let result = self.execute_step(&qualified, body, description); self.path .pop(); @@ -263,6 +243,56 @@ impl<'i, P: Prompt> Runner<'i, P> { self.path .pop(); } + + result + } + + fn execute_step( + &mut self, + qualified: &str, + body: &'i Operation<'i>, + description: &'i [Operation<'i>], + ) -> Result { + if self + .completed + .contains(qualified) + { + return Ok(Outcome::Done(Value::Unitus)); + } + if let Outcome::Quit = self.walk(body)? { + return Ok(Outcome::Quit); + } + + let mut description_text = String::new(); + for op in description { + if !description_text.is_empty() { + description_text.push('\n'); + } + match super::evaluator::evaluate(&mut self.env, op)? { + Value::Literali(s) => description_text.push_str(&s), + other => description_text.push_str(&other.to_string()), + } + } + + self.prompt + .step(qualified, &description_text); + let outcome = outcome_from( + self.prompt + .ask(), + ); + if let Outcome::Quit = outcome { + return Ok(Outcome::Quit); + } + + let record = Record { + recorded: now_iso8601(), + path: qualified.to_string(), + outcome: record_outcome(&outcome), + }; + self.appender + .append(&record)?; + self.completed + .insert(qualified.to_string()); Ok(outcome) } } @@ -277,21 +307,6 @@ fn outcome_from(input: UserInput) -> Outcome { } } -/// Render a `Paragraph` to plain text by concatenating each -/// `Descriptive`. Plain text comes through verbatim; code inlines -/// and invocations come through in their `{ ... }` / `(...)` -/// source forms. -fn render_paragraph(p: &crate::language::Paragraph<'_>) -> String { - let mut text = String::new(); - for descriptive in &p.0 { - text.push_str(&crate::formatting::render_descriptive( - descriptive, - &crate::formatting::Identity, - )); - } - text -} - /// Project the runner's in-memory `Outcome` into the on-disk shape /// the PFFTT writer expects. Done always serializes as `result = ()` /// for now — rendered-value persistence is future work. Quit is diff --git a/src/translation/translator.rs b/src/translation/translator.rs index fd9fe2a..cc04772 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -180,12 +180,13 @@ impl<'i> Translator<'i> { } } let body = Operation::Sequence(ops); + let description_ops = self.translate_paragraphs(description); let entry = &mut self .program .subroutines[id.0]; entry.title = title; - entry.description = description; + entry.description = description_ops; entry.parameters = procedure .parameters .as_ref() @@ -221,9 +222,12 @@ impl<'i> Translator<'i> { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } } + let title_op = title + .as_ref() + .map(|p| Box::new(self.translate_paragraph(p))); Operation::Section { numeral, - title: title.as_ref(), + title: title_op, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -240,10 +244,11 @@ impl<'i> Translator<'i> { for sub in subscopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } + let description_ops = self.translate_paragraphs(description); Operation::Step { ordinal: Ordinal::Dependent(ordinal), attributes: attrs.to_vec(), - description: description.as_slice(), + description: description_ops, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -259,10 +264,11 @@ impl<'i> Translator<'i> { for sub in subscopes { self.append_attributes(&mut body_ops, &mut responses, sub, attrs); } + let description_ops = self.translate_paragraphs(description); Operation::Step { ordinal: Ordinal::Parallel, attributes: attrs.to_vec(), - description: description.as_slice(), + description: description_ops, body: Box::new(Operation::Sequence(body_ops)), responses, } @@ -340,6 +346,41 @@ impl<'i> Translator<'i> { } } + fn translate_paragraphs( + &mut self, + paragraphs: &'i [language::Paragraph<'i>], + ) -> Vec> { + paragraphs + .iter() + .map(|p| self.translate_paragraph(p)) + .collect() + } + + fn translate_paragraph(&mut self, paragraph: &'i language::Paragraph<'i>) -> Operation<'i> { + let language::Paragraph(descriptives, _) = paragraph; + let fragments = descriptives + .iter() + .map(|d| self.fragment_from_descriptive(d)) + .collect(); + Operation::String(fragments) + } + + fn fragment_from_descriptive( + &mut self, + descriptive: &'i language::Descriptive<'i>, + ) -> Fragment<'i> { + match descriptive { + language::Descriptive::Text(text) => Fragment::Text(text), + language::Descriptive::CodeInline(expr) => { + Fragment::Interpolation(self.translate_expression(expr)) + } + language::Descriptive::Application(invocation) => { + Fragment::Interpolation(Operation::Invoke(self.translate_invocation(invocation))) + } + language::Descriptive::Binding(inner, _) => self.fragment_from_descriptive(inner), + } + } + /// Plain Text is display-only and not hoisted. Every other Descriptive /// requires runtime evaluation: Application invokes a procedure; /// CodeInline evaluates the inner Expression (a Variable read needs the From d98fafabc1370f486e7c658133fddff141be22b0 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 16 May 2026 22:38:30 +1000 Subject: [PATCH 16/25] Preliminary loop evaluation --- src/runner/checks/runner.rs | 89 +++++++++++++++++++++++++++++++++++++ src/runner/runner.rs | 79 ++++++++++++++++++++++++++++---- 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 05eb911..cb26e7c 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -389,3 +389,92 @@ fn bind_in_body_interpolates_into_description() { .expect("step event"); assert_eq!(description_text, "the answer is 42"); } + +#[test] +fn resolved_invoke_descends_into_subroutine() { + use crate::language::Identifier as LangIdentifier; + use crate::program::{Invocable, SubroutineId, SubroutineRef}; + + let mut fixture = StoreFixture::new("invoke-descent"); + + // Build a Program with two subroutines: + // index 0: anonymous wrapper whose body invokes index 1 + // index 1: `helper`, body contains a single Step "1" + let mut program = Program::new(); + let mut wrapper = Subroutine::anonymous(); + wrapper.body = Operation::Sequence(vec![Operation::Invoke(Invocable { + target: SubroutineRef::Resolved(SubroutineId(1)), + arguments: Vec::new(), + })]); + program + .subroutines + .push(wrapper); + + let mut helper = Subroutine::new(LangIdentifier::new("helper")); + helper.body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + program + .subroutines + .push(helper); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let step_fqns: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Step { qualified, .. } = e { + Some(qualified.as_str()) + } else { + None + } + }) + .collect(); + // The Step inside `helper` was reached and prompted, with the + // procedure prefix on the FQN. + assert_eq!(step_fqns, vec!["helper:1"]); +} + +#[test] +fn loop_inside_step_produces_one_result() { + let mut fixture = StoreFixture::new("loop-in-step"); + + // A Step whose body contains a Loop. The Loop announces but does + // not record a Result; the enclosing Step records exactly one. + let loop_op = Operation::Loop { + names: &[], + over: None, + body: Box::new(Operation::Sequence(vec![])), + responses: Vec::new(), + }; + let the_step = Operation::Step { + ordinal: Ordinal::Dependent("1"), + attributes: Vec::new(), + description: Vec::new(), + body: Box::new(loop_op), + responses: Vec::new(), + }; + let body = Operation::Sequence(vec![the_step]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let pfftt = fixture.pfftt_contents(); + assert_eq!( + pfftt + .matches("outcome =") + .count(), + 1 + ); +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 2172013..6d949cb 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -8,7 +8,7 @@ use super::evaluator::Environment; use super::path::{PathSegment, QualifiedPath}; use super::prompt::{Prompt, UserInput}; use super::state::{Appender, Outcome as RecordOutcome, Record, RecordError, RunId}; -use crate::program::{Operation, Ordinal, Program}; +use crate::program::{Executable, Invocable, Operation, Ordinal, Program, SubroutineRef}; use crate::value::Value; /// What executing an Operation (or evaluating a Step at any scale) @@ -133,9 +133,19 @@ impl<'i, P: Prompt> Runner<'i, P> { // body of a procedure) is treated as Dependent. self.walk_step(op, 0) } - // TODO - Operation::Loop { body, .. } => self.walk(body), - Operation::Invoke(_) | Operation::Execute(_) => Ok(Outcome::Done(Value::Unitus)), + Operation::Loop { + names, over, body, .. + } => { + self.prompt + .announce(&describe_loop(names, over.as_deref())); + self.walk(body) + } + Operation::Invoke(invocable) => self.walk_invoke(invocable), + Operation::Execute(executable) => { + self.prompt + .announce(&describe_execute(executable)); + Ok(Outcome::Done(Value::Unitus)) + } Operation::Bind { .. } | Operation::Variable(_) | Operation::Number(_) @@ -148,6 +158,35 @@ impl<'i, P: Prompt> Runner<'i, P> { } } + fn walk_invoke(&mut self, invocable: &'i Invocable<'i>) -> Result { + match &invocable.target { + SubroutineRef::Resolved(id) => { + let subroutine = &self + .program + .subroutines[id.0]; + let name = subroutine + .name + .as_ref() + .map(|n| n.value); + if let Some(name) = name { + self.path + .push(PathSegment::Procedure(name)); + } + let result = self.walk(&subroutine.body); + if name.is_some() { + self.path + .pop(); + } + result + } + SubroutineRef::Unresolved(id) => { + self.prompt + .announce(&format!("<{}>", id.value)); + Ok(Outcome::Done(Value::Unitus)) + } + } + } + fn walk_sequence(&mut self, ops: &'i [Operation<'i>]) -> Result { let mut parallel_idx: usize = 0; for op in ops { @@ -179,13 +218,13 @@ impl<'i, P: Prompt> Runner<'i, P> { ) -> Result { self.path .push(PathSegment::Section(numeral)); - let result = self.execute_section(title, body); + let result = self.perform_section(title, body); self.path .pop(); result } - fn execute_section( + fn perform_section( &mut self, title: Option<&'i Operation<'i>>, body: &'i Operation<'i>, @@ -235,7 +274,7 @@ impl<'i, P: Prompt> Runner<'i, P> { .path .render(); - let result = self.execute_step(&qualified, body, description); + let result = self.perform_step(&qualified, body, description); self.path .pop(); @@ -247,7 +286,7 @@ impl<'i, P: Prompt> Runner<'i, P> { result } - fn execute_step( + fn perform_step( &mut self, qualified: &str, body: &'i Operation<'i>, @@ -297,6 +336,30 @@ impl<'i, P: Prompt> Runner<'i, P> { } } +fn describe_loop( + names: &[crate::language::Identifier<'_>], + over: Option<&Operation<'_>>, +) -> String { + let joined: Vec<&str> = names + .iter() + .map(|n| n.value) + .collect(); + match over { + None => "repeat".to_string(), + Some(_) if joined.is_empty() => "foreach".to_string(), + Some(_) => format!("foreach {}", joined.join(", ")), + } +} + +fn describe_execute(executable: &Executable<'_>) -> String { + format!( + "{}(...)", + executable + .target + .value + ) +} + /// Lift a `UserInput` from the prompt into the runner's `Outcome`. fn outcome_from(input: UserInput) -> Outcome { match input { From 607dcf0b504dd48f642ebb5e8c55730460fa099c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 12:06:11 +1000 Subject: [PATCH 17/25] Implement run and resume subcommands --- .gitignore | 1 + src/main.rs | 143 ++++++++++++++++++++++++++++++++++++++++++- src/runner/mod.rs | 51 ++++++++++++++- src/runner/runner.rs | 4 +- 4 files changed, 194 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index d14796c..0742404 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # rendering artifacts *.pdf .*.typ +/.store # documentation symlinks /doc/references diff --git a/src/main.rs b/src/main.rs index 654e5d8..a18c31f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use tracing_subscriber::{self, EnvFilter}; use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::parsing; +use technique::runner::{self, Outcome, RunId}; use technique::templating::{self, Checklist, NasaEsaIss, Procedure, Recipe, Source}; use technique::translation; @@ -506,7 +507,71 @@ fn main() { debug!(filename); - todo!(); + let filename = Path::new(filename); + let content = match parsing::load(&filename) { + Ok(data) => data, + Err(error) => { + eprintln!("{}", problem::concise_loading_error(&error)); + std::process::exit(1); + } + }; + + let technique = match parsing::parse(&filename, &content) { + Ok(document) => document, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_parsing_error(&error, &filename, &content, &Terminal) + ); + } + + eprintln!( + "\nUnable to parse input file. Try `technique check {}` for details.", + &filename.to_string_lossy() + ); + std::process::exit(1); + } + }; + + let program = match translation::translate(&technique) { + Ok(program) => program, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_translation_error( + &error, &filename, &content, &Terminal + ) + ); + } + std::process::exit(1); + } + }; + + match runner::start(filename, &program) { + Ok((id, Outcome::Quit)) => { + eprintln!("paused; resume with `technique resume {}`", id.render()); + std::process::exit(0); + } + Ok((_, _)) => std::process::exit(0), + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + } } Some(("resume", submatches)) => { let id = submatches @@ -515,7 +580,81 @@ fn main() { debug!(id); - todo!(); + let id = match RunId::parse(id) { + Ok(id) => id, + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + }; + + let filename = match runner::locate(id) { + Ok(path) => path, + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + }; + + let content = match parsing::load(&filename) { + Ok(data) => data, + Err(error) => { + eprintln!("{}", problem::concise_loading_error(&error)); + std::process::exit(1); + } + }; + + let technique = match parsing::parse(&filename, &content) { + Ok(document) => document, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::full_parsing_error(&error, &filename, &content, &Terminal) + ); + } + std::process::exit(1); + } + }; + + let program = match translation::translate(&technique) { + Ok(program) => program, + Err(errors) => { + for (i, error) in errors + .iter() + .enumerate() + { + if i > 0 { + eprintln!(); + } + eprintln!( + "{}", + problem::concise_translation_error( + &error, &filename, &content, &Terminal + ) + ); + } + std::process::exit(1); + } + }; + + match runner::resume(id, &program) { + Ok(Outcome::Quit) => { + eprintln!("paused; continue with `technique resume {}`", id.render()); + std::process::exit(0); + } + Ok(_) => std::process::exit(0), + Err(error) => { + eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); + std::process::exit(1); + } + } } Some(("language", _)) => { debug!("Starting Language Server"); diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 1bff19c..1b91c9b 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -2,6 +2,11 @@ //! prompting the operator and recording each completed step to a state store //! so a run can be resumed after interruption. +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::program::Program; + mod evaluator; mod manifest; mod path; @@ -9,5 +14,49 @@ mod prompt; mod runner; mod state; -pub use runner::RunnerError; +pub use runner::{Outcome, RunnerError}; pub use state::{RecordError, RunId}; + +use prompt::Console; +use runner::{now_iso8601, Runner}; +use state::{construct_state_path, Appender, Store}; + +const STORE_ROOT: &str = ".store"; + +/// Allocate a new run, persist its manifest, and walk the program to +/// completion or until the user signals they are quitting. +pub fn start<'i>( + document: &Path, + program: &'i Program<'i>, +) -> Result<(RunId, Outcome), RunnerError> { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (id, run_dir, _manifest) = store.create(document, now_iso8601())?; + let pfftt = construct_state_path(&run_dir, document); + let appender = Appender::open(pfftt)?; + let mut runner = Runner::with_pieces(program, appender, HashSet::new(), Console::new()); + let outcome = runner.run()?; + Ok((id, outcome)) +} + +/// Read the manifest of an existing run, returning the source document +/// path so the caller can load and re-translate it before resuming. +pub fn locate(id: RunId) -> Result { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (manifest, _, _) = store.open(id)?; + Ok(manifest.document) +} + +/// Open an existing run and walk the given program, short-circuiting +/// any step whose FQN has already been recorded. +pub fn resume<'i>( + document: &Path, + program: &'i Program<'i>, + id: RunId, +) -> Result { + let store = Store::new(PathBuf::from(STORE_ROOT)); + let (_manifest, completed, run_dir) = store.open(id)?; + let pfftt = construct_state_path(&run_dir, document); + let appender = Appender::open(pfftt)?; + let mut runner = Runner::with_pieces(program, appender, completed, Console::new()); + runner.run() +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 6d949cb..06c1f21 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -67,7 +67,6 @@ pub struct Runner<'i, P: Prompt> { path: QualifiedPath<'i>, } -#[allow(dead_code)] impl<'i, P: Prompt> Runner<'i, P> { pub fn with_pieces( program: &'i Program<'i>, @@ -87,6 +86,7 @@ impl<'i, P: Prompt> Runner<'i, P> { /// Consume the runner and return the inner prompt. Tests use this /// to assert on the Mock's event log after a run completes. + #[allow(dead_code)] pub fn into_prompt(self) -> P { self.prompt } @@ -387,7 +387,7 @@ fn record_outcome(outcome: &Outcome) -> RecordOutcome { /// Current UTC time as an RFC3339 second-precision string, used for /// the `recorded` field of every Result tablet. -fn now_iso8601() -> String { +pub(super) fn now_iso8601() -> String { use time::format_description::well_known::Rfc3339; time::OffsetDateTime::now_utc() .replace_nanosecond(0) From afc1e2955f8cabd65430d657cdab15afe6b4d3b8 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 12:21:23 +1000 Subject: [PATCH 18/25] Write absolute file:/// URI to manifest --- src/runner/mod.rs | 7 +++---- src/runner/state.rs | 9 +++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 1b91c9b..0b53b5b 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -49,13 +49,12 @@ pub fn locate(id: RunId) -> Result { /// Open an existing run and walk the given program, short-circuiting /// any step whose FQN has already been recorded. pub fn resume<'i>( - document: &Path, - program: &'i Program<'i>, id: RunId, + program: &'i Program<'i>, ) -> Result { let store = Store::new(PathBuf::from(STORE_ROOT)); - let (_manifest, completed, run_dir) = store.open(id)?; - let pfftt = construct_state_path(&run_dir, document); + let (manifest, completed, run_dir) = store.open(id)?; + let pfftt = construct_state_path(&run_dir, &manifest.document); let appender = Appender::open(pfftt)?; let mut runner = Runner::with_pieces(program, appender, completed, Console::new()); runner.run() diff --git a/src/runner/state.rs b/src/runner/state.rs index b16c8d9..d944bca 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -135,12 +135,17 @@ impl Store { document: &Path, started: String, ) -> Result<(RunId, PathBuf, Manifest), RunnerError> { + let absolute = + std::path::absolute(document).map_err(|error| RunnerError::StoreError { + path: document.to_path_buf(), + error, + })?; let (id, run_dir) = self.allocate()?; let manifest = Manifest { - document: document.to_path_buf(), + document: absolute, started, }; - let pfftt = construct_state_path(&run_dir, document); + let pfftt = construct_state_path(&run_dir, &manifest.document); let text = format_manifest(&manifest); std::fs::write(&pfftt, text) .map_err(|error| RunnerError::StoreError { path: pfftt, error })?; From 1ce9ba66ff87a4c5e2203b816f2fcb49b32fdb72 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 15:24:23 +1000 Subject: [PATCH 19/25] Drop matches! macro from runner checks --- src/domain/engine.rs | 15 ++++-------- src/domain/nasa_esa_iss/adapter.rs | 16 +++++++++--- src/formatting/formatter.rs | 8 +++++- src/highlighting/typst.rs | 12 +++++++-- src/parsing/checks/parser.rs | 6 ++++- src/parsing/checks/verify.rs | 6 ++++- src/runner/checks/runner.rs | 39 +++++++++++++++++++++--------- src/runner/mod.rs | 5 +--- src/runner/state.rs | 9 +++---- 9 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/domain/engine.rs b/src/domain/engine.rs index 32c797a..76e6b0b 100644 --- a/src/domain/engine.rs +++ b/src/domain/engine.rs @@ -79,12 +79,7 @@ impl<'i> Scope<'i> { /// Filters out ResponseBlock, CodeBlock, AttributeBlock, etc. pub fn substeps(&self) -> impl Iterator> { self.children() - .filter(|s| { - matches!( - s, - Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } - ) - }) + .filter(|s| s.is_step()) } /// Returns the text content of this step (first paragraph). @@ -168,10 +163,10 @@ impl<'i> Scope<'i> { /// Returns true if this scope represents a step (dependent or parallel). pub fn is_step(&self) -> bool { - matches!( - self, - Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } - ) + match self { + Scope::DependentBlock { .. } | Scope::ParallelBlock { .. } => true, + _ => false, + } } /// Returns section info (numeral, title) if this is a SectionChunk. diff --git a/src/domain/nasa_esa_iss/adapter.rs b/src/domain/nasa_esa_iss/adapter.rs index 3880643..f21bced 100644 --- a/src/domain/nasa_esa_iss/adapter.rs +++ b/src/domain/nasa_esa_iss/adapter.rs @@ -51,9 +51,19 @@ fn inline_procedures(doc: &mut Document) { } } - doc.body.retain(|node| { - !matches!(node, Node::Sequential { title: None, invocations, .. } if !invocations.is_empty()) - }); + doc.body + .retain(|node| { + if let Node::Sequential { + title: None, + invocations, + .. + } = node + { + invocations.is_empty() + } else { + true + } + }); } /// Collect ordinals from invocation-only steps: procedure_name -> ordinal. diff --git a/src/formatting/formatter.rs b/src/formatting/formatter.rs index 8d5c890..749a952 100644 --- a/src/formatting/formatter.rs +++ b/src/formatting/formatter.rs @@ -685,7 +685,13 @@ impl<'i> Formatter<'i> { if func .parameters .iter() - .any(|p| matches!(p, Expression::Multiline(_, _, _))) => + .any(|p| { + if let Expression::Multiline(_, _, _) = p { + true + } else { + false + } + }) => { line.flush(); self.add_fragment_reference(Syntax::Neutral, " "); diff --git a/src/highlighting/typst.rs b/src/highlighting/typst.rs index d8052d6..f6d5a7d 100644 --- a/src/highlighting/typst.rs +++ b/src/highlighting/typst.rs @@ -74,7 +74,11 @@ mod check { let result = escape_typst(input); // Should return borrowed reference when no quotes to escape - assert!(matches!(result, Cow::Borrowed(_))); + if let Cow::Borrowed(_) = result { + // ok + } else { + panic!("expected Cow::Borrowed"); + } assert_eq!(result, "hello world"); } @@ -84,7 +88,11 @@ mod check { let result = escape_typst(input); // Should return owned string when quotes need escaping - assert!(matches!(result, Cow::Owned(_))); + if let Cow::Owned(_) = result { + // ok + } else { + panic!("expected Cow::Owned"); + } assert_eq!(result, "hello \\\"world\\\""); } diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index deb8f3e..70664a8 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -2293,7 +2293,11 @@ fn parse_collecting_errors_basic() { assert!(errors.len() > 0); assert!(errors .iter() - .any(|e| matches!(e, ParsingError::InvalidHeader(_)))); + .any(|e| if let ParsingError::InvalidHeader(_) = e { + true + } else { + false + })); } } diff --git a/src/parsing/checks/verify.rs b/src/parsing/checks/verify.rs index 623decb..bee7e06 100644 --- a/src/parsing/checks/verify.rs +++ b/src/parsing/checks/verify.rs @@ -1044,7 +1044,11 @@ fn multiple_roles_with_dependent_substeps() { .. } => { for substep in substeps { - assert!(matches!(substep, Scope::DependentBlock { .. })); + if let Scope::DependentBlock { .. } = substep { + // ok + } else { + panic!("Expected DependentBlock substep"); + } } } _ => panic!("Expected AttributedBlock scopes"), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index cb26e7c..14ea767 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::program::{Operation, Ordinal, Program, Subroutine}; use crate::runner::prompt::{Event, Mock, UserInput}; use crate::runner::runner::{Outcome, Runner}; -use crate::runner::state::{Appender, Store}; +use crate::runner::state::{parse_record, Appender, Outcome as RecordOutcome, Store}; use crate::value::Value; // A small fixture builder. The Program borrows from its inputs, so the @@ -102,19 +102,34 @@ fn single_step_prompts_and_records() { assert_eq!(outcome, Outcome::Done(Value::Unitus)); let prompt = runner.into_prompt(); - let events = prompt.events(); - // Exactly one Step event and one Ask event. - assert!(events - .iter() - .any(|e| matches!(e, Event::Step { .. }))); - assert!(events - .iter() - .any(|e| matches!(e, Event::Ask))); + assert_eq!( + prompt.events(), + &[ + Event::Step { + qualified: "1".to_string(), + description: String::new(), + }, + Event::Ask, + ] + ); let pfftt = fixture.pfftt_contents(); - assert!(pfftt.contains("path = 1")); - assert!(pfftt.contains("outcome = Done")); - assert!(pfftt.contains("result = ()")); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + "[ document = file:///tmp/Test.tq, started = 2026-05-16T00:00:00Z ]" + ); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!(record.path, "1"); + assert_eq!(record.outcome, RecordOutcome::Done(Some("()".to_string()))); } #[test] diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 0b53b5b..af62185 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -48,10 +48,7 @@ pub fn locate(id: RunId) -> Result { /// Open an existing run and walk the given program, short-circuiting /// any step whose FQN has already been recorded. -pub fn resume<'i>( - id: RunId, - program: &'i Program<'i>, -) -> Result { +pub fn resume<'i>(id: RunId, program: &'i Program<'i>) -> Result { let store = Store::new(PathBuf::from(STORE_ROOT)); let (manifest, completed, run_dir) = store.open(id)?; let pfftt = construct_state_path(&run_dir, &manifest.document); diff --git a/src/runner/state.rs b/src/runner/state.rs index d944bca..e779f09 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -135,11 +135,10 @@ impl Store { document: &Path, started: String, ) -> Result<(RunId, PathBuf, Manifest), RunnerError> { - let absolute = - std::path::absolute(document).map_err(|error| RunnerError::StoreError { - path: document.to_path_buf(), - error, - })?; + let absolute = std::path::absolute(document).map_err(|error| RunnerError::StoreError { + path: document.to_path_buf(), + error, + })?; let (id, run_dir) = self.allocate()?; let manifest = Manifest { document: absolute, From 19ed9bf5de445dbad649ab8d1577700fb2b8aa2c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 17:09:38 +1000 Subject: [PATCH 20/25] Improve test coverage --- src/problem/messages.rs | 7 ++ src/runner/checks/evaluator.rs | 7 +- src/runner/checks/path.rs | 15 +++- src/runner/checks/runner.rs | 159 ++++++++++++++++++++++----------- src/runner/checks/state.rs | 65 ++++++++++++++ src/runner/evaluator.rs | 5 +- src/runner/path.rs | 18 ++-- src/runner/runner.rs | 1 + 8 files changed, 203 insertions(+), 74 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index 371bd61..af71a4a 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1090,6 +1090,13 @@ pub fn generate_runner_error(error: &RunnerError, _renderer: &dyn Render) -> (St ), "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), ), + RunnerError::BindNotTuple { expected } => ( + format!( + "Binding requires a tuple: {} names but the value is not a tuple", + expected + ), + "Binding multiple variables requires the procedure being invoked or function being called to return a tuple of the same size.".to_string(), + ), RunnerError::UserQuit => ( "Interrupted".to_string(), "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index 2abbc12..f0186d7 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -202,7 +202,7 @@ fn multi_name_bind_wrong_arity_errors() { } #[test] -fn multi_name_bind_non_parametriq_errors() { +fn multi_name_bind_against_scalar_errors_as_not_tuple() { let mut env = Environment::new(); env.extend( "scalar".to_string(), @@ -214,10 +214,9 @@ fn multi_name_bind_non_parametriq_errors() { value: Box::new(Operation::Variable(Identifier::new("scalar"))), }; match evaluate(&mut env, &bind) { - Err(RunnerError::BindArityMismatch { expected, actual }) => { + Err(RunnerError::BindNotTuple { expected }) => { assert_eq!(expected, 2); - assert_eq!(actual, 1); } - other => panic!("expected BindArityMismatch, got {:?}", other), + other => panic!("expected BindNotTuple, got {:?}", other), } } diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs index 5a9166d..5ea9989 100644 --- a/src/runner/checks/path.rs +++ b/src/runner/checks/path.rs @@ -65,7 +65,7 @@ fn nested_attribute_frames_each_contribute_a_segment() { } #[test] -fn reset_attribute_suppresses_frame() { +fn reset_role_alone_suppresses_frame() { let frame = vec![Attribute::Role(Identifier::new("*"), Span::default())]; let mut stack = QualifiedPath::new(); stack.push(PathSegment::DependentStep("1")); @@ -74,6 +74,19 @@ fn reset_attribute_suppresses_frame() { assert_eq!(stack.render(), "1/a"); } +#[test] +fn reset_role_preserves_sibling_place() { + let frame = vec![ + Attribute::Role(Identifier::new("*"), Span::default()), + Attribute::Place(Identifier::new("kitchen"), Span::default()), + ]; + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::DependentStep("1")); + stack.push(PathSegment::Attributes(&frame)); + stack.push(PathSegment::DependentStep("a")); + assert_eq!(stack.render(), "1/^kitchen/a"); +} + #[test] fn procedure_uses_colon_separator() { let mut stack = QualifiedPath::new(); diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 14ea767..9fd5b2e 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1,10 +1,12 @@ use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use crate::parsing; use crate::program::{Operation, Ordinal, Program, Subroutine}; use crate::runner::prompt::{Event, Mock, UserInput}; use crate::runner::runner::{Outcome, Runner}; use crate::runner::state::{parse_record, Appender, Outcome as RecordOutcome, Store}; +use crate::translation::translate; use crate::value::Value; // A small fixture builder. The Program borrows from its inputs, so the @@ -132,6 +134,70 @@ fn single_step_prompts_and_records() { assert_eq!(record.outcome, RecordOutcome::Done(Some("()".to_string()))); } +#[test] +fn skip_outcome_is_recorded() { + let mut fixture = StoreFixture::new("skip-records"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Skip]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + let outcome = runner + .run() + .expect("run"); + assert_eq!(outcome, Outcome::Done(Value::Unitus)); + + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 2); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!(record.path, "1"); + assert_eq!(record.outcome, RecordOutcome::Skipped); +} + +#[test] +fn fail_outcome_is_recorded_with_reason() { + let mut fixture = StoreFixture::new("fail-records"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + + let prompt = Mock::with_answers([UserInput::Fail]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 2); + let record = parse_record(lines[1]).expect("parse record"); + assert_eq!(record.path, "1"); + assert_eq!( + record.outcome, + RecordOutcome::Failed(Some("\"Failed\"".to_string())) + ); +} + #[test] fn two_steps_prompted_in_source_order() { let mut fixture = StoreFixture::new("two-steps"); @@ -357,83 +423,67 @@ fn parallel_step_index_starts_at_one() { #[test] fn bind_in_body_interpolates_into_description() { - use crate::language::{Identifier as LangIdentifier, Numeric as LangNumeric}; - use crate::program::Fragment; + let source = r#" +% technique v1 - let mut fixture = StoreFixture::new("bind-then-interpolate"); +test : - // Body: bind `answer` to 42. The description references `{answer}`, - // so when the prompt fires the operator should see "the answer is 42". - let names: &'static [LangIdentifier<'static>] = - Box::leak(Box::new([LangIdentifier::new("answer")])); - let bind = Operation::Bind { - names, - value: Box::new(Operation::Number(LangNumeric::Integral(42))), - }; - let description = vec![Operation::String(vec![ - Fragment::Text("the answer is "), - Fragment::Interpolation(Operation::Variable(LangIdentifier::new("answer"))), - ])]; - let the_step = Operation::Step { - ordinal: Ordinal::Dependent("1"), - attributes: Vec::new(), - description, - body: Box::new(bind), - responses: Vec::new(), - }; - let body = Operation::Sequence(vec![the_step]); - let program = anonymous_with_body(body); +1. { 42 ~ answer } +2. Result: { answer } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); - let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut fixture = StoreFixture::new("bind-then-interpolate"); + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); runner .run() .expect("run"); let prompt = runner.into_prompt(); - let description_text = prompt + let descriptions: Vec<&str> = prompt .events() .iter() - .find_map(|e| { + .filter_map(|e| { if let Event::Step { description, .. } = e { Some(description.as_str()) } else { None } }) - .expect("step event"); - assert_eq!(description_text, "the answer is 42"); + .collect(); + // Step 1 only binds, so its description renders to empty. Step 2 + // sees the binding established by step 1 and interpolates it. + // The parser strips whitespace adjacent to inline `{ ... }` fragments, + // hence "Result:42" rather than "Result: 42". + assert_eq!(descriptions, vec!["", "Result:42"]); } #[test] fn resolved_invoke_descends_into_subroutine() { - use crate::language::Identifier as LangIdentifier; - use crate::program::{Invocable, SubroutineId, SubroutineRef}; + let source = r#" +% technique v1 - let mut fixture = StoreFixture::new("invoke-descent"); +main : - // Build a Program with two subroutines: - // index 0: anonymous wrapper whose body invokes index 1 - // index 1: `helper`, body contains a single Step "1" - let mut program = Program::new(); - let mut wrapper = Subroutine::anonymous(); - wrapper.body = Operation::Sequence(vec![Operation::Invoke(Invocable { - target: SubroutineRef::Resolved(SubroutineId(1)), - arguments: Vec::new(), - })]); - program - .subroutines - .push(wrapper); +{ + () +} - let mut helper = Subroutine::new(LangIdentifier::new("helper")); - helper.body = Operation::Sequence(vec![step( - Ordinal::Dependent("1"), - Operation::Sequence(vec![]), - )]); - program - .subroutines - .push(helper); +helper : +1. helper step + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("invoke-descent"); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); runner @@ -453,7 +503,8 @@ fn resolved_invoke_descends_into_subroutine() { }) .collect(); // The Step inside `helper` was reached and prompted, with the - // procedure prefix on the FQN. + // helper procedure as the FQN prefix (the outer `main` frame is + // overridden by the inner Procedure segment). assert_eq!(step_fqns, vec!["helper:1"]); } diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 40427e4..6df8df8 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -236,6 +236,71 @@ fn format_record_failed_with_reason_emits_sibling_field() { ); } +#[test] +fn format_record_skipped_emits_no_sibling() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "wait:3".to_string(), + outcome: Outcome::Skipped, + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = wait:3, outcome = Skipped ]\n" + ); +} + +#[test] +fn format_record_failed_without_reason_emits_no_sibling() { + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "ping:1".to_string(), + outcome: Outcome::Failed(None), + }; + assert_eq!( + format_record(&record), + "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed ]\n" + ); +} + +#[test] +fn record_round_trips_through_format_and_parse() { + let cases = [ + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "a:1".to_string(), + outcome: Outcome::Done(None), + }, + Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + path: "a:2".to_string(), + outcome: Outcome::Done(Some("\"penguin\"".to_string())), + }, + Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + path: "a:3".to_string(), + outcome: Outcome::Skipped, + }, + Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + path: "a:4".to_string(), + outcome: Outcome::Failed(None), + }, + Record { + recorded: "2026-05-14T12:00:04Z".to_string(), + path: "a:5".to_string(), + outcome: Outcome::Failed(Some("\"unreachable\"".to_string())), + }, + ]; + for original in &cases { + let text = format_record(original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + let parsed = parse_record(line).expect("parse"); + assert_eq!(&parsed, original); + } +} + #[test] fn parse_manifest_reads_expected_text() { let line = "[ document = file:///foo/bar.tq, started = 2026-05-14T01:02:03Z ]"; diff --git a/src/runner/evaluator.rs b/src/runner/evaluator.rs index 7e6c30d..460475f 100644 --- a/src/runner/evaluator.rs +++ b/src/runner/evaluator.rs @@ -94,10 +94,7 @@ pub fn evaluate<'i>(env: &mut Environment, op: &Operation<'i>) -> Result { let Value::Parametriq(values) = v else { - return Err(RunnerError::BindArityMismatch { - expected: n, - actual: 1, - }); + return Err(RunnerError::BindNotTuple { expected: n }); }; if values.len() != n { return Err(RunnerError::BindArityMismatch { diff --git a/src/runner/path.rs b/src/runner/path.rs index 1380c57..ecba5f5 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -88,20 +88,13 @@ fn render_segment(segment: &PathSegment) -> Option { } fn render_attributes(frame: &[language::Attribute]) -> Option { - // The @* reset role makes the entire frame contribute nothing. - for attr in frame { - if let language::Attribute::Role(id, _) = attr { - if id.value == "*" { - return None; - } - } - } - if frame.is_empty() { - return None; - } let mut text = String::new(); for attr in frame { match attr { + // `@*` resets the inherited role; it contributes no segment of + // its own. A sibling Place attribute in the same frame is + // unaffected and still renders. + language::Attribute::Role(id, _) if id.value == "*" => {} language::Attribute::Role(id, _) => { text.push('@'); text.push_str(id.value); @@ -112,6 +105,9 @@ fn render_attributes(frame: &[language::Attribute]) -> Option { } } } + if text.is_empty() { + return None; + } Some(text) } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 06c1f21..7fdf5f7 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -49,6 +49,7 @@ pub enum RunnerError { MissingEntryProcedure, UnboundVariable(String), BindArityMismatch { expected: usize, actual: usize }, + BindNotTuple { expected: usize }, UserQuit, } From 74bf87f70a21cf0d7b43ff5d4b9c00d1833a19c2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 20:36:52 +1000 Subject: [PATCH 21/25] Improve error path coverage --- src/runner/checks/runner.rs | 39 ++++++++++++++++- src/runner/checks/state.rs | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 9fd5b2e..2b81cbc 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -131,7 +131,9 @@ fn single_step_prompts_and_records() { ); let record = parse_record(lines[1]).expect("parse record"); assert_eq!(record.path, "1"); - assert_eq!(record.outcome, RecordOutcome::Done(Some("()".to_string()))); + let RecordOutcome::Done(_) = record.outcome else { + panic!("expected Done, got {:?}", record.outcome); + }; } #[test] @@ -508,6 +510,41 @@ helper : assert_eq!(step_fqns, vec!["helper:1"]); } +#[test] +fn execute_announces_function_call() { + let source = r#" +% technique v1 + +test : + +1. Do this { journal("hello") } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + let mut fixture = StoreFixture::new("execute-announce"); + let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); + let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); + runner + .run() + .expect("run"); + + let prompt = runner.into_prompt(); + let announcements: Vec<&str> = prompt + .events() + .iter() + .filter_map(|e| { + if let Event::Announce(text) = e { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + assert_eq!(announcements, vec!["journal(...)"]); +} + #[test] fn loop_inside_step_produces_one_result() { let mut fixture = StoreFixture::new("loop-in-step"); diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 6df8df8..7f020c9 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -353,3 +353,88 @@ fn parse_record_rejects_unknown_outcome() { other => panic!("expected UnknownOutcome, got {:?}", other), } } + +#[test] +fn parse_manifest_missing_document_errors() { + let line = "[ started = 2026-05-14T01:02:03Z ]"; + match parse_manifest(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "document"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_manifest_missing_started_errors() { + let line = "[ document = file:///x.tq ]"; + match parse_manifest(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "started"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_record_missing_path_errors() { + let line = "[ recorded = 2026-05-14T12:00:00Z, outcome = Done ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "path"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_record_missing_outcome_errors() { + let line = "[ recorded = 2026-05-14T12:00:00Z, path = a:1 ]"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MissingField(name)) => { + assert_eq!(name, "outcome"); + } + other => panic!("expected MissingField, got {:?}", other), + } +} + +#[test] +fn parse_record_without_brackets_errors() { + let line = "recorded = 2026-05-14T12:00:00Z, path = a:1, outcome = Done"; + match parse_record(line) { + Err(crate::runner::state::RecordError::MalformedTablet) => {} + other => panic!("expected MalformedTablet, got {:?}", other), + } +} + +#[test] +fn open_empty_pfftt_file_reports_manifest_missing() { + let base = std::env::temp_dir().join("technique-empty-pfftt"); + let _ = std::fs::remove_dir_all(&base); + let run_dir = base.join("000001"); + std::fs::create_dir_all(&run_dir).unwrap(); + std::fs::write(run_dir.join("Test.pfftt"), "").unwrap(); + + let store = Store::new(base.clone()); + match store.open(RunId(1)) { + Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), + other => panic!("expected ManifestMissing, got {:?}", other), + } + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn open_run_directory_without_pfftt_reports_manifest_missing() { + let base = std::env::temp_dir().join("technique-no-pfftt"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("000001")).unwrap(); + + let store = Store::new(base.clone()); + match store.open(RunId(1)) { + Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), + other => panic!("expected ManifestMissing, got {:?}", other), + } + + let _ = std::fs::remove_dir_all(&base); +} From 76aea8759680be6af1e09cbcbda35b9dbb30c251 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 22:03:33 +1000 Subject: [PATCH 22/25] Preserve whitespace in strings when translating --- src/runner/checks/runner.rs | 4 +--- src/translation/translator.rs | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 2b81cbc..3c59685 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -461,9 +461,7 @@ test : .collect(); // Step 1 only binds, so its description renders to empty. Step 2 // sees the binding established by step 1 and interpolates it. - // The parser strips whitespace adjacent to inline `{ ... }` fragments, - // hence "Result:42" rather than "Result: 42". - assert_eq!(descriptions, vec!["", "Result:42"]); + assert_eq!(descriptions, vec!["", "Result: 42"]); } #[test] diff --git a/src/translation/translator.rs b/src/translation/translator.rs index cc04772..67248c9 100644 --- a/src/translation/translator.rs +++ b/src/translation/translator.rs @@ -356,12 +356,29 @@ impl<'i> Translator<'i> { .collect() } + // Descriptive paragraphs are whitespace-agnostic and re-wrappable: the + // parser strips edge whitespace from each Text fragment, so the original + // single-space joins between adjacent tokens are lost. Reinsert one + // separator space between each pair of fragments here so the runtime + // renderer sees the canonical form the formatter would emit. Expression + // string literals translated elsewhere preserve whitespace verbatim and + // are not affected by this rule. fn translate_paragraph(&mut self, paragraph: &'i language::Paragraph<'i>) -> Operation<'i> { let language::Paragraph(descriptives, _) = paragraph; - let fragments = descriptives + let mut fragments = Vec::with_capacity( + descriptives + .len() + .saturating_mul(2), + ); + for (i, descriptive) in descriptives .iter() - .map(|d| self.fragment_from_descriptive(d)) - .collect(); + .enumerate() + { + if i > 0 { + fragments.push(Fragment::Text(" ")); + } + fragments.push(self.fragment_from_descriptive(descriptive)); + } Operation::String(fragments) } From 5f11876cf85bba23d70a86f22c93a80dcf35dd44 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 22:21:03 +1000 Subject: [PATCH 23/25] Consolidate duplicate tests --- src/runner/checks/prompt.rs | 37 ++---- src/runner/checks/state.rs | 224 ++++++++++++++---------------------- src/value/checks/types.rs | 31 ++--- 3 files changed, 101 insertions(+), 191 deletions(-) diff --git a/src/runner/checks/prompt.rs b/src/runner/checks/prompt.rs index ba3f9cf..2f37bdb 100644 --- a/src/runner/checks/prompt.rs +++ b/src/runner/checks/prompt.rs @@ -57,50 +57,31 @@ fn mock_ask_without_answers_panics() { } #[test] -fn console_done_input() { - let input = Cursor::new(b"d\n"); +fn console_input() { let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); -} -#[test] -fn console_skip_input() { - let input = Cursor::new(b"s\n"); let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); assert_eq!(p.ask(), UserInput::Skip); -} -#[test] -fn console_fail_input() { - let input = Cursor::new(b"f\n"); let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b"f\n"), &mut output); assert_eq!(p.ask(), UserInput::Fail); -} -#[test] -fn console_quit_input() { - let input = Cursor::new(b"q\n"); let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b"q\n"), &mut output); assert_eq!(p.ask(), UserInput::Quit); -} -#[test] -fn console_uppercase_done_accepted() { - let input = Cursor::new(b"DONE\n"); + // Case-insensitive on the first character. let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b"DONE\n"), &mut output); assert_eq!(p.ask(), UserInput::Done(Value::Unitus)); -} -#[test] -fn console_leading_whitespace_tolerated() { - let input = Cursor::new(b" q\n"); + // Leading whitespace is tolerated. let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); + let mut p = Console::with_handles(Cursor::new(b" q\n"), &mut output); assert_eq!(p.ask(), UserInput::Quit); } diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 7f020c9..71c9eaf 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -6,59 +6,33 @@ use crate::runner::state::{ }; #[test] -fn run_id_parse_padded() { - let id = RunId::parse("000007").expect("parse padded"); +fn run_id_parse() { + let id = RunId::parse("7").expect("parse"); assert_eq!(id, RunId(7)); -} -#[test] -fn run_id_parse_unpadded() { - let id = RunId::parse("7").expect("parse unpadded"); + let id = RunId::parse("000007").expect("parse"); assert_eq!(id, RunId(7)); -} -#[test] -fn run_id_parse_large() { - let id = RunId::parse("123456").expect("parse large"); + let id = RunId::parse("123456").expect("parse"); assert_eq!(id, RunId(123456)); } #[test] -fn run_id_parse_rejects_empty() { - match RunId::parse("") { - Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, ""), - other => panic!("expected InvalidRunId, got {:?}", other), - } -} - -#[test] -fn run_id_parse_rejects_alphabetic() { - match RunId::parse("abc") { - Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, "abc"), - other => panic!("expected InvalidRunId, got {:?}", other), - } -} - -#[test] -fn run_id_parse_rejects_negative() { - match RunId::parse("-1") { - Err(RunnerError::InvalidRunId(text)) => assert_eq!(text, "-1"), - other => panic!("expected InvalidRunId, got {:?}", other), +fn run_id_parse_rejects_non_decimal() { + for text in ["", "abc", "-1"] { + match RunId::parse(text) { + Err(RunnerError::InvalidRunId(got)) => assert_eq!(got, text), + other => panic!("expected InvalidRunId for {:?}, got {:?}", text, other), + } } } #[test] -fn run_id_render_pads_to_six() { - assert_eq!(RunId(7).render(), "000007"); +fn run_id_render_six_digit_padding() { assert_eq!(RunId(0).render(), "000000"); - assert_eq!(RunId(142).render(), "000142"); + assert_eq!(RunId(7).render(), "000007"); assert_eq!(RunId(15003).render(), "015003"); - assert_eq!(RunId(123456).render(), "123456"); -} - -#[test] -fn run_id_render_wider_than_six_unpadded() { - // Six digits is the convention but larger values render naturally. + // Six digits is the convention, but larger values render unpadded. assert_eq!(RunId(1_234_567).render(), "1234567"); } @@ -198,7 +172,10 @@ fn create_writes_pfftt_file_with_expected_content() { } #[test] -fn format_record_produces_expected_text() { +fn format_record_pins_on_disk_text() { + // Each variant produces a distinct line shape — sibling fields appear only + // when the corresponding Outcome carries a payload. The round-trip test + // below covers parse compatibility; this one pins exact bytes. let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), path: "make_coffee:2".to_string(), @@ -208,97 +185,104 @@ fn format_record_produces_expected_text() { format_record(&record), "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]\n" ); -} -#[test] -fn format_record_done_with_result_emits_sibling_field() { let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "lookup:1".to_string(), + path: "make_coffee:2".to_string(), outcome: Outcome::Done(Some("\"penguin\"".to_string())), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = lookup:1, outcome = Done, result = \"penguin\" ]\n" + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done, result = \"penguin\" ]\n" ); -} -#[test] -fn format_record_failed_with_reason_emits_sibling_field() { let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "ping:1".to_string(), - outcome: Outcome::Failed(Some("\"network unplugged\"".to_string())), + path: "make_coffee:2".to_string(), + outcome: Outcome::Skipped, }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed, reason = \"network unplugged\" ]\n" + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Skipped ]\n" ); -} -#[test] -fn format_record_skipped_emits_no_sibling() { let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "wait:3".to_string(), - outcome: Outcome::Skipped, + path: "make_coffee:2".to_string(), + outcome: Outcome::Failed(None), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = wait:3, outcome = Skipped ]\n" + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed ]\n" ); -} -#[test] -fn format_record_failed_without_reason_emits_no_sibling() { let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "ping:1".to_string(), - outcome: Outcome::Failed(None), + path: "make_coffee:2".to_string(), + outcome: Outcome::Failed(Some("\"network unplugged\"".to_string())), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed ]\n" + "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed, reason = \"network unplugged\" ]\n" ); } #[test] fn record_round_trips_through_format_and_parse() { - let cases = [ - Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "a:1".to_string(), - outcome: Outcome::Done(None), - }, - Record { - recorded: "2026-05-14T12:00:01Z".to_string(), - path: "a:2".to_string(), - outcome: Outcome::Done(Some("\"penguin\"".to_string())), - }, - Record { - recorded: "2026-05-14T12:00:02Z".to_string(), - path: "a:3".to_string(), - outcome: Outcome::Skipped, - }, - Record { - recorded: "2026-05-14T12:00:03Z".to_string(), - path: "a:4".to_string(), - outcome: Outcome::Failed(None), - }, - Record { - recorded: "2026-05-14T12:00:04Z".to_string(), - path: "a:5".to_string(), - outcome: Outcome::Failed(Some("\"unreachable\"".to_string())), - }, - ]; - for original in &cases { - let text = format_record(original); - let line = text - .strip_suffix('\n') - .expect("trailing newline"); - let parsed = parse_record(line).expect("parse"); - assert_eq!(&parsed, original); - } + let original = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + path: "a:1".to_string(), + outcome: Outcome::Done(None), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + path: "a:2".to_string(), + outcome: Outcome::Done(Some("\"penguin\"".to_string())), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + path: "a:3".to_string(), + outcome: Outcome::Skipped, + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + path: "a:4".to_string(), + outcome: Outcome::Failed(None), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); + + let original = Record { + recorded: "2026-05-14T12:00:04Z".to_string(), + path: "a:5".to_string(), + outcome: Outcome::Failed(Some("\"unreachable\"".to_string())), + }; + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); } #[test] @@ -309,40 +293,6 @@ fn parse_manifest_reads_expected_text() { assert_eq!(manifest.started, "2026-05-14T01:02:03Z"); } -#[test] -fn parse_record_yields_expected_fields() { - let line = "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]"; - let record = parse_record(line).expect("parse"); - assert_eq!( - record, - Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Done(None), - } - ); -} - -#[test] -fn parse_record_done_with_result_folds_into_variant() { - let line = "[ recorded = 2026-05-14T12:00:00Z, path = lookup:1, outcome = Done, result = \"penguin\" ]"; - let record = parse_record(line).expect("parse"); - assert_eq!( - record.outcome, - Outcome::Done(Some("\"penguin\"".to_string())) - ); -} - -#[test] -fn parse_record_failed_with_reason_folds_into_variant() { - let line = "[ recorded = 2026-05-14T12:00:00Z, path = ping:1, outcome = Failed, reason = \"network unplugged\" ]"; - let record = parse_record(line).expect("parse"); - assert_eq!( - record.outcome, - Outcome::Failed(Some("\"network unplugged\"".to_string())) - ); -} - #[test] fn parse_record_rejects_unknown_outcome() { let line = "[ recorded = 2026-05-14T12:00:00Z, path = x:1, outcome = Maybe ]"; @@ -355,7 +305,7 @@ fn parse_record_rejects_unknown_outcome() { } #[test] -fn parse_manifest_missing_document_errors() { +fn parse_manifest_rejects_missing_required_field() { let line = "[ started = 2026-05-14T01:02:03Z ]"; match parse_manifest(line) { Err(crate::runner::state::RecordError::MissingField(name)) => { @@ -363,10 +313,7 @@ fn parse_manifest_missing_document_errors() { } other => panic!("expected MissingField, got {:?}", other), } -} -#[test] -fn parse_manifest_missing_started_errors() { let line = "[ document = file:///x.tq ]"; match parse_manifest(line) { Err(crate::runner::state::RecordError::MissingField(name)) => { @@ -377,7 +324,7 @@ fn parse_manifest_missing_started_errors() { } #[test] -fn parse_record_missing_path_errors() { +fn parse_record_rejects_missing_required_field() { let line = "[ recorded = 2026-05-14T12:00:00Z, outcome = Done ]"; match parse_record(line) { Err(crate::runner::state::RecordError::MissingField(name)) => { @@ -385,10 +332,7 @@ fn parse_record_missing_path_errors() { } other => panic!("expected MissingField, got {:?}", other), } -} -#[test] -fn parse_record_missing_outcome_errors() { let line = "[ recorded = 2026-05-14T12:00:00Z, path = a:1 ]"; match parse_record(line) { Err(crate::runner::state::RecordError::MissingField(name)) => { diff --git a/src/value/checks/types.rs b/src/value/checks/types.rs index 6526483..cf6f2b0 100644 --- a/src/value/checks/types.rs +++ b/src/value/checks/types.rs @@ -1,42 +1,27 @@ use crate::value::{Numeric, Value}; #[test] -fn integral_renders_via_formatter() { +fn value_display() { + assert_eq!(Value::Unitus.to_string(), ""); + + let v = Value::Literali("just text".to_string()); + assert_eq!(v.to_string(), "\"just text\""); + let v = Value::Quanticle(Numeric::Integral(42)); assert_eq!(v.to_string(), "42"); -} -#[test] -fn render_tabularum_formats_as_bracketed_pairs() { let v = Value::Tabularum(vec![ ("first".to_string(), Value::Literali("Anna".to_string())), ("last".to_string(), Value::Literali("Kowalski".to_string())), ]); assert_eq!(v.to_string(), "[first = \"Anna\", last = \"Kowalski\"]"); -} - -#[test] -fn render_unitus_is_empty() { - assert_eq!(Value::Unitus.to_string(), ""); -} -#[test] -fn render_futurae_is_braced() { - assert_eq!(Value::Futurae("name".to_string()).to_string(), "{name}"); -} - -#[test] -fn render_literali_is_quoted() { - let v = Value::Literali("just text".to_string()); - assert_eq!(v.to_string(), "\"just text\""); -} - -#[test] -fn render_parametriq_formats_as_bracketed_list() { let v = Value::Parametriq(vec![ Value::Literali("a".to_string()), Value::Literali("b".to_string()), Value::Quanticle(Numeric::Integral(3)), ]); assert_eq!(v.to_string(), "[\"a\", \"b\", 3]"); + + assert_eq!(Value::Futurae("name".to_string()).to_string(), "{name}"); } From e6b612a7db06e5f263f5cc42bbe3929ca393509b Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 22:46:54 +1000 Subject: [PATCH 24/25] Further consolidate duplicate tests and fix comments --- src/runner/checks/evaluator.rs | 15 ++------- src/runner/checks/path.rs | 14 ++++----- src/runner/checks/runner.rs | 56 ++++++---------------------------- src/runner/checks/state.rs | 14 ++++----- 4 files changed, 26 insertions(+), 73 deletions(-) diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index f0186d7..6455dbe 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -5,17 +5,14 @@ use crate::runner::runner::RunnerError; use crate::value; #[test] -fn unbound_variable_errors() { +fn variable_lookup() { let op = Operation::Variable(Identifier::new("missing")); let mut env = Environment::new(); match evaluate(&mut env, &op) { Err(RunnerError::UnboundVariable(name)) => assert_eq!(name, "missing"), other => panic!("expected UnboundVariable, got {:?}", other), } -} -#[test] -fn bound_variable_looks_up() { let mut env = Environment::new(); env.extend( "name".to_string(), @@ -35,7 +32,7 @@ fn number_evaluates_to_quanticle() { } #[test] -fn string_fragments_interpolate_bound_variable() { +fn string_interpolation() { let mut env = Environment::new(); env.extend( "name".to_string(), @@ -48,10 +45,7 @@ fn string_fragments_interpolate_bound_variable() { ]); let v = evaluate(&mut env, &op).expect("evaluated"); assert_eq!(v, value::Value::Literali("Hello, World!".to_string())); -} -#[test] -fn string_interpolation_propagates_unbound_error() { let op = Operation::String(vec![ Fragment::Text("hi "), Fragment::Interpolation(Operation::Variable(Identifier::new("nope"))), @@ -115,7 +109,7 @@ fn bind_extends_env_for_subsequent_lookup() { } #[test] -fn sequence_returns_last_value() { +fn sequence_evaluation() { let seq = Operation::Sequence(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(2)), @@ -124,10 +118,7 @@ fn sequence_returns_last_value() { let mut env = Environment::new(); let v = evaluate(&mut env, &seq).expect("evaluated"); assert_eq!(v, value::Value::Quanticle(value::Numeric::Integral(3))); -} -#[test] -fn empty_sequence_returns_unitus() { let seq = Operation::Sequence(vec![]); let mut env = Environment::new(); let v = evaluate(&mut env, &seq).expect("evaluated"); diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs index 5ea9989..8d5eaa7 100644 --- a/src/runner/checks/path.rs +++ b/src/runner/checks/path.rs @@ -65,17 +65,16 @@ fn nested_attribute_frames_each_contribute_a_segment() { } #[test] -fn reset_role_alone_suppresses_frame() { +fn reset_role() { + // `@*` alone suppresses the whole attribute frame's contribution. let frame = vec![Attribute::Role(Identifier::new("*"), Span::default())]; let mut stack = QualifiedPath::new(); stack.push(PathSegment::DependentStep("1")); stack.push(PathSegment::Attributes(&frame)); stack.push(PathSegment::DependentStep("a")); assert_eq!(stack.render(), "1/a"); -} -#[test] -fn reset_role_preserves_sibling_place() { + // A sibling Place attribute in the same frame still renders. let frame = vec![ Attribute::Role(Identifier::new("*"), Span::default()), Attribute::Place(Identifier::new("kitchen"), Span::default()), @@ -88,15 +87,14 @@ fn reset_role_preserves_sibling_place() { } #[test] -fn procedure_uses_colon_separator() { +fn procedure_segment() { + // Single procedure: name becomes the prefix, joined to the rest with `:`. let mut stack = QualifiedPath::new(); stack.push(PathSegment::Procedure("make_coffee")); stack.push(PathSegment::DependentStep("2")); assert_eq!(stack.render(), "make_coffee:2"); -} -#[test] -fn procedure_descent_replaces_outer_prefix() { + // Nested procedure: the inner frame replaces the outer prefix entirely. let mut stack = QualifiedPath::new(); stack.push(PathSegment::Procedure("outer")); stack.push(PathSegment::DependentStep("1")); diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 3c59685..6e8616a 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -88,33 +88,19 @@ fn anonymous_with_body(body: Operation<'static>) -> Program<'static> { } #[test] -fn single_step_prompts_and_records() { - let mut fixture = StoreFixture::new("single-step"); +fn step_outcomes_recorded() { + let mut fixture = StoreFixture::new("step-done"); let body = Operation::Sequence(vec![step( Ordinal::Dependent("1"), Operation::Sequence(vec![]), )]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); let outcome = runner .run() .expect("run"); assert_eq!(outcome, Outcome::Done(Value::Unitus)); - - let prompt = runner.into_prompt(); - assert_eq!( - prompt.events(), - &[ - Event::Step { - qualified: "1".to_string(), - description: String::new(), - }, - Event::Ask, - ] - ); - let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt .lines() @@ -134,24 +120,18 @@ fn single_step_prompts_and_records() { let RecordOutcome::Done(_) = record.outcome else { panic!("expected Done, got {:?}", record.outcome); }; -} -#[test] -fn skip_outcome_is_recorded() { - let mut fixture = StoreFixture::new("skip-records"); + let mut fixture = StoreFixture::new("step-skip"); let body = Operation::Sequence(vec![step( Ordinal::Dependent("1"), Operation::Sequence(vec![]), )]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Skip]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); - let outcome = runner + runner .run() .expect("run"); - assert_eq!(outcome, Outcome::Done(Value::Unitus)); - let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt .lines() @@ -161,27 +141,20 @@ fn skip_outcome_is_recorded() { .is_empty() }) .collect(); - assert_eq!(lines.len(), 2); let record = parse_record(lines[1]).expect("parse record"); - assert_eq!(record.path, "1"); assert_eq!(record.outcome, RecordOutcome::Skipped); -} -#[test] -fn fail_outcome_is_recorded_with_reason() { - let mut fixture = StoreFixture::new("fail-records"); + let mut fixture = StoreFixture::new("step-fail"); let body = Operation::Sequence(vec![step( Ordinal::Dependent("1"), Operation::Sequence(vec![]), )]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Fail]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); runner .run() .expect("run"); - let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt .lines() @@ -191,9 +164,7 @@ fn fail_outcome_is_recorded_with_reason() { .is_empty() }) .collect(); - assert_eq!(lines.len(), 2); let record = parse_record(lines[1]).expect("parse record"); - assert_eq!(record.path, "1"); assert_eq!( record.outcome, RecordOutcome::Failed(Some("\"Failed\"".to_string())) @@ -311,8 +282,10 @@ fn quit_propagates_and_stops_walking() { } #[test] -fn section_renders_path_segment() { - let mut fixture = StoreFixture::new("section-path"); +fn section_walking() { + use crate::program::Fragment; + + let mut fixture = StoreFixture::new("section-no-title"); let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); let body = Operation::Sequence(vec![Operation::Section { numeral: "I", @@ -321,13 +294,11 @@ fn section_renders_path_segment() { responses: Vec::new(), }]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); runner .run() .expect("run"); - let prompt = runner.into_prompt(); let events = prompt.events(); let section_fqns: Vec<&str> = events @@ -352,13 +323,8 @@ fn section_renders_path_segment() { .collect(); assert_eq!(section_fqns, vec!["I"]); assert_eq!(step_fqns, vec!["I/1"]); -} -#[test] -fn section_title_renders_from_paragraph() { - use crate::program::Fragment; - - let mut fixture = StoreFixture::new("section-title"); + let mut fixture = StoreFixture::new("section-with-title"); let title = Operation::String(vec![Fragment::Text("Setup")]); let inner = step(Ordinal::Dependent("1"), Operation::Sequence(vec![])); let body = Operation::Sequence(vec![Operation::Section { @@ -368,13 +334,11 @@ fn section_title_renders_from_paragraph() { responses: Vec::new(), }]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::with_pieces(&program, fixture.take_appender(), HashSet::new(), prompt); runner .run() .expect("run"); - let prompt = runner.into_prompt(); let section_title = prompt .events() diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 71c9eaf..f6091ef 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -171,11 +171,11 @@ fn create_writes_pfftt_file_with_expected_content() { let _ = std::fs::remove_dir_all(&base); } +// Each variant produces a distinct line shape — sibling fields appear only +// when the corresponding Outcome carries a payload. The round-trip test +// below covers parse compatibility; this one pins exact bytes. #[test] fn format_record_pins_on_disk_text() { - // Each variant produces a distinct line shape — sibling fields appear only - // when the corresponding Outcome carries a payload. The round-trip test - // below covers parse compatibility; this one pins exact bytes. let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), path: "make_coffee:2".to_string(), @@ -351,8 +351,11 @@ fn parse_record_without_brackets_errors() { } } +// `ManifestMissing` covers two setups: a run directory containing an empty +// PFFTT file (file present, no manifest tablet inside), and a run directory +// with no PFFTT file at all. #[test] -fn open_empty_pfftt_file_reports_manifest_missing() { +fn open_missing_manifest() { let base = std::env::temp_dir().join("technique-empty-pfftt"); let _ = std::fs::remove_dir_all(&base); let run_dir = base.join("000001"); @@ -366,10 +369,7 @@ fn open_empty_pfftt_file_reports_manifest_missing() { } let _ = std::fs::remove_dir_all(&base); -} -#[test] -fn open_run_directory_without_pfftt_reports_manifest_missing() { let base = std::env::temp_dir().join("technique-no-pfftt"); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(base.join("000001")).unwrap(); From 92ac8880846285e4cdc795f1f60dea71de89f240 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 17 May 2026 22:51:01 +1000 Subject: [PATCH 25/25] Use temp dirs properly and guards to ensure test cleanup --- src/runner/checks/state.rs | 124 +++++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 46 deletions(-) diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index f6091ef..e04e164 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -5,6 +5,26 @@ use crate::runner::state::{ format_record, parse_manifest, parse_record, Outcome, Record, RunId, Store, }; +// A scratch directory under the system temp dir, cleaned up on drop so panics +// in a test do not leak it. Tests construct one per fixture they need. +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!("technique-{}", name)); + let _ = std::fs::remove_dir_all(&path); + TempDir { path } + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + #[test] fn run_id_parse() { let id = RunId::parse("7").expect("parse"); @@ -38,10 +58,12 @@ fn run_id_render_six_digit_padding() { #[test] fn store_allocate_assigns_monotonic_ids() { - let base = std::env::temp_dir().join("technique-allocate-monotonic"); - let _ = std::fs::remove_dir_all(&base); + let dir = TempDir::new("allocate-monotonic"); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); let (first, _) = store .allocate() .expect("first"); @@ -50,34 +72,38 @@ fn store_allocate_assigns_monotonic_ids() { .expect("second"); assert_eq!(first, RunId(1)); assert_eq!(second, RunId(2)); - - let _ = std::fs::remove_dir_all(&base); } #[test] fn store_allocate_resumes_from_existing_max() { - let base = std::env::temp_dir().join("technique-allocate-resume"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(base.join("000007")).unwrap(); - - let store = Store::new(base.clone()); + let dir = TempDir::new("allocate-resume"); + std::fs::create_dir_all( + dir.path + .join("000007"), + ) + .unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); let (id, _) = store .allocate() .expect("allocate"); assert_eq!(id, RunId(8)); - - let _ = std::fs::remove_dir_all(&base); } #[test] fn manifest_round_trip_through_create_and_open() { - let base = std::env::temp_dir().join("technique-manifest-roundtrip"); - let _ = std::fs::remove_dir_all(&base); + let dir = TempDir::new("manifest-roundtrip"); let document = PathBuf::from("/somewhere/NetworkProbe.tq"); let started = "2026-05-14T12:34:56Z".to_string(); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); let (id, _, written) = store .create(&document, started.clone()) .expect("create"); @@ -89,16 +115,15 @@ fn manifest_round_trip_through_create_and_open() { assert_eq!(read.document, document); assert_eq!(read.started, started); assert!(completed.is_empty()); - - let _ = std::fs::remove_dir_all(&base); } #[test] fn open_replays_three_result_paths() { - let base = std::env::temp_dir().join("technique-replay-three"); - let _ = std::fs::remove_dir_all(&base); + let dir = TempDir::new("replay-three"); - let run_dir = base.join("000001"); + let run_dir = dir + .path + .join("000001"); std::fs::create_dir_all(&run_dir).unwrap(); let mut file = String::new(); file.push_str("[ document = file:///foo/Test.tq, started = 2026-05-14T12:00:00Z ]\n"); @@ -119,7 +144,10 @@ fn open_replays_three_result_paths() { })); std::fs::write(run_dir.join("Test.pfftt"), file).unwrap(); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); let (manifest, completed, _) = store .open(RunId(1)) .expect("open"); @@ -129,34 +157,34 @@ fn open_replays_three_result_paths() { assert!(completed.contains("test:1")); assert!(completed.contains("test:2")); assert!(completed.contains("test:3")); - - let _ = std::fs::remove_dir_all(&base); } #[test] fn open_missing_run_returns_no_such_run() { - let base = std::env::temp_dir().join("technique-no-such-run"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base).unwrap(); + let dir = TempDir::new("no-such-run"); + std::fs::create_dir_all(&dir.path).unwrap(); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); match store.open(RunId(42)) { Err(RunnerError::NoSuchRun(id)) => assert_eq!(id, RunId(42)), other => panic!("expected NoSuchRun, got {:?}", other), } - - let _ = std::fs::remove_dir_all(&base); } #[test] fn create_writes_pfftt_file_with_expected_content() { - let base = std::env::temp_dir().join("technique-create-bytes"); - let _ = std::fs::remove_dir_all(&base); + let dir = TempDir::new("create-bytes"); let document = PathBuf::from("/somewhere/NetworkProbe.tq"); let started = "2026-05-14T12:34:56Z".to_string(); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); let (_, run_dir, _) = store .create(&document, started) .expect("create"); @@ -167,8 +195,6 @@ fn create_writes_pfftt_file_with_expected_content() { on_disk, "[ document = file:///somewhere/NetworkProbe.tq, started = 2026-05-14T12:34:56Z ]\n" ); - - let _ = std::fs::remove_dir_all(&base); } // Each variant produces a distinct line shape — sibling fields appear only @@ -356,29 +382,35 @@ fn parse_record_without_brackets_errors() { // with no PFFTT file at all. #[test] fn open_missing_manifest() { - let base = std::env::temp_dir().join("technique-empty-pfftt"); - let _ = std::fs::remove_dir_all(&base); - let run_dir = base.join("000001"); + let dir = TempDir::new("empty-pfftt"); + let run_dir = dir + .path + .join("000001"); std::fs::create_dir_all(&run_dir).unwrap(); std::fs::write(run_dir.join("Test.pfftt"), "").unwrap(); - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); match store.open(RunId(1)) { Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), other => panic!("expected ManifestMissing, got {:?}", other), } - let _ = std::fs::remove_dir_all(&base); + let dir = TempDir::new("no-pfftt"); + std::fs::create_dir_all( + dir.path + .join("000001"), + ) + .unwrap(); - let base = std::env::temp_dir().join("technique-no-pfftt"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(base.join("000001")).unwrap(); - - let store = Store::new(base.clone()); + let store = Store::new( + dir.path + .clone(), + ); match store.open(RunId(1)) { Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), other => panic!("expected ManifestMissing, got {:?}", other), } - - let _ = std::fs::remove_dir_all(&base); }