From 9ae7ead9c9630f344c4ccf95cc4ca6f4ca21e5d1 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 4 Jun 2026 10:31:38 +1000 Subject: [PATCH 01/11] Create drivers with interactive and automatic modes --- .gitignore | 1 + src/main.rs | 21 +++- src/runner/checks/{prompt.rs => driver.rs} | 48 +++++---- src/runner/checks/runner.rs | 107 +++++++++++++++---- src/runner/{prompt.rs => driver.rs} | 116 +++++++++++++++------ src/runner/mod.rs | 20 +++- src/runner/runner.rs | 42 ++++---- 7 files changed, 261 insertions(+), 94 deletions(-) rename src/runner/checks/{prompt.rs => driver.rs} (75%) rename src/runner/{prompt.rs => driver.rs} (67%) diff --git a/.gitignore b/.gitignore index 0742404..4ee1607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.vscode +/.claude /target /plans diff --git a/src/main.rs b/src/main.rs index e31481f..362dfc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use technique::formatting::{self, Identity}; use technique::highlighting::{self, Terminal}; use technique::linking; use technique::parsing; -use technique::runner::{self, Library, Outcome, RunId}; +use technique::runner::{self, Library, Mode, Outcome, RunId}; use technique::templating::{self, Checklist, NasaEsaIss, Procedure, Recipe, Source}; use technique::translation; @@ -301,6 +301,15 @@ fn main() { .action(ArgAction::Set) .help("The kind of procedure this Technique document represents. By default the value specified in the input document's metadata will be used, falling back to source if unspecified."), ) + .arg( + Arg::new("mode") + .short('m') + .long("mode") + .value_parser(["interactive", "automatic"]) + .default_value("interactive") + .action(ArgAction::Set) + .help("Whether to walk the procedure interactively, prompting the operator at each step, or automatically, taking each step's computed value and running to completion or first failure."), + ) .arg( Arg::new("arguments") .num_args(0..) @@ -689,6 +698,14 @@ fn main() { debug!(?arguments); + let mode = match submatches + .get_one::("mode") + .map(String::as_str) + { + Some("automatic") => Mode::Automatic, + _ => Mode::Interactive, + }; + let filename = Path::new(filename); let content = match parsing::load(&filename) { Ok(data) => data, @@ -779,7 +796,7 @@ fn main() { std::process::exit(1); } - match runner::start(filename, &program, &arguments, library) { + match runner::start(mode, filename, &program, &arguments, library) { Ok((run_id, Outcome::Quit)) => { eprintln!("paused; resume with `technique resume {}`", run_id.render()); std::process::exit(0); diff --git a/src/runner/checks/prompt.rs b/src/runner/checks/driver.rs similarity index 75% rename from src/runner/checks/prompt.rs rename to src/runner/checks/driver.rs index 74907fc..bc03f13 100644 --- a/src/runner/checks/prompt.rs +++ b/src/runner/checks/driver.rs @@ -1,6 +1,6 @@ use std::io::Cursor; -use crate::runner::prompt::{Console, Event, Mock, Prompt, UserInput}; +use crate::runner::driver::{Console, Driver, Event, Mock, UserInput}; use crate::value::Value; #[test] @@ -10,16 +10,16 @@ fn mock_returns_canned_answers_in_order() { UserInput::Skip, UserInput::Quit, ]); - assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); - assert_eq!(p.ask(&[]), UserInput::Skip); - assert_eq!(p.ask(&[]), UserInput::Quit); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Skip); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); } #[test] fn mock_records_step_and_ask_events() { let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); p.step("local_network:I/1", "Check the cable."); - let _ = p.ask(&[]); + let _ = p.ask(&[], &Value::Unitus); assert_eq!( p.events(), &[ @@ -35,7 +35,7 @@ fn mock_records_step_and_ask_events() { #[test] fn mock_records_offered_choices() { let mut p = Mock::with_answers([UserInput::Done(Value::Literali("Yes".to_string()))]); - let _ = p.ask(&["Yes", "No"]); + let _ = p.ask(&["Yes", "No"], &Value::Unitus); assert_eq!( p.events(), &[Event::Ask { @@ -51,7 +51,7 @@ fn console_response_choices() { let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"2\n"), &mut output); assert_eq!( - p.ask(&["Yes", "No"]), + p.ask(&["Yes", "No"], &Value::Unitus), UserInput::Done(Value::Literali("No".to_string())) ); let written = String::from_utf8(output).expect("utf8"); @@ -61,14 +61,14 @@ fn console_response_choices() { // Skip / fail / quit stay available when choices are offered. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); - assert_eq!(p.ask(&["Yes", "No"]), UserInput::Skip); + assert_eq!(p.ask(&["Yes", "No"], &Value::Unitus), UserInput::Skip); // An out-of-range number and a bare `d` both re-prompt; the valid // pick that follows is accepted. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"9\nd\n1\n"), &mut output); assert_eq!( - p.ask(&["Yes", "No"]), + p.ask(&["Yes", "No"], &Value::Unitus), UserInput::Done(Value::Literali("Yes".to_string())) ); } @@ -94,36 +94,48 @@ fn mock_records_section_and_announce() { #[should_panic(expected = "Mock::ask called with no canned answers remaining")] fn mock_ask_without_answers_panics() { let mut p = Mock::new(); - let _ = p.ask(&[]); + let _ = p.ask(&[], &Value::Unitus); } #[test] fn console_input() { let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Skip); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Skip); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"f\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Fail); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Fail); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"q\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Quit); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); // Case-insensitive on the first character. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b"DONE\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); // Leading whitespace is tolerated. let mut output: Vec = Vec::new(); let mut p = Console::with_handles(Cursor::new(b" q\n"), &mut output); - assert_eq!(p.ask(&[]), UserInput::Quit); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); +} + +#[test] +fn console_done_accepts_presented_value() { + // Pressing done accepts the body's computed value, presented on the input + // line, as the step's value. + let mut output: Vec = Vec::new(); + let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); + assert_eq!( + p.ask(&[], &Value::Literali("probe output".to_string())), + UserInput::Done(Value::Literali("probe output".to_string())) + ); } #[test] @@ -131,7 +143,7 @@ fn console_unrecognized_input_reprompts() { let input = Cursor::new(b"x\nd\n"); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(&[]), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); // Two prompts written: one for the rejected `x`, one for the // accepted `d`. The prompt text contains "[d]one". let written = String::from_utf8(output).expect("utf8"); @@ -148,7 +160,7 @@ fn console_eof_returns_quit() { let input = Cursor::new(b""); let mut output: Vec = Vec::new(); let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(&[]), UserInput::Quit); + assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); } #[test] diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 1c21ca6..582aa2a 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -6,9 +6,9 @@ use crate::parsing; use crate::program::{ Executable, ExecutableRef, Fragment, Operation, Ordinal, Program, Subroutine, }; +use crate::runner::driver::{Automatic, Event, Mock, UserInput}; use crate::runner::evaluator::Environment; use crate::runner::library::Library; -use crate::runner::prompt::{Event, Mock, UserInput}; use crate::runner::runner::{bind_parameters, Outcome, Runner, RunnerError}; use crate::runner::state::{parse_record, Appender, State, Store, Value as RecordValue}; use crate::translation::translate; @@ -230,7 +230,7 @@ fn two_steps_prompted_in_source_order() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt .events() .iter() @@ -272,7 +272,7 @@ fn pre_completed_step_short_circuits() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt .events() .iter() @@ -310,7 +310,7 @@ fn quit_propagates_and_stops_walking() { .expect("run"); assert_eq!(outcome, Outcome::Quit); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt .events() .iter() @@ -367,7 +367,7 @@ fn section_walking() { runner .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let events = prompt.events(); let section_fqns: Vec<&str> = events .iter() @@ -414,7 +414,7 @@ fn section_walking() { runner .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let section_title = prompt .events() .iter() @@ -454,7 +454,7 @@ fn parallel_step_index_starts_at_one() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt .events() .iter() @@ -500,7 +500,7 @@ test : .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let descriptions: Vec<&str> = prompt .events() .iter() @@ -550,7 +550,7 @@ helper : .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt .events() .iter() @@ -601,7 +601,7 @@ greet(name) : .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<(&str, &str)> = prompt .events() .iter() @@ -718,7 +718,7 @@ test : .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let announcements: Vec<&str> = prompt .events() .iter() @@ -825,7 +825,7 @@ fn repeat_loops_until_quit() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<&str> = prompt .events() .iter() @@ -895,7 +895,7 @@ fn foreach_walks_body_once_per_list_element() { // The body is walked once per element. Each Step event carries an // `[n]` iteration segment in its path and the description it saw, // confirming the iteration variable was bound to that element. - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<(&str, &str)> = prompt .events() .iter() @@ -969,7 +969,7 @@ fn foreach_over_seq_builtin_runs() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<(&str, &str)> = prompt .events() .iter() @@ -1052,7 +1052,7 @@ fn foreach_destructures_tuple_elements() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<&str> = prompt .events() .iter() @@ -1109,7 +1109,7 @@ fn foreach_widens_primitive_to_singleton() { .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let steps: Vec<(&str, &str)> = prompt .events() .iter() @@ -1304,7 +1304,7 @@ greet(name) : .run(env) .expect("run"); - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let descriptions: Vec<&str> = prompt .events() .iter() @@ -1348,7 +1348,7 @@ test : .expect("run"); // The prompt offered the two declared responses as choices. - let prompt = runner.into_prompt(); + let prompt = runner.into_driver(); let asked: Vec<&Vec> = prompt .events() .iter() @@ -1378,3 +1378,74 @@ test : State::Done(Some(RecordValue::Literal("Yes".to_string()))) ); } + +#[test] +fn automatic_driver_records_body_value() { + // A value-bearing body under the automatic driver: no operator, no canned + // answers; the step's outcome is the body's computed value, recorded. + let mut fixture = StoreFixture::new("automatic-records-value"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::String(vec![Fragment::Text("probe output")]), + )]); + let program = anonymous_with_body(body); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + let env = Environment::new(); + let outcome = runner + .run(env) + .expect("run"); + assert_eq!( + outcome, + Outcome::Done(Value::Literali("probe output".to_string())) + ); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!( + record.state, + State::Done(Some(RecordValue::Literal("probe output".to_string()))) + ); + + // A pure-prose step (empty body) records () — nothing was computed. + let mut fixture = StoreFixture::new("automatic-empty-body"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::Sequence(vec![]), + )]); + let program = anonymous_with_body(body); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + let env = Environment::new(); + runner + .run(env) + .expect("run"); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); +} diff --git a/src/runner/prompt.rs b/src/runner/driver.rs similarity index 67% rename from src/runner/prompt.rs rename to src/runner/driver.rs index ef0d594..8282b24 100644 --- a/src/runner/prompt.rs +++ b/src/runner/driver.rs @@ -1,18 +1,26 @@ -//! Prompt trait, console implementation, and test mock. +//! Driver trait, console and automatic implementations, and test mock. //! -//! The walker tells the prompt what to show (`step`, `section`, -//! `announce`) and then asks for the operator's verdict (`ask`). The -//! split lets a console implementation render output to stdout, then -//! read a line from stdin, while a test mock simply records what the -//! walker tried to show and returns canned responses. +//! A driver is what walks the operator (or no one) through a run; the +//! run's `Mode` selects which. The walker tells the driver what to show +//! (`step`, `section`, `announce`) and then asks for the step's outcome +//! (`ask`). `Console` renders to stdout and reads the operator's verdict +//! from stdin; `Automatic` takes the body's computed value with no human; +//! `Mock` records what the walker tried to show and returns canned answers. use std::io::{self, BufRead, Write}; use crate::value::Value; +/// Which driver walks a run: `Interactive` prompts the user, `Automatic` runs +/// to completion, taking each step's body value as the result. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Mode { + Interactive, + Automatic, +} + /// The person executing each step indicates a verdict on each prompt as /// follows: -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum UserInput { Done(Value), @@ -21,11 +29,9 @@ pub enum UserInput { Quit, } -/// What the walker uses to talk to the user executing the Technique. The two -/// initial implementations are for an interactive console, `Console`, and for -/// test cases, `Mock`. -#[allow(dead_code)] -pub trait Prompt { +/// What the walker uses to drive a run. Implementations are the interactive +/// console `Console`, the no-operator `Automatic`, and the test `Mock`. +pub trait Driver { /// Show the step's Qualified Name and rendered description. /// The implementation displays them; it does not block waiting for /// input — the walker calls `ask` for that separately. @@ -38,25 +44,24 @@ pub trait Prompt { /// Execute / Unresolved Invoke announce-only, resume diagnostics. fn announce(&mut self, message: &str); - /// Block until the operator answers the most recent `step` prompt. - /// When `choices` is non-empty the operator selects one of those - /// response values, yielding `Done(Literali(choice))`; an empty slice - /// presents the plain done/skip/fail/quit verdict, yielding - /// `Done(Unitus)`. Skip, fail, and quit remain available either way. - fn ask(&mut self, choices: &[&str]) -> UserInput; + /// Answer the most recent `step` prompt. `produced` is the value the + /// step body computed, offered as the step's value: `Console` presents it + /// for the operator to accept, `Automatic` takes it directly. When + /// `choices` is non-empty the operator instead selects one of those + /// response values, yielding `Done(Literali(choice))`. Skip, fail, and + /// quit are available either way. + fn ask(&mut self, choices: &[&str], produced: &Value) -> UserInput; } /// Interactive console prompt. Writes to stdout, reads line-buffered /// stdin. `d` → Done, `s` → Skip, `f` → Fail, `q` → Quit; matched /// case-insensitively on the first non-whitespace character. Anything /// else re-prompts. -#[allow(dead_code)] pub struct Console { input: R, output: W, } -#[allow(dead_code)] impl Console, io::Stdout> { /// Default console wired to process stdin (line-buffered) and /// stdout. @@ -68,17 +73,17 @@ impl Console, io::Stdout> { } } -#[allow(dead_code)] +#[cfg(test)] impl Console { /// Construct a console over arbitrary input and output handles. - /// Useful for end-to-end tests that exercise the actual rendering - /// and parsing paths without needing a TTY. + /// Useful for tests that exercise the actual rendering and parsing + /// paths without needing a TTY. pub fn with_handles(input: R, output: W) -> Self { Console { input, output } } } -impl Prompt for Console { +impl Driver for Console { fn step(&mut self, fqn: &str, description: &str) { let _ = writeln!(self.output, " {}", fqn); let _ = writeln!(self.output, "{}", description); @@ -96,7 +101,7 @@ impl Prompt for Console { let _ = writeln!(self.output, "{}", message); } - fn ask(&mut self, choices: &[&str]) -> UserInput { + fn ask(&mut self, choices: &[&str], produced: &Value) -> UserInput { loop { if choices.is_empty() { let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); @@ -139,7 +144,7 @@ impl Prompt for Console { .next() .map(|c| c.to_ascii_lowercase()) { - Some('d') if choices.is_empty() => return UserInput::Done(Value::Unitus), + Some('d') if choices.is_empty() => return UserInput::Done(produced.clone()), Some('s') => return UserInput::Skip, Some('f') => return UserInput::Fail, Some('q') => return UserInput::Quit, @@ -149,10 +154,56 @@ impl Prompt for Console { } } +/// No-operator driver: writes a trace of each step to its output and takes +/// the body's computed value as the step's outcome, running to completion +/// or first failure. A pure-prose step (empty body value) records (). +pub struct Automatic { + output: W, +} + +impl Automatic { + pub fn new() -> Self { + Automatic { + output: io::stdout(), + } + } +} + +#[cfg(test)] +impl Automatic { + pub fn with_handle(output: W) -> Self { + Automatic { output } + } +} + +impl Driver for Automatic { + fn step(&mut self, fqn: &str, description: &str) { + let _ = writeln!(self.output, " {}", fqn); + if !description.is_empty() { + let _ = writeln!(self.output, "{}", description); + } + } + + fn section(&mut self, qualified: &str, title: &str) { + let _ = writeln!(self.output, "=== {} ===", qualified); + if !title.is_empty() { + let _ = writeln!(self.output, "{}", title); + } + } + + fn announce(&mut self, message: &str) { + let _ = writeln!(self.output, "{}", message); + } + + fn ask(&mut self, _choices: &[&str], produced: &Value) -> UserInput { + UserInput::Done(produced.clone()) + } +} + /// Simulated prompt responses for test cases. Returns answers from a /// pre-loaded queue and records every announcement / step / section call so a /// test can assert what the walker tried to show. -#[allow(dead_code)] +#[cfg(test)] #[derive(Debug, Default)] pub struct Mock { answers: std::collections::VecDeque, @@ -161,7 +212,7 @@ pub struct Mock { /// One thing the walker showed (or attempted to show). Tests use this /// to inspect ordering and content of the walker's user-facing output. -#[allow(dead_code)] +#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub enum Event { Step { @@ -178,7 +229,7 @@ pub enum Event { }, } -#[allow(dead_code)] +#[cfg(test)] impl Mock { /// Construct a Mock with no canned answers — useful for tests that /// only inspect announcements and never reach an `ask` call. @@ -204,7 +255,8 @@ impl Mock { } } -impl Prompt for Mock { +#[cfg(test)] +impl Driver for Mock { fn step(&mut self, fqn: &str, description: &str) { self.events .push(Event::Step { @@ -226,7 +278,7 @@ impl Prompt for Mock { .push(Event::Announce(message.to_string())); } - fn ask(&mut self, choices: &[&str]) -> UserInput { + fn ask(&mut self, choices: &[&str], _produced: &Value) -> UserInput { self.events .push(Event::Ask { choices: choices @@ -241,5 +293,5 @@ impl Prompt for Mock { } #[cfg(test)] -#[path = "checks/prompt.rs"] +#[path = "checks/driver.rs"] mod check; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 40e86b3..31580e5 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -8,20 +8,21 @@ use std::path::{Path, PathBuf}; use crate::program::Program; mod context; +mod driver; mod evaluator; mod library; mod path; -mod prompt; mod runner; mod state; pub use context::Context; +pub use driver::Mode; pub use library::{Builtin, Library, Native}; pub use runner::{Outcome, RunnerError}; pub use state::{RecordError, RunId}; +use driver::{Automatic, Console}; use evaluator::Environment; -use prompt::Console; use runner::{bind_parameters, now_iso8601, Runner}; use state::{construct_state_path, Appender, Record, State, Store}; @@ -32,6 +33,7 @@ const STORE_ROOT: &str = ".store"; /// or quitting. Command-line arguments are bound to the entry procedure's /// parameters before the beginning the walk. pub fn start<'i>( + mode: Mode, document: &Path, program: &'i Program<'i>, arguments: &[String], @@ -42,8 +44,18 @@ pub fn start<'i>( let (run_id, run_dir) = store.create(document, now_iso8601())?; let pfftt = construct_state_path(&run_dir, document); let appender = Appender::open(pfftt, run_id)?; - let mut runner = Runner::new(program, appender, HashSet::new(), Console::new(), library); - let outcome = runner.run(env)?; + let outcome = match mode { + Mode::Interactive => { + let mut runner = + Runner::new(program, appender, HashSet::new(), Console::new(), library); + runner.run(env)? + } + Mode::Automatic => { + let mut runner = + Runner::new(program, appender, HashSet::new(), Automatic::new(), library); + runner.run(env)? + } + }; Ok((run_id, outcome)) } diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 293d9d9..33f3345 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -5,10 +5,10 @@ use std::io; use std::path::PathBuf; use super::context::Context; +use super::driver::{Driver, UserInput}; use super::evaluator::Environment; use super::library::Library; use super::path::{PathSegment, QualifiedPath}; -use super::prompt::{Prompt, UserInput}; use super::state::{ Appender, InvokeTarget, Record, RecordError, RunId, State, Value as RecordValue, }; @@ -94,40 +94,40 @@ pub enum RunnerError { /// already-completed step FQNs, an append handle to write results, and the /// prompt the operator interacts through. #[allow(dead_code)] -pub struct Runner<'i, P: Prompt> { +pub struct Runner<'i, D: Driver> { program: &'i Program<'i>, appender: Appender, completed: HashSet, - prompt: P, + driver: D, path: QualifiedPath<'i>, library: Library, context: Context, } -impl<'i, P: Prompt> Runner<'i, P> { +impl<'i, D: Driver> Runner<'i, D> { pub fn new( program: &'i Program<'i>, appender: Appender, completed: HashSet, - prompt: P, + driver: D, library: Library, ) -> Self { Runner { program, appender, completed, - prompt, + driver, path: QualifiedPath::new(), library, context: Context::native(), } } - /// Consume the runner and return the inner prompt. Tests use this + /// Consume the runner and return the inner driver. Tests use this /// to assert on the Mock's event log after a run completes. - #[allow(dead_code)] - pub fn into_prompt(self) -> P { - self.prompt + #[cfg(test)] + pub fn into_driver(self) -> D { + self.driver } /// Walk the entry procedure top to bottom. Entry-procedure @@ -198,7 +198,7 @@ impl<'i, P: Prompt> Runner<'i, P> { }; self.appender .append(&record)?; - self.prompt + self.driver .announce(&describe_execute(&function)); // Linking resolves every Execute against the library, so a // target still Unresolved here means a resume-time runtime @@ -310,7 +310,7 @@ impl<'i, P: Prompt> Runner<'i, P> { result } SubroutineRef::Unresolved(id) => { - self.prompt + self.driver .announce(&format!("<{}>", id.value)); Ok(Outcome::Done(Value::Unitus)) } @@ -333,7 +333,7 @@ impl<'i, P: Prompt> Runner<'i, P> { over: Option<&'i Operation<'i>>, body: &'i Operation<'i>, ) -> Result { - self.prompt + self.driver .announce(&describe_loop(names, over)); match over { None => { @@ -444,7 +444,7 @@ impl<'i, P: Prompt> Runner<'i, P> { }, None => String::new(), }; - self.prompt + self.driver .section(&qualified, &title_text); self.walk(env, body) } @@ -522,9 +522,11 @@ impl<'i, P: Prompt> Runner<'i, P> { self.appender .append(&begin)?; - if let Outcome::Quit = self.walk(env, body)? { - return Ok(Outcome::Quit); - } + let produced = match self.walk(env, body)? { + Outcome::Quit => return Ok(Outcome::Quit), + Outcome::Done(value) => value, + Outcome::Skipped | Outcome::Failed(_) => Value::Unitus, + }; let mut description_text = String::new(); for op in description { @@ -537,7 +539,7 @@ impl<'i, P: Prompt> Runner<'i, P> { } } - self.prompt + self.driver .step(qualified, &description_text); let choices: Vec<&str> = responses @@ -545,8 +547,8 @@ impl<'i, P: Prompt> Runner<'i, P> { .map(|r| r.value) .collect(); let outcome = outcome_from( - self.prompt - .ask(&choices), + self.driver + .ask(&choices, &produced), ); if let Outcome::Quit = outcome { return Ok(Outcome::Quit); From db4c8b706d3116f54caebf7c1e3574af3ffb1c62 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 4 Jun 2026 11:17:14 +1000 Subject: [PATCH 02/11] Add crossterm dependency --- Cargo.lock | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + 2 files changed, 251 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1baf9c4..0ced17e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -69,9 +69,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "bstr" @@ -157,6 +157,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.12.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "deranged" version = "0.5.8" @@ -173,7 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -238,17 +263,32 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "lsp-server" @@ -291,13 +331,25 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -324,6 +376,29 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -354,6 +429,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + [[package]] name = "regex" version = "1.12.3" @@ -383,17 +467,30 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -405,6 +502,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -468,6 +571,37 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -496,6 +630,7 @@ name = "technique" version = "0.5.7" dependencies = [ "clap", + "crossterm", "ignore", "lsp-server", "lsp-types", @@ -515,8 +650,8 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -658,21 +793,58 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -682,6 +854,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0e170f9..90b7cb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" [dependencies] clap = { version = "4.5.16", features = [ "wrap_help" ] } +crossterm = "0.28" ignore = "0.4" lsp-server = "0.7.9" lsp-types = "0.97" From b3aab7331884c683161d96f8f908c34c656ee8c2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Thu, 4 Jun 2026 18:06:01 +1000 Subject: [PATCH 03/11] Interactive input using raw mode --- src/problem/messages.rs | 9 + src/runner/checks/driver.rs | 290 ++++++++++++++++--------- src/runner/checks/state.rs | 27 +++ src/runner/driver.rs | 420 ++++++++++++++++++++++++++++++------ src/runner/mod.rs | 7 + src/runner/runner.rs | 19 +- src/runner/state.rs | 41 +++- 7 files changed, 636 insertions(+), 177 deletions(-) diff --git a/src/problem/messages.rs b/src/problem/messages.rs index c549613..e1397bf 100644 --- a/src/problem/messages.rs +++ b/src/problem/messages.rs @@ -1242,6 +1242,15 @@ you can iterate over. format!("Cannot combine {} with {}", left, right), format!("Combining Values requires compatible kinds; a {} and a {} can't be added together.", left, right), ), + RunnerError::TerminalRequired => ( + "Running interactively requires a terminal".to_string(), + r#" +An interactive run writes its prompts to the terminal and reads user input +direclty, so its output can't be redirected to a file or pipe. Use `technique +run` in a terminal, or use `--mode=automatic` to run on auto; you can then +safely redirect the output. + "#.trim_ascii().to_string(), + ), RunnerError::UserQuit => ( "Interrupted".to_string(), "The user quit before the procedure was completed. Use `technique resume ` to continue.".to_string(), diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index bc03f13..661b333 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -1,6 +1,6 @@ -use std::io::Cursor; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::runner::driver::{Console, Driver, Event, Mock, UserInput}; +use crate::runner::driver::{draw, Console, Driver, Event, Interaction, Mock, UserInput}; use crate::value::Value; #[test] @@ -10,16 +10,16 @@ fn mock_returns_canned_answers_in_order() { UserInput::Skip, UserInput::Quit, ]); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Skip); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); + assert_eq!(p.ask(&[], Value::Unitus), UserInput::Done(Value::Unitus)); + assert_eq!(p.ask(&[], Value::Unitus), UserInput::Skip); + assert_eq!(p.ask(&[], Value::Unitus), UserInput::Quit); } #[test] fn mock_records_step_and_ask_events() { let mut p = Mock::with_answers([UserInput::Done(Value::Unitus)]); p.step("local_network:I/1", "Check the cable."); - let _ = p.ask(&[], &Value::Unitus); + let _ = p.ask(&[], Value::Unitus); assert_eq!( p.events(), &[ @@ -35,7 +35,7 @@ fn mock_records_step_and_ask_events() { #[test] fn mock_records_offered_choices() { let mut p = Mock::with_answers([UserInput::Done(Value::Literali("Yes".to_string()))]); - let _ = p.ask(&["Yes", "No"], &Value::Unitus); + let _ = p.ask(&["Yes", "No"], Value::Unitus); assert_eq!( p.events(), &[Event::Ask { @@ -44,35 +44,6 @@ fn mock_records_offered_choices() { ); } -#[test] -fn console_response_choices() { - // A numbered selection returns the chosen response value, and the - // choices are listed in the output. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"2\n"), &mut output); - assert_eq!( - p.ask(&["Yes", "No"], &Value::Unitus), - UserInput::Done(Value::Literali("No".to_string())) - ); - let written = String::from_utf8(output).expect("utf8"); - assert!(written.contains("1) Yes")); - assert!(written.contains("2) No")); - - // Skip / fail / quit stay available when choices are offered. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); - assert_eq!(p.ask(&["Yes", "No"], &Value::Unitus), UserInput::Skip); - - // An out-of-range number and a bare `d` both re-prompt; the valid - // pick that follows is accepted. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"9\nd\n1\n"), &mut output); - assert_eq!( - p.ask(&["Yes", "No"], &Value::Unitus), - UserInput::Done(Value::Literali("Yes".to_string())) - ); -} - #[test] fn mock_records_section_and_announce() { let mut p = Mock::new(); @@ -94,93 +65,218 @@ fn mock_records_section_and_announce() { #[should_panic(expected = "Mock::ask called with no canned answers remaining")] fn mock_ask_without_answers_panics() { let mut p = Mock::new(); - let _ = p.ask(&[], &Value::Unitus); + let _ = p.ask(&[], Value::Unitus); } #[test] -fn console_input() { +fn console_step_writes_fqn_and_description() { let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); + let mut p = Console::with_output(&mut output); + p.step("local_network:I/1", "Check the cable."); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("local_network:I/1")); + assert!(written.contains("Check the cable.")); +} +#[test] +fn console_section_writes_fqn_and_title() { let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"s\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Skip); + let mut p = Console::with_output(&mut output); + p.section("I", "Setup"); + let written = String::from_utf8(output).expect("utf8"); + assert!(written.contains("I")); + assert!(written.contains("Setup")); +} - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"f\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Fail); +#[test] +fn edit_unedited_enter_preserves_produced() { + // An untouched Unitus stays Unitus, not Literali(""). + let mut it = Interaction::begin(&[], Value::Unitus); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Unitus)) + ); +} - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"q\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); +#[test] +fn edit_typed_enter_returns_literali() { + let mut it = Interaction::begin(&[], Value::Unitus); + for c in "eth0".chars() { + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)), + None + ); + } + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("eth0".to_string()))) + ); +} - // Case-insensitive on the first character. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"DONE\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); +#[test] +fn edit_backspace_trims_candidate() { + // The Literali candidate starts with the cursor at the end. + let mut it = Interaction::begin(&[], Value::Literali("abc".to_string())); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("ab".to_string()))) + ); +} - // Leading whitespace is tolerated. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b" q\n"), &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); +#[test] +fn esc_menu_navigates_skip_fail_quit() { + let mut it = Interaction::begin(&[], Value::Unitus); + // Esc opens the menu on the first item, skip. + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); + + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Fail) + ); + + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + // Right past the end clamps on quit. + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Quit) + ); } #[test] -fn console_done_accepts_presented_value() { - // Pressing done accepts the body's computed value, presented on the input - // line, as the step's value. - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(Cursor::new(b"d\n"), &mut output); +fn esc_menu_backs_out_to_field() { + let mut it = Interaction::begin(&[], Value::Literali("x".to_string())); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + None + ); assert_eq!( - p.ask(&[], &Value::Literali("probe output".to_string())), - UserInput::Done(Value::Literali("probe output".to_string())) + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("x".to_string()))) ); } #[test] -fn console_unrecognized_input_reprompts() { - let input = Cursor::new(b"x\nd\n"); - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Done(Value::Unitus)); - // Two prompts written: one for the rejected `x`, one for the - // accepted `d`. The prompt text contains "[d]one". - let written = String::from_utf8(output).expect("utf8"); - assert!( - written - .matches("[d]one") - .count() - >= 2 +fn choices_navigate_and_accept() { + let mut it = Interaction::begin(&["Yes", "No"], Value::Unitus); + // First choice is the default. + let mut first = Interaction::begin(&["Yes", "No"], Value::Unitus); + assert_eq!( + first.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("Yes".to_string()))) + ); + // Right moves to the second. + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("No".to_string()))) ); } #[test] -fn console_eof_returns_quit() { - let input = Cursor::new(b""); - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); - assert_eq!(p.ask(&[], &Value::Unitus), UserInput::Quit); +fn choices_esc_opens_menu() { + let mut it = Interaction::begin(&["Yes", "No"], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); } #[test] -fn console_step_writes_fqn_and_description() { - let input = Cursor::new(b""); - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); - p.step("local_network:I/1", "Check the cable."); - let written = String::from_utf8(output).expect("utf8"); - assert!(written.contains("local_network:I/1")); - assert!(written.contains("Check the cable.")); +fn complex_value_is_read_only() { + let tablet = Value::Tabularum(vec![( + "name".to_string(), + Value::Literali("eth0".to_string()), + )]); + let mut it = Interaction::begin(&[], tablet.clone()); + // Typing into a frozen value does nothing; Enter accepts it intact. + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(tablet)) + ); } #[test] -fn console_section_writes_fqn_and_title() { - let input = Cursor::new(b""); - let mut output: Vec = Vec::new(); - let mut p = Console::with_handles(input, &mut output); - p.section("I", "Setup"); - let written = String::from_utf8(output).expect("utf8"); - assert!(written.contains("I")); - assert!(written.contains("Setup")); +fn multiline_scalar_is_read_only() { + // A step whose body computed multi-line text (e.g. captured exec output) + // is not editable inline; it is accepted intact, like a complex value. + let dump = Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()); + let mut it = Interaction::begin(&[], dump.clone()); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(dump)) + ); +} + +#[test] +fn render_frozen_shows_only_affordances() { + // A read-only value (already streamed above) is not re-echoed on the + // prompt line; the line carries no raw newline and only the key options. + let dump = Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()); + let it = Interaction::begin(&[], dump); + let mut out: Vec = Vec::new(); + draw(&mut out, &it).expect("draw"); + let written = String::from_utf8(out).expect("utf8"); + assert!(!written.contains('\n')); + assert!(!written.contains("eth0")); + assert!(written.contains("[enter]")); + assert!(written.contains("[esc]")); +} + +#[test] +fn ctrl_c_quits_from_any_field() { + let mut it = Interaction::begin(&[], Value::Unitus); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Some(UserInput::Quit) + ); +} + +#[test] +fn render_edit_shows_candidate_text() { + let it = Interaction::begin(&[], Value::Literali("hello".to_string())); + let mut out: Vec = Vec::new(); + draw(&mut out, &it).expect("draw"); + let written = String::from_utf8(out).expect("utf8"); + assert!(written.contains("hello")); +} + +#[test] +fn render_choices_lists_options() { + let it = Interaction::begin(&["Yes", "No"], Value::Unitus); + let mut out: Vec = Vec::new(); + draw(&mut out, &it).expect("draw"); + let written = String::from_utf8(out).expect("utf8"); + assert!(written.contains("Yes")); + assert!(written.contains("No")); } diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index ed98565..7c13b15 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -502,6 +502,14 @@ fn record_round_trips_through_format_and_parse() { path: "/a:7".to_string(), state: State::Done(Some(Value::Literal("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( + "1: lo\n inet 127.0.0.1/8\na quote \" and a slash \\".to_string(), + ))), + }, Record { recorded: "2026-05-14T12:00:03Z".to_string(), run_id: RunId(1), @@ -533,6 +541,25 @@ fn record_round_trips_through_format_and_parse() { } } +#[test] +fn multiline_literal_stays_on_one_record_line() { + // A multi-line value (e.g. captured exec output) must serialize to a + // single record line so the line-oriented store can read it back. + let record = Record { + 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()))), + }; + let text = format_record(&record); + assert_eq!( + text.matches('\n') + .count(), + 1 + ); + assert!(text.contains("\\n")); +} + #[test] fn parse_record_rejects_unknown_state() { let line = "2026-05-14T12:00:00Z 000001 /x:1 Maybe"; diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 8282b24..c6faeb1 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -1,13 +1,21 @@ -//! Driver trait, console and automatic implementations, and test mock. +//! Driver trait, console and automatic implementations, and a test mock. //! -//! A driver is what walks the operator (or no one) through a run; the -//! run's `Mode` selects which. The walker tells the driver what to show -//! (`step`, `section`, `announce`) and then asks for the step's outcome -//! (`ask`). `Console` renders to stdout and reads the operator's verdict -//! from stdin; `Automatic` takes the body's computed value with no human; -//! `Mock` records what the walker tried to show and returns canned answers. +//! A driver is what walks through a run; the run's `Mode` selects whether +//! this is a human user or a program running non-interactively. -use std::io::{self, BufRead, Write}; +//! The walker tells the driver what to show and then asks for the step's +//! outcome. `Console` drives a raw-mode terminal UI, presenting a step or +//! scope's returned Value as an editable candidate and reading the operator's +//! keystrokes; `Automatic` takes the body's returned Value with no human +//! intervention; `Mock` is for testing, recording what the walker tried to +//! show and returning canned answers. + +use std::io::{self, Write}; + +use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::style::{Attribute, SetAttribute}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; +use crossterm::{cursor, queue}; use crate::value::Value; @@ -49,41 +57,37 @@ pub trait Driver { /// for the operator to accept, `Automatic` takes it directly. When /// `choices` is non-empty the operator instead selects one of those /// response values, yielding `Done(Literali(choice))`. Skip, fail, and - /// quit are available either way. - fn ask(&mut self, choices: &[&str], produced: &Value) -> UserInput; + /// quit are available either way. `produced` is consumed: the driver + /// either moves it into the returned `Done` or discards it. + fn ask(&mut self, choices: &[&str], produced: Value) -> UserInput; } -/// Interactive console prompt. Writes to stdout, reads line-buffered -/// stdin. `d` → Done, `s` → Skip, `f` → Fail, `q` → Quit; matched -/// case-insensitively on the first non-whitespace character. Anything -/// else re-prompts. -pub struct Console { - input: R, +/// Interactive console prompt in a terminal. `step` / `section` / `announce` +/// print in "cooked mode" (to use the old terminfo slang term for it); `ask` +/// switches to "raw mode" to read keystrokes, presenting the step's value as +/// an editable candidate (if a scalar) or a read-only display (a complex +/// value) and offering an `` menu for Skip / Fail / Quit. The keystroke +/// logic lives in `Interaction`; this is the terminal shell around it. +pub struct Console { output: W, } -impl Console, io::Stdout> { - /// Default console wired to process stdin (line-buffered) and - /// stdout. +impl Console { pub fn new() -> Self { Console { - input: io::BufReader::new(io::stdin()), output: io::stdout(), } } } #[cfg(test)] -impl Console { - /// Construct a console over arbitrary input and output handles. - /// Useful for tests that exercise the actual rendering and parsing - /// paths without needing a TTY. - pub fn with_handles(input: R, output: W) -> Self { - Console { input, output } +impl Console { + pub fn with_output(output: W) -> Self { + Console { output } } } -impl Driver for Console { +impl Driver for Console { fn step(&mut self, fqn: &str, description: &str) { let _ = writeln!(self.output, " {}", fqn); let _ = writeln!(self.output, "{}", description); @@ -101,59 +105,333 @@ impl Driver for Console { let _ = writeln!(self.output, "{}", message); } - fn ask(&mut self, choices: &[&str], produced: &Value) -> UserInput { - loop { - if choices.is_empty() { - let _ = write!(self.output, "[d]one / [s]kip / [f]ail / [q]uit ? "); - } else { - for (i, choice) in choices + fn ask(&mut self, choices: &[&str], produced: Value) -> UserInput { + let mut interaction = Interaction::begin(choices, produced); + // The interactive path is guarded on stdout being a terminal before + // the walk begins, so a raw-mode failure here is an unexpected + // terminal fault rather than a redirect; bail by quitting. + if enable_raw_mode().is_err() { + let _ = writeln!(self.output, "(could not enter raw mode)"); + return UserInput::Quit; + } + let result = loop { + if draw(&mut self.output, &interaction).is_err() { + break UserInput::Quit; + } + match event::read() { + Ok(event::Event::Key(key)) if key.kind != KeyEventKind::Release => { + if let Some(input) = interaction.handle(key) { + break input; + } + } + Ok(_) => {} + Err(_) => break UserInput::Quit, + } + }; + let _ = disable_raw_mode(); + let _ = writeln!(self.output); + result + } +} + +/// Esc-menu options, in navigation order. +const MENU: [&str; 3] = ["skip", "fail", "quit"]; + +/// Prompt prefix shown before an editable / read-only candidate, and its +/// width in terminal columns (for placing the edit cursor). Later we will +/// toggle between ▶ and ■ +const PROMPT_TEXT: &str = "▶ "; +const PROMPT_WIDTH: u16 = 2; + +/// Longest scalar offered as an inline-editable candidate. A longer (or +/// multi-line) value can't be edited on one line, so it falls back to the +/// read-only display. +const INLINE_MAX: usize = 78; + +/// The candidate a step prompt presents: an editable scalar, a read-only +/// complex value, or a response selection. +enum Field { + Edit { + buffer: String, + cursor: usize, + edited: bool, + produced: Value, + }, + Frozen { + produced: Value, + }, + Choose { + choices: Vec, + active: usize, + }, +} + +/// Our state machine behind the "raw-mode" Console. `handle` +/// folds one key into the state, returning `Some(UserInput)` once the +/// operator has settled on an outcome. +struct Interaction { + field: Field, + menu: Option, +} + +impl Interaction { + /// Seed an interaction with the supplied choices and a Value. There is + /// some complex UI logic encoded here: + /// + /// - a non-empty `choices` array indicates we want to do selection + /// between these choices; + /// - a scalar Value becomes an editable buffer; and + /// - a complex value a read-only display. + fn begin(choices: &[&str], produced: Value) -> Self { + let field = if choices.is_empty() { + match produced { + Value::Unitus => edit(String::new(), Value::Unitus), + quantity @ Value::Quanticle(_) => { + let buffer = quantity.to_string(); + edit(buffer, quantity) + } + Value::Literali(text) if is_inline(&text) => { + edit(text.clone(), Value::Literali(text)) + } + other => Field::Frozen { produced: other }, + } + } else { + Field::Choose { + choices: choices .iter() - .enumerate() - { - let _ = writeln!(self.output, " {}) {}", i + 1, choice); + .map(|c| c.to_string()) + .collect(), + active: 0, + } + }; + Interaction { field, menu: None } + } + + fn handle(&mut self, key: KeyEvent) -> Option { + if key + .modifiers + .contains(KeyModifiers::CONTROL) + { + if let KeyCode::Char('c') = key.code { + // Believe it or not we actually have to handle + + // explicitly when in raw mode! + return Some(UserInput::Quit); + } + } + if self + .menu + .is_some() + { + self.menu_key(key.code) + } else { + self.field_key(key.code) + } + } + + fn menu_key(&mut self, code: KeyCode) -> Option { + let active = self + .menu + .as_mut()?; + match code { + KeyCode::Left => { + if *active > 0 { + *active -= 1; } - let _ = write!( - self.output, - "[1-{}] / [s]kip / [f]ail / [q]uit ? ", - choices.len() - ); + None } - let _ = self - .output - .flush(); - let mut line = String::new(); - match self - .input - .read_line(&mut line) - { - Ok(0) => return UserInput::Quit, - Ok(_) => {} - Err(_) => return UserInput::Quit, + KeyCode::Right => { + if *active + 1 < MENU.len() { + *active += 1; + } + None } - let trimmed = line.trim(); - // A numbered selection picks the corresponding response value. - if !choices.is_empty() { - if let Ok(n) = trimmed.parse::() { - if (1..=choices.len()).contains(&n) { - return UserInput::Done(Value::Literali(choices[n - 1].to_string())); + KeyCode::Enter => Some(menu_input(*active)), + KeyCode::Esc => { + self.menu = None; + None + } + _ => None, + } + } + + fn field_key(&mut self, code: KeyCode) -> Option { + match &mut self.field { + Field::Edit { + buffer, + cursor, + edited, + produced, + } => match code { + KeyCode::Enter => { + if *edited { + Some(UserInput::Done(Value::Literali(std::mem::take(buffer)))) + } else { + Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))) + } + } + KeyCode::Char(c) => { + buffer.insert(*cursor, c); + *cursor += c.len_utf8(); + *edited = true; + None + } + KeyCode::Backspace => { + if *cursor > 0 { + let start = prev_boundary(buffer, *cursor); + buffer.replace_range(start..*cursor, ""); + *cursor = start; + *edited = true; } + None } + KeyCode::Left => { + *cursor = prev_boundary(buffer, *cursor); + None + } + KeyCode::Right => { + *cursor = next_boundary(buffer, *cursor); + None + } + KeyCode::Esc => { + self.menu = Some(0); + None + } + _ => None, + }, + Field::Frozen { produced } => match code { + KeyCode::Enter => Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))), + KeyCode::Esc => { + self.menu = Some(0); + None + } + _ => None, + }, + Field::Choose { choices, active } => match code { + KeyCode::Left | KeyCode::Up => { + if *active > 0 { + *active -= 1; + } + None + } + KeyCode::Right | KeyCode::Down => { + if *active + 1 < choices.len() { + *active += 1; + } + None + } + KeyCode::Enter => Some(UserInput::Done(Value::Literali(std::mem::take( + &mut choices[*active], + )))), + KeyCode::Esc => { + self.menu = Some(0); + None + } + _ => None, + }, + } + } +} + +/// Draw the current interaction state onto a single, repeatedly-cleared +/// terminal line, leaving the edit cursor at the right column. +fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { + queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; + match interaction.menu { + Some(active) => { + write!(out, "esc: ")?; + render_choices(out, &MENU, active)?; + } + None => match &interaction.field { + Field::Edit { buffer, cursor, .. } => { + write!(out, "{}{}", PROMPT_TEXT, buffer)?; + let col = PROMPT_WIDTH + + buffer[..*cursor] + .chars() + .count() as u16; + queue!(out, cursor::MoveToColumn(col))?; } - match trimmed - .chars() - .next() - .map(|c| c.to_ascii_lowercase()) - { - Some('d') if choices.is_empty() => return UserInput::Done(produced.clone()), - Some('s') => return UserInput::Skip, - Some('f') => return UserInput::Fail, - Some('q') => return UserInput::Quit, - _ => continue, + Field::Frozen { .. } => { + // The value was already shown above; the prompt carries only + // the affordances, not a re-truncation of it. + write!(out, "{}[enter] done [esc] skip / fail / quit", PROMPT_TEXT)?; } + Field::Choose { choices, active } => { + let refs: Vec<&str> = choices + .iter() + .map(String::as_str) + .collect(); + render_choices(out, &refs, *active)?; + } + }, + } + out.flush() +} + +/// Render a horizontal row of options with the active one in reverse video. +fn render_choices(out: &mut W, choices: &[&str], active: usize) -> io::Result<()> { + for (i, choice) in choices + .iter() + .enumerate() + { + if i > 0 { + write!(out, " ")?; } + if i == active { + queue!(out, SetAttribute(Attribute::Reverse))?; + write!(out, " {} ", choice)?; + queue!(out, SetAttribute(Attribute::Reset))?; + } else { + write!(out, " {} ", choice)?; + } + } + Ok(()) +} + +/// Map an Esc-menu index to its outcome. +fn menu_input(index: usize) -> UserInput { + match index { + 0 => UserInput::Skip, + 1 => UserInput::Fail, + _ => UserInput::Quit, } } +/// Build an editable field from a scalar's text, cursor at the end. +fn edit(buffer: String, produced: Value) -> Field { + let cursor = buffer.len(); + Field::Edit { + buffer, + cursor, + edited: false, + produced, + } +} + +/// Whether a scalar's text fits on the single editable candidate line. +fn is_inline(text: &str) -> bool { + !text.contains('\n') + && text + .chars() + .count() + <= INLINE_MAX +} + +/// Byte index of the char boundary one character before `i`. +fn prev_boundary(s: &str, i: usize) -> usize { + s[..i] + .chars() + .next_back() + .map_or(i, |c| i - c.len_utf8()) +} + +/// Byte index of the char boundary one character after `i`. +fn next_boundary(s: &str, i: usize) -> usize { + s[i..] + .chars() + .next() + .map_or(i, |c| i + c.len_utf8()) +} + /// No-operator driver: writes a trace of each step to its output and takes /// the body's computed value as the step's outcome, running to completion /// or first failure. A pure-prose step (empty body value) records (). @@ -195,8 +473,8 @@ impl Driver for Automatic { let _ = writeln!(self.output, "{}", message); } - fn ask(&mut self, _choices: &[&str], produced: &Value) -> UserInput { - UserInput::Done(produced.clone()) + fn ask(&mut self, _choices: &[&str], produced: Value) -> UserInput { + UserInput::Done(produced) } } @@ -278,7 +556,7 @@ impl Driver for Mock { .push(Event::Announce(message.to_string())); } - fn ask(&mut self, choices: &[&str], _produced: &Value) -> UserInput { + fn ask(&mut self, choices: &[&str], _produced: Value) -> UserInput { self.events .push(Event::Ask { choices: choices diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 31580e5..5a930b9 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -3,6 +3,7 @@ //! so a run can be resumed after interruption. use std::collections::HashSet; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use crate::program::Program; @@ -46,6 +47,9 @@ pub fn start<'i>( let appender = Appender::open(pfftt, run_id)?; 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.run(env)? @@ -76,6 +80,9 @@ pub fn resume<'i>( program: &'i Program<'i>, library: Library, ) -> Result { + if !std::io::stdout().is_terminal() { + return Err(RunnerError::TerminalRequired); + } let store = Store::new(PathBuf::from(STORE_ROOT)); let (document, completed, run_dir) = store.open(run_id)?; let pfftt = construct_state_path(&run_dir, &document); diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 33f3345..1587901 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -85,6 +85,7 @@ pub enum RunnerError { ParameterUnexpected { actual: usize, }, + TerminalRequired, UserQuit, } @@ -522,12 +523,10 @@ impl<'i, D: Driver> Runner<'i, D> { self.appender .append(&begin)?; - let produced = match self.walk(env, body)? { - Outcome::Quit => return Ok(Outcome::Quit), - Outcome::Done(value) => value, - Outcome::Skipped | Outcome::Failed(_) => Value::Unitus, - }; - + // Show the step's heading and description before walking its body, + // so any output the body streams (e.g. exec) appears beneath the step + // it belongs to rather than ahead of it. The description interpolates + // only values bound by enclosing scopes, so it reads cleanly here. let mut description_text = String::new(); for op in description { if !description_text.is_empty() { @@ -542,13 +541,19 @@ impl<'i, D: Driver> Runner<'i, D> { self.driver .step(qualified, &description_text); + let produced = match self.walk(env, body)? { + Outcome::Quit => return Ok(Outcome::Quit), + Outcome::Done(value) => value, + Outcome::Skipped | Outcome::Failed(_) => Value::Unitus, + }; + let choices: Vec<&str> = responses .iter() .map(|r| r.value) .collect(); let outcome = outcome_from( self.driver - .ask(&choices, &produced), + .ask(&choices, produced), ); if let Outcome::Quit = outcome { return Ok(Outcome::Quit); diff --git a/src/runner/state.rs b/src/runner/state.rs index 60ffc9f..4c457f9 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -413,13 +413,50 @@ fn format_value(out: &mut String, value: &Value) { Value::Unit => out.push_str("()"), Value::Literal(text) => { out.push('"'); - out.push_str(text); + 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 +// line-oriented PFFTT format. +fn escape_literal(out: &mut String, text: &str) { + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + _ => out.push(c), + } + } +} + +// Reverse `escape_literal`. An unknown escape (or a trailing backslash) is a +// malformed record. +fn unescape_literal(text: &str) -> Result { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('\\') => out.push('\\'), + Some('"') => out.push('"'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + _ => return Err(RecordError::MalformedState), + } + } else { + out.push(c); + } + } + Ok(out) +} + // Parse a single PFFTT record line into a Record. pub(crate) fn parse_record(line: &str) -> Result { let line = line.trim_end_matches(['\r', '\n']); @@ -536,7 +573,7 @@ fn parse_value(text: &str) -> Result { if text == "()" { Ok(Value::Unit) } else if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') { - Ok(Value::Literal(text[1..text.len() - 1].to_string())) + 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 { From a21aca2be1221e357467ba6b522efb40a24bcb37 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Fri, 5 Jun 2026 15:36:13 +1000 Subject: [PATCH 04/11] Record single line literals as step result --- src/runner/checks/runner.rs | 36 ++++++++++++++++++++++++++++++++++++ src/runner/driver.rs | 8 ++++++-- src/runner/runner.rs | 12 +++++++----- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 582aa2a..159fca0 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1449,3 +1449,39 @@ fn automatic_driver_records_body_value() { let record = parse_record(lines[2]).expect("parse record"); assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); } + +#[test] +fn multiline_body_value_records_unit_but_still_propagates() { + // A step whose body computes multi-line text (raw exec output) records () + let mut fixture = StoreFixture::new("multiline-records-unit"); + let body = Operation::Sequence(vec![step( + Ordinal::Dependent("1"), + Operation::String(vec![Fragment::Text("1: lo\n2: eth0\n3: wlan0")]), + )]); + let program = anonymous_with_body(body); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashSet::new(), + Automatic::with_handle(Vec::new()), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + assert_eq!( + outcome, + Outcome::Done(Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string())) + ); + let pfftt = fixture.pfftt_contents(); + let lines: Vec<&str> = pfftt + .lines() + .filter(|line| { + !line + .trim() + .is_empty() + }) + .collect(); + let record = parse_record(lines[2]).expect("parse record"); + assert_eq!(record.state, State::Done(Some(RecordValue::Unit))); +} diff --git a/src/runner/driver.rs b/src/runner/driver.rs index c6faeb1..4e1f94c 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -177,7 +177,7 @@ struct Interaction { impl Interaction { /// Seed an interaction with the supplied choices and a Value. There is /// some complex UI logic encoded here: - /// + /// /// - a non-empty `choices` array indicates we want to do selection /// between these choices; /// - a scalar Value becomes an editable buffer; and @@ -353,7 +353,11 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { Field::Frozen { .. } => { // The value was already shown above; the prompt carries only // the affordances, not a re-truncation of it. - write!(out, "{}[enter] done [esc] skip / fail / quit", PROMPT_TEXT)?; + write!( + out, + "{}[enter] done [esc] skip / fail / quit", + PROMPT_TEXT + )?; } Field::Choose { choices, active } => { let refs: Vec<&str> = choices diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 1587901..f8d669d 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -602,13 +602,15 @@ fn outcome_from(input: UserInput) -> Outcome { } } -/// Project the runner's in-memory `Outcome` into the on-disk `State` -/// the PFFTT writer expects. A chosen response records as a quoted -/// literal; any other Done (the plain confirmation) records as unit. -/// Quit is unreachable here: the caller filters it out before recording. +/// 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. fn record_state(outcome: &Outcome) -> State { match outcome { - Outcome::Done(Value::Literali(text)) => { + Outcome::Done(Value::Literali(text)) if !text.contains('\n') => { State::Done(Some(RecordValue::Literal(text.clone()))) } Outcome::Done(_) => State::Done(Some(RecordValue::Unit)), From 822a87bae887d177c970e012e8e102fe8bae029c Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sat, 6 Jun 2026 20:19:18 +1000 Subject: [PATCH 05/11] Refine default and menu behaviour --- src/runner/checks/driver.rs | 92 +++++++++++++------ src/runner/driver.rs | 173 +++++++++++++++++++++++++----------- 2 files changed, 185 insertions(+), 80 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index 661b333..e3ee7fb 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -89,8 +89,9 @@ fn console_section_writes_fqn_and_title() { } #[test] -fn edit_unedited_enter_preserves_produced() { - // An untouched Unitus stays Unitus, not Literali(""). +fn default_enter_completes_with_produced() { + // The default is confirmation: Enter accepts the body's value intact, so + // an untouched Unitus stays Unitus, not Literali(""). let mut it = Interaction::begin(&[], Value::Unitus); assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), @@ -99,14 +100,19 @@ fn edit_unedited_enter_preserves_produced() { } #[test] -fn edit_typed_enter_returns_literali() { - let mut it = Interaction::begin(&[], Value::Unitus); - for c in "eth0".chars() { - assert_eq!( - it.handle(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)), - None - ); - } +fn esc_edit_typed_enter_returns_literali() { + // Editing is opt-in: Esc -> Edit (the first menu item) opens the buffer + // seeded from the value, which the operator can then extend. + let mut it = Interaction::begin(&[], Value::Literali("eth".to_string())); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE)), + None + ); assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Done(Value::Literali("eth0".to_string()))) @@ -114,9 +120,11 @@ fn edit_typed_enter_returns_literali() { } #[test] -fn edit_backspace_trims_candidate() { - // The Literali candidate starts with the cursor at the end. +fn esc_edit_seeds_buffer_and_backspace_trims() { + // Edit seeds the buffer from the produced value, cursor at the end. let mut it = Interaction::begin(&[], Value::Literali("abc".to_string())); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( it.handle(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), None @@ -128,42 +136,64 @@ fn edit_backspace_trims_candidate() { } #[test] -fn esc_menu_navigates_skip_fail_quit() { - let mut it = Interaction::begin(&[], Value::Unitus); - // Esc opens the menu on the first item, skip. - assert_eq!( - it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), - None - ); +fn esc_menu_navigates_edit_skip_fail_quit() { + // For an editable scalar the menu is edit, skip, fail, quit in order. + let editable = || Value::Literali("eth0".to_string()); + + let mut it = Interaction::begin(&[], editable()); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Skip) ); - let mut it = Interaction::begin(&[], Value::Unitus); + let mut it = Interaction::begin(&[], editable()); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Fail) ); - let mut it = Interaction::begin(&[], Value::Unitus); + let mut it = Interaction::begin(&[], editable()); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); - it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); // Right past the end clamps on quit. - it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + for _ in 0..5 { + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + } assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Some(UserInput::Quit) ); } +#[test] +fn esc_menu_omits_edit_for_unit_and_complex() { + // Neither a Unit step (pure confirmation) nor a complex value is + // inline-editable, so the menu skips Edit and the first item is Skip. + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); + + let tablet = Value::Tabularum(vec![("k".to_string(), Value::Unitus)]); + let mut it = Interaction::begin(&[], tablet); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); +} + #[test] fn esc_menu_backs_out_to_field() { let mut it = Interaction::begin(&[], Value::Literali("x".to_string())); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + // Esc out of the menu returns to the frozen value; Enter accepts it intact. assert_eq!( it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), None @@ -239,9 +269,10 @@ fn multiline_scalar_is_read_only() { } #[test] -fn render_frozen_shows_only_affordances() { +fn render_frozen_shows_only_triangle() { // A read-only value (already streamed above) is not re-echoed on the - // prompt line; the line carries no raw newline and only the key options. + // prompt line, and the menu options are not advertised — the normal prompt + // is just the "play" triangle. let dump = Value::Literali("1: lo\n2: eth0\n3: wlan0".to_string()); let it = Interaction::begin(&[], dump); let mut out: Vec = Vec::new(); @@ -249,8 +280,8 @@ fn render_frozen_shows_only_affordances() { let written = String::from_utf8(out).expect("utf8"); assert!(!written.contains('\n')); assert!(!written.contains("eth0")); - assert!(written.contains("[enter]")); - assert!(written.contains("[esc]")); + assert!(written.contains('▶')); + assert!(!written.contains("[enter]")); } #[test] @@ -264,7 +295,10 @@ fn ctrl_c_quits_from_any_field() { #[test] fn render_edit_shows_candidate_text() { - let it = Interaction::begin(&[], Value::Literali("hello".to_string())); + let mut it = Interaction::begin(&[], Value::Literali("hello".to_string())); + // Frozen by default; once edited, the candidate text is shown for editing. + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let mut out: Vec = Vec::new(); draw(&mut out, &it).expect("draw"); let written = String::from_utf8(out).expect("utf8"); diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 4e1f94c..e16dac9 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -64,10 +64,11 @@ pub trait Driver { /// Interactive console prompt in a terminal. `step` / `section` / `announce` /// print in "cooked mode" (to use the old terminfo slang term for it); `ask` -/// switches to "raw mode" to read keystrokes, presenting the step's value as -/// an editable candidate (if a scalar) or a read-only display (a complex -/// value) and offering an `` menu for Skip / Fail / Quit. The keystroke -/// logic lives in `Interaction`; this is the terminal shell around it. +/// switches to "raw mode" to read keystrokes. The default is confirmation: +/// `` completes the step, accepting the body's value intact. The +/// `` menu offers Skip / Fail / Quit and, for an editable scalar, Edit — +/// the one path to reshape the value. The keystroke logic lives in +/// `Interaction`; this is the terminal shell around it. pub struct Console { output: W, } @@ -134,8 +135,36 @@ impl Driver for Console { } } -/// Esc-menu options, in navigation order. -const MENU: [&str; 3] = ["skip", "fail", "quit"]; +/// One Esc-menu option. `Edit` reshapes the produced value in place; the rest +/// are the step exits. Navigation order is the slice order in `menu_items`. +#[derive(Debug, Clone, Copy, PartialEq)] +enum MenuItem { + Edit, + Skip, + Fail, + Quit, +} + +impl MenuItem { + fn label(self) -> &'static str { + match self { + MenuItem::Edit => "Edit", + MenuItem::Skip => "Skip", + MenuItem::Fail => "Fail", + MenuItem::Quit => "Quit", + } + } +} + +/// The two Esc-menus: with `Edit` first for an editable scalar, or just the +/// exits otherwise. +const EDIT_MENU: [MenuItem; 4] = [ + MenuItem::Edit, + MenuItem::Skip, + MenuItem::Fail, + MenuItem::Quit, +]; +const PLAIN_MENU: [MenuItem; 3] = [MenuItem::Skip, MenuItem::Fail, MenuItem::Quit]; /// Prompt prefix shown before an editable / read-only candidate, and its /// width in terminal columns (for placing the edit cursor). Later we will @@ -155,7 +184,7 @@ enum Field { buffer: String, cursor: usize, edited: bool, - produced: Value, + original: Value, }, Frozen { produced: Value, @@ -175,26 +204,22 @@ struct Interaction { } impl Interaction { - /// Seed an interaction with the supplied choices and a Value. There is - /// some complex UI logic encoded here: + /// Seed an interaction with the supplied choices and a Value. The + /// behaviour on pressing is confirmation that the step is done, + /// not data entry: `` completes the step and the step's value is + /// whatever its body produced. + /// + /// - a non-empty `choices` array selects between Responses; otherwise + /// + /// - the Value originally produced by the step is shown `Frozen`, and + /// `` accepts it intact. /// - /// - a non-empty `choices` array indicates we want to do selection - /// between these choices; - /// - a scalar Value becomes an editable buffer; and - /// - a complex value a read-only display. + /// Editing is opt-in via the `` menu (see `menu_items`), offered only + /// when the value is an editable scalar; `ask()` will later present that + /// same Edit field up-front. fn begin(choices: &[&str], produced: Value) -> Self { let field = if choices.is_empty() { - match produced { - Value::Unitus => edit(String::new(), Value::Unitus), - quantity @ Value::Quanticle(_) => { - let buffer = quantity.to_string(); - edit(buffer, quantity) - } - Value::Literali(text) if is_inline(&text) => { - edit(text.clone(), Value::Literali(text)) - } - other => Field::Frozen { produced: other }, - } + Field::Frozen { produced } } else { Field::Choose { choices: choices @@ -207,6 +232,30 @@ impl Interaction { Interaction { field, menu: None } } + /// The `` menu items for the current field. A `Frozen` editable scalar + /// offers `Edit` as the first item; everything else (a complex/multiline + /// value, an active Edit, a Response selection) offers only the exits. + fn menu_items(&self) -> &'static [MenuItem] { + match &self.field { + Field::Frozen { produced } if editable_seed(produced).is_some() => &EDIT_MENU, + _ => &PLAIN_MENU, + } + } + + /// Transition a `Frozen` scalar into an editable buffer seeded from it. + /// Reached only via the `Edit` menu item, which is offered only when the + /// seed exists, so the fallback restore never fires in practice. + fn enter_edit(&mut self) { + if let Field::Frozen { produced } = &mut self.field { + let taken = std::mem::replace(produced, Value::Unitus); + match editable_seed(&taken) { + Some(seed) => self.field = edit(seed, taken), + None => self.field = Field::Frozen { produced: taken }, + } + } + self.menu = None; + } + fn handle(&mut self, key: KeyEvent) -> Option { if key .modifiers @@ -229,23 +278,35 @@ impl Interaction { } fn menu_key(&mut self, code: KeyCode) -> Option { - let active = self + let len = self + .menu_items() + .len(); + let active = (*self .menu - .as_mut()?; + .as_ref()?) + .min(len - 1); match code { KeyCode::Left => { - if *active > 0 { - *active -= 1; + if active > 0 { + self.menu = Some(active - 1); } None } KeyCode::Right => { - if *active + 1 < MENU.len() { - *active += 1; + if active + 1 < len { + self.menu = Some(active + 1); } None } - KeyCode::Enter => Some(menu_input(*active)), + KeyCode::Enter => match self.menu_items()[active] { + MenuItem::Edit => { + self.enter_edit(); + None + } + MenuItem::Skip => Some(UserInput::Skip), + MenuItem::Fail => Some(UserInput::Fail), + MenuItem::Quit => Some(UserInput::Quit), + }, KeyCode::Esc => { self.menu = None; None @@ -260,13 +321,13 @@ impl Interaction { buffer, cursor, edited, - produced, + original, } => match code { KeyCode::Enter => { if *edited { Some(UserInput::Done(Value::Literali(std::mem::take(buffer)))) } else { - Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))) + Some(UserInput::Done(std::mem::replace(original, Value::Unitus))) } } KeyCode::Char(c) => { @@ -338,8 +399,15 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; match interaction.menu { Some(active) => { - write!(out, "esc: ")?; - render_choices(out, &MENU, active)?; + // The step is still running (timer ticking), so keep the same + // triangle; only the line's content changes to the menu choices. + write!(out, "{}", PROMPT_TEXT)?; + let labels: Vec<&str> = interaction + .menu_items() + .iter() + .map(|item| item.label()) + .collect(); + render_choices(out, &labels, active)?; } None => match &interaction.field { Field::Edit { buffer, cursor, .. } => { @@ -351,13 +419,10 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { queue!(out, cursor::MoveToColumn(col))?; } Field::Frozen { .. } => { - // The value was already shown above; the prompt carries only - // the affordances, not a re-truncation of it. - write!( - out, - "{}[enter] done [esc] skip / fail / quit", - PROMPT_TEXT - )?; + // The value (if any) was already shown above; the normal prompt + // is just the "play" triangle. The exit/edit options are not + // advertised here — they appear only once opens the menu. + write!(out, "{}", PROMPT_TEXT)?; } Field::Choose { choices, active } => { let refs: Vec<&str> = choices @@ -391,23 +456,29 @@ fn render_choices(out: &mut W, choices: &[&str], active: usize) -> io: Ok(()) } -/// Map an Esc-menu index to its outcome. -fn menu_input(index: usize) -> UserInput { - match index { - 0 => UserInput::Skip, - 1 => UserInput::Fail, - _ => UserInput::Quit, +/// The buffer an editable scalar seeds its Edit field with: the rendered +/// number for a Quanticle, the raw text for an inline Literali. `None` marks +/// a value with nothing to edit, coming from a step returning Unit and thus +/// is onlt pure confirmation. +fn editable_seed(produced: &Value) -> Option { + match produced { + Value::Unitus => None, + Value::Quanticle(_) => Some(produced.to_string()), + Value::Literali(text) if is_inline(text) => Some(text.clone()), + // Complex or multi-line values are not editable inline. + _ => None, } } -/// Build an editable field from a scalar's text, cursor at the end. -fn edit(buffer: String, produced: Value) -> Field { +/// Build an editable field from a scalar's text, cursor at the end. The +/// `original` value is returned verbatim if the buffer is accepted unedited. +fn edit(buffer: String, original: Value) -> Field { let cursor = buffer.len(); Field::Edit { buffer, cursor, edited: false, - produced, + original, } } From 4fda87bc08b4412b020b46f6864177636a5ddfc8 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Sun, 7 Jun 2026 00:01:43 +1000 Subject: [PATCH 06/11] Show descent into procedures and steps in more compact display --- src/runner/checks/driver.rs | 23 ++++---- src/runner/checks/runner.rs | 4 +- src/runner/driver.rs | 103 +++++++++++++++++++++++++++--------- src/runner/runner.rs | 17 +++++- 4 files changed, 110 insertions(+), 37 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index e3ee7fb..8478487 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -45,18 +45,22 @@ fn mock_records_offered_choices() { } #[test] -fn mock_records_section_and_announce() { +fn mock_records_enter_leave_and_announce() { let mut p = Mock::new(); - p.section("I", "Setup"); + p.enter("I", "Setup"); p.announce("Calling helper"); + p.leave("I"); assert_eq!( p.events(), &[ - Event::Section { + Event::Enter { qualified: "I".to_string(), title: "Setup".to_string(), }, Event::Announce("Calling helper".to_string()), + Event::Leave { + qualified: "I".to_string(), + }, ] ); } @@ -74,18 +78,18 @@ fn console_step_writes_fqn_and_description() { let mut p = Console::with_output(&mut output); p.step("local_network:I/1", "Check the cable."); let written = String::from_utf8(output).expect("utf8"); - assert!(written.contains("local_network:I/1")); - assert!(written.contains("Check the cable.")); + assert!(written.contains("→ local_network:I/1")); + assert!(written.contains(" Check the cable.")); } #[test] -fn console_section_writes_fqn_and_title() { +fn console_enter_writes_fqn_and_title() { let mut output: Vec = Vec::new(); let mut p = Console::with_output(&mut output); - p.section("I", "Setup"); + p.enter("I", "Setup"); let written = String::from_utf8(output).expect("utf8"); - assert!(written.contains("I")); - assert!(written.contains("Setup")); + assert!(written.contains("↘ I")); + assert!(written.contains(" Setup")); } #[test] @@ -311,6 +315,7 @@ fn render_choices_lists_options() { let mut out: Vec = Vec::new(); draw(&mut out, &it).expect("draw"); let written = String::from_utf8(out).expect("utf8"); + assert!(written.contains('▶')); assert!(written.contains("Yes")); assert!(written.contains("No")); } diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 159fca0..091cee0 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -372,7 +372,7 @@ fn section_walking() { let section_fqns: Vec<&str> = events .iter() .filter_map(|e| { - if let Event::Section { qualified, .. } = e { + if let Event::Enter { qualified, .. } = e { Some(qualified.as_str()) } else { None @@ -419,7 +419,7 @@ fn section_walking() { .events() .iter() .find_map(|e| { - if let Event::Section { title, .. } = e { + if let Event::Enter { title, .. } = e { Some(title.as_str()) } else { None diff --git a/src/runner/driver.rs b/src/runner/driver.rs index e16dac9..da09ec3 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -45,8 +45,14 @@ pub trait Driver { /// input — the walker calls `ask` for that separately. fn step(&mut self, qualified: &str, description: &str); - /// Announce entry to a Section, with its Qualified Name and title text. - fn section(&mut self, qualified: &str, title: &str); + /// Announce descent into a named scope — a Section or an invoked + /// subroutine — with its Qualified Name and title text (the `↘` marker). + fn enter(&mut self, qualified: &str, title: &str); + + /// Announce ascent back out of a named scope, with the Qualified Name of + /// the scope being left (the `↙` marker). Paired with `enter`; loop + /// iterations do not emit it. + fn leave(&mut self, qualified: &str); /// Surface an informational line — Loop body announcements, /// Execute / Unresolved Invoke announce-only, resume diagnostics. @@ -90,20 +96,19 @@ impl Console { impl Driver for Console { fn step(&mut self, fqn: &str, description: &str) { - let _ = writeln!(self.output, " {}", fqn); - let _ = writeln!(self.output, "{}", description); + render_step(&mut self.output, fqn, description); } - fn section(&mut self, qualified: &str, title: &str) { - let _ = writeln!(self.output); - let _ = writeln!(self.output, "=== {} ===", qualified); - if !title.is_empty() { - let _ = writeln!(self.output, "{}", title); - } + fn enter(&mut self, qualified: &str, title: &str) { + render_enter(&mut self.output, qualified, title); + } + + fn leave(&mut self, qualified: &str) { + render_leave(&mut self.output, qualified); } fn announce(&mut self, message: &str) { - let _ = writeln!(self.output, "{}", message); + write_indented(&mut self.output, message); } fn ask(&mut self, choices: &[&str], produced: Value) -> UserInput { @@ -130,11 +135,50 @@ impl Driver for Console { } }; let _ = disable_raw_mode(); - let _ = writeln!(self.output); + // The prompt is a live affordance, not a record: clear it on settle so + // the next state line reuses the row rather than leaving a trail of + // spent ▶ lines. + let _ = queue!( + self.output, + cursor::MoveToColumn(0), + Clear(ClearType::CurrentLine) + ); + let _ = self + .output + .flush(); result } } +/// Write prompt text into the two character gutter, one line at a time. +/// Console output sits in two bands: a one-glyph gutter (column 0) carrying +/// the directional markers — `↘` descend, `→` step, `▶` prompt — and the body +/// (column 2) carrying the qualified name, description, and prompt content. +/// Raw subprocess output (`exec`) is streamed verbetum and thus flush-left at +/// column 0. +fn write_indented(out: &mut W, text: &str) { + for line in text.lines() { + let _ = writeln!(out, " {}", line); + } +} + +/// Render a step's `→` line and indented description. +fn render_step(out: &mut W, fqn: &str, description: &str) { + let _ = writeln!(out, "→ {}", fqn); + write_indented(out, description); +} + +/// Render a named scope's `↘` descent line and indented title. +fn render_enter(out: &mut W, qualified: &str, title: &str) { + let _ = writeln!(out, "↘ {}", qualified); + write_indented(out, title); +} + +/// Render a named scope's `↙` ascent line. +fn render_leave(out: &mut W, qualified: &str) { + let _ = writeln!(out, "↙ {}", qualified); +} + /// One Esc-menu option. `Edit` reshapes the produced value in place; the rest /// are the step exits. Navigation order is the slice order in `menu_items`. #[derive(Debug, Clone, Copy, PartialEq)] @@ -425,6 +469,7 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { write!(out, "{}", PROMPT_TEXT)?; } Field::Choose { choices, active } => { + write!(out, "{}", PROMPT_TEXT)?; let refs: Vec<&str> = choices .iter() .map(String::as_str) @@ -531,21 +576,19 @@ impl Automatic { impl Driver for Automatic { fn step(&mut self, fqn: &str, description: &str) { - let _ = writeln!(self.output, " {}", fqn); - if !description.is_empty() { - let _ = writeln!(self.output, "{}", description); - } + render_step(&mut self.output, fqn, description); } - fn section(&mut self, qualified: &str, title: &str) { - let _ = writeln!(self.output, "=== {} ===", qualified); - if !title.is_empty() { - let _ = writeln!(self.output, "{}", title); - } + fn enter(&mut self, qualified: &str, title: &str) { + render_enter(&mut self.output, qualified, title); + } + + fn leave(&mut self, qualified: &str) { + render_leave(&mut self.output, qualified); } fn announce(&mut self, message: &str) { - let _ = writeln!(self.output, "{}", message); + write_indented(&mut self.output, message); } fn ask(&mut self, _choices: &[&str], produced: Value) -> UserInput { @@ -572,10 +615,13 @@ pub enum Event { qualified: String, description: String, }, - Section { + Enter { qualified: String, title: String, }, + Leave { + qualified: String, + }, Announce(String), Ask { choices: Vec, @@ -618,14 +664,21 @@ impl Driver for Mock { }); } - fn section(&mut self, fqn: &str, title: &str) { + fn enter(&mut self, fqn: &str, title: &str) { self.events - .push(Event::Section { + .push(Event::Enter { qualified: fqn.to_string(), title: title.to_string(), }); } + fn leave(&mut self, fqn: &str) { + self.events + .push(Event::Leave { + qualified: fqn.to_string(), + }); + } + fn announce(&mut self, message: &str) { self.events .push(Event::Announce(message.to_string())); diff --git a/src/runner/runner.rs b/src/runner/runner.rs index f8d669d..5846a33 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -298,6 +298,11 @@ impl<'i, D: Driver> Runner<'i, D> { .append(&record)?; self.path .push(PathSegment::Procedure(name)); + let descended = self + .path + .render(); + self.driver + .enter(&descended, ""); } // Walk the body against the callee's own environment; `local` @@ -305,8 +310,13 @@ impl<'i, D: Driver> Runner<'i, D> { let result = self.walk(&mut local, &subroutine.body); if name.is_some() { + let descended = self + .path + .render(); self.path .pop(); + self.driver + .leave(&descended); } result } @@ -424,8 +434,13 @@ impl<'i, D: Driver> Runner<'i, D> { self.path .push(PathSegment::Section(numeral)); let result = self.perform_section(env, title, body); + let qualified = self + .path + .render(); self.path .pop(); + self.driver + .leave(&qualified); result } @@ -446,7 +461,7 @@ impl<'i, D: Driver> Runner<'i, D> { None => String::new(), }; self.driver - .section(&qualified, &title_text); + .enter(&qualified, &title_text); self.walk(env, body) } From 0b1a2b5ec8333157862b690b7f4f53c0b0c1d7e2 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Jun 2026 11:26:42 +1000 Subject: [PATCH 07/11] Make Escape menu more consistent --- src/runner/checks/driver.rs | 17 +++++- src/runner/driver.rs | 111 ++++++++++++++++++++++++------------ 2 files changed, 88 insertions(+), 40 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index 8478487..0589f38 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -174,9 +174,9 @@ fn esc_menu_navigates_edit_skip_fail_quit() { } #[test] -fn esc_menu_omits_edit_for_unit_and_complex() { +fn esc_menu_disables_edit_for_unit_and_complex() { // Neither a Unit step (pure confirmation) nor a complex value is - // inline-editable, so the menu skips Edit and the first item is Skip. + // inline-editable, so Edit is greyed and the menu opens on Skip. let mut it = Interaction::begin(&[], Value::Unitus); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( @@ -193,6 +193,19 @@ fn esc_menu_omits_edit_for_unit_and_complex() { ); } +#[test] +fn menu_shows_greyed_edit_when_unavailable() { + // Edit is always listed so it stays discoverable; for a non-editable value + // it is drawn (greyed) alongside the exits, and the menu opens on Skip. + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut out: Vec = Vec::new(); + draw(&mut out, &it).expect("draw"); + let written = String::from_utf8(out).expect("utf8"); + assert!(written.contains("Edit")); + assert!(written.contains("Skip")); +} + #[test] fn esc_menu_backs_out_to_field() { let mut it = Interaction::begin(&[], Value::Literali("x".to_string())); diff --git a/src/runner/driver.rs b/src/runner/driver.rs index da09ec3..9e09c0e 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -200,15 +200,15 @@ impl MenuItem { } } -/// The two Esc-menus: with `Edit` first for an editable scalar, or just the -/// exits otherwise. -const EDIT_MENU: [MenuItem; 4] = [ +/// The Esc-menu, always shown in full. `Edit` leads but is greyed and +/// unselectable unless the produced value is an editable scalar, so the menu +/// teaches that some steps can be edited while most cannot. +const MENU: [MenuItem; 4] = [ MenuItem::Edit, MenuItem::Skip, MenuItem::Fail, MenuItem::Quit, ]; -const PLAIN_MENU: [MenuItem; 3] = [MenuItem::Skip, MenuItem::Fail, MenuItem::Quit]; /// Prompt prefix shown before an editable / read-only candidate, and its /// width in terminal columns (for placing the edit cursor). Later we will @@ -276,16 +276,40 @@ impl Interaction { Interaction { field, menu: None } } - /// The `` menu items for the current field. A `Frozen` editable scalar - /// offers `Edit` as the first item; everything else (a complex/multiline - /// value, an active Edit, a Response selection) offers only the exits. - fn menu_items(&self) -> &'static [MenuItem] { - match &self.field { - Field::Frozen { produced } if editable_seed(produced).is_some() => &EDIT_MENU, - _ => &PLAIN_MENU, + /// Whether a menu item is currently selectable. Only `Edit` is ever + /// disabled — offered when the produced value is an editable scalar, greyed + /// otherwise; the exits are always available. Navigation skips a disabled + /// item and the menu never opens onto one. + fn enabled(&self, item: MenuItem) -> bool { + match item { + MenuItem::Edit => match &self.field { + Field::Frozen { produced } => editable_seed(produced).is_some(), + _ => false, + }, + _ => true, } } + /// First selectable item, where the menu opens. The exits are always + /// enabled, so the fallback never fires. + fn first_enabled(&self) -> usize { + (0..MENU.len()) + .find(|&i| self.enabled(MENU[i])) + .unwrap_or(0) + } + + /// Nearest selectable item after / before `from`, or `None` at the edge — + /// so navigation stops rather than wrapping, and steps over a greyed item. + fn next_enabled(&self, from: usize) -> Option { + ((from + 1)..MENU.len()).find(|&i| self.enabled(MENU[i])) + } + + fn prev_enabled(&self, from: usize) -> Option { + (0..from) + .rev() + .find(|&i| self.enabled(MENU[i])) + } + /// Transition a `Frozen` scalar into an editable buffer seeded from it. /// Reached only via the `Edit` menu item, which is offered only when the /// seed exists, so the fallback restore never fires in practice. @@ -322,27 +346,24 @@ impl Interaction { } fn menu_key(&mut self, code: KeyCode) -> Option { - let len = self - .menu_items() - .len(); let active = (*self .menu .as_ref()?) - .min(len - 1); + .min(MENU.len() - 1); match code { KeyCode::Left => { - if active > 0 { - self.menu = Some(active - 1); + if let Some(prev) = self.prev_enabled(active) { + self.menu = Some(prev); } None } KeyCode::Right => { - if active + 1 < len { - self.menu = Some(active + 1); + if let Some(next) = self.next_enabled(active) { + self.menu = Some(next); } None } - KeyCode::Enter => match self.menu_items()[active] { + KeyCode::Enter => match MENU[active] { MenuItem::Edit => { self.enter_edit(); None @@ -360,6 +381,10 @@ impl Interaction { } fn field_key(&mut self, code: KeyCode) -> Option { + if let KeyCode::Esc = code { + self.menu = Some(self.first_enabled()); + return None; + } match &mut self.field { Field::Edit { buffer, @@ -397,18 +422,10 @@ impl Interaction { *cursor = next_boundary(buffer, *cursor); None } - KeyCode::Esc => { - self.menu = Some(0); - None - } _ => None, }, Field::Frozen { produced } => match code { KeyCode::Enter => Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))), - KeyCode::Esc => { - self.menu = Some(0); - None - } _ => None, }, Field::Choose { choices, active } => match code { @@ -427,10 +444,6 @@ impl Interaction { KeyCode::Enter => Some(UserInput::Done(Value::Literali(std::mem::take( &mut choices[*active], )))), - KeyCode::Esc => { - self.menu = Some(0); - None - } _ => None, }, } @@ -446,12 +459,7 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { // The step is still running (timer ticking), so keep the same // triangle; only the line's content changes to the menu choices. write!(out, "{}", PROMPT_TEXT)?; - let labels: Vec<&str> = interaction - .menu_items() - .iter() - .map(|item| item.label()) - .collect(); - render_choices(out, &labels, active)?; + render_menu(out, interaction, active)?; } None => match &interaction.field { Field::Edit { buffer, cursor, .. } => { @@ -501,6 +509,33 @@ fn render_choices(out: &mut W, choices: &[&str], active: usize) -> io: Ok(()) } +/// Render the Esc-menu: the active item in reverse video, a disabled item (a +/// greyed `Edit`) dimmed, the rest plain. The active item is always enabled, so +/// reverse and dim never apply to the same item. +fn render_menu(out: &mut W, interaction: &Interaction, active: usize) -> io::Result<()> { + for (i, item) in MENU + .iter() + .enumerate() + { + if i > 0 { + write!(out, " ")?; + } + let label = item.label(); + if i == active { + queue!(out, SetAttribute(Attribute::Reverse))?; + write!(out, " {} ", label)?; + queue!(out, SetAttribute(Attribute::Reset))?; + } else if !interaction.enabled(*item) { + queue!(out, SetAttribute(Attribute::Dim))?; + write!(out, " {} ", label)?; + queue!(out, SetAttribute(Attribute::Reset))?; + } else { + write!(out, " {} ", label)?; + } + } + Ok(()) +} + /// The buffer an editable scalar seeds its Edit field with: the rendered /// number for a Quanticle, the raw text for an inline Literali. `None` marks /// a value with nothing to edit, coming from a step returning Unit and thus From b05a7f19a91d475c8c349425f8c244fe9abdb67d Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Jun 2026 12:01:47 +1000 Subject: [PATCH 08/11] Add Reason submenu when Fail is selected --- src/runner/checks/driver.rs | 72 ++++++++++++++++- src/runner/checks/runner.rs | 4 +- src/runner/driver.rs | 150 ++++++++++++++++++++++++++++++------ src/runner/runner.rs | 2 +- 4 files changed, 200 insertions(+), 28 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index 0589f38..808cb90 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -156,9 +156,16 @@ fn esc_menu_navigates_edit_skip_fail_quit() { it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + // Fail opens a reason buffer; it settles once the reason is entered. assert_eq!( it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), - Some(UserInput::Fail) + None + ); + it.handle(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Fail("no".to_string())) ); let mut it = Interaction::begin(&[], editable()); @@ -206,6 +213,51 @@ fn menu_shows_greyed_edit_when_unavailable() { assert!(written.contains("Skip")); } +#[test] +fn fail_reason_backs_out_through_menu_to_field() { + // Fail opens the reason submenu; Esc closes it back to the menu (Fail still + // selectable), and a second Esc returns to the untouched frozen value, so + // Enter still completes the step with its produced value intact. + let mut it = Interaction::begin(&[], Value::Literali("eth0".to_string())); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + // Type into the reason, then abandon it. + it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + None + ); + // Back at the menu, Fail is still selectable. + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + None + ); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Done(Value::Literali("eth0".to_string()))) + ); +} + +#[test] +fn fail_reason_reopens_empty_after_abandon() { + // Abandoning a reason discards its text; reopening Fail starts fresh. + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Fail("y".to_string())) + ); +} + #[test] fn esc_menu_backs_out_to_field() { let mut it = Interaction::begin(&[], Value::Literali("x".to_string())); @@ -322,6 +374,24 @@ fn render_edit_shows_candidate_text() { assert!(written.contains("hello")); } +#[test] +fn render_reason_submenu_on_menu_line() { + // Selecting Fail shows the reason prompt and the typed buffer on the same + // line as the still-listed menu items. + let mut it = Interaction::begin(&[], Value::Unitus); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + it.handle(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + let mut out: Vec = Vec::new(); + draw(&mut out, &it).expect("draw"); + let written = String::from_utf8(out).expect("utf8"); + assert!(written.contains("Fail")); + assert!(written.contains("Reason?")); + assert!(written.contains("ok")); +} + #[test] fn render_choices_lists_options() { let it = Interaction::begin(&["Yes", "No"], Value::Unitus); diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 091cee0..b26e137 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -174,7 +174,7 @@ fn step_outcomes_recorded() { Operation::Sequence(vec![]), )]); let program = anonymous_with_body(body); - let prompt = Mock::with_answers([UserInput::Fail]); + let prompt = Mock::with_answers([UserInput::Fail("cable unplugged".to_string())]); let mut runner = Runner::new( &program, fixture.take_appender(), @@ -200,7 +200,7 @@ fn step_outcomes_recorded() { assert_eq!( record.state, State::Fail(Some(RecordValue::Tablet( - "[ reason = \"Failed\" ]".to_string() + "[ reason = \"cable unplugged\" ]".to_string() ))) ); } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 9e09c0e..02e1cb2 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -33,7 +33,7 @@ pub enum Mode { pub enum UserInput { Done(Value), Skip, - Fail, + Fail(String), Quit, } @@ -216,6 +216,12 @@ const MENU: [MenuItem; 4] = [ const PROMPT_TEXT: &str = "▶ "; const PROMPT_WIDTH: u16 = 2; +/// Appended to the menu line (after the items) when soliciting a reason for +/// e.g. failure; the typed buffer follows it. The Fail menu item stays +/// selected while it is shown, so it reads as a submenu rather than another +/// separate mode. +const REASON_PREFIX: &str = " Reason? "; + /// Longest scalar offered as an inline-editable candidate. A longer (or /// multi-line) value can't be edited on one line, so it falls back to the /// read-only display. @@ -239,12 +245,22 @@ enum Field { }, } +/// The inline buffer soliciting a fail reason, shown on the menu line while +/// Fail is highlighted. A submenu of the menu, not a Field: the underlying +/// `Frozen` value is left untouched so backing out restores it. +struct Reason { + buffer: String, + cursor: usize, +} + /// Our state machine behind the "raw-mode" Console. `handle` /// folds one key into the state, returning `Some(UserInput)` once the -/// operator has settled on an outcome. +/// operator has settled on an outcome. `reason` is the Fail submenu, open only +/// while `menu` rests on Fail. struct Interaction { field: Field, menu: Option, + reason: Option, } impl Interaction { @@ -273,7 +289,11 @@ impl Interaction { active: 0, } }; - Interaction { field, menu: None } + Interaction { + field, + menu: None, + reason: None, + } } /// Whether a menu item is currently selectable. Only `Edit` is ever @@ -336,6 +356,11 @@ impl Interaction { } } if self + .reason + .is_some() + { + self.reason_key(key.code) + } else if self .menu .is_some() { @@ -369,7 +394,15 @@ impl Interaction { None } MenuItem::Skip => Some(UserInput::Skip), - MenuItem::Fail => Some(UserInput::Fail), + MenuItem::Fail => { + // Open the reason submenu, leaving Fail highlighted and the + // underlying value untouched so Esc can back out cleanly. + self.reason = Some(Reason { + buffer: String::new(), + cursor: 0, + }); + None + } MenuItem::Quit => Some(UserInput::Quit), }, KeyCode::Esc => { @@ -380,6 +413,27 @@ impl Interaction { } } + /// Fold a key into the fail-reason submenu. `` settles as + /// `Fail(reason)` (an empty reason is taken as given); `` closes the + /// submenu back to the menu with Fail still highlighted; the rest edit the + /// inline buffer. + fn reason_key(&mut self, code: KeyCode) -> Option { + let reason = self + .reason + .as_mut()?; + match code { + KeyCode::Enter => Some(UserInput::Fail(std::mem::take(&mut reason.buffer))), + KeyCode::Esc => { + self.reason = None; + None + } + other => { + text_key(&mut reason.buffer, &mut reason.cursor, other); + None + } + } + } + fn field_key(&mut self, code: KeyCode) -> Option { if let KeyCode::Esc = code { self.menu = Some(self.first_enabled()); @@ -399,30 +453,12 @@ impl Interaction { Some(UserInput::Done(std::mem::replace(original, Value::Unitus))) } } - KeyCode::Char(c) => { - buffer.insert(*cursor, c); - *cursor += c.len_utf8(); - *edited = true; - None - } - KeyCode::Backspace => { - if *cursor > 0 { - let start = prev_boundary(buffer, *cursor); - buffer.replace_range(start..*cursor, ""); - *cursor = start; + other => { + if text_key(buffer, cursor, other) { *edited = true; } None } - KeyCode::Left => { - *cursor = prev_boundary(buffer, *cursor); - None - } - KeyCode::Right => { - *cursor = next_boundary(buffer, *cursor); - None - } - _ => None, }, Field::Frozen { produced } => match code { KeyCode::Enter => Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))), @@ -460,6 +496,18 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { // triangle; only the line's content changes to the menu choices. write!(out, "{}", PROMPT_TEXT)?; render_menu(out, interaction, active)?; + if let Some(reason) = &interaction.reason { + write!(out, "{}{}", REASON_PREFIX, reason.buffer)?; + let col = PROMPT_WIDTH + + menu_width() + + REASON_PREFIX + .chars() + .count() as u16 + + reason.buffer[..reason.cursor] + .chars() + .count() as u16; + queue!(out, cursor::MoveToColumn(col))?; + } } None => match &interaction.field { Field::Edit { buffer, cursor, .. } => { @@ -587,6 +635,60 @@ fn next_boundary(s: &str, i: usize) -> usize { .map_or(i, |c| i + c.len_utf8()) } +/// Apply one text-editing key to a buffer and cursor, returning whether the +/// buffer's content changed (an insertion or deletion) as opposed to a cursor +/// move or an unhandled key. Callers tracking an `edited` flag set it on a +/// `true` return; `` / `` are the caller's, not handled here. +fn text_key(buffer: &mut String, cursor: &mut usize, code: KeyCode) -> bool { + match code { + KeyCode::Char(c) => { + buffer.insert(*cursor, c); + *cursor += c.len_utf8(); + true + } + KeyCode::Backspace => { + if *cursor > 0 { + let start = prev_boundary(buffer, *cursor); + buffer.replace_range(start..*cursor, ""); + *cursor = start; + true + } else { + false + } + } + KeyCode::Left => { + *cursor = prev_boundary(buffer, *cursor); + false + } + KeyCode::Right => { + *cursor = next_boundary(buffer, *cursor); + false + } + _ => false, + } +} + +/// Visible width of the full menu — every item is drawn, and the reverse / dim +/// escapes are zero-width, so this is just the laid-out label widths. Used to +/// place the sub-menu cursor after the menu when shown on the same line. +fn menu_width() -> u16 { + let mut width = 0u16; + for (i, item) in MENU + .iter() + .enumerate() + { + if i > 0 { + width += 2; + } + width += item + .label() + .chars() + .count() as u16 + + 2; + } + width +} + /// No-operator driver: writes a trace of each step to its output and takes /// the body's computed value as the step's outcome, running to completion /// or first failure. A pure-prose step (empty body value) records (). diff --git a/src/runner/runner.rs b/src/runner/runner.rs index 5846a33..a0b49ce 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -612,7 +612,7 @@ fn outcome_from(input: UserInput) -> Outcome { match input { UserInput::Done(value) => Outcome::Done(value), UserInput::Skip => Outcome::Skipped, - UserInput::Fail => Outcome::Failed(Failure::Aborted("Failed".to_string())), + UserInput::Fail(reason) => Outcome::Failed(Failure::Aborted(reason)), UserInput::Quit => Outcome::Quit, } } From eeb8ccc67316362ba134d939ddcbdc4e21f6a041 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Jun 2026 22:48:37 +1000 Subject: [PATCH 09/11] Consolidate Pause and Quit and record as Stop event --- src/main.rs | 11 ++++--- src/runner/checks/runner.rs | 11 ++++--- src/runner/checks/state.rs | 12 +++++-- src/runner/runner.rs | 62 +++++++++++++++++++++++-------------- src/runner/state.rs | 20 ++++++------ 5 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/main.rs b/src/main.rs index 362dfc4..d5726ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -797,8 +797,11 @@ fn main() { } match runner::start(mode, filename, &program, &arguments, library) { - Ok((run_id, Outcome::Quit)) => { - eprintln!("paused; resume with `technique resume {}`", run_id.render()); + Ok((run_id, Outcome::Stopped)) => { + eprintln!( + "stopped; resume with `technique resume {}`", + run_id.render() + ); std::process::exit(0); } Ok((_, _)) => std::process::exit(0), @@ -902,9 +905,9 @@ fn main() { } match runner::resume(run_id, &program, library) { - Ok(Outcome::Quit) => { + Ok(Outcome::Stopped) => { eprintln!( - "paused; continue with `technique resume {}`", + "stopped; continue with `technique resume {}`", run_id.render() ); std::process::exit(0); diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index b26e137..ad9f931 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -308,7 +308,7 @@ fn quit_propagates_and_stops_walking() { let outcome = runner .run(env) .expect("run"); - assert_eq!(outcome, Outcome::Quit); + assert_eq!(outcome, Outcome::Stopped); let prompt = runner.into_driver(); let step_fqns: Vec<&str> = prompt @@ -325,9 +325,9 @@ fn quit_propagates_and_stops_walking() { // Only the first Step was prompted; the second never fired. assert_eq!(step_fqns, vec!["/1"]); - // 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. + // Quit records the step's Begin (the operator started looking at it), then + // a Stop lifecycle line at the root path — the deliberate-stop marker that + // tells a quit from a crash. No Done/Skip/Fail for the step itself. let pfftt = fixture.pfftt_contents(); let lines: Vec<&str> = pfftt .lines() @@ -337,9 +337,10 @@ fn quit_propagates_and_stops_walking() { .is_empty() }) .collect(); - assert_eq!(lines.len(), 2); + assert_eq!(lines.len(), 3); assert!(lines[0].contains(" Start ")); assert!(lines[1].ends_with(" Begin")); + assert!(lines[2].ends_with(" / Stop")); } #[test] diff --git a/src/runner/checks/state.rs b/src/runner/checks/state.rs index 7c13b15..754ba6e 100644 --- a/src/runner/checks/state.rs +++ b/src/runner/checks/state.rs @@ -292,11 +292,11 @@ fn format_record_pins_on_disk_text() { recorded: "2026-05-17T00:28:30Z".to_string(), run_id: RunId(15003), path: "/".to_string(), - state: State::Pause, + state: State::Stop, }; assert_eq!( format_record(&record), - "2026-05-17T00:28:30Z 015003 / Pause\n" + "2026-05-17T00:28:30Z 015003 / Stop\n" ); let record = Record { @@ -442,7 +442,7 @@ fn record_round_trips_through_format_and_parse() { recorded: "2026-05-17T00:28:25Z".to_string(), run_id: RunId(1), path: "/".to_string(), - state: State::Pause, + state: State::Stop, }, Record { recorded: "2026-05-17T00:28:25Z".to_string(), @@ -510,6 +510,12 @@ fn record_round_trips_through_format_and_parse() { "1: lo\n inet 127.0.0.1/8\na quote \" and a slash \\".to_string(), ))), }, + Record { + recorded: "2026-05-14T12:00:03Z".to_string(), + run_id: RunId(1), + path: "/".to_string(), + state: State::Stop, + }, Record { recorded: "2026-05-14T12:00:03Z".to_string(), run_id: RunId(1), diff --git a/src/runner/runner.rs b/src/runner/runner.rs index a0b49ce..c31ca94 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -16,21 +16,22 @@ use crate::language; use crate::program::{ExecutableRef, Invocable, Operation, Ordinal, Program, SubroutineRef}; use crate::value::Value; -/// What executing an Operation (or evaluating a Step at any scale) -/// produced. `Done(Value)` is the natural success — for a leaf Step -/// the operator's recorded value, for a Sequence / Section / procedure -/// body the unit value once the whole subtree is finished. `Skipped` and -/// `Failed` are operator verdicts on individual Steps. `Quit` is a -/// control signal that propagates immediately up the call stack: -/// nothing is recorded, a `technique resume` would pick up where -/// this run paused. +/// What executing an Operation (or evaluating a Step at any scale) produced. +/// `Done(Value)` is the natural success — for a leaf Step the operator's +/// recorded value, for a Sequence / Section / procedure body the unit value +/// once the whole subtree is finished. `Skipped` and `Failed` are operator +/// verdicts on individual Steps. `Stopped` is a control signal that +/// propagates immediately up the call stack to halt the walk; the deliberate +/// 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), Skipped, Failed(Failure), - Quit, + Stopped, } /// Why a Step failed. @@ -356,8 +357,8 @@ impl<'i, D: Driver> Runner<'i, D> { self.path .pop(); - if let Outcome::Quit = result? { - return Ok(Outcome::Quit); + if let Outcome::Stopped = result? { + return Ok(Outcome::Stopped); } number += 1; } @@ -385,8 +386,8 @@ impl<'i, D: Driver> Runner<'i, D> { self.path .pop(); - if let Outcome::Quit = result? { - return Ok(Outcome::Quit); + if let Outcome::Stopped = result? { + return Ok(Outcome::Stopped); } } Ok(Outcome::Done(Value::Unitus)) @@ -417,7 +418,7 @@ impl<'i, D: Driver> Runner<'i, D> { }; match outcome { Outcome::Done(value) => last = value, - Outcome::Quit => return Ok(Outcome::Quit), + Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Skipped | Outcome::Failed(_) => {} } } @@ -557,7 +558,7 @@ impl<'i, D: Driver> Runner<'i, D> { .step(qualified, &description_text); let produced = match self.walk(env, body)? { - Outcome::Quit => return Ok(Outcome::Quit), + Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, Outcome::Skipped | Outcome::Failed(_) => Value::Unitus, }; @@ -566,14 +567,27 @@ impl<'i, D: Driver> Runner<'i, D> { .iter() .map(|r| r.value) .collect(); - let outcome = outcome_from( - self.driver - .ask(&choices, produced), - ); - if let Outcome::Quit = outcome { - return Ok(Outcome::Quit); + let input = self + .driver + .ask(&choices, produced); + + // Quit halts the walk and is recorded as a Stop lifecycle event at the + // root path, distinguishing a deliberate stop from a crash (which records + // nothing). This step's Begin stands without a matching outcome, so + // resume re-runs it. + if let UserInput::Quit = input { + let suspend = Record { + recorded: now_iso8601(), + run_id, + path: "/".to_string(), + state: State::Stop, + }; + self.appender + .append(&suspend)?; + return Ok(Outcome::Stopped); } + let outcome = outcome_from(input); let record = Record { recorded: now_iso8601(), run_id, @@ -613,7 +627,7 @@ fn outcome_from(input: UserInput) -> Outcome { UserInput::Done(value) => Outcome::Done(value), UserInput::Skip => Outcome::Skipped, UserInput::Fail(reason) => Outcome::Failed(Failure::Aborted(reason)), - UserInput::Quit => Outcome::Quit, + UserInput::Quit => Outcome::Stopped, } } @@ -633,7 +647,9 @@ fn record_state(outcome: &Outcome) -> State { Outcome::Failed(Failure::Aborted(reason)) => State::Fail(Some(RecordValue::Tablet( format!("[ reason = \"{}\" ]", reason), ))), - Outcome::Quit => unreachable!("Quit is not recorded"), + Outcome::Stopped => { + unreachable!("Stop is recorded as a lifecycle event, not a step result") + } } } diff --git a/src/runner/state.rs b/src/runner/state.rs index 4c457f9..b05dfab 100644 --- a/src/runner/state.rs +++ b/src/runner/state.rs @@ -45,9 +45,11 @@ pub struct Record { } /// A lifecycle or step-outcome event; the keyword written into each PFFTT -/// record line. `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`). +/// record line. `Start`, `Stop`, 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`). `Stop` records a deliberate +/// quit — the run stays resumable, and the record distinguishes the quit from a +/// crash (which records nothing). /// `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. @@ -55,7 +57,7 @@ pub struct Record { #[derive(Debug, Clone, Eq, PartialEq)] pub enum State { Start { uri: String }, - Pause, + Stop, Resume, Invoke(InvokeTarget), Execute { function: String }, @@ -173,7 +175,7 @@ impl Store { /// 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 + /// into a set of completed step paths. `Stop` and `Resume` records /// are passed over. pub fn open(&self, run_id: RunId) -> Result<(PathBuf, HashSet, PathBuf), RunnerError> { let run_dir = self @@ -220,7 +222,7 @@ impl Store { completed.insert(record.path); } State::Start { .. } - | State::Pause + | State::Stop | State::Resume | State::Invoke(_) | State::Execute { .. } @@ -372,7 +374,7 @@ fn format_state(out: &mut String, state: &State) { out.push_str("Start "); out.push_str(uri); } - State::Pause => out.push_str("Pause"), + State::Stop => out.push_str("Stop"), State::Resume => out.push_str("Resume"), State::Invoke(target) => { out.push_str("Invoke "); @@ -504,11 +506,11 @@ fn parse_state(text: &str) -> Result { uri: uri.to_string(), }) } - "Pause" => { + "Stop" => { if rest.is_some() { return Err(RecordError::MalformedState); } - Ok(State::Pause) + Ok(State::Stop) } "Resume" => { if rest.is_some() { From ec15407ddfe18fbe8944861b56b780ccb4023e0f Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Jun 2026 22:50:28 +1000 Subject: [PATCH 10/11] Consolidate UX into a single active line --- src/runner/checks/driver.rs | 14 ++--- src/runner/driver.rs | 110 ++++++++++++++++++------------------ 2 files changed, 61 insertions(+), 63 deletions(-) diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index 808cb90..cae3b47 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -350,7 +350,6 @@ fn render_frozen_shows_only_triangle() { assert!(!written.contains('\n')); assert!(!written.contains("eth0")); assert!(written.contains('▶')); - assert!(!written.contains("[enter]")); } #[test] @@ -375,9 +374,9 @@ fn render_edit_shows_candidate_text() { } #[test] -fn render_reason_submenu_on_menu_line() { - // Selecting Fail shows the reason prompt and the typed buffer on the same - // line as the still-listed menu items. +fn render_reason_replaces_menu() { + // Choosing Fail replaces the menu with the reason prompt on the same line, + // keeping the ▶ prefix; the menu items are gone, and it stays one line. let mut it = Interaction::begin(&[], Value::Unitus); it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); it.handle(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); @@ -387,9 +386,10 @@ fn render_reason_submenu_on_menu_line() { let mut out: Vec = Vec::new(); draw(&mut out, &it).expect("draw"); let written = String::from_utf8(out).expect("utf8"); - assert!(written.contains("Fail")); - assert!(written.contains("Reason?")); - assert!(written.contains("ok")); + assert!(!written.contains('\n')); + assert!(written.contains('▶')); + assert!(written.contains("Reason? ok")); + assert!(!written.contains("Skip")); } #[test] diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 02e1cb2..98e7992 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -28,7 +28,9 @@ pub enum Mode { } /// The person executing each step indicates a verdict on each prompt as -/// follows: +/// follows. `Quit` stops the run (also Ctrl-C); the run stays resumable and is +/// recorded as a `Stop` lifecycle event, so a deliberate stop is distinguishable +/// from a crash. #[derive(Debug, Clone, PartialEq)] pub enum UserInput { Done(Value), @@ -136,10 +138,11 @@ impl Driver for Console { }; let _ = disable_raw_mode(); // The prompt is a live affordance, not a record: clear it on settle so - // the next state line reuses the row rather than leaving a trail of - // spent ▶ lines. + // the next state line reuses the row rather than leaving spent ▶ lines. + // Restore the caret, hidden while a menu was shown. let _ = queue!( self.output, + cursor::Show, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine) ); @@ -180,7 +183,7 @@ fn render_leave(out: &mut W, qualified: &str) { } /// One Esc-menu option. `Edit` reshapes the produced value in place; the rest -/// are the step exits. Navigation order is the slice order in `menu_items`. +/// are the step exits. Navigation order is the slice order in `MENU`. #[derive(Debug, Clone, Copy, PartialEq)] enum MenuItem { Edit, @@ -216,11 +219,9 @@ const MENU: [MenuItem; 4] = [ const PROMPT_TEXT: &str = "▶ "; const PROMPT_WIDTH: u16 = 2; -/// Appended to the menu line (after the items) when soliciting a reason for -/// e.g. failure; the typed buffer follows it. The Fail menu item stays -/// selected while it is shown, so it reads as a submenu rather than another -/// separate mode. -const REASON_PREFIX: &str = " Reason? "; +/// Shown on the prompt line (after the `▶` prefix) when soliciting a reason for +/// e.g. failure, replacing the menu; the typed buffer follows it. +const REASON_PREFIX: &str = "Reason? "; /// Longest scalar offered as an inline-editable candidate. A longer (or /// multi-line) value can't be edited on one line, so it falls back to the @@ -351,7 +352,7 @@ impl Interaction { { if let KeyCode::Char('c') = key.code { // Believe it or not we actually have to handle + - // explicitly when in raw mode! + // explicitly when in raw mode! It reads as a blunt Quit. return Some(UserInput::Quit); } } @@ -486,43 +487,56 @@ impl Interaction { } } -/// Draw the current interaction state onto a single, repeatedly-cleared -/// terminal line, leaving the edit cursor at the right column. +/// Draw the current interaction state onto the repeatedly-cleared prompt line. +/// One line throughout: the confirm triangle, the Edit / response candidate, the +/// menu, or — once Fail is chosen — the `Reason?` entry, which replaces the menu +/// on the same line (keeping the `▶` prefix). The caret shows only where input +/// is awaited at a point (confirm prompt, Edit buffer, reason field) and is +/// hidden where a reverse-video highlight is the indicator (menu, choices). fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; + + // Where the caret lands; `None` hides it. + let mut cursor_col: Option = None; match interaction.menu { Some(active) => { // The step is still running (timer ticking), so keep the same - // triangle; only the line's content changes to the menu choices. + // triangle; only the line's content changes. write!(out, "{}", PROMPT_TEXT)?; - render_menu(out, interaction, active)?; - if let Some(reason) = &interaction.reason { - write!(out, "{}{}", REASON_PREFIX, reason.buffer)?; - let col = PROMPT_WIDTH - + menu_width() - + REASON_PREFIX - .chars() - .count() as u16 - + reason.buffer[..reason.cursor] - .chars() - .count() as u16; - queue!(out, cursor::MoveToColumn(col))?; + match &interaction.reason { + Some(reason) => { + write!(out, "{}{}", REASON_PREFIX, reason.buffer)?; + cursor_col = Some( + PROMPT_WIDTH + + REASON_PREFIX + .chars() + .count() as u16 + + reason.buffer[..reason.cursor] + .chars() + .count() as u16, + ); + } + None => render_menu(out, interaction, active)?, } } None => match &interaction.field { Field::Edit { buffer, cursor, .. } => { write!(out, "{}{}", PROMPT_TEXT, buffer)?; - let col = PROMPT_WIDTH - + buffer[..*cursor] - .chars() - .count() as u16; - queue!(out, cursor::MoveToColumn(col))?; + cursor_col = Some( + PROMPT_WIDTH + + buffer[..*cursor] + .chars() + .count() as u16, + ); } Field::Frozen { .. } => { // The value (if any) was already shown above; the normal prompt - // is just the "play" triangle. The exit/edit options are not - // advertised here — they appear only once opens the menu. + // is just the "play" triangle. The caret rests just past it: the + // step is awaiting input (an to confirm, or typed text + // once editing). The exit/edit options are not advertised here — + // they appear only once opens the menu. write!(out, "{}", PROMPT_TEXT)?; + cursor_col = Some(PROMPT_WIDTH); } Field::Choose { choices, active } => { write!(out, "{}", PROMPT_TEXT)?; @@ -534,6 +548,11 @@ fn draw(out: &mut W, interaction: &Interaction) -> io::Result<()> { } }, } + + match cursor_col { + Some(col) => queue!(out, cursor::Show, cursor::MoveToColumn(col))?, + None => queue!(out, cursor::Hide)?, + } out.flush() } @@ -571,14 +590,14 @@ fn render_menu(out: &mut W, interaction: &Interaction, active: usize) let label = item.label(); if i == active { queue!(out, SetAttribute(Attribute::Reverse))?; - write!(out, " {} ", label)?; + write!(out, "{}", label)?; queue!(out, SetAttribute(Attribute::Reset))?; } else if !interaction.enabled(*item) { queue!(out, SetAttribute(Attribute::Dim))?; - write!(out, " {} ", label)?; + write!(out, "{}", label)?; queue!(out, SetAttribute(Attribute::Reset))?; } else { - write!(out, " {} ", label)?; + write!(out, "{}", label)?; } } Ok(()) @@ -668,27 +687,6 @@ fn text_key(buffer: &mut String, cursor: &mut usize, code: KeyCode) -> bool { } } -/// Visible width of the full menu — every item is drawn, and the reverse / dim -/// escapes are zero-width, so this is just the laid-out label widths. Used to -/// place the sub-menu cursor after the menu when shown on the same line. -fn menu_width() -> u16 { - let mut width = 0u16; - for (i, item) in MENU - .iter() - .enumerate() - { - if i > 0 { - width += 2; - } - width += item - .label() - .chars() - .count() as u16 - + 2; - } - width -} - /// No-operator driver: writes a trace of each step to its output and takes /// the body's computed value as the step's outcome, running to completion /// or first failure. A pure-prose step (empty body value) records (). From e47ccfdc7881c092df4c6d023e7fbf16171e2454 Mon Sep 17 00:00:00 2001 From: Andrew Cowie Date: Wed, 10 Jun 2026 22:51:36 +1000 Subject: [PATCH 11/11] Update dependencies --- Cargo.lock | 30 +++++++++++++++--------------- Cargo.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ced17e..5d67936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bstr" @@ -163,7 +163,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot", @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -286,9 +286,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lsp-server" @@ -435,14 +435,14 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -463,9 +463,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -473,7 +473,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -486,7 +486,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -627,7 +627,7 @@ dependencies = [ [[package]] name = "technique" -version = "0.5.7" +version = "0.6.0" dependencies = [ "clap", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 90b7cb6..70f9c32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "technique" -version = "0.5.7" +version = "0.6.0" edition = "2021" description = "A domain specific language for procedures." authors = [ "Andrew Cowie" ]