diff --git a/Cargo.lock b/Cargo.lock index c82acf0..5869ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -382,6 +388,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -675,6 +693,7 @@ dependencies = [ "ignore", "lsp-server", "lsp-types", + "nix", "owo-colors", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9eebc0a..505812f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ crossterm = "0.29" ignore = "0.4" lsp-server = "0.7.9" lsp-types = "0.97" +nix = { version = "0.29", features = ["poll"] } owo-colors = "4" regex = "1.11.1" serde = { version = "1.0.209", features = [ "derive" ] } diff --git a/src/main.rs b/src/main.rs index 26cf923..e8f77ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -839,7 +839,7 @@ fn main() { Some("automatic") => Mode::Automatic, Some("quiet") => Mode::Quiet, Some("interactive") => Mode::Interactive, - _ => unreachable!() + _ => unreachable!(), } }; @@ -967,6 +967,7 @@ fn main() { ); std::process::exit(0); } + Ok((_, Outcome::Failed(_) | Outcome::Throw(_))) => std::process::exit(1), Ok((_, _)) => std::process::exit(0), Err(error) => { eprintln!("{}", problem::concise_runner_error(&error, &Terminal)); diff --git a/src/runner/checks/driver.rs b/src/runner/checks/driver.rs index f2aa805..5450bd8 100644 --- a/src/runner/checks/driver.rs +++ b/src/runner/checks/driver.rs @@ -1,7 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::runner::driver::{ - draw, edit, is_list_forma, Automatic, Console, Driver, Event, Interaction, Mock, UserInput, + draw, edit, is_list_forma, Automatic, Console, Driver, Event, Interaction, Mock, Standing, + UserInput, }; use crate::value::{Numeric, Value}; @@ -160,6 +161,51 @@ fn default_enter_completes_with_produced() { ); } +#[test] +fn overrule_fail_enter_propagates() { + // At a failed sign-off the default is the failure itself: a bare Enter + // settles Fail and never silently lifts it. + let mut it = Interaction::overrule(Standing::Fail); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Fail(String::new())) + ); +} + +#[test] +fn overrule_fail_menu_o_overrides() { + // Override is reachable only deliberately — from the menu — and settles as + // Override, which the runner lifts to a rollup-severing Done. + let mut it = Interaction::overrule(Standing::Fail); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), + Some(UserInput::Override) + ); +} + +#[test] +fn overrule_skip_enter_propagates() { + // An all-skipped scope defaults to Skip, not Done. + let mut it = Interaction::overrule(Standing::Skip); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(UserInput::Skip) + ); +} + +#[test] +fn override_inert_without_a_failure() { + // A Skip standing has nothing to override, so the menu's `o` is greyed and + // does nothing. + let mut it = Interaction::overrule(Standing::Skip); + it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + it.handle(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), + None + ); +} + #[test] fn esc_edit_typed_enter_returns_literali() { // Editing is opt-in: Esc -> Edit (the first menu item) opens the buffer @@ -511,6 +557,7 @@ fn list_prompt() -> Interaction { field: edit(String::new(), Value::Literali(String::new()), true), menu: None, reason: None, + standing: Standing::Done, } } diff --git a/src/runner/checks/evaluator.rs b/src/runner/checks/evaluator.rs index db6789b..6bc41d2 100644 --- a/src/runner/checks/evaluator.rs +++ b/src/runner/checks/evaluator.rs @@ -9,7 +9,7 @@ use crate::value; #[test] fn variable_lookup() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Variable(Identifier::new("missing")); let mut env = Environment::new(); match evaluate(&library, &context, &mut env, &op) { @@ -30,7 +30,7 @@ fn variable_lookup() { #[test] fn number_evaluates_to_quanticle() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Number(LangNumeric::Integral(42)); let mut env = Environment::new(); let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); @@ -40,7 +40,7 @@ fn number_evaluates_to_quanticle() { #[test] fn string_interpolation() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "name".to_string(), @@ -68,7 +68,7 @@ fn string_interpolation() { #[test] fn multiline_joins_with_newlines() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Multiline(None, vec!["foo", "bar", "baz"]); let mut env = Environment::new(); let v = evaluate(&library, &context, &mut env, &op).expect("evaluated"); @@ -78,7 +78,7 @@ fn multiline_joins_with_newlines() { #[test] fn tablet_entries_evaluate() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Tablet(vec![ Entry { label: "name", @@ -109,7 +109,7 @@ fn tablet_entries_evaluate() { #[test] fn list_elements_evaluate() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::List(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(4)), @@ -130,7 +130,7 @@ fn list_elements_evaluate() { #[test] fn bind_extends_env_for_subsequent_lookup() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let names = [Identifier::new("greeting")]; let bind = Operation::Bind { names: &names, @@ -150,7 +150,7 @@ fn bind_extends_env_for_subsequent_lookup() { #[test] fn sequence_evaluation() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let seq = Operation::Sequence(vec![ Operation::Number(LangNumeric::Integral(1)), Operation::Number(LangNumeric::Integral(2)), @@ -173,7 +173,7 @@ fn sequence_evaluation() { #[test] fn multi_name_bind_destructures_parametriq() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "triple".to_string(), @@ -212,7 +212,7 @@ fn multi_name_bind_destructures_parametriq() { #[test] fn multi_name_bind_wrong_arity_errors() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "pair".to_string(), @@ -243,7 +243,7 @@ fn multi_name_bind_wrong_arity_errors() { #[test] fn multi_name_bind_against_scalar_errors_as_not_tuple() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let mut env = Environment::new(); env.extend( "scalar".to_string(), @@ -266,7 +266,7 @@ fn multi_name_bind_against_scalar_errors_as_not_tuple() { #[test] fn execute_dispatches_resolved_builtin() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let id = library .resolve("seq") .expect("seq registered"); @@ -292,7 +292,7 @@ fn execute_dispatches_resolved_builtin() { #[test] fn execute_unresolved_function_errors() { let library = Library::core(); - let context = Context::native(); + let context = Context::native(false); let op = Operation::Execute(Executable { target: ExecutableRef::Unresolved(Identifier::new("click")), arguments: Vec::new(), diff --git a/src/runner/checks/runner.rs b/src/runner/checks/runner.rs index 253e050..62a32bf 100644 --- a/src/runner/checks/runner.rs +++ b/src/runner/checks/runner.rs @@ -1254,9 +1254,10 @@ check : } #[test] -fn automatic_failing_exec_fails_step_and_continues() { - // A non-zero exec exit settles its step Fail; the walk continues to the - // sibling below rather than aborting the run. +fn automatic_failing_exec_fails_run_and_continues() { + // A non-zero exec exit settles its step Fail and, with no user present to + // overrule it, rolls the autonomous run up to Failed; the walk still + // continues to the sibling below rather than aborting at the failure. let source = r#" % technique v1 @@ -1286,8 +1287,8 @@ check : .run(Environment::new()) .expect("run"); match outcome { - Outcome::Done(_) => {} - other => panic!("expected Done, got {:?}", other), + Outcome::Failed(_) => {} + other => panic!("expected Failed, got {:?}", other), } let pfftt = fixture.pfftt_contents(); @@ -1310,6 +1311,71 @@ check : .any(|r| r.path == "/check:/2")); } +const ONE_FAILED_STEP: &str = r#" +% technique v1 + +check : + +1. A step the user fails + "#; + +#[test] +fn interactive_override_severs_the_rollup_to_done() { + // The user fails the step, then deliberately Overrides the procedure's + // sign-off: the override settles it Done, severing the rollup so the failed + // child does not propagate. Only an interactive run can do this. + let source = ONE_FAILED_STEP.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("interactive-override"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + Mock::with_answers([UserInput::Fail("not done".to_string()), UserInput::Override]), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Done(_) => {} + other => panic!("expected Done after override, got {:?}", other), + } +} + +#[test] +fn interactive_accepting_a_failure_propagates() { + // Without an Override, the failed step stands: the procedure rolls up to + // Failed even with the user at the controls. + let source = ONE_FAILED_STEP.trim_ascii(); + let document = parsing::parse(Path::new("Test.tq"), source).expect("parsed"); + let mut program = translate(&document).expect("translated"); + resolve(&mut program).expect("resolve"); + + let mut fixture = StoreFixture::new("interactive-propagate"); + let mut runner = Runner::new( + &program, + fixture.take_appender(), + HashMap::new(), + // The step's Fail, then the sign-off accepts the standing failure. + Mock::with_answers([ + UserInput::Fail("not done".to_string()), + UserInput::Fail(String::new()), + ]), + Library::stub(), + ); + let outcome = runner + .run(Environment::new()) + .expect("run"); + match outcome { + Outcome::Failed(_) => {} + other => panic!("expected Failed, got {:?}", other), + } +} + #[test] fn description_instruction_records_under_step_zero() { // An exec in the procedure description runs in the anonymous step-0 diff --git a/src/runner/context.rs b/src/runner/context.rs index 5bf1252..6c7e442 100644 --- a/src/runner/context.rs +++ b/src/runner/context.rs @@ -12,8 +12,18 @@ pub struct Context { sink: Sink, } -enum Sink { +/// Which of a child's two output streams a run of bytes came from; `write_run` +/// reddens `Stderr` on a colour terminal so the user can tell them apart. +#[derive(Clone, Copy)] +pub enum Stream { Stdout, + Stderr, +} + +enum Sink { + // The terminal. `colour` records whether ANSI escapes are wanted, decided + // by the caller from the `raw_output || is_terminal()` gate. + Stdout { colour: bool }, // An in-memory buffer, used by tests. Kept a distinct variant from // `Stdout` so the terminal path carries no interior-mutability wrapper. // The `RefCell` lets `write(&self)` append through the shared reference @@ -22,11 +32,13 @@ enum Sink { } impl Context { - /// The default context of native host capabilities, writing through to - /// standard output. Builtins that are pure functions to manipulate Values - /// ignore it. - pub fn native() -> Self { - Context { sink: Sink::Stdout } + /// The default context, writing through to standard output. `colour` says + /// whether builtins that tee child output (e.g. `exec`) may tag it with + /// ANSI escapes; pure builtins ignore the context entirely. + pub fn native(colour: bool) -> Self { + Context { + sink: Sink::Stdout { colour }, + } } /// A context that captures everything written through it in memory, for @@ -46,7 +58,7 @@ impl Context { /// calls `flush()` so output appears to the user live. pub fn write(&self, bytes: &[u8]) -> io::Result<()> { match &self.sink { - Sink::Stdout => { + Sink::Stdout { .. } => { let mut out = io::stdout(); out.write_all(bytes)?; out.flush() @@ -60,6 +72,24 @@ impl Context { } } + /// Tee a run of child output to the user, making the foreground colour + /// red for any `Stream::Stderr` output when run in a terminal. The reset + /// every time is necessary in case the user interrupts with Ctrl+C, + /// preventing the subsequent output from incorrectly being in red. + pub fn write_run(&self, run: &[u8], stream: Stream) -> io::Result<()> { + let red = match stream { + Stream::Stderr => self.colour(), + Stream::Stdout => false, + }; + if red { + self.write(b"\x1b[31m")?; + self.write(run)?; + self.write(b"\x1b[0m") + } else { + self.write(run) + } + } + /// Pass a complete, already known, text string through to the user. This /// is just a convenience over `write()` for whole messages such as status /// lines or announcements. @@ -68,13 +98,21 @@ impl Context { self.write(message.as_bytes()) } + /// Whether this sink is a colour-capable terminal; a capture buffer is not. + fn colour(&self) -> bool { + match &self.sink { + Sink::Stdout { colour } => *colour, + Sink::Capture(_) => false, + } + } + /// The bytes captured by a `capture()` context; empty for a native one. pub fn captured(&self) -> Vec { match &self.sink { Sink::Capture(buffer) => buffer .borrow() .clone(), - Sink::Stdout => Vec::new(), + Sink::Stdout { .. } => Vec::new(), } } } diff --git a/src/runner/driver.rs b/src/runner/driver.rs index 9b357f1..5088457 100644 --- a/src/runner/driver.rs +++ b/src/runner/driver.rs @@ -16,7 +16,7 @@ use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::style::{ Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor, Stylize, }; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, size, Clear, ClearType}; use crossterm::{cursor, queue}; use super::path::display_path; @@ -44,9 +44,21 @@ pub enum UserInput { Done(Value), Skip, Fail(String), + /// Deliberately accept a node whose child failed, settling it `Done ()` and + /// severing the rollup so the failure does not propagate above. Reachable + /// only from the `` menu at a failed node, never the Enter default. + Override, Quit, } +/// The default verdict a node's rolled-up body leaves standing at its sign-off. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Standing { + Done, + Skip, + Fail, +} + /// What the walker uses to drive a run. Implementations are the interactive /// console `Console`, the no-operator `Automatic`, the no-output `Headless`, /// the debugging `Transcript`, and the test `Mock`. @@ -139,6 +151,17 @@ pub trait Driver { /// drives unattended `Done`/`Skip` as in `ask`. fn seal(&mut self, qualified: &str, produced: Value, computable: bool) -> UserInput; + /// Sign off a node that rolled up to a failure or skip. When running + /// interactively a failure may be Overridden to Done. + fn overrule(&mut self, qualified: &str, marker: &str, standing: Standing) -> UserInput { + let _ = (qualified, marker); + match standing { + Standing::Done => UserInput::Done(Value::Unitus), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + } + } + /// Render the settled verdict line for a step or scope close: `marker` /// (`→` step, `↙` scope close), Qualified Name, and the verdict's glyph. /// Quit renders nothing. @@ -241,7 +264,11 @@ impl Driver for Console { if let UserInput::Quit = input { } else { let renderer = self.renderer(); - write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), renderer); + write_marker_line( + &mut self.output, + &format!("⇒ {}", display_path(qualified)), + renderer, + ); } input } @@ -262,6 +289,10 @@ impl Driver for Console { prompt(&mut self.output, qualified, "↙", &[], produced) } + fn overrule(&mut self, qualified: &str, marker: &str, standing: Standing) -> UserInput { + prompt_overrule(&mut self.output, qualified, marker, standing) + } + fn settle(&mut self, marker: &str, qualified: &str, verdict: &UserInput) { let qualified = display_path(qualified); let renderer = self.renderer(); @@ -304,6 +335,21 @@ fn prompt( result } +fn prompt_overrule( + out: &mut W, + qualified: &str, + marker: &str, + standing: Standing, +) -> UserInput { + let qualified = display_path(qualified); + let result = interact(out, Interaction::overrule(standing), |o, i| { + draw(o, &qualified, marker, i) + }); + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); + let _ = out.flush(); + result +} + /// Present a read-only action on the live prompt line — `» {path} {verb} {label} ▶` /// — and settle on the user's verdict. On Done it leaves a compact dark-grey /// trace `» {path} {name}()`; Skip / Fail / Quit clear the line for the step's @@ -334,6 +380,8 @@ fn prompt_action( /// becoming the '$', reminiscent of a shell. fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserInput { let qualified = display_path(qualified); + // Seed the editable line + let script = script.trim_end(); let field = edit( script.to_string(), Value::Literali(script.to_string()), @@ -345,42 +393,19 @@ fn prompt_command(out: &mut W, qualified: &str, script: &str) -> UserI field, menu: None, reason: None, + standing: Standing::Done, }, |o, i| draw(o, &qualified, "→", i), ); - let col = "→" - .chars() - .count() as u16 - + 1 - + qualified - .chars() - .count() as u16 - + 1; - match &result { - UserInput::Done(produced) => { - let ran = if let Value::Literali(text) = produced { - text.trim_end() - } else { - script.trim_end() - }; - let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); - let _ = writeln!(out, "{}", format!("→ {} $ {}", qualified, ran).dark_grey()); - } - UserInput::Quit => { - let _ = writeln!(out); - } - UserInput::Skip => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "⊘".yellow()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } - UserInput::Fail(_) => { - let _ = queue!(out, cursor::MoveToColumn(col)); - let _ = write!(out, "{}", "✗".red()); - let _ = queue!(out, Clear(ClearType::UntilNewLine)); - let _ = writeln!(out); - } + + let _ = queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine)); + if let UserInput::Done(produced) = &result { + let ran = if let Value::Literali(text) = produced { + text.trim_end() + } else { + script.trim_end() + }; + let _ = writeln!(out, "{}", format!("→ {} $ {}", qualified, ran).dark_grey()); } let _ = out.flush(); result @@ -397,6 +422,7 @@ fn prompt_acquire(out: &mut W, label: &str, list: bool) -> UserInput { field, menu: None, reason: None, + standing: Standing::Done, }, |o, i| draw(o, label, "↘", i), ); @@ -484,7 +510,7 @@ fn render_enter(out: &mut W, qualified: &str, renderer: &dyn Render) { /// renders no glyph). fn verdict_glyph(verdict: &UserInput) -> Option<(&'static str, Syntax)> { match verdict { - UserInput::Done(_) => Some(("✓", Syntax::Done)), + UserInput::Done(_) | UserInput::Override => Some(("✓", Syntax::Done)), UserInput::Skip => Some(("⊘", Syntax::Skip)), UserInput::Fail(_) => Some(("✗", Syntax::Fail)), UserInput::Quit => None, @@ -509,12 +535,7 @@ fn render_settle( /// Render the run's closing `⇐` boundary line, the rolled-up verdict glyph /// following the label just as a scope's `↙` sign-off carries its own. Quit /// renders nothing, as in `render_settle`. -fn render_conclude( - out: &mut W, - label: &str, - verdict: &UserInput, - renderer: &dyn Render, -) { +fn render_conclude(out: &mut W, label: &str, verdict: &UserInput, renderer: &dyn Render) { let (glyph, syntax) = match verdict_glyph(verdict) { Some(pair) => pair, None => return, @@ -529,7 +550,10 @@ fn render_conclude( /// a shell prompt (distinct from the operator's `▶` edit prompt). Trailing /// whitespace is trimmed so a code block's blank tail line is not echoed. fn render_command(out: &mut W, qualified: &str, script: &str, renderer: &dyn Render) { - let line = renderer.style(Syntax::Marker, &format!("{} $ {}", qualified, script.trim_end())); + let line = renderer.style( + Syntax::Marker, + &format!("{} $ {}", qualified, script.trim_end()), + ); let _ = writeln!(out, "{}", line); } @@ -550,6 +574,7 @@ enum MenuItem { Edit, Skip, Fail, + Override, Quit, } @@ -559,18 +584,21 @@ impl MenuItem { MenuItem::Edit => "Edit", MenuItem::Skip => "Skip", MenuItem::Fail => "Fail", + MenuItem::Override => "Override", MenuItem::Quit => "Quit", } } } -/// 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] = [ +/// The Esc-menu, always shown in full. `Edit` leads but is greyed unless the +/// produced value is an editable scalar; `Override` is greyed unless a child +/// failed, so the menu teaches both which steps can be edited and that a +/// failure can be deliberately accepted. +const MENU: [MenuItem; 5] = [ MenuItem::Edit, MenuItem::Skip, MenuItem::Fail, + MenuItem::Override, MenuItem::Quit, ]; @@ -666,6 +694,10 @@ struct Interaction { field: Field, menu: Option, reason: Option, + /// The node's rolled-up default — `Done` for an ordinary prompt, `Fail` or + /// `Skip` at an `overrule`. Colours the `▶`, enables `Override` (only on + /// `Fail`), and is where the `` menu opens. + standing: Standing, } impl Interaction { @@ -698,31 +730,64 @@ impl Interaction { field, menu: None, reason: None, + standing: Standing::Done, + } + } + + /// Seed an interaction to sign off a node whose body rolled up to `standing` + /// (`Fail` or `Skip`). No value to accept, so the field is a frozen Unit; + /// the standing colours the `▶` and, on `Fail`, lights up `Override`. + fn overrule(standing: Standing) -> Self { + Interaction { + field: Field::Frozen { + produced: Value::Unitus, + }, + menu: None, + reason: None, + standing, } } - /// 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. + /// Whether a menu item is currently selectable. `Edit` is offered only when + /// the produced value is an editable scalar; `Override` only when a child + /// failed (`standing` is `Fail`); 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, }, + MenuItem::Override => self.standing == Standing::Fail, _ => true, } } - /// First selectable item, where the menu opens. The exits are always - /// enabled, so the fallback never fires. + /// First selectable item. 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) } + /// Where the menu opens: on the node's standing verdict at an overrule (so + /// the cursor rests on what Enter would settle), otherwise the first + /// selectable item. + fn landing(&self) -> usize { + let target = match self.standing { + Standing::Fail => Some(MenuItem::Fail), + Standing::Skip => Some(MenuItem::Skip), + Standing::Done => None, + }; + target + .and_then(|item| { + MENU.iter() + .position(|m| *m == item) + }) + .unwrap_or_else(|| self.first_enabled()) + } + /// 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 { @@ -792,6 +857,7 @@ impl Interaction { }); None } + MenuItem::Override => Some(UserInput::Override), MenuItem::Quit => Some(UserInput::Quit), } } @@ -825,6 +891,9 @@ impl Interaction { KeyCode::Enter => self.activate(MENU[active]), KeyCode::Char('s') | KeyCode::Char('S') => self.choose(MenuItem::Skip), KeyCode::Char('f') | KeyCode::Char('F') => self.choose(MenuItem::Fail), + KeyCode::Char('o') | KeyCode::Char('O') if self.enabled(MenuItem::Override) => { + self.choose(MenuItem::Override) + } KeyCode::Char('q') | KeyCode::Char('Q') => self.choose(MenuItem::Quit), KeyCode::Esc => { self.menu = None; @@ -857,9 +926,10 @@ impl Interaction { fn field_key(&mut self, code: KeyCode) -> Option { if let KeyCode::Esc = code { - self.menu = Some(self.first_enabled()); + self.menu = Some(self.landing()); return None; } + let standing = self.standing; match &mut self.field { Field::Edit { buffer, @@ -901,7 +971,15 @@ impl Interaction { } }, Field::Frozen { produced } => match code { - KeyCode::Enter => Some(UserInput::Done(std::mem::replace(produced, Value::Unitus))), + // Enter accepts the standing verdict: an ordinary prompt's Done, + // but at an overrule the failure (or skip) the body left — so + // Enter never silently lifts a failure; only the Override menu + // item does. + KeyCode::Enter => Some(match standing { + Standing::Done => UserInput::Done(std::mem::replace(produced, Value::Unitus)), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + }), _ => None, }, Field::Choose { choices, active } => match code { @@ -939,14 +1017,30 @@ fn draw( queue!(out, cursor::MoveToColumn(0), Clear(ClearType::CurrentLine))?; let prefix = prompt_prefix_width(qualified, settle); + // The `▶` previews the verdict Enter will settle: blue for an ordinary Done, + // else the failure / skip glyph's own colour so the triangle matches the + // `✗` / `⊘` it foreshadows. + let symbol = match interaction.standing { + Standing::Done => PROMPT_SYMBOL.blue(), + Standing::Fail => PROMPT_SYMBOL.with(Color::Rgb { + r: 0xcc, + g: 0x00, + b: 0x00, + }), + Standing::Skip => PROMPT_SYMBOL.with(Color::Rgb { + r: 0xc4, + g: 0xa0, + b: 0x00, + }), + }; write!( out, "{} {} ", format!("{} {}", settle, qualified).dark_grey(), - PROMPT_SYMBOL.blue(), + symbol, )?; - let cursor_col = draw_tail(out, interaction, prefix)?; - place_cursor(out, cursor_col) + let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; + place_cursor(out, cursor_col, end_col) } /// Draw the read-only action prompt line: `» {path} {verb} {label} ▶`, the verb @@ -968,32 +1062,41 @@ fn draw_action( } write!(out, " {} ", PROMPT_SYMBOL.blue())?; let prefix = action_prefix_width(qualified, verb, label); - let cursor_col = draw_tail(out, interaction, prefix)?; - place_cursor(out, cursor_col) + let (cursor_col, end_col) = draw_tail(out, interaction, prefix)?; + place_cursor(out, cursor_col, end_col) } /// Render the interaction state after the prompt prefix — the Esc menu, the -/// fail-reason buffer, or the field's content — returning the cursor column -/// (`None` hides it). Shared by the step/command prompt and the action prompt. +/// fail-reason buffer, or the field's content — returning the target cursor +/// column (`None` hides it) and the column writing finished at. Both are +/// absolute columns from the line start; when the content is wider than the +/// terminal they exceed its width and `place_cursor` resolves the wrap. Shared +/// by the step/command prompt and the action prompt. fn draw_tail( out: &mut W, interaction: &Interaction, prefix: u16, -) -> io::Result> { +) -> io::Result<(Option, u16)> { let mut cursor_col: Option = None; + let mut end_col: u16 = prefix; match interaction.menu { Some(active) => match &interaction.reason { Some(reason) => { write!(out, "{}{}", REASON_PREFIX, reason.buffer)?; + let lead = prefix + + REASON_PREFIX + .chars() + .count() as u16; cursor_col = Some( - prefix - + REASON_PREFIX - .chars() - .count() as u16 - + reason.buffer[..reason.cursor] - .chars() - .count() as u16, + lead + reason.buffer[..reason.cursor] + .chars() + .count() as u16, ); + end_col = lead + + reason + .buffer + .chars() + .count() as u16; } None => render_menu(out, interaction, active)?, }, @@ -1018,6 +1121,11 @@ fn draw_tail( .chars() .count() as u16, ); + end_col = prefix + + buffer + .chars() + .count() as u16 + + if *bracketed { 2 } else { 0 }; } Field::Frozen { .. } => { cursor_col = Some(prefix); @@ -1031,14 +1139,44 @@ fn draw_tail( } }, } - Ok(cursor_col) + Ok((cursor_col, end_col)) } /// Position (or hide) the cursor after a prompt line is drawn, then flush. -fn place_cursor(out: &mut W, cursor_col: Option) -> io::Result<()> { +/// `end_col` is where writing left the cursor; `cursor_col` is where it belongs. +/// When the content wrapped, an absolute `MoveToColumn` past the right margin +/// clamps to the edge, so the target column is resolved against the terminal +/// width into a move back from the end instead. +fn place_cursor(out: &mut W, cursor_col: Option, end_col: u16) -> io::Result<()> { match cursor_col { - Some(col) => queue!(out, cursor::Show, cursor::MoveToColumn(col))?, - None => queue!(out, cursor::Hide)?, + None => { + queue!(out, cursor::Hide)?; + } + Some(target) if target >= end_col => { + // The cursor belongs at the end of what was just written, which is + // exactly where writing left it — moving would only fight the wrap. + queue!(out, cursor::Show)?; + } + Some(target) => { + let width = size() + .map(|(cols, _)| cols) + .unwrap_or(80) + .max(1); + // The physical cursor sits at the end; step up to the target's row + // (a column that exactly fills a row leaves the cursor on it, not the + // next) and across to its column. + let end_row = if end_col % width == 0 { + end_col / width - 1 + } else { + end_col / width + }; + let up = end_row - target / width; + queue!(out, cursor::Show)?; + if up > 0 { + queue!(out, cursor::MoveUp(up))?; + } + queue!(out, cursor::MoveToColumn(target % width))?; + } } out.flush() } @@ -1301,7 +1439,11 @@ impl Driver for Automatic { } fn depart(&mut self, qualified: &str) -> UserInput { - write_marker_line(&mut self.output, &format!("⇒ {}", display_path(qualified)), self.renderer); + write_marker_line( + &mut self.output, + &format!("⇒ {}", display_path(qualified)), + self.renderer, + ); UserInput::Done(Value::Unitus) } @@ -1310,7 +1452,12 @@ impl Driver for Automatic { } fn command(&mut self, qualified: &str, script: &str) -> UserInput { - render_command(&mut self.output, &display_path(qualified), script, self.renderer); + render_command( + &mut self.output, + &display_path(qualified), + script, + self.renderer, + ); UserInput::Done(Value::Literali(script.to_string())) } @@ -1409,7 +1556,7 @@ impl Transcript { fn trace_outcome(&mut self, path: &str, produced: Value, outcome: &UserInput) { let outcome = match outcome { - UserInput::Done(_) => Disposition::Done, + UserInput::Done(_) | UserInput::Override => Disposition::Done, UserInput::Skip => Disposition::Skip, UserInput::Fail(reason) => Disposition::Fail(reason.clone()), UserInput::Quit => Disposition::Stop, @@ -1829,6 +1976,20 @@ impl Driver for Mock { UserInput::Done(Value::Unitus) } + fn overrule(&mut self, qualified: &str, _marker: &str, standing: Standing) -> UserInput { + self.events + .push(Event::Seal { + qualified: qualified.to_string(), + }); + self.answers + .pop_front() + .unwrap_or(match standing { + Standing::Done => UserInput::Done(Value::Unitus), + Standing::Skip => UserInput::Skip, + Standing::Fail => UserInput::Fail(String::new()), + }) + } + fn settle(&mut self, _marker: &str, _qualified: &str, _verdict: &UserInput) {} fn acquire(&mut self, _qualified: &str, name: Option<&str>, forma: Option<&str>) -> UserInput { diff --git a/src/runner/library.rs b/src/runner/library.rs index 3a38e33..707ba52 100644 --- a/src/runner/library.rs +++ b/src/runner/library.rs @@ -1,9 +1,12 @@ //! The function table for the evaluator. -use std::io::Read; +use std::io::{self, Read}; +use std::os::fd::AsFd; use std::process::{Command, Stdio}; -use super::context::Context; +use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; + +use super::context::{Context, Stream}; use super::runner::RunnerError; use crate::program::ExecutableId; use crate::value::{Numeric, Value}; @@ -264,12 +267,13 @@ fn pairs(_context: &Context, args: &[Value]) -> Result { Ok(Value::Arraeum(pairs)) } -/// `exec(script)` — run a shell script, teeing its combined stdout and stderr -/// through the Context to the operator as it streams while accumulating it as -/// the return value. Output is held as bytes until the end so a chunk split -/// mid-UTF-8 is harmless and only one String is allocated. Trailing newlines -/// are trimmed from the captured value (matching the usual experience when -/// doing shell `$(...)` substitution). A non-zero exit is an error. +/// Run a shell script, teeing its output to the user chunk by chunk as it +/// streams while accumulating the output as the return value. The child's +/// stdout and stderr are kept on separate pipes so stderr can be shown in +/// red; both are drained together using `poll()` so neither deadlocks. Bytes +/// are decoded at the end, so a chunk split mid-UTF-8 is harmless; trailing +/// newlines are trimmed (matching shell substitution). A non-zero exit is an +/// error. fn exec(context: &Context, args: &[Value]) -> Result { let script = match &args[0] { Value::Literali(script) => script, @@ -281,34 +285,118 @@ fn exec(context: &Context, args: &[Value]) -> Result { } }; - // Fold the child's stderr into its stdout (`exec 2>&1`) so the two streams - // are teed and captured together as one ordered transcript. A single pipe - // keeps this to one reader, with no risk of deadlocking on a full stderr - // buffer the way two separately drained pipes would. let mut child = Command::new("bash") .arg("-c") - .arg(format!("exec 2>&1\n{}", script)) + .arg(script) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .map_err(RunnerError::ExecError)?; - let mut stdout = child + let mut out = child .stdout .take() .expect("child stdout was piped"); + let mut err = child + .stderr + .take() + .expect("child stderr was piped"); + + // The captured return value stays plain; the Context reddens stderr on a + // colour terminal as it writes. Each stream keeps a `pending` buffer + // holding partial lines, as bytes are only flushed to terminal at newline + // to avoid the two stream stomping on each other. let mut captured = Vec::new(); + let mut out_pending = Vec::new(); + let mut err_pending = Vec::new(); + let mut out_open = true; + let mut err_open = true; let mut buffer = [0u8; 8192]; - loop { - let count = stdout - .read(&mut buffer) - .map_err(RunnerError::ExecError)?; - if count == 0 { - break; + + while out_open || err_open { + // Ask poll() which still-open stream is readable, copying the readiness + // out into flags so the borrowed fds are released before the readers + // are borrowed mutably below. + let (mut out_ready, mut err_ready) = (false, false); + { + let mut fds = Vec::with_capacity(2); + let mut tags = Vec::with_capacity(2); + if out_open { + fds.push(PollFd::new(out.as_fd(), PollFlags::POLLIN)); + tags.push(true); + } + if err_open { + fds.push(PollFd::new(err.as_fd(), PollFlags::POLLIN)); + tags.push(false); + } + poll(&mut fds, PollTimeout::NONE) + .map_err(|e| RunnerError::ExecError(io::Error::from(e)))?; + for (fd, is_out) in fds + .iter() + .zip(&tags) + { + if !fd + .revents() + .unwrap_or(PollFlags::empty()) + .is_empty() + { + if *is_out { + out_ready = true; + } else { + err_ready = true; + } + } + } } + + // A single read after a readiness signal never blocks: it yields the + // available bytes, or zero once the stream has closed. + if out_ready { + let count = out + .read(&mut buffer) + .map_err(RunnerError::ExecError)?; + if count == 0 { + out_open = false; + } else { + tee( + &buffer[..count], + &mut out_pending, + Stream::Stdout, + context, + &mut captured, + )?; + } + } + if err_ready { + let count = err + .read(&mut buffer) + .map_err(RunnerError::ExecError)?; + if count == 0 { + err_open = false; + } else { + tee( + &buffer[..count], + &mut err_pending, + Stream::Stderr, + context, + &mut captured, + )?; + } + } + } + + // Flush each stream's final partial content, if any is remaining. + if !out_pending.is_empty() { + captured.extend_from_slice(&out_pending); context - .write(&buffer[..count]) + .write_run(&out_pending, Stream::Stdout) + .map_err(RunnerError::ExecError)?; + } + if !err_pending.is_empty() { + captured.extend_from_slice(&err_pending); + context + .write_run(&err_pending, Stream::Stderr) .map_err(RunnerError::ExecError)?; - captured.extend_from_slice(&buffer[..count]); } let status = child @@ -330,6 +418,34 @@ fn exec(context: &Context, args: &[Value]) -> Result { )) } +/// Tee a chunk to the user, recording it plain in `captured` and writing it +/// through the Context. Only whole lines are written through; the trailing +/// partial line waits in `pending` for its newline (or the stream's close), so +/// a `Stream::Stderr` run never lands inside a `Stream::Stdout` line. A newline +/// is always a UTF-8 boundary, so this never splits a character either. +fn tee( + bytes: &[u8], + pending: &mut Vec, + stream: Stream, + context: &Context, + captured: &mut Vec, +) -> Result<(), RunnerError> { + pending.extend_from_slice(bytes); + let ready = match pending + .iter() + .rposition(|b| *b == b'\n' || *b == b'\r') + { + Some(last) => last + 1, + None => return Ok(()), + }; + captured.extend_from_slice(&pending[..ready]); + context + .write_run(&pending[..ready], stream) + .map_err(RunnerError::ExecError)?; + pending.drain(..ready); + Ok(()) +} + /// `now()` — the current wall-clock time as an ISO 8601 string. A read of /// external state, hence part of the system layer rather than `core`. fn now(_context: &Context, _args: &[Value]) -> Result { diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 27de115..eefb944 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -53,9 +53,9 @@ pub fn start<'i>( let label = document_label(document); let outcome = match mode { Mode::Quiet => { - let mut runner = - Runner::new(program, appender, completed, Headless::new(), library) - .with_document(label); + let mut runner = Runner::new(program, appender, completed, Headless::new(), library) + .with_context(Context::native(colour)) + .with_document(label); runner.run(env)? } Mode::Interactive => { @@ -63,6 +63,7 @@ pub fn start<'i>( return Err(RunnerError::TerminalRequired); } let mut runner = Runner::new(program, appender, completed, Console::new(), library) + .with_context(Context::native(colour)) .with_document(label); runner.run(env)? } @@ -74,6 +75,7 @@ pub fn start<'i>( Automatic::new(colour), library, ) + .with_context(Context::native(colour)) .with_document(label); runner.run(env)? } @@ -100,21 +102,24 @@ pub fn inspect<'i>( let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Console::new()); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } Mode::Automatic => { let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Automatic::new(colour)); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } Mode::Quiet => { let appender = Appender::sink(); let completed = HashMap::new(); let driver = Transcript::new(Headless::new()); - let mut runner = Runner::new(program, appender, completed, driver, library); + let mut runner = Runner::new(program, appender, completed, driver, library) + .with_context(Context::native(colour)); runner.run(env) } } @@ -152,6 +157,7 @@ pub fn resume<'i>( }; appender.append(&record)?; let mut runner = Runner::new(program, appender, completed, Console::new(), library) + .with_context(Context::native(std::io::stdout().is_terminal())) .with_inputs(inputs) .with_document(document_label(&document)); let env = Environment::new(); diff --git a/src/runner/runner.rs b/src/runner/runner.rs index d6b2673..a95e179 100644 --- a/src/runner/runner.rs +++ b/src/runner/runner.rs @@ -5,7 +5,7 @@ use std::io; use std::path::PathBuf; use super::context::Context; -use super::driver::{Driver, UserInput}; +use super::driver::{Driver, Standing, UserInput}; use super::evaluator::Environment; use super::library::{Library, Nature}; use super::path::{PathSegment, QualifiedPath}; @@ -132,7 +132,7 @@ impl<'i, D: Driver> Runner<'i, D> { driver, path: QualifiedPath::new(), library, - context: Context::native(), + context: Context::native(false), document: None, } } @@ -405,6 +405,9 @@ impl<'i, D: Driver> Runner<'i, D> { } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), UserInput::Fail(reason) => Ok(Outcome::Throw(Failure::Aborted(reason))), + UserInput::Override => { + unreachable!("a command prompt never offers Override") + } UserInput::Quit => self.record_stop(), } } @@ -426,6 +429,9 @@ impl<'i, D: Driver> Runner<'i, D> { } UserInput::Skip => Ok(Outcome::Skipped(Value::Unitus)), UserInput::Fail(reason) => Ok(Outcome::Throw(Failure::Aborted(reason))), + UserInput::Override => { + unreachable!("an action prompt never offers Override") + } UserInput::Quit => self.record_stop(), } } @@ -941,6 +947,7 @@ impl<'i, D: Driver> Runner<'i, D> { UserInput::Fail(reason) => { return Ok(Outcome::Failed(Failure::Aborted(reason))) } + UserInput::Override => unreachable!("an acquire prompt never offers Override"), UserInput::Quit => return self.record_stop(), } } @@ -1079,6 +1086,9 @@ impl<'i, D: Driver> Runner<'i, D> { ) -> Result { let mut parallel_idx: usize = 0; let mut last = Value::Unitus; + let mut failed: Option = None; + let mut done_seen = false; + let mut skip_seen = false; for op in ops { let outcome = match op { Operation::Step { ordinal, .. } => { @@ -1094,16 +1104,35 @@ impl<'i, D: Driver> Runner<'i, D> { _ => self.walk(env, op)?, }; match outcome { - Outcome::Done(value) | Outcome::Skipped(value) => last = value, + Outcome::Done(value) => { + done_seen = true; + last = value; + } + Outcome::Skipped(value) => { + skip_seen = true; + last = value; + } Outcome::Stopped => return Ok(Outcome::Stopped), // A Throw is a hard failure mid-body; it propagates up to the - // enclosing step. A plain Fail is a step's recorded verdict and - // the sequence continues to its siblings. + // enclosing step at once. A Fail is a step's recorded verdict; the + // sequence runs on to its siblings but carries the failure so the + // enclosing scope rolls up as failed (its sign-off may overrule). Outcome::Throw(failure) => return Ok(Outcome::Throw(failure)), - Outcome::Failed(_) => {} + Outcome::Failed(failure) => { + if failed.is_none() { + failed = Some(failure); + } + } } } - Ok(Outcome::Done(last)) + // Roll the children up by worst-wins precedence: any failure fails the + // sequence; otherwise a single green makes it Done; only an all-skipped + // (non-empty) sequence rolls up to Skip. + match failed { + Some(failure) => Ok(Outcome::Failed(failure)), + None if !done_seen && skip_seen => Ok(Outcome::Skipped(last)), + None => Ok(Outcome::Done(last)), + } } fn walk_section( @@ -1325,9 +1354,32 @@ impl<'i, D: Driver> Runner<'i, D> { match self.walk(env, body)? { Outcome::Stopped => return Ok(Outcome::Stopped), Outcome::Done(value) => value, - // The body settled itself — a declined command beat (Skip / Fail) - // or a thrown exec failure, which catches here as a Fail. Record - // and show its verdict without an acceptance prompt. + // A rolled-up child failure signs off through `overrule`: the + // failure stands and propagates by default, but an interactive + // run may Override it to Done, severing the rollup. + Outcome::Failed(_) => { + let input = self + .driver + .overrule(qualified, "→", Standing::Fail); + if let UserInput::Quit = input { + return self.record_stop(); + } + self.driver + .settle("→", qualified, &input); + let outcome = outcome_from(input); + let record = Record { + recorded: now_iso8601(), + run_id, + path: qualified.to_string(), + state: record_state(&outcome), + }; + self.appender + .append(&record)?; + return Ok(outcome); + } + // The body settled itself — a declined command beat (Skip) or a + // thrown exec failure (caught here as a Fail). Record and show + // its verdict without an acceptance prompt. settled => { let settled = match settled { Outcome::Throw(failure) => Outcome::Failed(failure), @@ -1559,6 +1611,33 @@ impl<'i, D: Driver> Runner<'i, D> { outcome: Outcome, computable: bool, ) -> Result { + let standing = match &outcome { + Outcome::Failed(_) | Outcome::Throw(_) => Some(Standing::Fail), + Outcome::Skipped(_) => Some(Standing::Skip), + _ => None, + }; + if let Some(standing) = standing { + let input = self + .driver + .overrule(qualified, "↙", standing); + if let UserInput::Quit = input { + return self.record_stop(); + } + self.driver + .settle("↙", qualified, &input); + let settled = outcome_from(input); + let record = Record { + recorded: now_iso8601(), + run_id: self + .appender + .run_id(), + path: qualified.to_string(), + state: record_state(&settled), + }; + self.appender + .append(&record)?; + return Ok(settled); + } let produced = match outcome { Outcome::Done(value) | Outcome::Skipped(value) => value, _ => Value::Unitus, @@ -1662,10 +1741,13 @@ fn computable(op: &Operation) -> bool { } } -/// Lift a `UserInput` from the prompt into the runner's `Outcome`. +/// Lift a `UserInput` from the prompt into the runner's `Outcome`. An Override +/// settles `Done ()`, severing the rollup so the failed child below does not +/// propagate past this node. fn outcome_from(input: UserInput) -> Outcome { match input { UserInput::Done(value) => Outcome::Done(value), + UserInput::Override => Outcome::Done(Value::Unitus), UserInput::Skip => Outcome::Skipped(Value::Unitus), UserInput::Fail(reason) => Outcome::Failed(Failure::Aborted(reason)), UserInput::Quit => Outcome::Stopped, diff --git a/tests/samples/runner/ConfirmRelease.pfftt b/tests/golden/runner/ConfirmRelease.pfftt similarity index 100% rename from tests/samples/runner/ConfirmRelease.pfftt rename to tests/golden/runner/ConfirmRelease.pfftt diff --git a/tests/samples/runner/ConfirmRelease.tq b/tests/golden/runner/ConfirmRelease.tq similarity index 100% rename from tests/samples/runner/ConfirmRelease.tq rename to tests/golden/runner/ConfirmRelease.tq diff --git a/tests/samples/runner/DemolitionBeams.pfftt b/tests/golden/runner/DemolitionBeams.pfftt similarity index 100% rename from tests/samples/runner/DemolitionBeams.pfftt rename to tests/golden/runner/DemolitionBeams.pfftt diff --git a/tests/samples/runner/DemolitionBeams.tq b/tests/golden/runner/DemolitionBeams.tq similarity index 100% rename from tests/samples/runner/DemolitionBeams.tq rename to tests/golden/runner/DemolitionBeams.tq diff --git a/tests/samples/runner/ExecutionInSteps.pfftt b/tests/golden/runner/ExecutionInSteps.pfftt similarity index 100% rename from tests/samples/runner/ExecutionInSteps.pfftt rename to tests/golden/runner/ExecutionInSteps.pfftt diff --git a/tests/samples/runner/ExecutionInSteps.tq b/tests/golden/runner/ExecutionInSteps.tq similarity index 100% rename from tests/samples/runner/ExecutionInSteps.tq rename to tests/golden/runner/ExecutionInSteps.tq diff --git a/tests/samples/runner/InspectHatches.pfftt b/tests/golden/runner/InspectHatches.pfftt similarity index 100% rename from tests/samples/runner/InspectHatches.pfftt rename to tests/golden/runner/InspectHatches.pfftt diff --git a/tests/samples/runner/InspectHatches.tq b/tests/golden/runner/InspectHatches.tq similarity index 100% rename from tests/samples/runner/InspectHatches.tq rename to tests/golden/runner/InspectHatches.tq diff --git a/tests/samples/runner/PatioCleaning.pfftt b/tests/golden/runner/PatioCleaning.pfftt similarity index 100% rename from tests/samples/runner/PatioCleaning.pfftt rename to tests/golden/runner/PatioCleaning.pfftt diff --git a/tests/samples/runner/PatioCleaning.tq b/tests/golden/runner/PatioCleaning.tq similarity index 100% rename from tests/samples/runner/PatioCleaning.tq rename to tests/golden/runner/PatioCleaning.tq diff --git a/tests/parsing/samples.rs b/tests/parsing/samples.rs index 262aacb..7c5b859 100644 --- a/tests/parsing/samples.rs +++ b/tests/parsing/samples.rs @@ -33,5 +33,6 @@ fn check_directory(dir: &Path) { #[test] fn ensure_parse() { check_directory(Path::new("tests/samples/parsing/")); + check_directory(Path::new("tests/samples/runner/")); check_directory(Path::new("examples/minimal/")); } diff --git a/tests/runner/samples.rs b/tests/runner/samples.rs index 5f6af44..0afbdc4 100644 --- a/tests/runner/samples.rs +++ b/tests/runner/samples.rs @@ -35,7 +35,7 @@ fn strip_timestamp_and_runid(trail: &str) -> Vec { /// matching `.pfftt` also fails. #[test] fn ensure_run() { - let dir = Path::new("tests/samples/runner/"); + let dir = Path::new("tests/golden/runner/"); let files = list_technique_documents(dir); let mut failures = Vec::new(); diff --git a/tests/samples/runner/Curl.tq b/tests/samples/runner/Curl.tq new file mode 100644 index 0000000..b8b6c76 --- /dev/null +++ b/tests/samples/runner/Curl.tq @@ -0,0 +1 @@ +1. { exec("curl -L -o fedora.iso https://download.fedoraproject.org/pub/fedora/linux/releases/44/Workstation/x86_64/iso/Fedora-Workstation-Live-44-1.7.x86_64.iso") } diff --git a/tests/samples/runner/Find.tq b/tests/samples/runner/Find.tq new file mode 100644 index 0000000..e8e4e12 --- /dev/null +++ b/tests/samples/runner/Find.tq @@ -0,0 +1 @@ +1. { exec("find /tmp") }