From 45b0e2b055d611cb0f6e37dee39bc86a82489c43 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 20 Jun 2026 17:39:14 +1000 Subject: [PATCH 1/6] Restore step bindings on resume from recorded values --- src/runner/checks/runner.rs | 175 ++++++++----- src/runner/checks/state.rs | 59 +++-- src/runner/mod.rs | 17 +- src/runner/runner.rs | 84 +++++-- src/runner/state.rs | 478 +++++++++++++++++++++++++++++++----- tests/runner/samples.rs | 4 +- 6 files changed, 642 insertions(+), 175 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index d24e6e3..ddb812d 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::language; @@ -13,7 +13,7 @@ use crate::runner::evaluator::Environment; use crate::runner::library::Library; use crate::runner::runner::{bind_parameters, render_argument_echo, Outcome, Runner, RunnerError}; use crate::runner::state::{ - parse_record, Appender, InvokeTarget, State, Store, Value as RecordValue, + parse_record, Appender, InvokeTarget, State, Store, }; use crate::translation::translate; use crate::value::Value; @@ -133,7 +133,7 @@ fn step_outcomes_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -176,7 +176,7 @@ fn step_outcomes_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -207,7 +207,7 @@ fn step_outcomes_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -228,9 +228,10 @@ fn step_outcomes_recorded() { let record = parse_record(lines[2]).expect("parse record"); assert_eq!( record.state, - State::Fail(Some(RecordValue::Tablet( - "[ reason = \"cable unplugged\" ]".to_string() - ))) + State::Fail(Some(Value::Tabularum(vec![( + "reason".to_string(), + Value::Literali("cable unplugged".to_string()), + )]))) ); } @@ -248,7 +249,7 @@ fn empty_fail_reason_records_none() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -293,7 +294,7 @@ helper : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), Library::stub(), ); @@ -330,7 +331,7 @@ fn two_steps_prompted_in_source_order() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -365,8 +366,8 @@ fn pre_completed_step_short_circuits() { // 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 mut completed = HashMap::new(); + completed.insert("/1".to_string(), Value::Unitus); let prompt = Mock::with_answers([UserInput::Done(Value::Unitus)]); let mut runner = Runner::new( @@ -409,7 +410,7 @@ fn quit_propagates_and_stops_walking() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -469,7 +470,7 @@ fn section_walking() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -529,7 +530,7 @@ fn section_walking() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -568,7 +569,7 @@ fn parallel_step_index_starts_at_one() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -614,7 +615,7 @@ test : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -676,7 +677,7 @@ cycle(s) : Situation -> Done let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -736,7 +737,7 @@ cycle(s) : Situation -> Done let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -793,7 +794,7 @@ cycle(s) : Situation -> Done let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -846,7 +847,7 @@ helper : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -895,7 +896,7 @@ inner : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -946,7 +947,7 @@ greet(name) : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -999,7 +1000,7 @@ peek : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1028,7 +1029,7 @@ test : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1071,7 +1072,7 @@ test : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1132,7 +1133,7 @@ check : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), library, ); @@ -1178,7 +1179,7 @@ check : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), library, ); @@ -1239,7 +1240,7 @@ fn loop_inside_step_produces_one_result() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1293,7 +1294,7 @@ fn repeat_loops_until_quit() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1364,7 +1365,7 @@ fn foreach_walks_body_once_per_list_element() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1446,7 +1447,7 @@ fn foreach_over_seq_builtin_runs() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, library, ); @@ -1540,7 +1541,7 @@ fn foreach_destructures_tuple_elements() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1603,7 +1604,7 @@ fn foreach_widens_primitive_to_singleton() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1653,7 +1654,7 @@ fn foreach_over_unit_iterates_nothing() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Mock::new(), Library::stub(), ); @@ -1706,7 +1707,7 @@ fn foreach_over_non_list_or_unbound_errors() { let mut runner = Runner::new( &program, tuple_fixture.take_appender(), - HashSet::new(), + HashMap::new(), Mock::new(), Library::stub(), ); @@ -1729,7 +1730,7 @@ fn foreach_over_non_list_or_unbound_errors() { let mut runner = Runner::new( &program, tablet_fixture.take_appender(), - HashSet::new(), + HashMap::new(), Mock::new(), Library::stub(), ); @@ -1743,7 +1744,7 @@ fn foreach_over_non_list_or_unbound_errors() { let mut runner = Runner::new( &program, unbound_fixture.take_appender(), - HashSet::new(), + HashMap::new(), Mock::new(), Library::stub(), ); @@ -1901,7 +1902,7 @@ greet(name) : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1944,7 +1945,7 @@ Brew using { 42 ~ water } then serve it hot. let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -1990,7 +1991,7 @@ make_coffee : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -2033,7 +2034,7 @@ test : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -2071,7 +2072,7 @@ test : let record = parse_record(lines[3]).expect("parse record"); assert_eq!( record.state, - State::Done(Some(RecordValue::Literal("Yes".to_string()))) + State::Done(Some(Value::Literali("Yes".to_string()))) ); } @@ -2086,7 +2087,7 @@ fn automatic_records_done_for_computable_step_skip_for_prose() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), Library::stub(), ); @@ -2119,19 +2120,23 @@ fn automatic_records_done_for_computable_step_skip_for_prose() { ); assert_eq!( state, - State::Done(Some(RecordValue::Literal("probe output".to_string()))) + State::Done(Some(Value::Literali("probe output".to_string()))) ); - // Multi-line text propagates intact; the record projects it to Unit. + // Multi-line text propagates intact and records faithfully; the codec + // escapes the newlines so the value stays on one record line. let (outcome, state) = record_of( - "multiline-records-unit", + "multiline-records-value", Operation::String(vec![Fragment::Text("1: lo\n2: eth0\n3: wlan0")]), ); assert_eq!( outcome, Outcome::Done(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string())) ); - assert_eq!(state, State::Done(Some(RecordValue::Unit))); + assert_eq!( + state, + State::Done(Some(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()))) + ); // A pure-prose step (empty body) has nothing to compute and records Skip. let (_, state) = record_of("automatic-empty-body", Operation::Sequence(vec![])); @@ -2156,7 +2161,7 @@ fn sequence_value_is_last_member() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), Library::stub(), ); @@ -2210,7 +2215,7 @@ fn deferred_invoke_is_prompted_and_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -2245,7 +2250,7 @@ fn deferred_invoke_is_prompted_and_recorded() { ))); assert!(records.contains(&( "/".to_string(), - State::Done(Some(RecordValue::Unit)) + State::Done(Some(Value::Unitus)) ))); // The operator declines: Skip is recorded at the external's FQP. (The @@ -2258,7 +2263,7 @@ fn deferred_invoke_is_prompted_and_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -2281,7 +2286,7 @@ fn deferred_invoke_is_prompted_and_recorded() { let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), Automatic::with_handle(Vec::new()), Library::stub(), ); @@ -2328,7 +2333,7 @@ cleanup : let mut runner = Runner::new( &program, fixture.take_appender(), - HashSet::new(), + HashMap::new(), prompt, Library::stub(), ); @@ -2366,3 +2371,63 @@ cleanup : .count(); assert_eq!(substeps, 2); } + +#[test] +fn resume_rehydrates_binding_made_inside_a_completed_loop() { + // The DeleteAccount resume bug: a completed `foreach` step binds a variable + // inside its loop body that a later `foreach` consumes. Skipping the + // completed step wholesale lost that binding; resume must re-walk it so the + // nested binding rehydrates from the recorded values, leaving the later + // loop with something to iterate rather than an UnboundVariable. + let source = r#" +% technique v1 + +cleanup : + + 1. enumerate things ~ items + 2. { foreach item in items } + - identify the active things ~ seen + 3. { foreach token in seen } + - record { token } + "# + .trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + // The prior run completed steps 1 and 2 (and 2's two iterations); only + // step 3 remains. `items` was acquired as a two-element list; `seen` was + // bound once per iteration inside the loop. + let mut completed = HashMap::new(); + completed.insert( + "/cleanup:/1".to_string(), + Value::Arraeum(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + ]), + ); + completed.insert( + "/cleanup:/2/[1]/-1".to_string(), + Value::Literali("x".to_string()), + ); + completed.insert( + "/cleanup:/2/[2]/-1".to_string(), + Value::Literali("y".to_string()), + ); + completed.insert("/cleanup:/2".to_string(), Value::Unitus); + + let mut runner = Runner::new( + &program, + Appender::memory(), + completed, + Automatic::new(false), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("resume must not raise UnboundVariable"); + assert!(if let Outcome::Done(_) = outcome { + true + } else { + false + }); +} diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 4973f70..05adfcd 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -1,9 +1,10 @@ use std::path::{Path, PathBuf}; use crate::runner::runner::RunnerError; +use crate::value::Value; use crate::runner::state::{ fail_reason, format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, - Store, Value, + Store, }; // A scratch directory under the system temp dir, cleaned up on drop so panics @@ -213,9 +214,9 @@ fn open_replays_done_skip_fail_into_completed() { assert_eq!(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")); + assert!(completed.contains_key("/test:1")); + assert!(completed.contains_key("/test:2")); + assert!(completed.contains_key("/test:3")); } // Resume and Begin records in the middle of the file are lifecycle @@ -248,7 +249,7 @@ fn open_skips_resume_and_begin_during_replay() { recorded: "2026-05-14T12:00:02Z".to_string(), run_id: RunId(1), path: "/test:1".to_string(), - state: State::Done(Some(Value::Unit)), + state: State::Done(Some(Value::Unitus)), })); file.push_str(&format_record(&Record { recorded: "2026-05-14T12:00:03Z".to_string(), @@ -267,7 +268,7 @@ fn open_skips_resume_and_begin_during_replay() { .expect("open"); assert_eq!(completed.len(), 1); - assert!(completed.contains("/test:1")); + assert!(completed.contains_key("/test:1")); } #[test] @@ -385,7 +386,7 @@ fn format_record_pins_on_disk_text() { recorded: "2026-05-14T12:00:00Z".to_string(), run_id: RunId(1), path: "/make_coffee:2".to_string(), - state: State::Done(Some(Value::Unit)), + state: State::Done(Some(Value::Unitus)), }; assert_eq!( format_record(&record), @@ -396,20 +397,21 @@ fn format_record_pins_on_disk_text() { recorded: "2026-05-17T00:29:15Z".to_string(), run_id: RunId(15003), path: "/local_network:3".to_string(), - state: State::Done(Some(Value::Tablet( - "[ address = \"192.168.1.1\" ]".to_string(), - ))), + state: State::Done(Some(Value::Tabularum(vec![( + "address".to_string(), + Value::Literali("192.168.1.1".to_string()), + )]))), }; assert_eq!( format_record(&record), - "2026-05-17T00:29:15Z 015003 /local_network:3 Done [ address = \"192.168.1.1\" ]\n" + "2026-05-17T00:29:15Z 015003 /local_network:3 Done [ \"address\" = \"192.168.1.1\" ]\n" ); let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), run_id: RunId(1), path: "/before_anesthesia:2".to_string(), - state: State::Done(Some(Value::Literal("Not Applicable".to_string()))), + state: State::Done(Some(Value::Literali("Not Applicable".to_string()))), }; assert_eq!( format_record(&record), @@ -442,13 +444,14 @@ fn format_record_pins_on_disk_text() { recorded: "2026-05-14T12:00:00Z".to_string(), run_id: RunId(1), path: "/make_coffee:2".to_string(), - state: State::Fail(Some(Value::Tablet( - "[ reason = \"network unplugged\" ]".to_string(), - ))), + state: State::Fail(Some(Value::Tabularum(vec![( + "reason".to_string(), + Value::Literali("network unplugged".to_string()), + )]))), }; assert_eq!( format_record(&record), - "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ reason = \"network unplugged\" ]\n" + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ \"reason\" = \"network unplugged\" ]\n" ); } @@ -463,7 +466,7 @@ fn fail_reason_escapes_and_stays_on_one_line() { let line = format_record(&record); assert_eq!( line, - "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ reason = \"said \\\"unplug\\\"\\nthen left\" ]\n" + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ \"reason\" = \"said \\\"unplug\\\"\\nthen left\" ]\n" ); assert_eq!( line.matches('\n') @@ -532,27 +535,28 @@ fn record_round_trips_through_format_and_parse() { recorded: "2026-05-14T12:00:01Z".to_string(), run_id: RunId(1), path: "/a:2".to_string(), - state: State::Done(Some(Value::Unit)), + state: State::Done(Some(Value::Unitus)), }, Record { recorded: "2026-05-14T12:00:02Z".to_string(), run_id: RunId(1), path: "/a:3".to_string(), - state: State::Done(Some(Value::Tablet( - "[ address = \"10.0.0.1\" ]".to_string(), - ))), + state: State::Done(Some(Value::Tabularum(vec![( + "address".to_string(), + Value::Literali("10.0.0.1".to_string()), + )]))), }, Record { recorded: "2026-05-14T12:00:02Z".to_string(), run_id: RunId(1), path: "/a:7".to_string(), - state: State::Done(Some(Value::Literal("Not Applicable".to_string()))), + state: State::Done(Some(Value::Literali("Not Applicable".to_string()))), }, Record { recorded: "2026-05-14T12:00:02Z".to_string(), run_id: RunId(1), path: "/a:8".to_string(), - state: State::Done(Some(Value::Literal( + state: State::Done(Some(Value::Literali( "1: lo\n inet 127.0.0.1/8\na quote \" and a slash \\".to_string(), ))), }, @@ -578,9 +582,10 @@ fn record_round_trips_through_format_and_parse() { recorded: "2026-05-14T12:00:05Z".to_string(), run_id: RunId(1), path: "/a:6".to_string(), - state: State::Fail(Some(Value::Tablet( - "[ reason = \"unreachable\" ]".to_string(), - ))), + state: State::Fail(Some(Value::Tabularum(vec![( + "reason".to_string(), + Value::Literali("unreachable".to_string()), + )]))), }, ]; @@ -601,7 +606,7 @@ fn multiline_literal_stays_on_one_record_line() { recorded: "2026-05-14T12:00:00Z".to_string(), run_id: RunId(1), path: "/a:1".to_string(), - state: State::Done(Some(Value::Literal("first\nsecond\nthird".to_string()))), + state: State::Done(Some(Value::Literali("first\nsecond\nthird".to_string()))), }; let text = format_record(&record); assert_eq!( diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 3d50073..3286b98 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -2,7 +2,7 @@ //! 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::collections::HashMap; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -47,20 +47,21 @@ pub fn start<'i>( let (run_id, run_dir) = store.create(document, now_iso8601(), libraries)?; let pfftt = construct_state_path(&run_dir, document); let appender = Appender::open(pfftt, run_id)?; + let completed = HashMap::new(); let outcome = match mode { Mode::Interactive => { if !std::io::stdout().is_terminal() { return Err(RunnerError::TerminalRequired); } let mut runner = - Runner::new(program, appender, HashSet::new(), Console::new(), library); + Runner::new(program, appender, completed, Console::new(), library); runner.run(env)? } Mode::Automatic => { let mut runner = Runner::new( program, appender, - HashSet::new(), + completed, Automatic::new(colour), library, ); @@ -86,15 +87,17 @@ pub fn inspect<'i>( if !std::io::stdout().is_terminal() { return Err(RunnerError::TerminalRequired); } + let appender = Appender::sink(); + let completed = HashMap::new(); let driver = Transcript::new(Console::new()); - let mut runner = - Runner::new(program, Appender::sink(), HashSet::new(), driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library); runner.run(env) } Mode::Automatic => { + let appender = Appender::sink(); + let completed = HashMap::new(); let driver = Transcript::new(Automatic::new(colour)); - let mut runner = - Runner::new(program, Appender::sink(), HashSet::new(), driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library); runner.run(env) } } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 30cbd47..2e18340 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -1,6 +1,6 @@ //! Interactive walker over a translated Program. -use std::collections::HashSet; +use std::collections::HashMap; use std::io; use std::path::PathBuf; @@ -9,9 +9,7 @@ use super::driver::{Driver, UserInput}; use super::evaluator::Environment; use super::library::{Library, Nature}; use super::path::{PathSegment, QualifiedPath}; -use super::state::{ - Appender, InvokeTarget, Record, RecordError, RunId, State, Value as RecordValue, -}; +use super::state::{Appender, InvokeTarget, Record, RecordError, RunId, State}; use crate::language; use crate::program::{ Executable, ExecutableRef, Invocable, Locale, Operation, Ordinal, Program, Subroutine, @@ -107,12 +105,12 @@ pub enum RunnerError { /// Execute a Technique interactively by walking the `Program` tree. Tracks /// the position in the document via a `QualifiedPath` stack, carries an /// `Environment` with known result values. Holds the set of step FQNs already -/// completed in a *prior* run — the resume snapshotplus an append handle to +/// completed in a *prior* run — the resume snapshot plus an append handle to /// write results and the prompt the operator interacts through. pub struct Runner<'i, D: Driver> { program: &'i Program<'i>, appender: Appender, - completed: HashSet, + completed: HashMap, driver: D, path: QualifiedPath<'i>, library: Library, @@ -123,7 +121,7 @@ impl<'i, D: Driver> Runner<'i, D> { pub fn new( program: &'i Program<'i>, appender: Appender, - completed: HashSet, + completed: HashMap, driver: D, library: Library, ) -> Self { @@ -460,7 +458,7 @@ impl<'i, D: Driver> Runner<'i, D> { let lexical = super::path::render_path(&lexical_segments); if self .completed - .contains(&lexical) + .contains_key(&lexical) { return Ok(Outcome::Done(Value::Unitus)); } @@ -601,7 +599,7 @@ impl<'i, D: Driver> Runner<'i, D> { .render(); if self .completed - .contains(&qualified) + .contains_key(&qualified) { self.path .pop(); @@ -672,8 +670,8 @@ impl<'i, D: Driver> Runner<'i, D> { .acquire(&qualified, name, None) { UserInput::Done(value) => { - super::evaluator::bind_names(env, names, value)?; - Ok(Outcome::Done(Value::Unitus)) + super::evaluator::bind_names(env, names, value.clone())?; + Ok(Outcome::Done(value)) } UserInput::Skip => { super::evaluator::bind_names(env, names, Value::Unitus)?; @@ -684,8 +682,8 @@ impl<'i, D: Driver> Runner<'i, D> { } } else { let value = super::evaluator::evaluate(&self.library, &self.context, env, value)?; - super::evaluator::bind_names(env, names, value)?; - Ok(Outcome::Done(Value::Unitus)) + super::evaluator::bind_names(env, names, value.clone())?; + Ok(Outcome::Done(value)) } } @@ -813,7 +811,7 @@ impl<'i, D: Driver> Runner<'i, D> { .render(); if self .completed - .contains(&qualified) + .contains_key(&qualified) { self.path .pop(); @@ -909,11 +907,23 @@ impl<'i, D: Driver> Runner<'i, D> { source: &'i language::Scope<'i>, responses: &[&'i language::Response<'i>], ) -> Result { - if self + if let Some(value) = self .completed - .contains(qualified) + .get(qualified) { - return Ok(Outcome::Done(Value::Unitus)); + let value = value.clone(); + // A replayed step does not re-run its body, so we re-establish + // any results it made by re-binding the step's names to their + // recorded values. A step that nests further work (a `foreach` + // loop, substeps) is re-walked instead: if its descendants are + // all completed too, they short-circuit without re-prompting + // while re-hydrating bindings made inside the loop body. + if nests_work(body) { + self.walk(env, body)?; + } else if let Some(names) = binding_names(body) { + super::evaluator::bind_names(env, names, value.clone())?; + } + return Ok(Outcome::Done(value)); } // Mark the start of work on this step before walking its body, @@ -1170,17 +1180,12 @@ fn outcome_from(input: UserInput) -> Outcome { } /// Project the runner's in-memory `Outcome` into the on-disk `State` for the -/// PFFTT file. A single-line input (a chosen response or whatever the user -/// typed) records as a literal string. Multi-line literals (raw exec output) -/// record as unit. The in-memory `Outcome` still carries the full value, so a -/// value bound with `~` remains available in scope regardless. Quit is -/// unreachable here: the caller filters it out before recording. +/// PFFTT file. A `Done` records its full value (serialized by the state codec), +/// so a value bound with `~` rehydrates on resume. Quit is unreachable here: +/// the caller filters it out before recording. fn record_state(outcome: &Outcome) -> State { match outcome { - Outcome::Done(Value::Literali(text)) if !text.contains('\n') => { - State::Done(Some(RecordValue::Literal(text.clone()))) - } - Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), + Outcome::Done(value) => State::Done(Some(value.clone())), Outcome::Skipped(_) => State::Skip, Outcome::Failed(Failure::Aborted(reason)) | Outcome::Throw(Failure::Aborted(reason)) => { if reason.is_empty() { @@ -1197,6 +1202,33 @@ fn record_state(outcome: &Outcome) -> State { } } +/// The names a step body binds, if any: the first `Bind` reached without +/// descending through a nested step, loop, or section. Used on resume to +/// rebind a replayed step's value into the environment. +fn binding_names<'i>(op: &Operation<'i>) -> Option<&'i [language::Identifier<'i>]> { + match op { + Operation::Bind { names, .. } => Some(names), + Operation::Sequence(ops) => ops + .iter() + .find_map(binding_names), + _ => None, + } +} + +/// Whether a step body nests further executable scopes — a `foreach`/`repeat` +/// loop or substeps — as opposed to being a leaf of prose, a binding, or a +/// call. A completed step that nests work is re-walked on resume so bindings +/// made inside it rehydrate; a leaf is restored from its recorded value alone. +fn nests_work(op: &Operation) -> bool { + match op { + Operation::Loop { .. } | Operation::Step { .. } => true, + Operation::Sequence(ops) => ops + .iter() + .any(nests_work), + _ => false, + } +} + /// Render the entry call with arguments bound to each parameter in /// `value ~ name` form, e.g. `connectivity_check([] ~ e, 0 ~ s)`. fn render_argument_echo(name: &str, params: &[language::Identifier], env: &Environment) -> String { diff --git a/src/runner/state.rs b/src/runner/state.rs index 82f8a12..22dbeaf 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -1,10 +1,11 @@ //! On-disk state store and run identifiers. -use std::collections::HashSet; +use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; use super::runner::RunnerError; +use crate::value; /// Monotonic identifier for a run. Conventionally rendered and stored as a /// six-digit zero-padded string. @@ -36,7 +37,7 @@ pub enum RecordError { /// One record line on disk. #[allow(dead_code)] -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Record { pub recorded: String, pub run_id: RunId, @@ -54,7 +55,7 @@ pub struct Record { /// implicit — the next event's path reveals the resumed procedure). /// `Execute` records a host-function call from inside a step body. #[allow(dead_code)] -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum State { Start { uri: String }, Stop, @@ -62,9 +63,9 @@ pub enum State { Invoke(InvokeTarget), Execute { function: String }, Begin, - Done(Option), + Done(Option), Skip, - Fail(Option), + Fail(Option), } /// The target of an `Invoke`: either a named procedure (rendered as @@ -76,19 +77,6 @@ pub enum InvokeTarget { Uri(String), } -/// A `Value` carried by a Done or Fail state. Three on-disk forms, -/// handled by `format_value` / `parse_value` below: `unit` (`()`), a -/// double-quoted `literal` (the form a chosen response records as), and a -/// `tablet`; tablets currently round-trip as opaque text until tablet -/// typing in the runner lands. -#[allow(dead_code)] -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum Value { - Unit, - Literal(String), - Tablet(String), -} - /// On-disk store of runs, rooted at some base directory (conventionally /// `.store/` relative to the operator's current directory). #[allow(dead_code)] @@ -177,14 +165,16 @@ impl Store { Ok((run_id, run_dir)) } - /// Open an existing run. Parses the leading `Start` record to recover - /// the source document and the libraries it was run with, then replays - /// `Done` / `Skip` / `Fail` records into a set of completed step paths. - /// `Stop` and `Resume` records are passed over. + /// Open an existing run. Parses the leading `Start` record to recover the + /// source document and the libraries it was run with, then replays + /// records into a map of completed step paths to the value each recorded + /// (`Done` with its value, `Skip`/`Fail` mapping to `Unitus`), used to + /// rehydrate bindings on resume. `Stop` and `Resume` records are passed + /// over. pub fn open( &self, run_id: RunId, - ) -> Result<(PathBuf, Vec, HashSet, PathBuf), RunnerError> { + ) -> Result<(PathBuf, Vec, HashMap, PathBuf), RunnerError> { let run_dir = self .base .join(run_id.render()); @@ -215,13 +205,16 @@ impl Store { _ => return Err(RunnerError::StartMissing(run_id)), }; - let mut completed = HashSet::new(); + let mut completed = HashMap::new(); for line in lines { let record = parse_record(line) .map_err(|error| RunnerError::MalformedRecord { run_id, error })?; match record.state { - State::Done(_) | State::Skip | State::Fail(_) => { - completed.insert(record.path); + State::Done(value) => { + completed.insert(record.path, value.unwrap_or(value::Value::Unitus)); + } + State::Skip | State::Fail(_) => { + completed.insert(record.path, value::Value::Unitus); } State::Start { .. } | State::Stop @@ -466,7 +459,7 @@ fn format_state(out: &mut String, state: &State) { out.push_str("Done"); if let Some(v) = value { out.push(' '); - format_value(out, v); + out.push_str(&serialize_value(v)); } } State::Skip => out.push_str("Skip"), @@ -474,24 +467,12 @@ fn format_state(out: &mut String, state: &State) { out.push_str("Fail"); if let Some(v) = value { out.push(' '); - format_value(out, v); + out.push_str(&serialize_value(v)); } } } } -fn format_value(out: &mut String, value: &Value) { - match value { - Value::Unit => out.push_str("()"), - Value::Literal(text) => { - out.push('"'); - escape_literal(out, text); - out.push('"'); - } - Value::Tablet(text) => out.push_str(text), - } -} - // Escape a literal so it occupies a single record line: backslash and quote // are protected, and newlines/carriage returns become `\n` / `\r` so an // embedded multi-line value (e.g. captured exec output) survives the @@ -530,14 +511,12 @@ fn unescape_literal(text: &str) -> Result { } // Build the `Value` recorded for a failed step: a single-entry tablet -// `[ reason = "" ]`. The reason is operator free text, so it is -// escaped exactly as a literal field is, keeping the record on one line and -// proof against an embedded quote, backslash, or newline. -pub(crate) fn fail_reason(reason: &str) -> Value { - let mut text = String::from("[ reason = \""); - escape_literal(&mut text, reason); - text.push_str("\" ]"); - Value::Tablet(text) +// `[ "reason" = "" ]` carrying the user's free-text reason. +pub(crate) fn fail_reason(reason: &str) -> value::Value { + value::Value::Tabularum(vec![( + "reason".to_string(), + value::Value::Literali(reason.to_string()), + )]) } // Parse a single PFFTT record line into a Record. @@ -645,25 +624,408 @@ fn parse_state(text: &str) -> Result { } } -fn parse_optional_value(rest: Option<&str>) -> Result, RecordError> { +fn parse_optional_value(rest: Option<&str>) -> Result, RecordError> { match rest { None => Ok(None), - Some(text) => Ok(Some(parse_value(text)?)), + Some(text) => Ok(Some(deserialize_value(text)?)), } } -fn parse_value(text: &str) -> Result { +// Single-line PFFTT text form for a runtime `value::Value`, so a completed +// step's result survives in the trail and rehydrates on resume. +// +// Unitus -> () +// Literali(s) -> "" +// Quanticle(n) -> canonical numeric text (via formatting::render_numeric) +// Arraeum(items) -> [item, item] (empty: []) +// Tabularum(pairs) -> ["label" = value] (empty: [=], unambiguous vs []) +// Parametriq(vals) -> (val, val) +// Futurae(name) -> {name} +pub(crate) fn serialize_value(value: &value::Value) -> String { + let mut out = String::new(); + write_value(&mut out, value); + out +} + +fn write_value(out: &mut String, value: &value::Value) { + match value { + value::Value::Unitus => out.push_str("()"), + value::Value::Literali(text) => { + out.push('"'); + escape_literal(out, text); + out.push('"'); + } + value::Value::Quanticle(numeric) => out.push_str(&render_value_numeric(numeric)), + value::Value::Futurae(name) => { + out.push('{'); + out.push_str(name); + out.push('}'); + } + value::Value::Arraeum(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push_str("[ "); + for (i, item) in items + .iter() + .enumerate() + { + if i > 0 { + out.push_str(", "); + } + write_value(out, item); + } + out.push_str(" ]"); + } + value::Value::Tabularum(pairs) => { + if pairs.is_empty() { + out.push_str("[=]"); + return; + } + out.push_str("[ "); + for (i, (label, item)) in pairs + .iter() + .enumerate() + { + if i > 0 { + out.push_str(", "); + } + out.push('"'); + escape_literal(out, label); + out.push('"'); + out.push_str(" = "); + write_value(out, item); + } + out.push_str(" ]"); + } + value::Value::Parametriq(values) => { + out.push_str("( "); + for (i, item) in values + .iter() + .enumerate() + { + if i > 0 { + out.push_str(", "); + } + write_value(out, item); + } + out.push_str(" )"); + } + } +} + +// Render an owned value::Numeric by reconstructing the borrowed +// language::Numeric and delegating to the shared number renderer. +fn render_value_numeric(numeric: &value::Numeric) -> String { + match numeric { + value::Numeric::Integral(i) => { + let n = crate::language::Numeric::Integral(*i); + crate::formatting::render_numeric(&n, &crate::formatting::Identity) + } + value::Numeric::Scientific(q) => { + let qb = crate::language::Quantity { + mantissa: q.mantissa, + uncertainty: q.uncertainty, + magnitude: q.magnitude, + symbol: &q.symbol, + }; + let n = crate::language::Numeric::Scientific(qb); + crate::formatting::render_numeric(&n, &crate::formatting::Identity) + } + } +} + +pub(crate) fn deserialize_value(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(RecordError::MalformedState); + } if text == "()" { - Ok(Value::Unit) - } else if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') { - Ok(Value::Literal(unescape_literal(&text[1..text.len() - 1])?)) - } else if text.starts_with('[') && text.ends_with(']') { - Ok(Value::Tablet(text.to_string())) - } else { - Err(RecordError::MalformedState) + return Ok(value::Value::Unitus); + } + let first = text + .chars() + .next() + .unwrap(); + match first { + '"' => { + if text.len() < 2 || !text.ends_with('"') { + return Err(RecordError::MalformedState); + } + let inner = &text[1..text.len() - 1]; + Ok(value::Value::Literali(unescape_literal(inner)?)) + } + '{' => { + if !text.ends_with('}') { + return Err(RecordError::MalformedState); + } + Ok(value::Value::Futurae( + text[1..text.len() - 1].to_string(), + )) + } + '(' => { + if !text.ends_with(')') { + return Err(RecordError::MalformedState); + } + let inner = text[1..text.len() - 1].trim(); + if inner.is_empty() { + return Ok(value::Value::Parametriq(Vec::new())); + } + let parts = split_top_level(inner, ',')?; + let mut values = Vec::with_capacity(parts.len()); + for part in parts { + values.push(deserialize_value(part.trim())?); + } + Ok(value::Value::Parametriq(values)) + } + '[' => { + if !text.ends_with(']') { + return Err(RecordError::MalformedState); + } + let inner = text[1..text.len() - 1].trim(); + if inner.is_empty() { + return Ok(value::Value::Arraeum(Vec::new())); + } + if inner == "=" { + return Ok(value::Value::Tabularum(Vec::new())); + } + let parts = split_top_level(inner, ',')?; + // A bracket is a tablet if its first entry carries a top-level + // ` = `; otherwise it is a list. + if has_top_level_equals(parts[0]) { + let mut pairs = Vec::with_capacity(parts.len()); + for part in parts { + let (label, rest) = + split_once_top_level_equals(part).ok_or(RecordError::MalformedState)?; + let label = label.trim(); + if label.len() < 2 || !label.starts_with('"') || !label.ends_with('"') { + return Err(RecordError::MalformedState); + } + let label = unescape_literal(&label[1..label.len() - 1])?; + pairs.push((label, deserialize_value(rest.trim())?)); + } + Ok(value::Value::Tabularum(pairs)) + } else { + let mut items = Vec::with_capacity(parts.len()); + for part in parts { + items.push(deserialize_value(part.trim())?); + } + Ok(value::Value::Arraeum(items)) + } + } + _ => { + let n = crate::parsing::parse_numeric(text).ok_or(RecordError::MalformedState)?; + Ok(value::Value::Quanticle(value::Numeric::from(&n))) + } + } +} + +// Split `text` on `delim`, but only at top level: not inside double quotes +// (honouring `\"` escapes), nor inside nested `[]`, `()`, or `{}`. +fn split_top_level(text: &str, delim: char) -> Result, RecordError> { + let mut parts = Vec::new(); + let bytes = text.as_bytes(); + let mut depth = 0i32; + let mut in_quote = false; + let mut start = 0usize; + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i] as char; + if in_quote { + if c == '\\' { + i += 2; + continue; + } + if c == '"' { + in_quote = false; + } + i += 1; + continue; + } + match c { + '"' => in_quote = true, + '[' | '(' | '{' => depth += 1, + ']' | ')' | '}' => depth -= 1, + _ if c == delim && depth == 0 => { + parts.push(&text[start..i]); + start = i + 1; + } + _ => {} + } + i += 1; } + if in_quote || depth != 0 { + return Err(RecordError::MalformedState); + } + parts.push(&text[start..]); + Ok(parts) +} + +// True if `text` contains a top-level ` = ` separator (outside quotes/brackets). +fn has_top_level_equals(text: &str) -> bool { + split_once_top_level_equals(text).is_some() +} + +// Split `text` once at the first top-level ` = ` separator. +fn split_once_top_level_equals(text: &str) -> Option<(&str, &str)> { + let bytes = text.as_bytes(); + let mut depth = 0i32; + let mut in_quote = false; + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i] as char; + if in_quote { + if c == '\\' { + i += 2; + continue; + } + if c == '"' { + in_quote = false; + } + i += 1; + continue; + } + match c { + '"' => in_quote = true, + '[' | '(' | '{' => depth += 1, + ']' | ')' | '}' => depth -= 1, + ' ' if depth == 0 + && bytes.get(i + 1) == Some(&b'=') + && bytes.get(i + 2) == Some(&b' ') => + { + return Some((&text[..i], &text[i + 3..])); + } + _ => {} + } + i += 1; + } + None } #[cfg(test)] #[path = "checks/state.rs"] mod check; + +#[cfg(test)] +mod codec_check { + use super::{deserialize_value, serialize_value}; + use crate::language::Decimal; + use crate::value::{Numeric, Quantity, Value}; + + fn roundtrip(v: Value) { + let text = serialize_value(&v); + let back = deserialize_value(&text) + .unwrap_or_else(|e| panic!("deserialize failed for {:?}: {:?}", text, e)); + assert_eq!(v, back, "round-trip mismatch via {:?}", text); + } + + #[test] + fn primitives() { + roundtrip(Value::Unitus); + roundtrip(Value::Futurae("x".to_string())); + roundtrip(Value::Quanticle(Numeric::Integral(42))); + roundtrip(Value::Quanticle(Numeric::Integral(-7))); + roundtrip(Value::Quanticle(Numeric::Integral(0))); + } + + #[test] + fn empty_collections_stay_distinct() { + roundtrip(Value::Arraeum(Vec::new())); + roundtrip(Value::Tabularum(Vec::new())); + assert_eq!(serialize_value(&Value::Arraeum(Vec::new())), "[]"); + assert_eq!(serialize_value(&Value::Tabularum(Vec::new())), "[=]"); + assert_eq!( + deserialize_value("[]").unwrap(), + Value::Arraeum(Vec::new()) + ); + assert_eq!( + deserialize_value("[=]").unwrap(), + Value::Tabularum(Vec::new()) + ); + } + + #[test] + fn nested_lists_and_tablets() { + roundtrip(Value::Arraeum(vec![ + Value::Literali("a".to_string()), + Value::Literali("b".to_string()), + ])); + roundtrip(Value::Arraeum(vec![Value::Tabularum(vec![( + "k".to_string(), + Value::Literali("v".to_string()), + )])])); + roundtrip(Value::Tabularum(vec![ + ("reason".to_string(), Value::Literali("boom".to_string())), + ("count".to_string(), Value::Quanticle(Numeric::Integral(3))), + ])); + roundtrip(Value::Parametriq(vec![ + Value::Literali("a".to_string()), + Value::Quanticle(Numeric::Integral(42)), + Value::Unitus, + ])); + } + + #[test] + fn literals_with_specials_do_not_break_splitter() { + roundtrip(Value::Literali( + "comma, equals = brack [ ] quote \" newline\nend".to_string(), + )); + roundtrip(Value::Arraeum(vec![ + Value::Literali("a, b".to_string()), + Value::Literali("c = d".to_string()), + Value::Literali("[ ( {".to_string()), + ])); + roundtrip(Value::Tabularum(vec![( + "weird, key = ]".to_string(), + Value::Literali("v, w".to_string()), + )])); + } + + #[test] + fn quantities() { + roundtrip(Value::Quanticle(Numeric::Scientific(Quantity { + mantissa: Decimal { + number: 149, + precision: 0, + }, + uncertainty: None, + magnitude: None, + symbol: "kg".to_string(), + }))); + roundtrip(Value::Quanticle(Numeric::Scientific(Quantity { + mantissa: Decimal { + number: 59722, + precision: 4, + }, + uncertainty: Some(Decimal { + number: 6, + precision: 4, + }), + magnitude: Some(24), + symbol: "kg".to_string(), + }))); + } + + #[test] + fn deeply_nested_mixed() { + roundtrip(Value::Parametriq(vec![ + Value::Tabularum(vec![ + ( + "list".to_string(), + Value::Arraeum(vec![ + Value::Quanticle(Numeric::Integral(1)), + Value::Tabularum(vec![( + "inner".to_string(), + Value::Literali("deep, \"quoted\"".to_string()), + )]), + ]), + ), + ("future".to_string(), Value::Futurae("pending".to_string())), + ]), + Value::Unitus, + Value::Arraeum(Vec::new()), + Value::Tabularum(Vec::new()), + ])); + } +} diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs index 7abb142..1cc4796 100644 --- a/tests/runner/samples.rs +++ b/tests/runner/samples.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use std::fs; use std::path::Path; @@ -71,7 +71,7 @@ fn ensure_run() { let mut runner = Runner::new( &program, Appender::memory(), - HashSet::new(), + HashMap::new(), Headless::new(), library, ); From 866071374c5935023ebd11c5267a211bfd3df80c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 20 Jun 2026 17:45:50 +1000 Subject: [PATCH 2/6] Trim trailing newlines from exec() output --- src/runner/library.rs | 9 +++++++-- tests/samples/runner/ConfirmRelease.pfftt | 4 ++-- tests/samples/runner/ConfirmRelease.tq | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/runner/library.rs b/src/runner/library.rs index 6f85155..1ae1501 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -239,7 +239,9 @@ fn pairs(_context: &Context, args: &[Value]) -> Result { /// `exec(script)` — run a shell script, teeing its stdout through the Context /// to the operator as it streams while accumulating it as the return value. /// Output is held as bytes until the end so a chunk split mid-UTF-8 is -/// harmless and only one String is allocated. A non-zero exit is an error. +/// harmless and only one String is allocated. Trailing newlines are trimmed +/// from the captured value (matching the usual experience when doing shell +/// `$(...)` substitution). A non-zero exit is an error. fn exec(context: &Context, args: &[Value]) -> Result { let script = match &args[0] { Value::Literali(script) => script, @@ -288,8 +290,11 @@ fn exec(context: &Context, args: &[Value]) -> Result { )); } + let output = String::from_utf8_lossy(&captured); Ok(Value::Literali( - String::from_utf8_lossy(&captured).into_owned(), + output + .trim_end_matches(['\n', '\r']) + .to_string(), )) } diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/samples/runner/ConfirmRelease.pfftt index b3be2b6..149d56d 100644 --- a/tests/samples/runner/ConfirmRelease.pfftt +++ b/tests/samples/runner/ConfirmRelease.pfftt @@ -5,5 +5,5 @@ 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Begin 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Execute exec() 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Execute exec() -2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Done () -2026-06-14T05:58:08.700Z 000001 /confirm_release: Done () +2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Done "2" +2026-06-14T05:58:08.700Z 000001 /confirm_release: Done "2" diff --git a/tests/samples/runner/ConfirmRelease.tq b/tests/samples/runner/ConfirmRelease.tq index 9ef3088..562c7b8 100644 --- a/tests/samples/runner/ConfirmRelease.tq +++ b/tests/samples/runner/ConfirmRelease.tq @@ -7,3 +7,4 @@ confirm_release : 1. Suggested release tag { "v1.0" } and then { "v2.0" } 2. Proceed once the tag above is correct with { exec("echo 1") } then { exec("echo 2") } + \ No newline at end of file From 97cd85158bfef641378c5a53045f396f4cf70067 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 20 Jun 2026 18:54:26 +1000 Subject: [PATCH 3/6] Record values supplied to procedures as Input --- src/runner/checks/runner.rs | 110 +++++++++++++++++++++++++- src/runner/checks/state.rs | 28 +++++-- src/runner/mod.rs | 7 +- src/runner/runner.rs | 150 +++++++++++++++++++++++++++++++----- src/runner/state.rs | 131 ++++++++++++++++++++++++++++++- 5 files changed, 396 insertions(+), 30 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index ddb812d..9f6fa88 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -13,7 +13,7 @@ use crate::runner::evaluator::Environment; use crate::runner::library::Library; use crate::runner::runner::{bind_parameters, render_argument_echo, Outcome, Runner, RunnerError}; use crate::runner::state::{ - parse_record, Appender, InvokeTarget, State, Store, + parse_record, Appender, InvokeTarget, State, Store, Supplied, }; use crate::translation::translate; use crate::value::Value; @@ -2431,3 +2431,111 @@ cleanup : false }); } + +// An elided invocation `` acquires its parameter `name` from the +// operator. The argument it was called with is recorded as an `Input` at the +// callee's path so a resume can restore it. +const ACQUIRE_INPUT_SOURCE: &str = r#" +% technique v1 + +greet : + + 1. + +hail(name) : Text -> () + + 1. Say { name } +"#; + +#[test] +fn invoke_records_supplied_input() { + let source = ACQUIRE_INPUT_SOURCE.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + // The operator supplies "World" at the acquire prompt; the rest are step + // and scope sign-offs. + let prompt = Mock::with_answers([ + UserInput::Done(Value::Literali("World".to_string())), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + Appender::memory(), + HashMap::new(), + prompt, + Library::stub(), + ); + runner + .run(Environment::new()) + .expect("run"); + + let trail = runner + .into_appender() + .contents() + .to_string(); + assert!( + trail.contains("/hail: Input ( \"World\" ~ name )"), + "trail was:\n{}", + trail + ); +} + +#[test] +fn resume_restores_invoke_input_without_reprompting() { + let source = ACQUIRE_INPUT_SOURCE.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); + let program = translate(&document).expect("translate"); + + // The prior run recorded the argument acquired for ``. Resume with + // that input preloaded: the acquire must not fire, and `name` is bound from + // the record (so `{ name }` evaluates rather than raising UnboundVariable). + let mut inputs = HashMap::new(); + inputs.insert( + "/hail:".to_string(), + vec![Supplied { + value: Value::Literali("World".to_string()), + name: Some("name".to_string()), + }], + ); + + let prompt = Mock::with_answers([ + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + UserInput::Done(Value::Unitus), + ]); + let mut runner = Runner::new( + &program, + Appender::memory(), + HashMap::new(), + prompt, + Library::stub(), + ) + .with_inputs(inputs); + let outcome = runner + .run(Environment::new()) + .expect("resume runs without re-acquiring"); + assert!(if let Outcome::Done(_) = outcome { + true + } else { + false + }); + + let prompt = runner.into_driver(); + let acquired = prompt + .events() + .iter() + .filter(|event| { + if let Event::Acquire { .. } = event { + true + } else { + false + } + }) + .count(); + assert_eq!(acquired, 0); +} diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 05adfcd..2fdf15e 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -4,7 +4,7 @@ use crate::runner::runner::RunnerError; use crate::value::Value; use crate::runner::state::{ fail_reason, format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, - Store, + Store, Supplied, }; // A scratch directory under the system temp dir, cleaned up on drop so panics @@ -135,7 +135,7 @@ fn create_and_open_round_trips_document_path() { let (run_id, _) = store .create(&document, started, &[]) .expect("create"); - let (read_document, libraries, completed, _) = store + let (read_document, libraries, completed, _, _) = store .open(run_id) .expect("open"); @@ -159,7 +159,7 @@ fn create_and_open_round_trips_libraries() { let (run_id, _) = store .create(&document, started, &selected) .expect("create"); - let (read_document, libraries, _, _) = store + let (read_document, libraries, _, _, _) = store .open(run_id) .expect("open"); @@ -208,7 +208,7 @@ fn open_replays_done_skip_fail_into_completed() { dir.path .clone(), ); - let (document, _, completed, _) = store + let (document, _, completed, _, _) = store .open(RunId(1)) .expect("open"); @@ -263,7 +263,7 @@ fn open_skips_resume_and_begin_during_replay() { dir.path .clone(), ); - let (_, _, completed, _) = store + let (_, _, completed, _, _) = store .open(RunId(1)) .expect("open"); @@ -587,6 +587,24 @@ fn record_round_trips_through_format_and_parse() { Value::Literali("unreachable".to_string()), )]))), }, + Record { + recorded: "2026-05-14T12:00:06Z".to_string(), + run_id: RunId(1), + path: "/decommission:".to_string(), + state: State::Input(vec![ + Supplied { + value: Value::Literali("acme-corp".to_string()), + name: Some("authority".to_string()), + }, + Supplied { + value: Value::Arraeum(vec![ + Value::Literali("east".to_string()), + Value::Literali("west".to_string()), + ]), + name: None, + }, + ]), + }, ]; for original in cases { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 3286b98..a45475e 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -108,7 +108,7 @@ pub fn inspect<'i>( /// re-translate, and re-link it before resuming. pub fn locate(run_id: RunId) -> Result<(PathBuf, Vec), RunnerError> { let store = Store::new(PathBuf::from(STORE_ROOT)); - let (document, libraries, _, _) = store.open(run_id)?; + let (document, libraries, _, _, _) = store.open(run_id)?; Ok((document, libraries)) } @@ -124,7 +124,7 @@ pub fn resume<'i>( return Err(RunnerError::TerminalRequired); } let store = Store::new(PathBuf::from(STORE_ROOT)); - let (document, _, completed, run_dir) = store.open(run_id)?; + let (document, _, completed, inputs, run_dir) = store.open(run_id)?; let pfftt = construct_state_path(&run_dir, &document); let mut appender = Appender::open(pfftt, run_id)?; let record = Record { @@ -134,7 +134,8 @@ pub fn resume<'i>( state: State::Resume, }; appender.append(&record)?; - let mut runner = Runner::new(program, appender, completed, Console::new(), library); + let mut runner = + Runner::new(program, appender, completed, Console::new(), library).with_inputs(inputs); let env = Environment::new(); runner.run(env) } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 2e18340..947cf8b 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -9,7 +9,7 @@ use super::driver::{Driver, UserInput}; use super::evaluator::Environment; use super::library::{Library, Nature}; use super::path::{PathSegment, QualifiedPath}; -use super::state::{Appender, InvokeTarget, Record, RecordError, RunId, State}; +use super::state::{Appender, InvokeTarget, Record, RecordError, RunId, State, Supplied}; use crate::language; use crate::program::{ Executable, ExecutableRef, Invocable, Locale, Operation, Ordinal, Program, Subroutine, @@ -26,7 +26,6 @@ use crate::value::Value; /// quit was already recorded as a `State::Stop` lifecycle event, so this /// carries no payload — a `technique resume` picks up from the first step /// with no recorded outcome. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum Outcome { Done(Value), @@ -40,7 +39,6 @@ pub enum Outcome { } /// Why a Step failed. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum Failure { Aborted(String), @@ -49,7 +47,6 @@ pub enum Failure { /// 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), @@ -111,6 +108,7 @@ pub struct Runner<'i, D: Driver> { program: &'i Program<'i>, appender: Appender, completed: HashMap, + inputs: HashMap>, driver: D, path: QualifiedPath<'i>, library: Library, @@ -129,6 +127,7 @@ impl<'i, D: Driver> Runner<'i, D> { program, appender, completed, + inputs: HashMap::new(), driver, path: QualifiedPath::new(), library, @@ -136,6 +135,14 @@ impl<'i, D: Driver> Runner<'i, D> { } } + /// Seed the runner with the inputs recorded by a prior run — the values + /// supplied to the entry procedure and to each invocation — so a resume + /// restores them rather than re-prompting. Empty on a fresh run. + pub fn with_inputs(mut self, inputs: HashMap>) -> Self { + self.inputs = inputs; + self + } + /// Consume the runner and return the inner driver after a run completes. /// Used to read a `Headless` driver's result count or to assert on the /// Mock's event log. @@ -185,6 +192,7 @@ impl<'i, D: Driver> Runner<'i, D> { let params = entry .parameters .unwrap_or(&[]); + self.restore_or_record_inputs(&mut env, &qualified, params)?; if params.is_empty() { self.driver .enter(&qualified); @@ -477,6 +485,17 @@ impl<'i, D: Driver> Runner<'i, D> { .formae() }) .unwrap_or_default(); + // A prior run's recorded inputs for this callee: an + // argument the operator was prompted for (an elided call + // or a `?` hole) is restored from here on resume instead + // of re-acquiring. An argument the author supplied as a + // part of the source document is always re-evaluated + // instead, so a loop variable still varies. + let recorded = self + .inputs + .get(&lexical) + .cloned(); + let mut supplied: Vec = Vec::new(); if invocable.elided { for i in 0..subroutine.arity() { let bind = params @@ -485,16 +504,28 @@ impl<'i, D: Driver> Runner<'i, D> { let forma = formae .get(i) .map(|f| f.value); - let value = match self - .driver - .acquire(&invoked, bind, forma) + let value = match recorded + .as_ref() + .and_then(|r| r.get(i)) { - UserInput::Done(value) => value, - other => return self.abandon(&lexical, other), + Some(s) => s + .value + .clone(), + None => match self + .driver + .acquire(&invoked, bind, forma) + { + UserInput::Done(value) => value, + other => return self.abandon(&lexical, other), + }, }; if let Some(bind) = bind { - local.extend(bind.to_string(), value); + local.extend(bind.to_string(), value.clone()); } + supplied.push(Supplied { + value, + name: bind.map(|b| b.to_string()), + }); } } else { for (i, arg) in invocable @@ -506,22 +537,36 @@ impl<'i, D: Driver> Runner<'i, D> { .get(i) .map(|p| p.value); let value = if let Operation::Hole = arg { - let forma = formae - .get(i) - .map(|f| f.value); - match self - .driver - .acquire(&invoked, bind, forma) + match recorded + .as_ref() + .and_then(|r| r.get(i)) { - UserInput::Done(value) => value, - other => return self.abandon(&lexical, other), + Some(s) => s + .value + .clone(), + None => { + let forma = formae + .get(i) + .map(|f| f.value); + match self + .driver + .acquire(&invoked, bind, forma) + { + UserInput::Done(value) => value, + other => return self.abandon(&lexical, other), + } + } } } else { super::evaluator::evaluate(&self.library, &self.context, env, arg)? }; if let Some(bind) = bind { - local.extend(bind.to_string(), value); + local.extend(bind.to_string(), value.clone()); } + supplied.push(Supplied { + value, + name: bind.map(|b| b.to_string()), + }); } } @@ -540,6 +585,12 @@ impl<'i, D: Driver> Runner<'i, D> { self.begin_scope(&lexical)?; + // Record the inputs supplied to this invocation, unless they + // were restored from a prior run (already in the trail). + if recorded.is_none() { + self.record_inputs(&lexical, supplied)?; + } + let saved = self .path .replace(lexical_segments); @@ -1074,6 +1125,67 @@ impl<'i, D: Driver> Runner<'i, D> { Ok(()) } + /// Record the values supplied to a procedure's parameters at its own path, + /// so a resume restores them rather than re-prompting. A procedure with no + /// parameters records nothing. + fn record_inputs( + &mut self, + qualified: &str, + supplied: Vec, + ) -> Result<(), RunnerError> { + if supplied.is_empty() { + return Ok(()); + } + let run_id = self + .appender + .run_id(); + self.appender + .append(&Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: State::Input(supplied), + }) + } + + /// At a procedure's entry, restore its parameter bindings from a prior + /// run's recorded inputs if present (resume), otherwise record the inputs + /// it was called with (a fresh run). Used for the entry procedure, whose + /// arguments come from the command line. + fn restore_or_record_inputs( + &mut self, + env: &mut Environment, + qualified: &str, + params: &[language::Identifier<'i>], + ) -> Result<(), RunnerError> { + if let Some(supplied) = self + .inputs + .get(qualified) + .cloned() + { + for item in supplied { + if let Some(name) = item.name { + env.extend(name, item.value); + } + } + return Ok(()); + } + let supplied = params + .iter() + .map(|p| Supplied { + value: env + .lookup(p.value) + .cloned() + .unwrap_or(Value::Unitus), + name: Some( + p.value + .to_string(), + ), + }) + .collect(); + self.record_inputs(qualified, supplied) + } + /// Sign off a completed structural scope — a Section at its close, or the /// whole run at the entry procedure. fn seal_scope( diff --git a/src/runner/state.rs b/src/runner/state.rs index 22dbeaf..680ea0e 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -54,6 +54,8 @@ pub struct Record { /// `Invoke` records dispatch into another procedure (the return is /// implicit — the next event's path reveals the resumed procedure). /// `Execute` records a host-function call from inside a step body. +/// `Input` records the values supplied to a procedure so a resume can restore +/// the state without re-prompting for information already entered. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum State { @@ -62,12 +64,23 @@ pub enum State { Resume, Invoke(InvokeTarget), Execute { function: String }, + Input(Vec), Begin, Done(Option), Skip, Fail(Option), } +/// One value supplied to a procedure's parameter: bound to a named parameter +/// (recorded as `value ~ name`), or positional when the parameter is unnamed +/// (recorded as a bare `value`). +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub struct Supplied { + pub value: value::Value, + pub name: Option, +} + /// The target of an `Invoke`: either a named procedure (rendered as /// `name:`) or a URI to an external technique. #[allow(dead_code)] @@ -174,7 +187,16 @@ impl Store { pub fn open( &self, run_id: RunId, - ) -> Result<(PathBuf, Vec, HashMap, PathBuf), RunnerError> { + ) -> Result< + ( + PathBuf, + Vec, + HashMap, + HashMap>, + PathBuf, + ), + RunnerError, + > { let run_dir = self .base .join(run_id.render()); @@ -206,6 +228,7 @@ impl Store { }; let mut completed = HashMap::new(); + let mut inputs: HashMap> = HashMap::new(); for line in lines { let record = parse_record(line) .map_err(|error| RunnerError::MalformedRecord { run_id, error })?; @@ -216,6 +239,9 @@ impl Store { State::Skip | State::Fail(_) => { completed.insert(record.path, value::Value::Unitus); } + State::Input(supplied) => { + inputs.insert(record.path, supplied); + } State::Start { .. } | State::Stop | State::Resume @@ -224,7 +250,7 @@ impl Store { | State::Begin => {} } } - Ok((document, libraries, completed, run_dir)) + Ok((document, libraries, completed, inputs, run_dir)) } // Scan the store for the highest existing run identifier and return @@ -454,6 +480,10 @@ fn format_state(out: &mut String, state: &State) { out.push_str(function); out.push_str("()"); } + State::Input(supplied) => { + out.push_str("Input "); + format_supplied(out, supplied); + } State::Begin => out.push_str("Begin"), State::Done(value) => { out.push_str("Done"); @@ -473,6 +503,62 @@ fn format_state(out: &mut String, state: &State) { } } +// Format a procedure's supplied inputs as `( value ~ name, value, … )`: each +// value serialized by the value codec, a named parameter followed by `~ name`, +// an unnamed one left bare. +fn format_supplied(out: &mut String, supplied: &[Supplied]) { + out.push('('); + for (i, item) in supplied + .iter() + .enumerate() + { + if i > 0 { + out.push(','); + } + out.push(' '); + out.push_str(&serialize_value(&item.value)); + if let Some(name) = &item.name { + out.push_str(" ~ "); + out.push_str(name); + } + } + out.push_str(" )"); +} + +// Reverse `format_supplied`: parse `( value ~ name, value, … )` into the +// supplied inputs. Each top-level item is a codec value optionally followed by +// a top-level ` ~ ` and the parameter name. +fn parse_supplied(text: &str) -> Result, RecordError> { + let inner = text + .trim() + .strip_prefix('(') + .and_then(|t| t.strip_suffix(')')) + .ok_or(RecordError::MalformedState)? + .trim(); + if inner.is_empty() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for part in split_top_level(inner, ',')? { + let part = part.trim(); + let supplied = match split_once_top_level_tilde(part) { + Some((value, name)) => Supplied { + value: deserialize_value(value.trim())?, + name: Some( + name.trim() + .to_string(), + ), + }, + None => Supplied { + value: deserialize_value(part)?, + name: None, + }, + }; + out.push(supplied); + } + Ok(out) +} + // Escape a literal so it occupies a single record line: backslash and quote // are protected, and newlines/carriage returns become `\n` / `\r` so an // embedded multi-line value (e.g. captured exec output) survives the @@ -606,6 +692,10 @@ fn parse_state(text: &str) -> Result { function: name.to_string(), }) } + "Input" => { + let payload = rest.ok_or(RecordError::MalformedState)?; + Ok(State::Input(parse_supplied(payload)?)) + } "Begin" => { if rest.is_some() { return Err(RecordError::MalformedState); @@ -903,6 +993,43 @@ fn split_once_top_level_equals(text: &str) -> Option<(&str, &str)> { None } +// Split `text` once at the first top-level ` ~ ` separator (the binding +// operator joining a supplied value to its parameter name). +fn split_once_top_level_tilde(text: &str) -> Option<(&str, &str)> { + let bytes = text.as_bytes(); + let mut depth = 0i32; + let mut in_quote = false; + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i] as char; + if in_quote { + if c == '\\' { + i += 2; + continue; + } + if c == '"' { + in_quote = false; + } + i += 1; + continue; + } + match c { + '"' => in_quote = true, + '[' | '(' | '{' => depth += 1, + ']' | ')' | '}' => depth -= 1, + ' ' if depth == 0 + && bytes.get(i + 1) == Some(&b'~') + && bytes.get(i + 2) == Some(&b' ') => + { + return Some((&text[..i], &text[i + 3..])); + } + _ => {} + } + i += 1; + } + None +} + #[cfg(test)] #[path = "checks/state.rs"] mod check; From c91af2565ea4eb93c08bc90284577cc1f509d213 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 20 Jun 2026 21:50:48 +1000 Subject: [PATCH 4/6] Refine use of Input state to when user data is entered --- src/runner/runner.rs | 72 +++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 947cf8b..e910fc4 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -188,11 +188,11 @@ impl<'i, D: Driver> Runner<'i, D> { let qualified = self .path .render(); - self.begin_scope(&qualified)?; let params = entry .parameters .unwrap_or(&[]); self.restore_or_record_inputs(&mut env, &qualified, params)?; + self.begin_scope(&qualified)?; if params.is_empty() { self.driver .enter(&qualified); @@ -485,17 +485,20 @@ impl<'i, D: Driver> Runner<'i, D> { .formae() }) .unwrap_or_default(); - // A prior run's recorded inputs for this callee: an - // argument the operator was prompted for (an elided call - // or a `?` hole) is restored from here on resume instead - // of re-acquiring. An argument the author supplied as a - // part of the source document is always re-evaluated - // instead, so a loop variable still varies. + + // A prior run's recorded inputs for this callee. A + // prompted argument (an elided call or a `?` hole) is + // restored from here on resume, in prompted order, rather + // than re-acquired. An argument the author supplied as a + // source expression is re-evaluated, so a loop variable + // still varies — and, being re-derivable, does not need + // to be recorded. let recorded = self .inputs .get(&lexical) .cloned(); - let mut supplied: Vec = Vec::new(); + let mut prompted: Vec = Vec::new(); + let mut taken = 0usize; if invocable.elided { for i in 0..subroutine.arity() { let bind = params @@ -506,7 +509,7 @@ impl<'i, D: Driver> Runner<'i, D> { .map(|f| f.value); let value = match recorded .as_ref() - .and_then(|r| r.get(i)) + .and_then(|r| r.get(taken)) { Some(s) => s .value @@ -519,10 +522,11 @@ impl<'i, D: Driver> Runner<'i, D> { other => return self.abandon(&lexical, other), }, }; + taken += 1; if let Some(bind) = bind { local.extend(bind.to_string(), value.clone()); } - supplied.push(Supplied { + prompted.push(Supplied { value, name: bind.map(|b| b.to_string()), }); @@ -536,10 +540,10 @@ impl<'i, D: Driver> Runner<'i, D> { let bind = params .get(i) .map(|p| p.value); - let value = if let Operation::Hole = arg { - match recorded + if let Operation::Hole = arg { + let value = match recorded .as_ref() - .and_then(|r| r.get(i)) + .and_then(|r| r.get(taken)) { Some(s) => s .value @@ -556,22 +560,33 @@ impl<'i, D: Driver> Runner<'i, D> { other => return self.abandon(&lexical, other), } } + }; + taken += 1; + if let Some(bind) = bind { + local.extend(bind.to_string(), value.clone()); } + prompted.push(Supplied { + value, + name: bind.map(|b| b.to_string()), + }); } else { - super::evaluator::evaluate(&self.library, &self.context, env, arg)? - }; - if let Some(bind) = bind { - local.extend(bind.to_string(), value.clone()); + let value = super::evaluator::evaluate( + &self.library, + &self.context, + env, + arg, + )?; + if let Some(bind) = bind { + local.extend(bind.to_string(), value); + } } - supplied.push(Supplied { - value, - name: bind.map(|b| b.to_string()), - }); } } - // Record the Invoke at the call site, then descend onto the - // callee's lexical address, restored on return. + // Record the dispatch once the arguments are in hand — + // declining at the prompt above returns before this, so a + // declined call records no Invoke. Recorded at answer-time, + // so the gap from the previous event is the operator wait. let run_id = self .appender .run_id(); @@ -583,14 +598,15 @@ impl<'i, D: Driver> Runner<'i, D> { state: State::Invoke(InvokeTarget::Procedure(name.to_string())), })?; - self.begin_scope(&lexical)?; - - // Record the inputs supplied to this invocation, unless they - // were restored from a prior run (already in the trail). + // Record the prompted inputs (answered just now) before + // Begin, unless they were restored from a prior run (already + // in the trail). if recorded.is_none() { - self.record_inputs(&lexical, supplied)?; + self.record_inputs(&lexical, prompted)?; } + self.begin_scope(&lexical)?; + let saved = self .path .replace(lexical_segments); From cd62dcf18742b4d32c68eac499ba2f763972f2a2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 21 Jun 2026 10:16:24 +1000 Subject: [PATCH 5/6] Fix infinite loop bug --- src/parsing/checks/parser.rs | 31 +++++++++++++++++++++++++++++++ src/parsing/parser.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index f395bd4..3185daa 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -2647,3 +2647,34 @@ This is { exec(a, _ => panic!("Third element should be text"), } } + +#[test] +fn code_inline_binding() { + let mut input = Parser::new(); + input.initialize("inspect { item } ~ seen\n"); + let paragraphs = input + .read_descriptive() + .unwrap(); + + let descriptives = ¶graphs[0].0; + assert_eq!(descriptives.len(), 2); + + match &descriptives[0] { + Descriptive::Text(text) => assert_eq!(*text, "inspect"), + _ => panic!("First element should be text"), + } + + match &descriptives[1] { + Descriptive::Binding(bound, names) => { + match bound.as_ref() { + Descriptive::CodeInline(Expression::Variable(Identifier { value, .. }, _)) => { + assert_eq!(*value, "item") + } + _ => panic!("Bound part should be a code inline variable"), + } + assert_eq!(names.len(), 1); + assert_eq!(names[0].value, "seen"); + } + _ => panic!("Second element should be a binding"), + } +} diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index 8bd6625..a47574f 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -2402,11 +2402,40 @@ impl<'i> Parser<'i> { if c == '{' { let expressions = parser.read_code_block()?; + let mut inlines = vec![]; for expr in expressions { if let Expression::Separator = expr { continue; } - content.push(Descriptive::CodeInline(expr)); + inlines.push(Descriptive::CodeInline(expr)); + } + + parser.trim_whitespace(); + if parser.peek_next_char() == Some('~') { + parser.advance(1); + parser.trim_whitespace(); + let start_pos = parser.offset; + let variable = parser.read_identifier()?; + + parser.trim_whitespace(); + if parser + .source + .starts_with(',') + { + return Err(ParsingError::MissingParenthesis( + Span::new(start_pos, 0), + )); + } + + if let Some(last) = inlines.pop() { + content.extend(inlines); + content.push(Descriptive::Binding( + Box::new(last), + vec![variable], + )); + } + } else { + content.extend(inlines); } } else if parser .source From 6dcb519a31850e5d4d258bc024b379e189ca0f94 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 21 Jun 2026 10:16:54 +1000 Subject: [PATCH 6/6] Run code formatter --- src/runner/checks/runner.rs | 8 ++++---- src/runner/checks/state.rs | 2 +- src/runner/mod.rs | 3 +-- src/runner/state.rs | 9 ++------- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 9f6fa88..ad2c9f8 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -12,9 +12,7 @@ use crate::runner::driver::{Automatic, Event, Mock, UserInput}; use crate::runner::evaluator::Environment; use crate::runner::library::Library; use crate::runner::runner::{bind_parameters, render_argument_echo, Outcome, Runner, RunnerError}; -use crate::runner::state::{ - parse_record, Appender, InvokeTarget, State, Store, Supplied, -}; +use crate::runner::state::{parse_record, Appender, InvokeTarget, State, Store, Supplied}; use crate::translation::translate; use crate::value::Value; @@ -2135,7 +2133,9 @@ fn automatic_records_done_for_computable_step_skip_for_prose() { ); assert_eq!( state, - State::Done(Some(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()))) + State::Done(Some(Value::Literali( + "1: lo\n2: eth0\n3: wlan0".to_string() + ))) ); // A pure-prose step (empty body) has nothing to compute and records Skip. diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 2fdf15e..526c1f0 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -1,11 +1,11 @@ use std::path::{Path, PathBuf}; use crate::runner::runner::RunnerError; -use crate::value::Value; use crate::runner::state::{ fail_reason, format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, Store, Supplied, }; +use crate::value::Value; // 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. diff --git a/src/runner/mod.rs b/src/runner/mod.rs index a45475e..b7769e1 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -53,8 +53,7 @@ pub fn start<'i>( if !std::io::stdout().is_terminal() { return Err(RunnerError::TerminalRequired); } - let mut runner = - Runner::new(program, appender, completed, Console::new(), library); + let mut runner = Runner::new(program, appender, completed, Console::new(), library); runner.run(env)? } Mode::Automatic => { diff --git a/src/runner/state.rs b/src/runner/state.rs index 680ea0e..0f6575b 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -850,9 +850,7 @@ pub(crate) fn deserialize_value(text: &str) -> Result if !text.ends_with('}') { return Err(RecordError::MalformedState); } - Ok(value::Value::Futurae( - text[1..text.len() - 1].to_string(), - )) + Ok(value::Value::Futurae(text[1..text.len() - 1].to_string())) } '(' => { if !text.ends_with(')') { @@ -1062,10 +1060,7 @@ mod codec_check { roundtrip(Value::Tabularum(Vec::new())); assert_eq!(serialize_value(&Value::Arraeum(Vec::new())), "[]"); assert_eq!(serialize_value(&Value::Tabularum(Vec::new())), "[=]"); - assert_eq!( - deserialize_value("[]").unwrap(), - Value::Arraeum(Vec::new()) - ); + assert_eq!(deserialize_value("[]").unwrap(), Value::Arraeum(Vec::new())); assert_eq!( deserialize_value("[=]").unwrap(), Value::Tabularum(Vec::new())