Skip to content
4 changes: 4 additions & 0 deletions src/formatting/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub enum Syntax {
Language,
Attribute,
Structure,
Marker,
Done,
Skip,
Fail,
BlockBegin,
BlockEnd,
}
Expand Down
12 changes: 12 additions & 0 deletions src/highlighting/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ impl Render for Terminal {
.color(owo_colors::Rgb(153, 153, 153))
.bold()
.to_string(),
Syntax::Marker => content
.color(owo_colors::Rgb(0x55, 0x57, 0x53))
.to_string(),
Syntax::Done => content
.color(owo_colors::Rgb(0x4e, 0x9a, 0x06))
.to_string(),
Syntax::Skip => content
.color(owo_colors::Rgb(0xc4, 0xa0, 0x00))
.to_string(),
Syntax::Fail => content
.color(owo_colors::Rgb(0xcc, 0x00, 0x00))
.to_string(),
Syntax::BlockBegin | Syntax::BlockEnd => String::new(),
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/highlighting/typst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ impl Render for Typst {
Syntax::Language => markup("fill: rgb(0xc4, 0xa0, 0x00), weight: \"bold\"", &content),
Syntax::Attribute => markup("weight: \"bold\"", &content),
Syntax::Structure => markup("fill: rgb(0x99, 0x99, 0x99), weight: \"bold\"", &content),
Syntax::Marker => markup("fill: rgb(0x55, 0x57, 0x53)", &content),
Syntax::Done => markup("fill: rgb(0x4e, 0x9a, 0x06)", &content),
Syntax::Skip => markup("fill: rgb(0xc4, 0xa0, 0x00)", &content),
Syntax::Fail => markup("fill: rgb(0xcc, 0x00, 0x00)", &content),
Syntax::BlockBegin | Syntax::BlockEnd => String::new(),
}
}
Expand Down
17 changes: 16 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ fn main() {
.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("raw-control-chars")
.short('R')
.long("raw-control-chars")
.action(ArgAction::SetTrue)
.help("Emit ANSI escape codes for syntax highlighting even if output is redirected to a pipe or file when running in automatic mode."),
)
.arg(
Arg::new("arguments")
.num_args(0..)
Expand Down Expand Up @@ -706,6 +713,14 @@ fn main() {
_ => Mode::Interactive,
};

let raw_output = *submatches
.get_one::<bool>("raw-control-chars")
.unwrap(); // flags are always present since SetTrue implies default_value

debug!(raw_output);

let colour = raw_output || std::io::stdout().is_terminal();

let filename = Path::new(filename);
let content = match parsing::load(&filename) {
Ok(data) => data,
Expand Down Expand Up @@ -796,7 +811,7 @@ fn main() {
std::process::exit(1);
}

match runner::start(mode, filename, &program, &arguments, library) {
match runner::start(mode, colour, filename, &program, &arguments, library) {
Ok((run_id, Outcome::Stopped)) => {
eprintln!(
"stopped; resume with `technique resume {}`",
Expand Down
30 changes: 20 additions & 10 deletions src/problem/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1270,10 +1270,17 @@ function being called to return a tuple of the same size.
.trim_ascii()
.to_string(),
),
RunnerError::ParameterArityMismatch { expected, actual } => (
RunnerError::ParameterArityMismatch {
procedure,
parameters,
actual,
} => (
format!(
"Wrong number of arguments: procedure expects {} but {} given",
expected, actual
"Wrong number of arguments: {} expects {} ({}) but {} given",
procedure,
parameters.len(),
parameters.join(", "),
actual
),
r#"
Arguments after the filename are passed as the parameters for the entry
Expand All @@ -1282,15 +1289,18 @@ procedure at the top of the Technique document.
.trim_ascii()
.to_string(),
),
RunnerError::ParameterUnexpected { actual } => (
RunnerError::ParameterUnexpected { procedure, actual } => (
format!(
"Unexpected arguments: procedure takes no parameters but {} given",
actual
"Unexpected arguments: {} takes no parameters but {} given",
procedure, actual
),
r#"
Arguments were supplied on the command-line but the entry procedure at the top
of the document doesn't take any parameters.
"#
format!(
r#"
Arguments were supplied on the command-line but {} doesn't take any
parameters.
"#,
procedure
)
.trim_ascii()
.to_string(),
),
Expand Down
84 changes: 59 additions & 25 deletions src/runner/checks/driver.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::runner::driver::{draw, Console, Driver, Event, Interaction, Mock, UserInput};
use crate::runner::driver::{
draw, Automatic, Console, Driver, Event, Interaction, Mock, UserInput,
};
use crate::value::{Numeric, Value};

#[test]
Expand All @@ -11,18 +13,18 @@ fn mock_returns_canned_answers_in_order() {
UserInput::Quit,
]);
assert_eq!(
p.ask("/I/1", &[], Value::Unitus),
p.ask("/I/1", &[], Value::Unitus, true),
UserInput::Done(Value::Unitus)
);
assert_eq!(p.ask("/I/1", &[], Value::Unitus), UserInput::Skip);
assert_eq!(p.ask("/I/1", &[], Value::Unitus), UserInput::Quit);
assert_eq!(p.ask("/I/1", &[], Value::Unitus, true), UserInput::Skip);
assert_eq!(p.ask("/I/1", &[], Value::Unitus, true), 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("/local_network:I/1", &[], Value::Unitus);
let _ = p.ask("/local_network:I/1", &[], Value::Unitus, true);
assert_eq!(
p.events(),
&[
Expand All @@ -41,7 +43,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("I/1", &["Yes", "No"], Value::Unitus);
let _ = p.ask("I/1", &["Yes", "No"], Value::Unitus, true);
assert_eq!(
p.events(),
&[Event::Ask {
Expand Down Expand Up @@ -71,7 +73,7 @@ fn mock_records_enter_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("I/1", &[], Value::Unitus);
let _ = p.ask("I/1", &[], Value::Unitus, true);
}

#[test]
Expand All @@ -84,6 +86,47 @@ fn console_step_writes_fqn_and_description() {
assert!(written.contains(" Check the cable."));
}

#[test]
fn automatic_settles_done_when_effectful_skip_otherwise() {
let mut p = Automatic::with_handle(Vec::new());
assert_eq!(
p.ask("/I/1", &[], Value::Literali("ran".to_string()), true),
UserInput::Done(Value::Literali("ran".to_string()))
);
assert_eq!(p.ask("/I/2", &[], Value::Unitus, false), UserInput::Skip);
assert_eq!(
p.seal("/I", Value::Unitus, true),
UserInput::Done(Value::Unitus)
);
assert_eq!(p.seal("/II", Value::Unitus, false), UserInput::Skip);
}

#[test]
fn automatic_settle_renders_verdict_glyph() {
let mut output: Vec<u8> = Vec::new();
let mut p = Automatic::with_handle(&mut output);
p.settle("→", "/I/1", &UserInput::Done(Value::Unitus));
p.settle("→", "/I/2", &UserInput::Skip);
p.settle("↙", "/I", &UserInput::Done(Value::Unitus));
let written = String::from_utf8(output).expect("utf8");
assert!(written.contains("→ I/1 ✓"));
assert!(written.contains("→ I/2 ⊘"));
assert!(written.contains("↙ I ✓"));
}

#[test]
fn console_settle_writes_verdict_line() {
let mut output: Vec<u8> = Vec::new();
let mut p = Console::with_output(&mut output);
p.settle("→", "/I/1", &UserInput::Done(Value::Unitus));
p.settle("↙", "/I", &UserInput::Skip);
let written = String::from_utf8(output).expect("utf8");
assert!(written.contains("→ I/1"));
assert!(written.contains("✓"));
assert!(written.contains("↙ I"));
assert!(written.contains("⊘"));
}

#[test]
fn console_enter_writes_fqn() {
let mut output: Vec<u8> = Vec::new();
Expand Down Expand Up @@ -141,10 +184,12 @@ fn esc_edit_seeds_buffer_and_backspace_trims() {
}

#[test]
fn edited_quanticle_stays_a_quanticle() {
fn quanticle_edit_roundtrips() {
let quanticle = || Value::Quanticle(Numeric::Integral(42));

// Editing a numeric value and changing it keeps it numeric: 42 -> 43 is
// re-parsed back to a Quanticle, not flattened to text.
let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42)));
let mut it = Interaction::begin(&[], quanticle());
it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
it.handle(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
Expand All @@ -156,26 +201,20 @@ fn edited_quanticle_stays_a_quanticle() {
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(UserInput::Done(Value::Quanticle(Numeric::Integral(43))))
);
}

#[test]
fn unedited_quanticle_returns_verbatim() {
// Entering and leaving the edit without a change returns the original
// numeric value untouched.
let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42)));
let mut it = Interaction::begin(&[], quanticle());
it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(UserInput::Done(Value::Quanticle(Numeric::Integral(42))))
Some(UserInput::Done(quanticle()))
);
}

#[test]
fn edited_quanticle_rejects_non_numeric() {
// A numeric value edited into something that is not a number is not
// accepted: Enter stays in the edit so it can be corrected.
let mut it = Interaction::begin(&[], Value::Quanticle(Numeric::Integral(42)));
let mut it = Interaction::begin(&[], quanticle());
it.handle(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
it.handle(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
Expand Down Expand Up @@ -350,13 +389,13 @@ fn choices_esc_opens_menu() {
}

#[test]
fn complex_value_is_read_only() {
fn read_only_values_accept_intact() {
// A tablet and multi-line text are both read-only: typing is ignored.
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
Expand All @@ -365,12 +404,7 @@ fn complex_value_is_read_only() {
it.handle(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(UserInput::Done(tablet))
);
}

#[test]
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!(
Expand Down
31 changes: 30 additions & 1 deletion src/runner/checks/path.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::language::{Attribute, Identifier, Span};
use crate::runner::path::{PathSegment, QualifiedPath};
use crate::runner::path::{display_path, PathSegment, QualifiedPath};

#[test]
fn empty_stack_renders_root() {
Expand Down Expand Up @@ -119,6 +119,35 @@ fn procedure_segment() {
assert_eq!(stack.render(), "/outer:/1/inner:/2");
}

#[test]
fn display_trims_entry_head() {
// Within a section the entry head is dropped, the numeral anchors instead.
assert_eq!(
display_path("/connectivity_check:/VI/check_aws_health:/7"),
"VI/check_aws_health:/7"
);

// A flat entry with no section keeps its name; only the leading slash goes.
assert_eq!(
display_path("/connectivity_check:/1"),
"connectivity_check:/1"
);
assert_eq!(
display_path("/activate_crisis_management:/-1"),
"activate_crisis_management:/-1"
);

// An ` <invocation>` annotation on the numeral is kept; the head still drops.
assert_eq!(
display_path("/connectivity_check:/VII <service_endpoint>"),
"VII <service_endpoint>"
);

// An anonymous technique has no head to drop; the leading slash still goes.
assert_eq!(display_path("/I/1"), "I/1");
assert_eq!(display_path("/I"), "I");
}

#[test]
fn full_qualified_example_from_objective() {
// /2/@barista/a/-1 — dependent step 2, role @barista, substep a, first parallel sub-substep
Expand Down
Loading