diff --git a/src/args.rs b/src/args.rs index f7c395d..2028abb 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,5 +1,6 @@ //! The command line: which command, and for a Run, its Issue URL and flags, //! or for a Pass, its flags and any arguments its own kind takes. +//! Also owns public retry guidance: explicit asks rendered as literal POSIX words. use std::iter::Peekable; use std::mem::discriminant; @@ -66,7 +67,7 @@ pub struct PassArgs { /// The word that lets a Run start a Base fix, which the command a Base fix /// is offered with ends in. -pub const BASE_FIX: &str = "base-fix"; +const BASE_FIX: &str = "base-fix"; /// A Run's arguments. pub struct RunArgs { @@ -79,6 +80,99 @@ pub struct RunArgs { pub given: Option, } +/// Retry the original Issue URL with Base fixing allowed, preserving only +/// explicit public choices so omitted choices still consult the User config. +pub fn retry_with_base_fix(issue: &IssueUrl, flags: &Flags) -> String { + let flags = Flags { + base_fix: Some(BaseFixAsk::Allow), + ..flags.clone() + }; + let mut words = vec!["thirdshift".to_string(), issue.url.clone()]; + public_flag_words(&flags, &mut words); + shell_command(words) +} + +/// Retry a Security run with fixing allowed, retaining the original optional +/// Base branch rather than the one launch preparation resolved. +pub fn retry_with_security_fix(base: Option<&str>, flags: &Flags) -> String { + let flags = Flags { + security_fix: Some(crate::security::FixAsk::Allow), + ..flags.clone() + }; + let mut words = vec!["thirdshift".to_string(), "secure".to_string()]; + if let Some(base) = base { + words.extend(["base".into(), base.into()]); + } + public_flag_words(&flags, &mut words); + shell_command(words) +} + +/// Serialize public asks, never computed retry text or hidden child Run facts. +fn public_flag_words(flags: &Flags, words: &mut Vec) { + match flags.goal { + Some(Goal::Merged) => words.push("merge".into()), + Some(Goal::ReadyForReview) => words.push("--no-merge".into()), + None => {} + } + match &flags.email { + Some(NotificationAsk::Send(to)) => { + words.push("--email".into()); + if let Some(to) = to { + words.push(to.clone()); + } + } + Some(NotificationAsk::Skip) => words.push("--no-email".into()), + None => {} + } + if let Some(parallel) = flags.parallel { + words.extend(["parallel".into(), parallel.to_string()]); + } + match flags.security_fix { + Some(crate::security::FixAsk::Allow) => words.push("security-fix".into()), + Some(crate::security::FixAsk::Forbid) => words.push("no-security-fix".into()), + None => {} + } + match flags.security_review { + Some(crate::security::review::Ask::Allow) => words.push("security-review".into()), + Some(crate::security::review::Ask::Forbid) => words.push("no-security-review".into()), + None => {} + } + let asked = &flags.harness; + if let Some(harness) = asked.harness { + words.extend(["harness".into(), harness.name().into()]); + } + if let Some(model) = &asked.model_and_effort.model { + words.extend(["model".into(), model.clone()]); + } + if let Some(effort) = &asked.model_and_effort.effort { + words.extend(["effort".into(), effort.clone()]); + } + match &flags.base_fix { + Some(BaseFixAsk::Allow) => words.push(BASE_FIX.into()), + Some(BaseFixAsk::Forbid) => words.push("no-base-fix".into()), + Some(BaseFixAsk::Undecided { .. }) | None => {} + } +} + +/// Render literal POSIX shell words; readable safe words need no quoting. +fn shell_command(words: Vec) -> String { + words + .into_iter() + .map(|word| { + if !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_/.:@=".contains(c)) + { + word + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} + /// Parse the arguments after the program name. `help`, `version`, `update`, /// `setup`, `email-test`, `architect`, `pickup` and `secure` are commands only as the /// first argument. Otherwise it is a Run: one Issue URL, with each Run flag at @@ -376,6 +470,210 @@ mod tests { const URL: &str = "https://github.com/acme/widgets/issues/7"; + use crate::test_support::guidance::words as shell_words; + + #[test] + fn base_fix_guidance_preserves_a_model_label_with_spaces() { + let flags = Flags { + harness: harness::Asked { + model_and_effort: ModelAndEffort { + model: Some("Model Label With Spaces".into()), + effort: None, + }, + ..harness::Asked::default() + }, + ..Flags::default() + }; + let command = retry_with_base_fix(&IssueUrl::parse(URL).unwrap(), &flags); + let Command::Run(run) = parse(&shell_words(&command)).unwrap() else { + panic!("not a Run: {command}"); + }; + assert_eq!( + run.flags, + Flags { + base_fix: Some(BaseFixAsk::Allow), + ..flags + } + ); + assert_eq!(run.issue.url, URL); + assert!(run.given.is_none()); + } + + /// Supplied public asks cover positive, negative, undecided and omitted choices. + fn guidance_flags() -> Vec { + vec![ + Flags::default(), + Flags { + goal: Some(Goal::Merged), + email: Some(NotificationAsk::Send(Some("day@example.com".into()))), + parallel: NonZeroUsize::new(2), + base_fix: Some(BaseFixAsk::Forbid), + security_fix: Some(crate::security::FixAsk::Forbid), + security_review: Some(crate::security::review::Ask::Allow), + harness: harness::Asked { + harness: Some(Harness::Codex), + model_and_effort: ModelAndEffort { + model: Some("gpt-6.1-sol".into()), + effort: Some("max".into()), + }, + }, + }, + Flags { + goal: Some(Goal::ReadyForReview), + email: Some(NotificationAsk::Skip), + parallel: NonZeroUsize::new(1), + base_fix: Some(BaseFixAsk::Allow), + security_fix: Some(crate::security::FixAsk::Allow), + security_review: Some(crate::security::review::Ask::Forbid), + ..Flags::default() + }, + Flags { + email: Some(NotificationAsk::Send(None)), + base_fix: Some(BaseFixAsk::Undecided { + retry: "computed child retry must stay private".into(), + }), + ..Flags::default() + }, + ] + } + + #[test] + fn both_guidance_operations_round_trip_public_choices_and_enable_permission_once() { + let issue = IssueUrl::parse(URL).unwrap(); + for flags in guidance_flags() { + let command = retry_with_base_fix(&issue, &flags); + let words = shell_words(&command); + assert_eq!(words.iter().filter(|word| *word == "base-fix").count(), 1); + let Command::Run(run) = parse(&words).unwrap() else { + panic!("{command}") + }; + assert_eq!(run.issue, issue); + assert!(run.given.is_none()); + assert_eq!( + run.flags, + Flags { + base_fix: Some(BaseFixAsk::Allow), + ..flags.clone() + } + ); + + for base in [None, Some("main")] { + let command = retry_with_security_fix(base, &flags); + let words = shell_words(&command); + assert_eq!( + words.iter().filter(|word| *word == "security-fix").count(), + 1 + ); + let Command::Secure(pass) = parse(&words).unwrap() else { + panic!("{command}") + }; + assert_eq!(pass.base.as_deref(), base); + let public_base_fix = match &flags.base_fix { + Some(BaseFixAsk::Undecided { .. }) => None, + other => other.clone(), + }; + assert_eq!( + pass.flags, + Flags { + security_fix: Some(crate::security::FixAsk::Allow), + base_fix: public_base_fix, + ..flags.clone() + } + ); + } + } + } + + #[test] + fn guidance_preserves_literal_shell_punctuation_in_every_public_value() { + let issue = IssueUrl::parse(URL).unwrap(); + for value in [ + "Model Label With Spaces", + "O'Brien", + "\"double quotes\"", + "Unicode 模型 π", + "literal $fixture ${fixture} $(printf fixture) `printf fixture` ; & | < > ( ) * ? ! [ ] \\ \t\nnext", + ] { + let flags = Flags { + email: Some(NotificationAsk::Send(Some(format!("literal@{value}")))), + harness: harness::Asked { + model_and_effort: ModelAndEffort { + model: Some(value.into()), + effort: Some(value.into()), + }, + ..harness::Asked::default() + }, + ..Flags::default() + }; + let command = retry_with_base_fix(&issue, &flags); + let Command::Run(run) = parse(&shell_words(&command)).unwrap() else { + panic!("{command}") + }; + assert_eq!( + run.flags, + Flags { + base_fix: Some(BaseFixAsk::Allow), + ..flags.clone() + } + ); + assert!(run.given.is_none()); + let command = retry_with_security_fix(Some(value), &flags); + let Command::Secure(pass) = parse(&shell_words(&command)).unwrap() else { + panic!("{command}") + }; + assert_eq!(pass.base.as_deref(), Some(value)); + assert_eq!( + pass.flags, + Flags { + security_fix: Some(crate::security::FixAsk::Allow), + ..flags + } + ); + } + } + + #[test] + fn both_guidance_operations_preserve_every_explicit_harness() { + for harness in [ + Harness::Claude, + Harness::Codex, + Harness::Agy, + Harness::Grok, + Harness::Muse, + Harness::OpenCode, + ] { + let flags = Flags { + harness: harness::Asked { + harness: Some(harness), + ..harness::Asked::default() + }, + ..Flags::default() + }; + let command = retry_with_base_fix(&IssueUrl::parse(URL).unwrap(), &flags); + let Command::Run(run) = parse(&shell_words(&command)).unwrap() else { + panic!("{command}") + }; + assert_eq!( + run.flags, + Flags { + base_fix: Some(BaseFixAsk::Allow), + ..flags.clone() + } + ); + let command = retry_with_security_fix(None, &flags); + let Command::Secure(pass) = parse(&shell_words(&command)).unwrap() else { + panic!("{command}") + }; + assert_eq!( + pass.flags, + Flags { + security_fix: Some(crate::security::FixAsk::Allow), + ..flags + } + ); + } + } + #[test] fn security_review_words_are_accepted_on_commands_that_start_runs() { for command in [URL, "pickup", "architect", "secure"] { diff --git a/src/asks.rs b/src/asks.rs index 01f479e..6f69d91 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -75,7 +75,7 @@ impl Asks { if config.base_fix { BaseFixAsk::Allow } else { - let retry = retry_with_base_fix(issue, flags); + let retry = args::retry_with_base_fix(issue, flags); BaseFixAsk::Undecided { retry } } }); @@ -212,47 +212,6 @@ impl Flags { } } -/// The command that starts the Run on `issue` again as `flags` asked for it, -/// with `base-fix` added. -fn retry_with_base_fix(issue: &IssueUrl, flags: &Flags) -> String { - let mut command = format!("thirdshift {}", issue.url); - match flags.goal { - Some(Goal::Merged) => command += " merge", - Some(Goal::ReadyForReview) => command += " --no-merge", - None => {} - } - match &flags.email { - Some(NotificationAsk::Send(Some(to))) => command += &format!(" --email {to}"), - Some(NotificationAsk::Send(None)) => command += " --email", - Some(NotificationAsk::Skip) => command += " --no-email", - None => {} - } - if let Some(parallel) = flags.parallel { - command += &format!(" parallel {parallel}"); - } - match flags.security_fix { - Some(crate::security::FixAsk::Allow) => command += " security-fix", - Some(crate::security::FixAsk::Forbid) => command += " no-security-fix", - None => {} - } - match flags.security_review { - Some(crate::security::review::Ask::Allow) => command += " security-review", - Some(crate::security::review::Ask::Forbid) => command += " no-security-review", - None => {} - } - let asked = &flags.harness; - if let Some(harness) = asked.harness { - command += &format!(" harness {}", harness.name()); - } - if let Some(model) = &asked.model_and_effort.model { - command += &format!(" model {model}"); - } - if let Some(effort) = &asked.model_and_effort.effort { - command += &format!(" effort {effort}"); - } - command + " " + args::BASE_FIX -} - #[cfg(test)] mod tests { use std::path::PathBuf; @@ -665,102 +624,29 @@ mod tests { ); } - /// The Run the command `retry` starts, read by the argument parser. - fn parsed(retry: &str) -> args::RunArgs { - let words: Vec = retry.split(' ').skip(1).map(str::to_string).collect(); - match args::parse(&words) { - Ok(args::Command::Run(run)) => run, - Ok(_) => panic!("{retry}: not a Run"), - Err(error) => panic!("{retry}: {error:#}"), - } - } - - /// The command `asks` offer a Base fix with. - fn retry_of(asks: Asks) -> String { - match asks.base_fix { - BaseFixAsk::Undecided { retry } => retry, - decided => panic!("no command to offer: {decided:?}"), - } - } - #[test] - fn the_retry_command_is_the_issue_url_and_the_flags_as_given_with_base_fix_added() { - let flags = |goal, email, parallel| Flags { - security_fix: None, - security_review: None, - goal, - email, - parallel, - base_fix: None, - harness: harness::Asked::default(), - }; - let on_codex = Flags { - harness: harness::Asked { - harness: Some(Harness::Codex), - model_and_effort: model_and_effort("gpt-6.1-sol", "max"), - }, - ..flags(Some(Goal::Merged), None, None) + fn all_dispatches_offer_the_original_issue_with_only_non_spec_parallel_removed() { + let flags = dispatch_flags(); + let expected = BaseFixAsk::Undecided { + retry: args::retry_with_base_fix(&issue(), &flags), }; - for (flags, retry) in [ - ( - on_codex, - format!( - "thirdshift {URL} merge harness codex model gpt-6.1-sol effort max base-fix" - ), - ), - (Flags::default(), format!("thirdshift {URL} base-fix")), - ( - flags(Some(Goal::Merged), Some(NotificationAsk::Send(None)), None), - format!("thirdshift {URL} merge --email base-fix"), - ), - ( - flags( - Some(Goal::ReadyForReview), - Some(NotificationAsk::Skip), - Some(n(2)), - ), - format!("thirdshift {URL} --no-merge --no-email parallel 2 base-fix"), - ), - ( - flags(None, Some(to("me@example.com")), None), - format!("thirdshift {URL} --email me@example.com base-fix"), - ), + for asks in [ + Asks::of_run(&issue(), &flags, &no_settings()), + Asks::of_architect_plan(&issue(), &flags, &no_settings()), + Asks::of_ready_issue(&issue(), true, &flags, &no_settings()), ] { - let config = no_settings(); - let allowed = Flags { - base_fix: Some(BaseFixAsk::Allow), - ..flags.clone() - }; - // Each kind of run offers the same command: a Run on the Issue - // URL, whatever dispatched it. - for (kind, asks) in [ - ("a Run", Asks::of_run(&issue(), &flags, &config)), - ( - "an Architect plan", - Asks::of_architect_plan(&issue(), &flags, &config), - ), - ( - "a Ready issue that is a Spec", - Asks::of_ready_issue(&issue(), true, &flags, &config), - ), - ] { - let offered = retry_of(asks); - assert_eq!(offered, retry, "{kind}"); - // The command it gives asks for what the run was asked for. - let again = parsed(&offered); - assert_eq!(again.issue.url, URL, "{kind}: {offered}"); - assert_eq!(again.flags, allowed, "{kind}: {offered}"); - assert_eq!(again.given, None, "{kind}: {offered}"); - } - // Without the `parallel` that would fail its Run by hand. - let offered = retry_of(Asks::of_ready_issue(&issue(), false, &flags, &config)); - let again = parsed(&offered); - let without_parallel = Flags { - parallel: None, - ..allowed - }; - assert_eq!(again.flags, without_parallel, "{offered}"); + assert_eq!(asks.base_fix, expected); } + let without_parallel = Flags { + parallel: None, + ..flags.clone() + }; + assert_eq!( + Asks::of_ready_issue(&issue(), false, &flags, &no_settings()).base_fix, + BaseFixAsk::Undecided { + retry: args::retry_with_base_fix(&issue(), &without_parallel), + } + ); } #[test] diff --git a/src/security.rs b/src/security.rs index a05a261..12dad39 100644 --- a/src/security.rs +++ b/src/security.rs @@ -63,25 +63,6 @@ impl From for Ended { } } -/// Preserve the typed command, quoting its words so the offered command can be run. -fn command_with_fixing() -> String { - let words = std::env::args() - .skip(1) - .map(|word| { - if word - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_/.:@=".contains(c)) - && !word.is_empty() - { - word - } else { - format!("'{}'", word.replace('\'', "'\\''")) - } - }) - .collect::>(); - format!("thirdshift {} security-fix", words.join(" ")) -} - pub use crate::github::RecordedFinding; pub enum Skipped { @@ -136,6 +117,8 @@ pub fn run( config: &UserConfig, harness: &mut Choice, ) -> Outcome { + let offer_command = (flags.security_fix.is_none() && config.security_fix.is_none()) + .then(|| crate::args::retry_with_security_fix(base, flags)); let Launch { directory, repo, @@ -148,8 +131,6 @@ pub fn run( } Err(error) => return Outcome::Audited(error.into()), }; - let offer_command = - (flags.security_fix.is_none() && config.security_fix.is_none()).then(command_with_fixing); run_through( &mut LaunchAndGitHub { launch: &directory, diff --git a/src/test_support.rs b/src/test_support.rs index 7d68cc3..4eda9a5 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -2,6 +2,8 @@ //! processes so interruption and fixture environment changes cannot leak into //! another test, and write executables other tests can't hold open. +pub(crate) mod guidance; + use std::process::Command; use crate::interrupt; diff --git a/src/test_support/guidance.rs b/src/test_support/guidance.rs new file mode 100644 index 0000000..c0e85e1 --- /dev/null +++ b/src/test_support/guidance.rs @@ -0,0 +1,20 @@ +//! Recover guidance argv through a POSIX shell without starting the factory. + +/// Recover exactly the argv a copied guidance command supplies. +pub fn words(command: &str) -> Vec { + let output = std::process::Command::new("sh") + .args([ + "-c", + &format!("thirdshift() {{ printf '%s\\0' \"$@\"; }}; {command}"), + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + let mut words = output.stdout.split(|byte| *byte == 0).collect::>(); + assert_eq!(words.pop(), Some(&b""[..])); + words + .into_iter() + .map(|word| String::from_utf8(word.to_vec()).unwrap()) + .collect() +} diff --git a/tests/base_fix.rs b/tests/base_fix.rs index 21ce2d1..2935e2e 100644 --- a/tests/base_fix.rs +++ b/tests/base_fix.rs @@ -399,13 +399,21 @@ const OR_SET: &str = "base.fix = true in ~/.thirdshift/config.toml, \ #[test] fn a_run_not_asked_about_a_base_fix_links_the_base_branchs_failing_checks_and_offers_one() { let scenario = Scenario::new(); + scenario.agent_does_in_session(1, "true\n"); // Requested Model check. scenario.agent_does(RUN_OPENS_PR_WITH_INHERITED_FAILURE); let cause = inherited_failure(&scenario.origin_git(&["rev-parse", "main"])); let resend = ResendStandIn::replying(200, ACCEPTED); let url = scenario.issue_url(7); let result = scenario.run_with_env( - &["merge", &url, "--email", "me@example.com"], + &[ + "merge", + &url, + "--email", + "me@example.com", + "model", + "Model Label With Spaces", + ], &[ ("THIRDSHIFT_RESEND_URL", resend.url()), ("RESEND_API_KEY", "re_test_123"), @@ -413,7 +421,11 @@ fn a_run_not_asked_about_a_base_fix_links_the_base_branchs_failing_checks_and_of ); assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); - let retry = format!("thirdshift {url} merge --email me@example.com base-fix"); + let retry = result + .stderr + .lines() + .find_map(|line| line.strip_prefix("thirdshift: Retry with: ")) + .expect("Base fix guidance"); assert!( result.stderr.contains(&format!( "thirdshift: {cause}\n\ @@ -438,6 +450,29 @@ fn a_run_not_asked_about_a_base_fix_links_the_base_branchs_failing_checks_and_of )), "text: {text}" ); + let words = support::guidance::words(retry); + assert_eq!(words.iter().filter(|word| word.as_str() == url).count(), 1); + assert_eq!(words.len(), 7); + for flag in ["merge", "base-fix"] { + assert_eq!( + words + .iter() + .filter(|word| word.trim_start_matches('-') == flag) + .count(), + 1 + ); + } + for pair in [ + ["email", "me@example.com"], + ["model", "Model Label With Spaces"], + ] { + assert!( + words + .windows(2) + .any(|actual| actual[0].trim_start_matches('-') == pair[0] && actual[1] == pair[1]), + "{words:?}" + ); + } // The cause alone is the Failed-run commit's message. assert_eq!( scenario.origin_log("issue-7").unwrap()[0], diff --git a/tests/security_run.rs b/tests/security_run.rs index 0318609..ea23b6a 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -1180,61 +1180,111 @@ fn a_successful_fix_awaiting_review_does_not_pause_allowed_security_work() { #[test] fn an_undecided_run_offers_both_ways_to_allow_a_reproduced_fix() { - for (config, permission, reproduced, offers) in [ - ("", None, true, true), - ("", Some("no-security-fix"), true, false), - ("[security]\nfix = false\n", None, true, false), - ( - "[security]\nfix = true\n", - Some("no-security-fix"), - true, - false, - ), - ("", None, false, false), - ] { - let scenario = Scenario::new(); - scenario.user_config_is(config); - scenario.agent_does_in_session(1, &audit_script(&json!([finding("offer")]).to_string())); - scenario.agent_does_in_session( - 2, - &reproduction_script(if reproduced { - "reproduced high single" - } else { - "not reproduced" - }), - ); - let resend = ResendStandIn::replying(200, r#"{"id":"sent"}"#); - let mut args = vec![ - "secure", - "base", - "main", - "harness", - "claude", - "email", - "day@example.com", - ]; - if let Some(permission) = permission { - args.push(permission); - } - let result = scenario.run_with_env(&args, &resend_env(&resend)); - assert_eq!(result.code, Some(0), "{}", result.stderr); - let (_, body) = the_one_notification(&resend); - for text in [&result.stderr, &body] { - assert_eq!( - text.contains( - "thirdshift secure base main harness claude email day@example.com security-fix" - ), - offers, - "{text}" - ); - assert_eq!( - text.contains("fix = true under [security]"), - offers, - "{text}" + for base in [None, Some("main")] { + for (config, permission, reproduced, offers) in [ + ("", None, true, true), + ("", Some("no-security-fix"), true, false), + ("[security]\nfix = false\n", None, true, false), + ( + "[security]\nfix = true\n", + Some("no-security-fix"), + true, + false, + ), + ("", None, false, false), + ] { + let scenario = Scenario::new(); + scenario.user_config_is(config); + scenario.agent_does_in_session(1, "true\n"); // Requested Model check. + scenario + .agent_does_in_session(2, &audit_script(&json!([finding("offer")]).to_string())); + scenario.agent_does_in_session( + 3, + &reproduction_script(if reproduced { + "reproduced high single" + } else { + "not reproduced" + }), ); - assert!(!text.contains("Private candidate write-up"), "{text}"); + let resend = ResendStandIn::replying(200, r#"{"id":"sent"}"#); + let mut args = vec![ + "secure", + "harness", + "claude", + "email", + "day@example.com", + "model", + "Model Label With Spaces", + ]; + if let Some(base) = base { + args.extend(["base", base]); + } + if let Some(permission) = permission { + args.push(permission); + } + let result = scenario.run_with_env(&args, &resend_env(&resend)); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let (_, body) = the_one_notification(&resend); + let mut offered_command = None; + for text in [&result.stderr, &body] { + let command = text.lines().find_map(|line| { + line.split_once("Allow fixing:") + .map(|(_, command)| command.trim()) + }); + assert_eq!(command.is_some(), offers, "{text}"); + if let Some(command) = command { + if let Some(previous) = offered_command { + assert_eq!(command, previous); + } + offered_command = Some(command); + let words = support::guidance::words(command); + assert_eq!(words.first().map(String::as_str), Some("secure")); + assert_eq!(words.len(), if base.is_some() { 10 } else { 8 }); + assert_eq!( + words + .iter() + .filter(|word| word.trim_start_matches('-') == "security-fix") + .count(), + 1 + ); + if let Some(base) = base { + assert!( + words + .windows(2) + .any(|pair| pair[0].trim_start_matches('-') == "base" + && pair[1] == base), + "{words:?}" + ); + } else { + assert!( + !words + .iter() + .any(|word| word.trim_start_matches('-') == "base") + ); + } + for pair in [ + ["email", "day@example.com"], + ["harness", "claude"], + ["model", "Model Label With Spaces"], + ] { + assert!( + words + .windows(2) + .any(|actual| actual[0].trim_start_matches('-') == pair[0] + && actual[1] == pair[1]), + "{words:?}" + ); + } + } + assert_eq!( + text.contains("fix = true under [security]"), + offers, + "{text}" + ); + assert!(!text.contains("Private candidate write-up"), "{text}"); + } + assert_eq!(scenario.claude_calls().len(), 3); } - assert_eq!(scenario.claude_calls().len(), 2); } } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 9aca6a4..54876c1 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -37,6 +37,9 @@ #![allow(dead_code)] +#[path = "../../src/test_support/guidance.rs"] +pub mod guidance; + pub mod fakes; pub mod resend;