diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index b2577d0..2731693 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -535,6 +535,17 @@ fn reading_invocations() { parameters: None }) ); + + // Any scheme reads as external; a `:` cannot occur in a local identifier. + input.initialize(""); + let result = input.read_invocation(); + assert_eq!( + result, + Ok(Invocation { + target: Target::Remote(External::new("file://./OtherDoor.tq")), + parameters: None + }) + ); } #[test] diff --git a/src/parsing/parser.rs b/src/parsing/parser.rs index cbdf51f..ccebb7c 100644 --- a/src/parsing/parser.rs +++ b/src/parsing/parser.rs @@ -2152,14 +2152,16 @@ impl<'i> Parser<'i> { Ok(symbol) } - /// Parse a target like or + /// Parse a target of either the form or + /// . A `:` cannot appear in a local identifier, + /// so its presence marks the content as an external URI. fn read_target(&mut self) -> Result, ParsingError> { let start_offset = self.offset; self.take_block_chars("an invocation", '<', '>', true, |inner| { let content = inner .source .trim(); - if content.starts_with("https://") { + if content.contains(':') { Ok(Target::Remote(External { value: content, span: inner.span_of(content), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index ac5cc6b..9004d5d 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -150,8 +150,8 @@ fn step_outcomes_recorded() { .is_empty() }) .collect(); - // Start + Begin + Done — three lines. - assert_eq!(lines.len(), 3); + // Start + Begin + Done + Finish — four lines. + assert_eq!(lines.len(), 4); assert_eq!( lines[0], "2026-05-16T00:00:00Z 000001 / Start file:///tmp/Test.tq" @@ -1411,8 +1411,9 @@ fn loop_inside_step_produces_one_result() { .run(env) .expect("run"); - // One Start record, then the enclosing step's Begin and Done — the - // Loop inside the step body does not record events of its own. + // One Start record, then the enclosing step's Begin and Done, then the + // closing Finish — the Loop inside the step body does not record events of + // its own. let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt .lines() @@ -1422,9 +1423,10 @@ fn loop_inside_step_produces_one_result() { .is_empty() }) .collect(); - assert_eq!(lines.len(), 3); + assert_eq!(lines.len(), 4); assert!(lines[1].ends_with(" Begin")); assert!(lines[2].contains(" Done")); + assert!(lines[3].ends_with(" Finish")); } #[test] diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index a8b9f26..b1f7153 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -314,6 +314,17 @@ fn format_record_pins_on_disk_text() { "2026-05-17T00:28:25Z 015003 / Resume\n" ); + let record = Record { + recorded: "2026-05-17T00:28:30Z".to_string(), + run_id: RunId(15003), + path: "/".to_string(), + state: State::Finish, + }; + assert_eq!( + format_record(&record), + "2026-05-17T00:28:30Z 015003 / Finish\n" + ); + let record = Record { recorded: "2026-05-17T00:28:30Z".to_string(), run_id: RunId(15003), @@ -487,6 +498,12 @@ fn record_round_trips_through_format_and_parse() { uri: "file:///foo/Bar.tq".to_string(), }, }, + Record { + recorded: "2026-05-17T00:28:25Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Finish, + }, Record { recorded: "2026-05-17T00:28:25Z".to_string(), run_id: RunId(1), diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 19a5fc8..a4219de 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -59,6 +59,14 @@ pub trait Driver { /// subroutine — with its Qualified Name (the `↘` marker). fn enter(&mut self, qualified: &str); + /// Cross into this document on the way in: the `⇒` boundary line carrying + /// the document name, version, and run identifier (`/ NetworkProbe,1 000096`). + fn commence(&mut self, label: &str); + + /// Cross out of this document on the way out: the `⇐` boundary line carrying + /// the run identifier (`/ 000096`). + fn conclude(&mut self, label: &str); + /// Display a line of formatted content at the left margin. fn display(&mut self, content: &str); @@ -176,6 +184,17 @@ impl Driver for Console { let _ = writeln!(self.output); } + fn commence(&mut self, label: &str) { + let renderer = self.renderer(); + write_marker_line(&mut self.output, &format!("⇒ {}", label), renderer); + let _ = writeln!(self.output); + } + + fn conclude(&mut self, label: &str) { + let renderer = self.renderer(); + write_marker_line(&mut self.output, &format!("⇐ {}", label), renderer); + } + fn display(&mut self, content: &str) { let _ = writeln!(self.output, "{}", content); let _ = writeln!(self.output); @@ -205,7 +224,7 @@ impl Driver for Console { } fn external(&mut self, qualified: &str) -> UserInput { - prompt(&mut self.output, qualified, "→", &[], Value::Unitus) + prompt(&mut self.output, qualified, "⇒", &[], Value::Unitus) } fn command(&mut self, qualified: &str, script: &str) -> UserInput { @@ -1173,6 +1192,15 @@ impl Driver for Automatic { let _ = writeln!(self.output); } + fn commence(&mut self, label: &str) { + write_marker_line(&mut self.output, &format!("⇒ {}", label), self.renderer); + let _ = writeln!(self.output); + } + + fn conclude(&mut self, label: &str) { + write_marker_line(&mut self.output, &format!("⇐ {}", label), self.renderer); + } + fn display(&mut self, content: &str) { let _ = writeln!(self.output, "{}", content); let _ = writeln!(self.output); @@ -1338,6 +1366,16 @@ impl Driver for Transcript { .enter(qualified); } + fn commence(&mut self, label: &str) { + self.inner + .commence(label); + } + + fn conclude(&mut self, label: &str) { + self.inner + .conclude(label); + } + fn display(&mut self, content: &str) { self.inner .display(content); @@ -1458,6 +1496,10 @@ impl Driver for Headless { fn enter(&mut self, _qualified: &str) {} + fn commence(&mut self, _label: &str) {} + + fn conclude(&mut self, _label: &str) {} + fn display(&mut self, _content: &str) {} fn announce(&mut self, _message: &str) {} @@ -1607,6 +1649,10 @@ impl Driver for Mock { }); } + fn commence(&mut self, _label: &str) {} + + fn conclude(&mut self, _label: &str) {} + fn display(&mut self, content: &str) { self.events .push(Event::Display(content.to_string())); diff --git a/src/runner/mod.rs b/src/runner/mod.rs index b7769e1..6d84332 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -48,12 +48,14 @@ pub fn start<'i>( let pfftt = construct_state_path(&run_dir, document); let appender = Appender::open(pfftt, run_id)?; let completed = HashMap::new(); + let label = document_label(document); let outcome = match mode { Mode::Interactive => { 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) + .with_document(label); runner.run(env)? } Mode::Automatic => { @@ -63,7 +65,8 @@ pub fn start<'i>( completed, Automatic::new(colour), library, - ); + ) + .with_document(label); runner.run(env)? } }; @@ -133,8 +136,21 @@ pub fn resume<'i>( state: State::Resume, }; appender.append(&record)?; - let mut runner = - Runner::new(program, appender, completed, Console::new(), library).with_inputs(inputs); + let mut runner = Runner::new(program, appender, completed, Console::new(), library) + .with_inputs(inputs) + .with_document(document_label(&document)); let env = Environment::new(); runner.run(env) } + +// The boundary trace lines name the document by its file stem (`NetworkProbe` +// for `NetworkProbe.tq`), matching the PFFTT file the run writes to. +fn document_label(document: &Path) -> String { + document + .file_stem() + .map(|s| { + s.to_string_lossy() + .into_owned() + }) + .unwrap_or_default() +} diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 3d363e8..3fd4a8f 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -113,6 +113,7 @@ pub struct Runner<'i, D: Driver> { path: QualifiedPath<'i>, library: Library, context: Context, + document: Option, } impl<'i, D: Driver> Runner<'i, D> { @@ -132,9 +133,17 @@ impl<'i, D: Driver> Runner<'i, D> { path: QualifiedPath::new(), library, context: Context::native(), + document: None, } } + /// Name the source document so the run brackets its walk double arrow + /// marked trace lines. + pub fn with_document(mut self, document: String) -> Self { + self.document = Some(document); + self + } + /// 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. @@ -167,6 +176,17 @@ impl<'i, D: Driver> Runner<'i, D> { /// anonymous wrapper if the document is top-level Steps, otherwise /// the first declared procedure. pub fn run(&mut self, mut env: Environment) -> Result { + if let Some(document) = &self.document { + let label = format!( + "/ {},1 #{}", + document, + self.appender + .run_id() + .render() + ); + self.driver + .commence(&label); + } if let Some(metadata) = self .program .prelude @@ -259,6 +279,27 @@ impl<'i, D: Driver> Runner<'i, D> { } else { result }; + // A run that walked to its end closes with a `Finish` record at the + // root and the double arrow marker. + if let Ok(outcome) = &result { + if let Outcome::Stopped = outcome { + } else { + self.record_finish()?; + if self + .document + .is_some() + { + let label = format!( + "/ #{}", + self.appender + .run_id() + .render() + ); + self.driver + .conclude(&label); + } + } + } result } @@ -785,7 +826,7 @@ impl<'i, D: Driver> Runner<'i, D> { } self.driver - .settle("→", &qualified, &input); + .settle("⇒", &qualified, &input); let outcome = outcome_from(input); self.appender .append(&Record { @@ -839,11 +880,15 @@ impl<'i, D: Driver> Runner<'i, D> { Some(text) => Some(text.as_str()), None => None, }; + // Set the acquired `(name : forma)` off from the path with a + // trailing space; an invocation prompt instead glues its arguments + // straight to the ``. + let prompt = format!("{qualified} "); let mut acquired = Vec::with_capacity(names.len()); for name in names { match self .driver - .acquire(&qualified, Some(name.value), forma) + .acquire(&prompt, Some(name.value), forma) { UserInput::Done(value) => acquired.push(value), UserInput::Skip => { @@ -1486,6 +1531,21 @@ impl<'i, D: Driver> Runner<'i, D> { Ok(outcome) } + /// Record a `Finish` at the root path, closing a run that walked to its end. + fn record_finish(&mut self) -> Result<(), RunnerError> { + let run_id = self + .appender + .run_id(); + let record = Record { + recorded: now_iso8601(), + run_id, + path: "/".to_string(), + state: State::Finish, + }; + self.appender + .append(&record) + } + /// Record a deliberate Stop at the root path and unwind the walk. fn record_stop(&mut self) -> Result { let run_id = self diff --git a/src/runner/state.rs b/src/runner/state.rs index d36ba9f..f6ad8c6 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -46,11 +46,12 @@ pub struct Record { } /// A lifecycle or step-outcome event; the keyword written into each PFFTT -/// record line. `Start`, `Stop`, and `Resume` are run-lifecycle events emitted -/// at the root path `/`; `Begin` marks entry into a step or scope (paired with the -/// eventual `Done`, `Skip`, or `Fail` at the same path). `Stop` records a deliberate +/// record line. `Start`, `Finish`, `Stop`, and `Resume` are run-lifecycle +/// events emitted at the root path `/`; `Begin` marks entry into a step or +/// scope (paired with the eventual `Done`, `Skip`, or `Fail` at the same path). +/// `Finish` closes a run that walked to its end; `Stop` records a deliberate /// quit — the run stays resumable, and the record distinguishes the quit from a -/// crash (which records nothing). +/// crash (which records nothing) and from a `Finish`. /// `Invoke` records dispatch into another procedure (the return is /// implicit — the next event's path reveals the resumed procedure). /// `Execute` and `Return` bracket an effectful host call (a `Command` or @@ -61,6 +62,7 @@ pub struct Record { #[derive(Debug, Clone, PartialEq)] pub enum State { Start { uri: String }, + Finish, Stop, Resume, Invoke(InvokeTarget), @@ -245,6 +247,7 @@ impl Store { inputs.insert(record.path, supplied); } State::Start { .. } + | State::Finish | State::Stop | State::Resume | State::Invoke(_) @@ -466,6 +469,7 @@ fn format_state(out: &mut String, state: &State) { out.push_str("Start "); out.push_str(uri); } + State::Finish => out.push_str("Finish"), State::Stop => out.push_str("Stop"), State::Resume => out.push_str("Resume"), State::Invoke(target) => { @@ -662,6 +666,12 @@ fn parse_state(text: &str) -> Result { uri: uri.to_string(), }) } + "Finish" => { + if rest.is_some() { + return Err(RecordError::MalformedState); + } + Ok(State::Finish) + } "Stop" => { if rest.is_some() { return Err(RecordError::MalformedState); diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs index f88f44e..5f6af44 100644 --- a/tests/runner/samples.rs +++ b/tests/runner/samples.rs @@ -29,9 +29,10 @@ fn strip_timestamp_and_runid(trail: &str) -> Vec { /// matches the expected `.pfftt` checked in beside the sample. The walk /// records pin each step's qualified path and outcome in walk order, so a /// wrong path, a missing seal, a dropped iteration segment, or a reordered -/// walk is caught. The in-memory capture holds only the walk (the store layer -/// writes Start), so the first line is skipped when comparing. A sample -/// without a matching `.pfftt` also fails. +/// walk is caught. The in-memory capture holds the walk and its closing +/// `Finish`, but not the opening `Start` (the store layer writes that), so the +/// expected file's first line is skipped when comparing. A sample without a +/// matching `.pfftt` also fails. #[test] fn ensure_run() { let dir = Path::new("tests/samples/runner/"); @@ -96,9 +97,10 @@ fn ensure_run() { .contents(), ); - // The expected file is a complete, valid PFFTT trail; its first line is - // the opening Start lifecycle record, which the in-memory walk capture - // does not include, so skip it before comparing the walk records. + // The expected file is a complete, valid PFFTT trail; its first line + // is the opening Start lifecycle record, which the in-memory walk + // capture does not include, so skip the first line before comparing + // the walk records. let expected_path = file.with_extension("pfftt"); let expected_text = fs::read_to_string(&expected_path).unwrap_or_else(|e| { panic!( diff --git a/tests/samples/parsing/DoorActions.tq b/tests/samples/parsing/DoorActions.tq new file mode 100644 index 0000000..5d9fec9 --- /dev/null +++ b/tests/samples/parsing/DoorActions.tq @@ -0,0 +1 @@ +- Build excitement for the prospect of being an open door diff --git a/tests/samples/parsing/OpenDoor.tq b/tests/samples/parsing/OpenDoor.tq new file mode 100644 index 0000000..3c3e8d5 --- /dev/null +++ b/tests/samples/parsing/OpenDoor.tq @@ -0,0 +1,4 @@ + 1. Open door + 2. Through door + 3. Close door, accepting that it will make a smug and self-satisified + sigh with the knowledge of a job well done. diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/samples/runner/ConfirmRelease.pfftt index db0fd27..9d940fd 100644 --- a/tests/samples/runner/ConfirmRelease.pfftt +++ b/tests/samples/runner/ConfirmRelease.pfftt @@ -9,3 +9,4 @@ 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Return "2" 2026-06-14T05:58:08.700Z 000001 /confirm_release:/2 Done "2" 2026-06-14T05:58:08.700Z 000001 /confirm_release: Done "2" +2026-06-14T05:58:08.700Z 000001 / Finish diff --git a/tests/samples/runner/ExecutionInSteps.pfftt b/tests/samples/runner/ExecutionInSteps.pfftt index 00b260e..477cd77 100644 --- a/tests/samples/runner/ExecutionInSteps.pfftt +++ b/tests/samples/runner/ExecutionInSteps.pfftt @@ -9,3 +9,4 @@ 2026-06-23T05:57:55.646Z 000088 /local_network:/1 Return "eth0 UP" 2026-06-23T05:57:55.807Z 000088 /local_network:/1 Done () 2026-06-23T05:57:55.972Z 000088 /local_network: Done () +2026-06-23T05:57:55.972Z 000088 / Finish diff --git a/tests/samples/runner/InspectHatches.pfftt b/tests/samples/runner/InspectHatches.pfftt index 5c8b706..a9fce99 100644 --- a/tests/samples/runner/InspectHatches.pfftt +++ b/tests/samples/runner/InspectHatches.pfftt @@ -19,3 +19,4 @@ 2026-06-14T05:58:08.700Z 000001 /inspect_access_hatches:/[3]/3 Begin 2026-06-14T05:58:08.700Z 000001 /inspect_access_hatches:/[3]/3 Done () 2026-06-14T05:58:08.700Z 000001 /inspect_access_hatches: Done () +2026-06-14T05:58:08.700Z 000001 / Finish diff --git a/tests/samples/runner/PatioCleaning.pfftt b/tests/samples/runner/PatioCleaning.pfftt index 54735e4..89f0065 100644 --- a/tests/samples/runner/PatioCleaning.pfftt +++ b/tests/samples/runner/PatioCleaning.pfftt @@ -72,3 +72,4 @@ 2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III/tidy_up: Done () 2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning:/III Done () 2026-06-14T05:58:08.773Z 000002 /high_pressure_cleaning: Done () +2026-06-14T05:58:08.773Z 000002 / Finish