diff --git a/src/domain/checklist/adapter.rs b/src/domain/checklist/adapter.rs index c1138769..0d0d730d 100644 --- a/src/domain/checklist/adapter.rs +++ b/src/domain/checklist/adapter.rs @@ -281,7 +281,7 @@ mod check { } fn extract(source: &str) -> super::Document { - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let doc = parsing::parse(path, source).unwrap(); ChecklistAdapter.extract(&doc) } diff --git a/src/domain/procedure/adapter.rs b/src/domain/procedure/adapter.rs index bde26bcb..d9456657 100644 --- a/src/domain/procedure/adapter.rs +++ b/src/domain/procedure/adapter.rs @@ -266,7 +266,7 @@ mod check { } fn extract(source: &str) -> super::Document { - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let doc = parsing::parse(path, source).unwrap(); ProcedureAdapter.extract(&doc) } diff --git a/src/domain/recipe/adapter.rs b/src/domain/recipe/adapter.rs index eed669e5..9902858e 100644 --- a/src/domain/recipe/adapter.rs +++ b/src/domain/recipe/adapter.rs @@ -341,7 +341,7 @@ mod check { } fn extract(source: &str) -> super::Document { - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let doc = parsing::parse(path, source).unwrap(); RecipeAdapter.extract(&doc) } diff --git a/src/main.rs b/src/main.rs index a18c31fb..a2242c54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -562,8 +562,8 @@ fn main() { }; match runner::start(filename, &program) { - Ok((id, Outcome::Quit)) => { - eprintln!("paused; resume with `technique resume {}`", id.render()); + Ok((run_id, Outcome::Quit)) => { + eprintln!("paused; resume with `technique resume {}`", run_id.render()); std::process::exit(0); } Ok((_, _)) => std::process::exit(0), @@ -580,15 +580,15 @@ fn main() { debug!(id); - let id = match RunId::parse(id) { - Ok(id) => id, + let run_id = match RunId::parse(id) { + Ok(run_id) => run_id, Err(error) => { eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); std::process::exit(1); } }; - let filename = match runner::locate(id) { + let filename = match runner::locate(run_id) { Ok(path) => path, Err(error) => { eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); @@ -644,9 +644,12 @@ fn main() { } }; - match runner::resume(id, &program) { + match runner::resume(run_id, &program) { Ok(Outcome::Quit) => { - eprintln!("paused; continue with `technique resume {}`", id.render()); + eprintln!( + "paused; continue with `technique resume {}`", + run_id.render() + ); std::process::exit(0); } Ok(_) => std::process::exit(0), diff --git a/src/parsing/checks/errors.rs b/src/parsing/checks/errors.rs index 9e882132..21f5b911 100644 --- a/src/parsing/checks/errors.rs +++ b/src/parsing/checks/errors.rs @@ -3,7 +3,7 @@ use std::path::Path; /// Helper function to check if parsing produces the expected error fn expect_error(content: &str, expected: ParsingError) { - let result = parse_with_recovery(Path::new("test.tq"), content); + let result = parse_with_recovery(Path::new("Test.tq"), content); match result { Ok(_) => panic!( "Expected parsing to fail, but it succeeded for input: {}", diff --git a/src/parsing/checks/parser.rs b/src/parsing/checks/parser.rs index 70664a8a..b6bcc040 100644 --- a/src/parsing/checks/parser.rs +++ b/src/parsing/checks/parser.rs @@ -2356,7 +2356,7 @@ broken : 1. Step one "#; - let result = parse_with_recovery(Path::new("test.tq"), content); + let result = parse_with_recovery(Path::new("Test.tq"), content); // Check that we get an error about the invalid signature match result { diff --git a/src/problem/messages.rs b/src/problem/messages.rs index af71a4a7..4a6363cf 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1055,21 +1055,21 @@ pub fn generate_translation_error<'i>( /// 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), + RunnerError::NoSuchRun(run_id) => ( + format!("No such run '{:06}'", run_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), + RunnerError::MalformedRecord { run_id, .. } => ( + format!("Malformed record for run '{:06}'", run_id.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::StartMissing(run_id) => ( + format!("Start record missing in run '{:06}'", run_id.0), + "The state file is present but its first record (the Start event) is missing or malformed.".to_string(), ), RunnerError::InvalidRunId(text) => ( format!("Invalid run identifier '{}'", text), diff --git a/src/runner/checks/path.rs b/src/runner/checks/path.rs index 8d5eaa73..b1c21ed5 100644 --- a/src/runner/checks/path.rs +++ b/src/runner/checks/path.rs @@ -2,16 +2,16 @@ use crate::language::{Attribute, Identifier, Span}; use crate::runner::path::{PathSegment, QualifiedPath}; #[test] -fn empty_stack_renders_empty_string() { +fn empty_stack_renders_root() { let stack = QualifiedPath::new(); - assert_eq!(stack.render(), ""); + 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"); + assert_eq!(stack.render(), "/I"); } #[test] @@ -19,7 +19,7 @@ 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"); + assert_eq!(stack.render(), "/I/2"); } #[test] @@ -27,14 +27,14 @@ 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"); + 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"); + assert_eq!(stack.render(), "/-3"); } #[test] @@ -46,7 +46,7 @@ fn attribute_frame_composes_role_and_place() { let mut stack = QualifiedPath::new(); stack.push(PathSegment::Attributes(&frame)); stack.push(PathSegment::DependentStep("a")); - assert_eq!(stack.render(), "@chef^kitchen/a"); + assert_eq!(stack.render(), "/@chef^kitchen/a"); } #[test] @@ -61,7 +61,7 @@ fn nested_attribute_frames_each_contribute_a_segment() { stack.push(PathSegment::Attributes(&outer)); stack.push(PathSegment::Attributes(&inner)); stack.push(PathSegment::DependentStep("a")); - assert_eq!(stack.render(), "2/@team/@chef^kitchen/a"); + assert_eq!(stack.render(), "/2/@team/@chef^kitchen/a"); } #[test] @@ -72,7 +72,7 @@ fn reset_role() { stack.push(PathSegment::DependentStep("1")); stack.push(PathSegment::Attributes(&frame)); stack.push(PathSegment::DependentStep("a")); - assert_eq!(stack.render(), "1/a"); + assert_eq!(stack.render(), "/1/a"); // A sibling Place attribute in the same frame still renders. let frame = vec![ @@ -83,16 +83,21 @@ fn reset_role() { stack.push(PathSegment::DependentStep("1")); stack.push(PathSegment::Attributes(&frame)); stack.push(PathSegment::DependentStep("a")); - assert_eq!(stack.render(), "1/^kitchen/a"); + assert_eq!(stack.render(), "/1/^kitchen/a"); } #[test] fn procedure_segment() { - // Single procedure: name becomes the prefix, joined to the rest with `:`. + // Procedure alone renders as `/name:` + let mut stack = QualifiedPath::new(); + stack.push(PathSegment::Procedure("local_network")); + assert_eq!(stack.render(), "/local_network:"); + + // Procedure with a step: `/name:N`. let mut stack = QualifiedPath::new(); stack.push(PathSegment::Procedure("make_coffee")); stack.push(PathSegment::DependentStep("2")); - assert_eq!(stack.render(), "make_coffee:2"); + assert_eq!(stack.render(), "/make_coffee:2"); // Nested procedure: the inner frame replaces the outer prefix entirely. let mut stack = QualifiedPath::new(); @@ -100,17 +105,17 @@ fn procedure_segment() { stack.push(PathSegment::DependentStep("1")); stack.push(PathSegment::Procedure("inner")); stack.push(PathSegment::DependentStep("2")); - assert_eq!(stack.render(), "inner: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 + // /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"); + assert_eq!(stack.render(), "/2/@barista/a/-1"); } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 6e8616a7..4857e7fa 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -5,7 +5,7 @@ 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::runner::state::{parse_record, Appender, State, Store, Value as RecordValue}; use crate::translation::translate; use crate::value::Value; @@ -24,11 +24,11 @@ impl StoreFixture { let _ = std::fs::remove_dir_all(&base); let store = Store::new(base.clone()); let document = PathBuf::from("/tmp/Test.tq"); - let (_, run_dir, _) = store + let (run_id, 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"); + let appender = Appender::open(pfftt, run_id).expect("open appender"); StoreFixture { base, appender: Some(appender), @@ -110,15 +110,19 @@ fn step_outcomes_recorded() { .is_empty() }) .collect(); - assert_eq!(lines.len(), 2); + // Start + Begin + Done — three lines. + assert_eq!(lines.len(), 3); assert_eq!( lines[0], - "[ document = file:///tmp/Test.tq, started = 2026-05-16T00:00:00Z ]" + "2026-05-16T00:00:00Z 000001 / Start file:///tmp/Test.tq" ); - let record = parse_record(lines[1]).expect("parse record"); - assert_eq!(record.path, "1"); - let RecordOutcome::Done(_) = record.outcome else { - panic!("expected Done, got {:?}", record.outcome); + let begin = parse_record(lines[1]).expect("parse begin"); + assert_eq!(begin.path, "/1"); + assert_eq!(begin.state, State::Begin); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!(record.path, "/1"); + let State::Done(_) = record.state else { + panic!("expected Done, got {:?}", record.state); }; let mut fixture = StoreFixture::new("step-skip"); @@ -141,8 +145,9 @@ fn step_outcomes_recorded() { .is_empty() }) .collect(); - let record = parse_record(lines[1]).expect("parse record"); - assert_eq!(record.outcome, RecordOutcome::Skipped); + // lines[1] is the Begin; lines[2] is the Skip outcome. + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!(record.state, State::Skip); let mut fixture = StoreFixture::new("step-fail"); let body = Operation::Sequence(vec![step( @@ -164,10 +169,13 @@ fn step_outcomes_recorded() { .is_empty() }) .collect(); - let record = parse_record(lines[1]).expect("parse record"); + // lines[1] is the Begin; lines[2] is the Fail outcome. + let record = parse_record(lines[2]).expect("parse record"); assert_eq!( - record.outcome, - RecordOutcome::Failed(Some("\"Failed\"".to_string())) + record.state, + State::Fail(Some(RecordValue::Tablet( + "[ reason = \"Failed\" ]".to_string() + ))) ); } @@ -201,7 +209,7 @@ fn two_steps_prompted_in_source_order() { } }) .collect(); - assert_eq!(step_fqns, vec!["1", "2"]); + assert_eq!(step_fqns, vec!["/1", "/2"]); } #[test] @@ -216,7 +224,7 @@ 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()); + 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); @@ -236,7 +244,7 @@ fn pre_completed_step_short_circuits() { } }) .collect(); - assert_eq!(step_fqns, vec!["2"]); + assert_eq!(step_fqns, vec!["/2"]); } #[test] @@ -268,17 +276,23 @@ fn quit_propagates_and_stops_walking() { }) .collect(); // Only the first Step was prompted; the second never fired. - assert_eq!(step_fqns, vec!["1"]); + assert_eq!(step_fqns, vec!["/1"]); - // Quit doesn't record an outcome — the PFFTT file contains the - // manifest tablet and nothing else. + // Quit records the step's Begin (the operator started looking at it) + // but no Done/Skip/Fail — so the file is exactly the opening Start + // plus that single Begin. let pfftt = fixture.pfftt_contents(); - assert_eq!( - pfftt - .matches("outcome =") - .count(), - 0 - ); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains(" Start ")); + assert!(lines[1].ends_with(" Begin")); } #[test] @@ -321,8 +335,8 @@ fn section_walking() { } }) .collect(); - assert_eq!(section_fqns, vec!["I"]); - assert_eq!(step_fqns, vec!["I/1"]); + assert_eq!(section_fqns, vec!["/I"]); + assert_eq!(step_fqns, vec!["/I/1"]); let mut fixture = StoreFixture::new("section-with-title"); let title = Operation::String(vec![Fragment::Text("Setup")]); @@ -384,7 +398,7 @@ fn parallel_step_index_starts_at_one() { } }) .collect(); - assert_eq!(step_fqns, vec!["-1", "-2"]); + assert_eq!(step_fqns, vec!["/-1", "/-2"]); } #[test] @@ -398,7 +412,7 @@ test : 2. Result: { answer } "# .trim_ascii(); - let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); let program = translate(&document).expect("translate"); let mut fixture = StoreFixture::new("bind-then-interpolate"); @@ -444,7 +458,7 @@ helper : 1. helper step "# .trim_ascii(); - let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); let program = translate(&document).expect("translate"); let mut fixture = StoreFixture::new("invoke-descent"); @@ -469,7 +483,7 @@ helper : // The Step inside `helper` was reached and prompted, with the // helper procedure as the FQN prefix (the outer `main` frame is // overridden by the inner Procedure segment). - assert_eq!(step_fqns, vec!["helper:1"]); + assert_eq!(step_fqns, vec!["/helper:1"]); } #[test] @@ -482,7 +496,7 @@ test : 1. Do this { journal("hello") } "# .trim_ascii(); - let document = parsing::parse(Path::new("test.tq"), source).expect("parse"); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parse"); let program = translate(&document).expect("translate"); let mut fixture = StoreFixture::new("execute-announce"); @@ -504,7 +518,7 @@ test : } }) .collect(); - assert_eq!(announcements, vec!["journal(...)"]); + assert_eq!(announcements, vec!["journal()"]); } #[test] @@ -535,11 +549,18 @@ fn loop_inside_step_produces_one_result() { .run() .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. let pfftt = fixture.pfftt_contents(); - assert_eq!( - pfftt - .matches("outcome =") - .count(), - 1 - ); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + assert_eq!(lines.len(), 3); + assert!(lines[1].ends_with(" Begin")); + assert!(lines[2].contains(" Done")); } diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index e04e1643..ec50c68c 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::runner::runner::RunnerError; use crate::runner::state::{ - format_record, parse_manifest, parse_record, Outcome, Record, RunId, Store, + format_record, parse_record, InvokeTarget, Record, RecordError, RunId, State, Store, Value, }; // A scratch directory under the system temp dir, cleaned up on drop so panics @@ -87,15 +87,15 @@ fn store_allocate_resumes_from_existing_max() { dir.path .clone(), ); - let (id, _) = store + let (run_id, _) = store .allocate() .expect("allocate"); - assert_eq!(id, RunId(8)); + assert_eq!(run_id, RunId(8)); } #[test] -fn manifest_round_trip_through_create_and_open() { - let dir = TempDir::new("manifest-roundtrip"); +fn create_writes_start_record_at_head() { + let dir = TempDir::new("create-start"); let document = PathBuf::from("/somewhere/NetworkProbe.tq"); let started = "2026-05-14T12:34:56Z".to_string(); @@ -104,21 +104,45 @@ fn manifest_round_trip_through_create_and_open() { dir.path .clone(), ); - let (id, _, written) = store - .create(&document, started.clone()) + let (run_id, 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, + format!( + "2026-05-14T12:34:56Z {} / Start file:///somewhere/NetworkProbe.tq\n", + run_id.render() + ) + ); +} + +#[test] +fn create_and_open_round_trips_document_path() { + let dir = TempDir::new("create-open-roundtrip"); + + let document = PathBuf::from("/somewhere/NetworkProbe.tq"); + let started = "2026-05-14T12:34:56Z".to_string(); + + let store = Store::new( + dir.path + .clone(), + ); + let (run_id, _) = store + .create(&document, started) .expect("create"); - let (read, completed, _) = store - .open(id) + let (read_document, completed, _) = store + .open(run_id) .expect("open"); - assert_eq!(written, read); - assert_eq!(read.document, document); - assert_eq!(read.started, started); + assert_eq!(read_document, document); assert!(completed.is_empty()); } #[test] -fn open_replays_three_result_paths() { +fn open_replays_done_skip_fail_into_completed() { let dir = TempDir::new("replay-three"); let run_dir = dir @@ -126,21 +150,31 @@ fn open_replays_three_result_paths() { .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:00Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Start { + uri: "file:///foo/Test.tq".to_string(), + }, + })); file.push_str(&format_record(&Record { recorded: "2026-05-14T12:00:01Z".to_string(), - path: "test:1".to_string(), - outcome: Outcome::Done(None), + run_id: RunId(1), + path: "/test:1".to_string(), + state: State::Done(None), })); file.push_str(&format_record(&Record { recorded: "2026-05-14T12:00:02Z".to_string(), - path: "test:2".to_string(), - outcome: Outcome::Skipped, + run_id: RunId(1), + path: "/test:2".to_string(), + state: State::Skip, })); file.push_str(&format_record(&Record { recorded: "2026-05-14T12:00:03Z".to_string(), - path: "test:3".to_string(), - outcome: Outcome::Failed(None), + run_id: RunId(1), + path: "/test:3".to_string(), + state: State::Fail(None), })); std::fs::write(run_dir.join("Test.pfftt"), file).unwrap(); @@ -148,15 +182,67 @@ fn open_replays_three_result_paths() { dir.path .clone(), ); - let (manifest, completed, _) = store + let (document, completed, _) = store .open(RunId(1)) .expect("open"); - assert_eq!(manifest.document, Path::new("/foo/Test.tq")); + 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("/test:1")); + assert!(completed.contains("/test:2")); + assert!(completed.contains("/test:3")); +} + +// Resume and Begin records in the middle of the file are lifecycle +// events, not step completions — they must not show up in the replayed +// `completed` set. +#[test] +fn open_skips_resume_and_begin_during_replay() { + let dir = TempDir::new("replay-skip-lifecycle"); + + let run_dir = dir + .path + .join("000001"); + std::fs::create_dir_all(&run_dir).unwrap(); + let mut file = String::new(); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Start { + uri: "file:///foo/Test.tq".to_string(), + }, + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + run_id: RunId(1), + path: "/test:1".to_string(), + state: State::Begin, + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:02Z".to_string(), + run_id: RunId(1), + path: "/test:1".to_string(), + state: State::Done(Some(Value::Unit)), + })); + file.push_str(&format_record(&Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Resume, + })); + std::fs::write(run_dir.join("Test.pfftt"), file).unwrap(); + + let store = Store::new( + dir.path + .clone(), + ); + let (_, completed, _) = store + .open(RunId(1)) + .expect("open"); + + assert_eq!(completed.len(), 1); + assert!(completed.contains("/test:1")); } #[test] @@ -169,219 +255,307 @@ fn open_missing_run_returns_no_such_run() { .clone(), ); match store.open(RunId(42)) { - Err(RunnerError::NoSuchRun(id)) => assert_eq!(id, RunId(42)), + Err(RunnerError::NoSuchRun(run_id)) => assert_eq!(run_id, RunId(42)), other => panic!("expected NoSuchRun, got {:?}", other), } } +// Each variant of `State` produces a distinct line shape. This pins exact +// bytes; the round-trip test below confirms parse compatibility. #[test] -fn create_writes_pfftt_file_with_expected_content() { - let dir = TempDir::new("create-bytes"); - - let document = PathBuf::from("/somewhere/NetworkProbe.tq"); - let started = "2026-05-14T12:34:56Z".to_string(); +fn format_record_pins_on_disk_text() { + let record = Record { + recorded: "2026-05-16T12:50:30Z".to_string(), + run_id: RunId(15003), + path: "/".to_string(), + state: State::Start { + uri: "file:///home/user/NetworkProbe.tq".to_string(), + }, + }; + assert_eq!( + format_record(&record), + "2026-05-16T12:50:30Z 015003 / Start file:///home/user/NetworkProbe.tq\n" + ); - let store = Store::new( - dir.path - .clone(), + let record = Record { + recorded: "2026-05-17T00:28:25Z".to_string(), + run_id: RunId(15003), + path: "/".to_string(), + state: State::Resume, + }; + assert_eq!( + format_record(&record), + "2026-05-17T00:28:25Z 015003 / Resume\n" ); - 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"); + let record = Record { + recorded: "2026-05-17T00:28:30Z".to_string(), + run_id: RunId(15003), + path: "/".to_string(), + state: State::Pause, + }; assert_eq!( - on_disk, - "[ document = file:///somewhere/NetworkProbe.tq, started = 2026-05-14T12:34:56Z ]\n" + format_record(&record), + "2026-05-17T00:28:30Z 015003 / Pause\n" ); -} -// 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() { let record = Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Done(None), + recorded: "2026-05-17T00:28:30Z".to_string(), + run_id: RunId(15003), + path: "/local_network:2".to_string(), + state: State::Begin, }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done ]\n" + "2026-05-17T00:28:30Z 015003 /local_network:2 Begin\n" ); let record = Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Done(Some("\"penguin\"".to_string())), + recorded: "2026-05-16T12:50:42Z".to_string(), + run_id: RunId(15003), + path: "/local_network:1".to_string(), + state: State::Execute { + function: "exec".to_string(), + }, }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Done, result = \"penguin\" ]\n" + "2026-05-16T12:50:42Z 015003 /local_network:1 Execute exec()\n" ); let record = Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Skipped, + recorded: "2026-05-17T00:31:30Z".to_string(), + run_id: RunId(15003), + path: "/local_network:5".to_string(), + state: State::Invoke(InvokeTarget::Procedure("probe_border_router".to_string())), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Skipped ]\n" + "2026-05-17T00:31:30Z 015003 /local_network:5 Invoke probe_border_router:\n" ); let record = Record { - recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Failed(None), + recorded: "2026-05-17T00:31:30Z".to_string(), + run_id: RunId(15003), + path: "/local_network:5".to_string(), + state: State::Invoke(InvokeTarget::Uri("file:///tmp/Other.tq".to_string())), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed ]\n" + "2026-05-17T00:31:30Z 015003 /local_network:5 Invoke file:///tmp/Other.tq\n" ); let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "make_coffee:2".to_string(), - outcome: Outcome::Failed(Some("\"network unplugged\"".to_string())), + run_id: RunId(1), + path: "/make_coffee:2".to_string(), + state: State::Done(None), }; assert_eq!( format_record(&record), - "[ recorded = 2026-05-14T12:00:00Z, path = make_coffee:2, outcome = Failed, reason = \"network unplugged\" ]\n" + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Done\n" ); -} -#[test] -fn record_round_trips_through_format_and_parse() { - let original = Record { + let record = Record { recorded: "2026-05-14T12:00:00Z".to_string(), - path: "a:1".to_string(), - outcome: Outcome::Done(None), + run_id: RunId(1), + path: "/make_coffee:2".to_string(), + state: State::Done(Some(Value::Unit)), }; - let text = format_record(&original); - let line = text - .strip_suffix('\n') - .expect("trailing newline"); - assert_eq!(parse_record(line).expect("parse"), original); + assert_eq!( + format_record(&record), + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Done ()\n" + ); - let original = Record { - recorded: "2026-05-14T12:00:01Z".to_string(), - path: "a:2".to_string(), - outcome: Outcome::Done(Some("\"penguin\"".to_string())), + let record = Record { + 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(), + ))), }; - let text = format_record(&original); - let line = text - .strip_suffix('\n') - .expect("trailing newline"); - assert_eq!(parse_record(line).expect("parse"), original); + assert_eq!( + format_record(&record), + "2026-05-17T00:29:15Z 015003 /local_network:3 Done [ address = \"192.168.1.1\" ]\n" + ); - let original = Record { - recorded: "2026-05-14T12:00:02Z".to_string(), - path: "a:3".to_string(), - outcome: Outcome::Skipped, + let record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/make_coffee:2".to_string(), + state: State::Skip, }; - let text = format_record(&original); - let line = text - .strip_suffix('\n') - .expect("trailing newline"); - assert_eq!(parse_record(line).expect("parse"), original); + assert_eq!( + format_record(&record), + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Skip\n" + ); - 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 record = Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/make_coffee:2".to_string(), + state: State::Fail(None), }; - let text = format_record(&original); - let line = text - .strip_suffix('\n') - .expect("trailing newline"); - assert_eq!(parse_record(line).expect("parse"), original); -} + assert_eq!( + format_record(&record), + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail\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"); + let record = Record { + 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(), + ))), + }; + assert_eq!( + format_record(&record), + "2026-05-14T12:00:00Z 000001 /make_coffee:2 Fail [ reason = \"network unplugged\" ]\n" + ); } #[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), +fn record_round_trips_through_format_and_parse() { + let cases = vec![ + Record { + recorded: "2026-05-16T12:50:30Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Start { + 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::Pause, + }, + Record { + recorded: "2026-05-17T00:28:25Z".to_string(), + run_id: RunId(15003), + path: "/".to_string(), + state: State::Resume, + }, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/a:1".to_string(), + state: State::Begin, + }, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/a:1".to_string(), + state: State::Execute { + function: "exec".to_string(), + }, + }, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/a:1".to_string(), + state: State::Invoke(InvokeTarget::Procedure("helper".to_string())), + }, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/a:1".to_string(), + state: State::Invoke(InvokeTarget::Uri("https://proc.ac/foo/Bar.tq".to_string())), + }, + Record { + recorded: "2026-05-14T12:00:00Z".to_string(), + run_id: RunId(1), + path: "/a:1".to_string(), + state: State::Done(None), + }, + Record { + recorded: "2026-05-14T12:00:01Z".to_string(), + run_id: RunId(1), + path: "/a:2".to_string(), + state: State::Done(Some(Value::Unit)), + }, + 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(), + ))), + }, + Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + run_id: RunId(1), + path: "/a:4".to_string(), + state: State::Skip, + }, + Record { + recorded: "2026-05-14T12:00:04Z".to_string(), + run_id: RunId(1), + path: "/a:5".to_string(), + state: State::Fail(None), + }, + Record { + 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(), + ))), + }, + ]; + + for original in cases { + let text = format_record(&original); + let line = text + .strip_suffix('\n') + .expect("trailing newline"); + assert_eq!(parse_record(line).expect("parse"), original); } } #[test] -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)) => { - assert_eq!(name, "document"); - } - other => panic!("expected MissingField, got {:?}", other), - } - - 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), +fn parse_record_rejects_unknown_state() { + let line = "2026-05-14T12:00:00Z 000001 /x:1 Maybe"; + match parse_record(line) { + Err(RecordError::UnknownState(text)) => assert_eq!(text, "Maybe"), + other => panic!("expected UnknownState, got {:?}", other), } } #[test] -fn parse_record_rejects_missing_required_field() { - let line = "[ recorded = 2026-05-14T12:00:00Z, outcome = Done ]"; +fn parse_record_rejects_too_few_fields() { + // Missing state keyword. + let line = "2026-05-14T12:00:00Z 000001 /x:1"; match parse_record(line) { - Err(crate::runner::state::RecordError::MissingField(name)) => { - assert_eq!(name, "path"); - } - other => panic!("expected MissingField, got {:?}", other), + Err(RecordError::MalformedRecord) => {} + other => panic!("expected MalformedRecord, got {:?}", other), } - let line = "[ recorded = 2026-05-14T12:00:00Z, path = a:1 ]"; + // Empty line. + let line = ""; match parse_record(line) { - Err(crate::runner::state::RecordError::MissingField(name)) => { - assert_eq!(name, "outcome"); - } - other => panic!("expected MissingField, got {:?}", other), + Err(RecordError::MalformedRecord) => {} + other => panic!("expected MalformedRecord, got {:?}", other), } } #[test] -fn parse_record_without_brackets_errors() { - let line = "recorded = 2026-05-14T12:00:00Z, path = a:1, outcome = Done"; +fn parse_record_rejects_non_decimal_run() { + let line = "2026-05-14T12:00:00Z abc /x:1 Done"; match parse_record(line) { - Err(crate::runner::state::RecordError::MalformedTablet) => {} - other => panic!("expected MalformedTablet, got {:?}", other), + Err(RecordError::MalformedRecord) => {} + other => panic!("expected MalformedRecord, got {:?}", other), } } -// `ManifestMissing` covers two setups: a run directory containing an empty -// PFFTT file (file present, no manifest tablet inside), and a run directory +// `StartMissing` covers two setups: a run directory containing an empty +// PFFTT file (file present, no Start record inside), and a run directory // with no PFFTT file at all. #[test] -fn open_missing_manifest() { +fn open_missing_start_record() { let dir = TempDir::new("empty-pfftt"); let run_dir = dir .path @@ -394,8 +568,8 @@ fn open_missing_manifest() { .clone(), ); match store.open(RunId(1)) { - Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), - other => panic!("expected ManifestMissing, got {:?}", other), + Err(RunnerError::StartMissing(run_id)) => assert_eq!(run_id, RunId(1)), + other => panic!("expected StartMissing, got {:?}", other), } let dir = TempDir::new("no-pfftt"); @@ -410,7 +584,7 @@ fn open_missing_manifest() { .clone(), ); match store.open(RunId(1)) { - Err(RunnerError::ManifestMissing(id)) => assert_eq!(id, RunId(1)), - other => panic!("expected ManifestMissing, got {:?}", other), + Err(RunnerError::StartMissing(run_id)) => assert_eq!(run_id, RunId(1)), + other => panic!("expected StartMissing, got {:?}", other), } } diff --git a/src/runner/manifest.rs b/src/runner/manifest.rs deleted file mode 100644 index e6957796..00000000 --- a/src/runner/manifest.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! 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/mod.rs b/src/runner/mod.rs index af62185f..0b705605 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use crate::program::Program; mod evaluator; -mod manifest; mod path; mod prompt; mod runner; @@ -19,40 +18,50 @@ pub use state::{RecordError, RunId}; use prompt::Console; use runner::{now_iso8601, Runner}; -use state::{construct_state_path, Appender, Store}; +use state::{construct_state_path, Appender, Record, State, 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. +/// Allocate a new run, write the opening `Start` record, and walk the program +/// to completion or until the user interrupts by signalling they are pausing +/// or 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 (run_id, run_dir) = store.create(document, now_iso8601())?; let pfftt = construct_state_path(&run_dir, document); - let appender = Appender::open(pfftt)?; + let appender = Appender::open(pfftt, run_id)?; let mut runner = Runner::with_pieces(program, appender, HashSet::new(), Console::new()); let outcome = runner.run()?; - Ok((id, outcome)) + Ok((run_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 { +/// Read the opening `Start` record of an existing run, returning the +/// source document path so the caller can load and re-translate it +/// before resuming. +pub fn locate(run_id: RunId) -> Result { let store = Store::new(PathBuf::from(STORE_ROOT)); - let (manifest, _, _) = store.open(id)?; - Ok(manifest.document) + let (document, _, _) = store.open(run_id)?; + Ok(document) } /// 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 { +/// any step whose FQN has already been recorded. Appends a `Resume` +/// record at the root path before walking. +pub fn resume<'i>(run_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); - let appender = Appender::open(pfftt)?; + let (document, completed, 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 { + recorded: now_iso8601(), + run_id, + path: "/".to_string(), + state: State::Resume, + }; + appender.append(&record)?; let mut runner = Runner::with_pieces(program, appender, completed, Console::new()); runner.run() } diff --git a/src/runner/path.rs b/src/runner/path.rs index ecba5f55..d75029fe 100644 --- a/src/runner/path.rs +++ b/src/runner/path.rs @@ -45,11 +45,13 @@ impl<'i> QualifiedPath<'i> { .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. + /// Render the current path as a PFFTT absolute path string, + /// always rooted at `/`. A `Procedure` segment opens a fresh scope, + /// so only segments after the last `Procedure` are shown; the + /// procedure's name (with trailing `:`) joins immediately to the + /// root. Other segments are `/`-joined after the procedure prefix. + /// 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; @@ -69,11 +71,13 @@ impl<'i> QualifiedPath<'i> { .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("/"), + let mut text = String::from("/"); + if let Some(name) = prefix { + text.push_str(name); + text.push(':'); } + text.push_str(&pieces.join("/")); + text } } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 7fdf5f75..9230d1db 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -7,7 +7,9 @@ use std::path::PathBuf; 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 super::state::{ + Appender, InvokeTarget, Record, RecordError, RunId, State, Value as RecordValue, +}; use crate::program::{Executable, Invocable, Operation, Ordinal, Program, SubroutineRef}; use crate::value::Value; @@ -43,8 +45,8 @@ pub enum Failure { pub enum RunnerError { NoSuchRun(RunId), StoreError { path: PathBuf, error: io::Error }, - MalformedRecord { run: RunId, error: RecordError }, - ManifestMissing(RunId), + MalformedRecord { run_id: RunId, error: RecordError }, + StartMissing(RunId), InvalidRunId(String), MissingEntryProcedure, UnboundVariable(String), @@ -143,6 +145,25 @@ impl<'i, P: Prompt> Runner<'i, P> { } Operation::Invoke(invocable) => self.walk_invoke(invocable), Operation::Execute(executable) => { + let qualified = self + .path + .render(); + let run_id = self + .appender + .run_id(); + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified, + state: State::Execute { + function: executable + .target + .value + .to_string(), + }, + }; + self.appender + .append(&record)?; self.prompt .announce(&describe_execute(executable)); Ok(Outcome::Done(Value::Unitus)) @@ -170,6 +191,20 @@ impl<'i, P: Prompt> Runner<'i, P> { .as_ref() .map(|n| n.value); if let Some(name) = name { + let qualified = self + .path + .render(); + let run_id = self + .appender + .run_id(); + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified, + state: State::Invoke(InvokeTarget::Procedure(name.to_string())), + }; + self.appender + .append(&record)?; self.path .push(PathSegment::Procedure(name)); } @@ -299,6 +334,22 @@ impl<'i, P: Prompt> Runner<'i, P> { { return Ok(Outcome::Done(Value::Unitus)); } + + // Mark the start of work on this step before walking its body, + // so any Invoke/Execute records emitted by the body land between + // this Begin and the eventual outcome record. + let run_id = self + .appender + .run_id(); + let begin = Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: State::Begin, + }; + self.appender + .append(&begin)?; + if let Outcome::Quit = self.walk(body)? { return Ok(Outcome::Quit); } @@ -316,6 +367,7 @@ impl<'i, P: Prompt> Runner<'i, P> { self.prompt .step(qualified, &description_text); + let outcome = outcome_from( self.prompt .ask(), @@ -326,8 +378,9 @@ impl<'i, P: Prompt> Runner<'i, P> { let record = Record { recorded: now_iso8601(), + run_id, path: qualified.to_string(), - outcome: record_outcome(&outcome), + state: record_state(&outcome), }; self.appender .append(&record)?; @@ -354,7 +407,7 @@ fn describe_loop( fn describe_execute(executable: &Executable<'_>) -> String { format!( - "{}(...)", + "{}()", executable .target .value @@ -371,30 +424,39 @@ fn outcome_from(input: UserInput) -> Outcome { } } -/// 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 { +/// Project the runner's in-memory `Outcome` into the on-disk `State` +/// the PFFTT writer expects. Done renders with an explicit unit +/// placeholder for now — capturing the operator's actual value into a +/// tablet is future work. Quit is unreachable here: the caller filters +/// it out before recording. +fn record_state(outcome: &Outcome) -> State { match outcome { - Outcome::Done(_) => RecordOutcome::Done(Some("()".to_string())), - Outcome::Skipped => RecordOutcome::Skipped, - Outcome::Failed(Failure::Aborted(reason)) => { - RecordOutcome::Failed(Some(format!("\"{}\"", reason))) - } + Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), + Outcome::Skipped => State::Skip, + Outcome::Failed(Failure::Aborted(reason)) => State::Fail(Some(RecordValue::Tablet( + format!("[ reason = \"{}\" ]", 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. +/// Current UTC time as an RFC3339 millisecond-precision string, used +/// for the `recorded` field of every Result tablet. The fraction is +/// truncated (not rounded) — sub-millisecond resolution is dropped — +/// and the millisecond field is always rendered as three digits, even +/// when trailing zeros would otherwise be elided. pub(super) 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 + let now = time::OffsetDateTime::now_utc(); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + now.year(), + u8::from(now.month()), + now.day(), + now.hour(), + now.minute(), + now.second(), + now.millisecond(), + ) } #[cfg(test)] diff --git a/src/runner/state.rs b/src/runner/state.rs index e779f09b..d4f554fd 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -4,7 +4,6 @@ use std::collections::HashSet; use std::io; use std::path::{Path, PathBuf}; -use super::manifest::Manifest; use super::runner::RunnerError; /// Monotonic identifier for a run. Conventionally rendered and stored as a @@ -30,44 +29,59 @@ impl RunId { /// Errors raised if a PFFTT file is malformed or invalid. #[derive(Debug, Eq, PartialEq)] pub enum RecordError { - MalformedTablet, - MissingField(&'static str), - UnknownOutcome(String), + MalformedRecord, + MalformedState, + UnknownState(String), } -/// One Result, recorded on disk in the form of a tablet. +/// One record line on disk. #[allow(dead_code)] #[derive(Debug, Clone, Eq, PartialEq)] pub struct Record { pub recorded: String, + pub run_id: RunId, pub path: String, - pub outcome: Outcome, + pub state: State, } -/// Outcome of executing a step. +/// A lifecycle or step-outcome event, mirroring the PFFTT BNF's `State` +/// production. `Start`, `Pause`, and `Resume` are run-lifecycle events +/// emitted at the root path `/`; `Begin` marks the moment work starts +/// on a step (paired with the eventual `Done`, `Skip`, or `Fail`). +/// `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. +#[allow(dead_code)] +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum State { + Start { uri: String }, + Pause, + Resume, + Invoke(InvokeTarget), + Execute { function: String }, + Begin, + Done(Option), + Skip, + Fail(Option), +} -/// 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. +/// The target of an `Invoke`: either a named procedure (rendered as +/// `name:`) or a URI to an external technique. +#[allow(dead_code)] #[derive(Debug, Clone, Eq, PartialEq)] -pub enum Outcome { - Done(Option), - Skipped, - Failed(Option), +pub enum InvokeTarget { + Procedure(String), + Uri(String), } -impl Outcome { - fn as_str(&self) -> &'static str { - match self { - Outcome::Done(_) => "Done", - Outcome::Skipped => "Skipped", - Outcome::Failed(_) => "Failed", - } - } +/// A `Value` carried by a Done or Fail state. The BNF admits `unit` or +/// `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, + Tablet(String), } /// On-disk store of runs, rooted at some base directory (conventionally @@ -127,46 +141,51 @@ 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`). + /// Allocate a new run and write its opening `Start` record. 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> { + ) -> Result<(RunId, PathBuf), 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: absolute, - started, + let (run_id, run_dir) = self.allocate()?; + let pfftt = construct_state_path(&run_dir, &absolute); + let record = Record { + recorded: started, + run_id, + path: "/".to_string(), + state: State::Start { + uri: format!("file://{}", absolute.display()), + }, }; - let pfftt = construct_state_path(&run_dir, &manifest.document); - let text = format_manifest(&manifest); - std::fs::write(&pfftt, text) + std::fs::write(&pfftt, format_record(&record)) .map_err(|error| RunnerError::StoreError { path: pfftt, error })?; - Ok((id, run_dir, manifest)) + Ok((run_id, run_dir)) } - /// 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> { + /// Open an existing run. Parses the leading `Start` record to recover + /// the source document, then replays `Done` / `Skip` / `Fail` records + /// into a set of completed step paths. `Pause` and `Resume` records + /// are passed over. + pub fn open(&self, run_id: RunId) -> Result<(PathBuf, HashSet, PathBuf), RunnerError> { let run_dir = self .base - .join(id.render()); + .join(run_id.render()); if !run_dir.is_dir() { - return Err(RunnerError::NoSuchRun(id)); + return Err(RunnerError::NoSuchRun(run_id)); } - let pfftt = find_pfftt_file(&run_dir, id)?; + let pfftt = find_pfftt_file(&run_dir, run_id)?; let content = std::fs::read_to_string(&pfftt).map_err(|error| RunnerError::StoreError { path: pfftt.clone(), error, })?; - let mut tablets = content + let mut lines = content .lines() .filter(|line| { !line @@ -174,19 +193,38 @@ impl Store { .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 first = lines + .next() + .ok_or(RunnerError::StartMissing(run_id))?; + let head = + parse_record(first).map_err(|error| RunnerError::MalformedRecord { run_id, error })?; + let document = match head.state { + State::Start { uri, .. } => { + let stripped = uri + .strip_prefix("file://") + .unwrap_or(&uri); + PathBuf::from(stripped) + } + _ => return Err(RunnerError::StartMissing(run_id)), }; let mut completed = HashSet::new(); - for line in tablets { + for line in lines { let record = parse_record(line) - .map_err(|error| RunnerError::MalformedRecord { run: id, error })?; - completed.insert(record.path); + .map_err(|error| RunnerError::MalformedRecord { run_id, error })?; + match record.state { + State::Done(_) | State::Skip | State::Fail(_) => { + completed.insert(record.path); + } + State::Start { .. } + | State::Pause + | State::Resume + | State::Invoke(_) + | State::Execute { .. } + | State::Begin => {} + } } - Ok((manifest, completed, run_dir)) + Ok((document, completed, run_dir)) } // Scan the store for the highest existing run identifier and return @@ -235,19 +273,22 @@ pub(crate) 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. +/// Append-only writer for a PFFTT file. Used by the runner to append a +/// record for each step boundary and lifecycle event. Carries the +/// `RunId` so callers can stamp it onto records without plumbing it +/// through every layer. #[allow(dead_code)] pub struct Appender { file: std::fs::File, path: PathBuf, + run_id: RunId, } #[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 { + /// runner writes the opening `Start` record first via `Store::create`). + pub fn open(path: PathBuf, run_id: RunId) -> Result { let file = std::fs::OpenOptions::new() .append(true) .open(&path) @@ -255,11 +296,16 @@ impl Appender { path: path.clone(), error, })?; - Ok(Appender { file, path }) + Ok(Appender { file, path, run_id }) } - /// Append one Result tablet line. Flushes are left to the OS; on Quit - /// the runner drops the Appender, which closes the file. + /// The `RunId` this Appender is writing records for. + pub fn run_id(&self) -> RunId { + self.run_id + } + + /// Append one record 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); @@ -275,7 +321,7 @@ impl Appender { } // Locate the single `*.pfftt` file in a run directory. -fn find_pfftt_file(run_dir: &Path, id: RunId) -> Result { +fn find_pfftt_file(run_dir: &Path, run_id: RunId) -> Result { let entries = std::fs::read_dir(run_dir).map_err(|error| RunnerError::StoreError { path: run_dir.to_path_buf(), error, @@ -294,133 +340,198 @@ fn find_pfftt_file(run_dir: &Path, id: RunId) -> Result { 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(), - }) + Err(RunnerError::StartMissing(run_id)) } -// 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. +// Serialize a Record in PFFTT line form. The format is: +// Timestamp RunId Path (State Value) followed by a newline. #[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(), + let mut text = String::new(); + text.push_str(&record.recorded); + text.push(' '); + text.push_str( + &record + .run_id + .render(), ); - match &record.outcome { - Outcome::Done(Some(result)) => { - text.push_str(", result = "); - text.push_str(result); + text.push(' '); + text.push_str(&record.path); + text.push(' '); + format_state(&mut text, &record.state); + text.push('\n'); + text +} + +fn format_state(out: &mut String, state: &State) { + match state { + State::Start { uri } => { + out.push_str("Start "); + out.push_str(uri); } - Outcome::Failed(Some(reason)) => { - text.push_str(", reason = "); - text.push_str(reason); + State::Pause => out.push_str("Pause"), + State::Resume => out.push_str("Resume"), + State::Invoke(target) => { + out.push_str("Invoke "); + match target { + InvokeTarget::Procedure(name) => { + out.push_str(name); + out.push(':'); + } + InvokeTarget::Uri(uri) => out.push_str(uri), + } + } + State::Execute { function } => { + out.push_str("Execute "); + out.push_str(function); + out.push_str("()"); + } + State::Begin => out.push_str("Begin"), + State::Done(value) => { + out.push_str("Done"); + if let Some(v) = value { + out.push(' '); + format_value(out, v); + } + } + State::Skip => out.push_str("Skip"), + State::Fail(value) => { + out.push_str("Fail"); + if let Some(v) = value { + out.push(' '); + format_value(out, v); + } } - _ => {} } - 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. +fn format_value(out: &mut String, value: &Value) { + match value { + Value::Unit => out.push_str("()"), + Value::Tablet(text) => out.push_str(text), + } +} + +// Parse a single PFFTT record line into a Record. 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())), - }; + let line = line.trim_end_matches(['\r', '\n']); + let mut parts = line.splitn(4, ' '); + let recorded = parts + .next() + .ok_or(RecordError::MalformedRecord)?; + let run_text = parts + .next() + .ok_or(RecordError::MalformedRecord)?; + let path = parts + .next() + .ok_or(RecordError::MalformedRecord)?; + let rest = parts + .next() + .ok_or(RecordError::MalformedRecord)?; + if recorded.is_empty() || run_text.is_empty() || path.is_empty() || rest.is_empty() { + return Err(RecordError::MalformedRecord); + } + let run_id = run_text + .parse::() + .map(RunId) + .map_err(|_| RecordError::MalformedRecord)?; + let state = parse_state(rest)?; Ok(Record { recorded: recorded.to_string(), + run_id, path: path.to_string(), - outcome, + state, }) } -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 parse_state(text: &str) -> Result { + let (keyword, rest) = match text.split_once(' ') { + Some((k, r)) => (k, Some(r)), + None => (text, None), + }; + match keyword { + "Start" => { + let uri = rest.ok_or(RecordError::MalformedState)?; + if uri.is_empty() { + return Err(RecordError::MalformedState); + } + Ok(State::Start { + uri: uri.to_string(), + }) + } + "Pause" => { + if rest.is_some() { + return Err(RecordError::MalformedState); + } + Ok(State::Pause) + } + "Resume" => { + if rest.is_some() { + return Err(RecordError::MalformedState); + } + Ok(State::Resume) + } + "Invoke" => { + let payload = rest.ok_or(RecordError::MalformedState)?; + if payload.is_empty() { + return Err(RecordError::MalformedState); + } + if payload.starts_with("https://") || payload.starts_with("file:///") { + Ok(State::Invoke(InvokeTarget::Uri(payload.to_string()))) + } else if let Some(name) = payload.strip_suffix(':') { + if name.is_empty() { + return Err(RecordError::MalformedState); + } + Ok(State::Invoke(InvokeTarget::Procedure(name.to_string()))) + } else { + Err(RecordError::MalformedState) + } + } + "Execute" => { + let payload = rest.ok_or(RecordError::MalformedState)?; + let name = payload + .strip_suffix("()") + .ok_or(RecordError::MalformedState)?; + if name.is_empty() { + return Err(RecordError::MalformedState); + } + Ok(State::Execute { + function: name.to_string(), + }) + } + "Begin" => { + if rest.is_some() { + return Err(RecordError::MalformedState); + } + Ok(State::Begin) + } + "Done" => Ok(State::Done(parse_optional_value(rest)?)), + "Skip" => { + if rest.is_some() { + return Err(RecordError::MalformedState); + } + Ok(State::Skip) + } + "Fail" => Ok(State::Fail(parse_optional_value(rest)?)), + other => Err(RecordError::UnknownState(other.to_string())), } +} - fn optional(&self, name: &'static str) -> Option<&'a str> { - self.0 - .iter() - .find(|(k, _)| *k == name) - .map(|(_, v)| *v) +fn parse_optional_value(rest: Option<&str>) -> Result, RecordError> { + match rest { + None => Ok(None), + Some(text) => Ok(Some(parse_value(text)?)), } } -// 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())); +fn parse_value(text: &str) -> Result { + if text == "()" { + Ok(Value::Unit) + } else if text.starts_with('[') && text.ends_with(']') { + Ok(Value::Tablet(text.to_string())) + } else { + Err(RecordError::MalformedState) } - Ok(entries) } #[cfg(test)] diff --git a/src/translation/checks/errors.rs b/src/translation/checks/errors.rs index 3eb37415..0b5fc8dc 100644 --- a/src/translation/checks/errors.rs +++ b/src/translation/checks/errors.rs @@ -16,7 +16,7 @@ make_coffee : make_coffee : "# .trim_ascii(); - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let document = parsing::parse(path, source).expect("parse"); let errors = translate(&document).expect_err("translate should fail"); diff --git a/src/translation/checks/translate.rs b/src/translation/checks/translate.rs index 968cd70d..f855abd4 100644 --- a/src/translation/checks/translate.rs +++ b/src/translation/checks/translate.rs @@ -13,7 +13,7 @@ use crate::translation::translate; #[test] fn empty_input_yields_empty_program() { let source = ""; - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); assert!(program @@ -29,7 +29,7 @@ fn single_procedure_registered() { make_coffee : "# .trim_ascii(); - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); @@ -57,7 +57,7 @@ second : third : "# .trim_ascii(); - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate"); @@ -85,7 +85,7 @@ I. Section One inner : () -> () "# .trim_ascii(); - let path = Path::new("test.tq"); + let path = Path::new("Test.tq"); let document = parsing::parse(path, source).expect("parse"); let program = translate(&document).expect("translate");