From 801c4364ce9ff04f08b12c62d49a233d2e6cb516 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 5 Oct 2026 04:02:14 -0400 Subject: [PATCH 1/2] Setup owns its flow and questions, tested in-process behind a seam (#411) A new Setup module owns Setup end to end: `setup` and the first Run's offer, the questions as a private child, and the helpers that write the answers into the User config. Everything it does outside itself goes through a private `Outside` seam, with a real adapter on the machine and a scripted one in tests. The User config module keeps parsing, loading, the defaults and completing. The Setup rules are now tested in-process, and the end-to-end Setup and Setup-offer tests they cover are deleted, keeping only those for the wiring into a Run and the command line, and the real terminal. Co-Authored-By: Claude Opus 5.5 --- src/command.rs | 5 +- src/config.rs | 422 +----- src/harness.rs | 2 +- src/main.rs | 4 +- src/resend_key.rs | 24 +- src/setup.rs | 2425 ++++++++++++++++++++++++++++++++++ src/{ => setup}/questions.rs | 278 ++-- tests/setup.rs | 1602 +--------------------- tests/setup_offer.rs | 259 +--- 9 files changed, 2669 insertions(+), 2352 deletions(-) create mode 100644 src/setup.rs rename src/{ => setup}/questions.rs (50%) diff --git a/src/command.rs b/src/command.rs index 102ccb16f..98d269aa8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -14,7 +14,7 @@ use std::process::ExitCode; use crate::architect::{self, Reviewed}; -use crate::config::{self, UserConfig}; +use crate::config::UserConfig; use crate::failed_run::FailedRun; use crate::harness::Choice; use crate::interrupt; @@ -25,6 +25,7 @@ use crate::pickup; use crate::progress; use crate::run::Ended; use crate::run_ending; +use crate::setup; use crate::skills; /// How a command that did factory work, or was skipped before any, ended. @@ -92,7 +93,7 @@ pub fn start( about: About, ) -> Result<(UserConfig, Started), ExitCode> { logs::begin(begin); - let config = config::offer_setup() + let config = setup::offer() .and_then(|()| UserConfig::load()) .map_err(|error| failure(&error))?; logs::configured(&config); diff --git a/src/config.rs b/src/config.rs index 883906ac3..1ab016502 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,11 +11,7 @@ use anyhow::{Context, Result, anyhow, bail}; use toml::{Table, Value}; use toml_edit::{DocumentMut, Item}; -use crate::git::Git; use crate::harness::{self, Harness, ModelAndEffort}; -use crate::questions::{self, Answers}; -use crate::resend_key::Credentials; -use crate::{email, github, progress}; /// The settings a User config can hold. Each is what a Run does when its /// command says nothing about it. @@ -93,7 +89,7 @@ impl UserConfig { /// Parse `text`, the contents of the User config at `path`, expanding a /// leading `~` to `home`. Any key or section thirdshift doesn't know is an /// error, so a typo can't silently leave a setting unset. - fn parse(text: &str, path: &Path, home: &Path) -> Result { + pub fn parse(text: &str, path: &Path, home: &Path) -> Result { let file = format!("the User config {}", path.display()); let table: Table = text .parse() @@ -180,297 +176,6 @@ impl UserConfig { } } -/// Setup: write the User config. From a terminal, the Setup questions come -/// first, each with the current value as its default answer, and the answers -/// are written, with `base.fix`, asked about only when every Run is a Merge -/// run, otherwise at its default; with no terminal, nothing is asked, and every setting is at -/// its default, with `email.to` as the suggested address, if there is one. -/// An existing User config is edited in place, once it parses as a Run would -/// parse it: its comments and key order stay, as do the values Setup didn't -/// ask about, and each key it lacks is added at its default, so Setup with no -/// terminal never resets a configured machine. A Resend API key the user -/// gave is saved in the Credentials once the User config is written; with no -/// terminal, the Credentials are never read or written. Nothing is written -/// until the last answer is in. A test email, if the user asked for one, goes -/// once the files are written, as `email-test` sends it. -pub fn setup() -> Result { - let (home, path) = home_and_path()?; - let existing = match std::fs::read_to_string(&path) { - Ok(text) => Some(text), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => { - return Err(error).with_context(|| format!("can't read {}", path.display())); - } - }; - let mut text = match &existing { - Some(text) => { - UserConfig::parse(text, &path, &home)?; - complete(text)? - } - None => with_email_to(suggested_address(&home)), - }; - let mut answered = None; - if questions::has_terminal() { - let answers; - (text, answers) = ask(&text, &path, &home)?; - answered = Some(answers); - } - let asked = answered.is_some(); - let mut written = match &existing { - None => { - write_new(&home, &path, &text)?; - format!("wrote the User config {}", path.display()) - } - Some(existing) if *existing == text && asked => { - format!( - "the User config {} already holds your answers", - path.display() - ) - } - Some(existing) if *existing == text => format!( - "the User config {} already lists every setting", - path.display() - ), - Some(_) => { - replace(&path, &text).with_context(|| format!("can't write {}", path.display()))?; - if asked { - format!("wrote your answers to the User config {}", path.display()) - } else { - format!( - "added the missing settings to the User config {}", - path.display() - ) - } - } - }; - let Some(asked) = &answered else { - return Ok(written); - }; - if let Some(key) = asked.answers.key() { - progress::step(written); - asked.credentials.save(key)?; - written = format!("wrote {}", asked.credentials); - } - if !asked.answers.send_test() { - return Ok(written); - } - progress::step(written); - let config = UserConfig::load()?; - Ok(email::send_test(None, &config.email)?.to_string()) -} - -/// The first Run's offer of Setup, made only from a terminal and only when -/// there is no User config. Yes asks the Setup questions and writes the -/// answers, and the Resend API key to the Credentials if the user gave one; -/// no writes every setting at its default, as `setup` with no terminal does, -/// and leaves the Credentials alone. Either way the Run then loads what was -/// written. A User config or Credentials that can't be written, or a test -/// email that can't go, is a warning, so the Run carries on; Credentials a -/// Run would refuse, or stdin closing before the last answer, end the command -/// before any work, with nothing written. -pub fn offer_setup() -> Result<()> { - if !questions::has_terminal() { - return Ok(()); - } - let (home, path) = home_and_path()?; - match std::fs::symlink_metadata(&path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - _ => return Ok(()), - } - let accepted = questions::offer(&path)?; - let mut text = with_email_to(suggested_address(&home)); - let mut answered = None; - if accepted { - let answers; - (text, answers) = ask(&text, &path, &home)?; - answered = Some(answers); - } - if let Err(error) = write_new(&home, &path, &text) { - progress::step(format_args!( - "warning: {error:#}; carrying on with the defaults" - )); - return Ok(()); - } - let Some(asked) = &answered else { - progress::step(format_args!( - "wrote the User config {} with every setting at its default; \ - thirdshift setup changes it", - path.display() - )); - return Ok(()); - }; - progress::step(format_args!("wrote the User config {}", path.display())); - if let Some(key) = asked.answers.key() { - match asked.credentials.save(key) { - Ok(()) => progress::step(format_args!("wrote {}", asked.credentials)), - Err(error) => progress::step(format_args!("warning: {error:#}")), - } - } - if asked.answers.send_test() { - let sent = UserConfig::load().and_then(|config| email::send_test(None, &config.email)); - match sent { - Ok(sent) => progress::step(sent), - Err(error) => progress::step(format_args!("warning: {error:#}")), - } - } - Ok(()) -} - -/// The Setup answers, and the Credentials read before asking, where a key -/// the user gave is saved. -struct Asked { - answers: Answers, - credentials: Credentials, -} - -/// Ask the Setup questions, with the settings in `text`, the User config at -/// `path`, and the key in the Credentials as the default answers. Credentials -/// a Run would refuse are refused before any question. Returns `text` with -/// the answers, and what was asked. -fn ask(text: &str, path: &Path, home: &Path) -> Result<(String, Asked)> { - let current = UserConfig::parse(text, path, home)?; - let credentials = Credentials::read()?; - let answers = questions::ask(¤t, &credentials, || suggested_address(home))?; - let text = with_answers(text, &answers)?; - Ok(( - text, - Asked { - answers, - credentials, - }, - )) -} - -/// Write `text` as the User config at `path`, where there is none yet, -/// creating `~/.thirdshift` under `home` if it is missing. -fn write_new(home: &Path, path: &Path, text: &str) -> Result<()> { - std::fs::create_dir_all(home.join(".thirdshift")) - .and_then(|()| std::fs::write(path, text)) - .with_context(|| format!("can't write {}", path.display())) -} - -/// `text`, a User config with every key, or a commented-out `email.to`, with -/// the values `answers` gives. Only those values change: the spacing and -/// comments around each stay, as does everything else in `text`. -fn with_answers(text: &str, answers: &Answers) -> Result { - let mut document: DocumentMut = text.parse().context("can't parse the User config")?; - set(&mut document, "merge", "always", answers.merge_always); - set(&mut document, "base", "fix", answers.base_fix); - set(&mut document, "launch", "pull", answers.launch_pull); - set( - &mut document, - "email", - "always", - answers.notifications.is_some(), - ); - if let Some(notifications) = &answers.notifications { - set(&mut document, "email", "from", notifications.from.as_str()); - set_email_to(&mut document, ¬ifications.to); - } - if let Some((harness, chosen)) = &answers.harness { - set(&mut document, "harness", "default", harness.name()); - let section = format!("harness.{}", harness.name()); - for (key, value) in [("model", &chosen.model), ("effort", &chosen.effort)] { - set(&mut document, §ion, key, value.as_deref().unwrap_or("")); - } - } - Ok(document.to_string()) -} - -/// Set `section.key`, which `document` holds, to `value`, keeping the -/// spacing and comment around the old value, and the comment in the same -/// column where the spaces before it allow. An equal value is left as it was -/// written. `section` may be a subsection, as `harness.claude`. -fn set(document: &mut DocumentMut, section: &str, key: &str, value: impl Into) { - let value = value.into(); - let old = section - .split('.') - .try_fold(document.as_item_mut(), |item, name| { - item.as_table_like_mut()?.get_mut(name) - }) - .and_then(Item::as_table_like_mut) - .and_then(|settings| settings.get_mut(key)) - .and_then(Item::as_value_mut) - .unwrap_or_else(|| panic!("a completed User config has {section}.{key}")); - let same = match (old.as_bool(), old.as_str()) { - (Some(old), _) => value.as_bool() == Some(old), - (_, Some(old)) => value.as_str() == Some(old), - _ => false, - }; - if same { - return; - } - let mut decor = old.decor().clone(); - let suffix = decor_suffix(&decor); - let spaces = suffix.len() - suffix.trim_start_matches(' ').len(); - if spaces > 0 && suffix[spaces..].starts_with('#') { - let width = |value: &toml_edit::Value| value.clone().decorated("", "").to_string().len(); - let spaces = (spaces + width(old)).saturating_sub(width(&value)).max(1); - decor.set_suffix(format!( - "{}{}", - " ".repeat(spaces), - suffix.trim_start_matches(' ') - )); - } - *old = value; - *old.decor_mut() = decor; -} - -/// Set `email.to` in `document` to `to`. With no `email.to` there yet, it -/// takes the place of the commented-out one, if there is one, written as -/// `DEFAULTS` would write it, with its comment. -fn set_email_to(document: &mut DocumentMut, to: &str) { - let email = &mut document["email"]; - let has_to = email - .as_table_like() - .is_some_and(|settings| settings.contains_key("to")); - if has_to { - set(document, "email", "to", to); - return; - } - let Some(email) = email.as_table_mut().filter(|email| !email.is_dotted()) else { - let settings = email.as_table_like_mut().expect("[email] is a section"); - settings.insert("to", Item::Value(to.into())); - return; - }; - let example: DocumentMut = with_email_to(Some(to.to_string())) - .parse() - .expect("DEFAULTS is valid TOML"); - let (key, item) = example["email"] - .as_table() - .and_then(|example| example.get_key_value("to")) - .expect("the example sets email.to"); - let mut key = key.clone(); - key.leaf_decor_mut().set_prefix(""); - let keys: Vec = email.iter().map(|(key, _)| key.to_string()).collect(); - let mut place = keys.len(); - for (at, name) in keys.iter().enumerate() { - let mut next = email.key_mut(name).expect("the key is in [email]"); - let prefix = decor_prefix(next.leaf_decor()); - let mut end = 0; - let found = prefix.split_inclusive('\n').find_map(|line| { - let start = end; - end += line.len(); - is_commented_out_email_to(line).then_some((start, end)) - }); - let Some((start, end)) = found else { - continue; - }; - next.leaf_decor_mut().set_prefix(&prefix[end..]); - key.leaf_decor_mut().set_prefix(&prefix[..start]); - place = at; - break; - } - email.insert_formatted(&key, item.clone()); - // Each key keeps its place, and `to` goes just before the key at `place`, - // or last: odd ranks for the keys that were there, an even one for `to`. - let rank = |name: &str| match keys.iter().position(|key| key == name) { - Some(at) => 2 * at + 1, - None => 2 * place, - }; - email.sort_values_by(|a, _, b, _| rank(a.get()).cmp(&rank(b.get()))); -} - /// Replace the file at `path` with `text` all at once, keeping its /// permissions, so a failed write leaves it as it was. pub fn replace(path: &Path, text: &str) -> std::io::Result<()> { @@ -489,7 +194,7 @@ pub fn replace(path: &Path, text: &str) -> std::io::Result<()> { /// section, or a key of a section `text` names only in its subsections' /// headers, after the last line of `text`. A key added to an inline table /// gets no comment, as TOML has no place for one there. -fn complete(text: &str) -> Result { +pub fn complete(text: &str) -> Result { let mut document: DocumentMut = text.parse().context("can't parse the User config")?; let defaults: DocumentMut = DEFAULTS.parse().expect("DEFAULTS is valid TOML"); let mut missing = DocumentMut::new(); @@ -612,7 +317,7 @@ fn note_email_to(email: &mut toml_edit::Table, defaults: &toml_edit::Table, text /// The text `decor` puts before a key: the comment and blank lines above it, /// and its indent. -fn decor_prefix(decor: &toml_edit::Decor) -> String { +pub fn decor_prefix(decor: &toml_edit::Decor) -> String { decor .prefix() .and_then(|prefix| prefix.as_str()) @@ -622,7 +327,7 @@ fn decor_prefix(decor: &toml_edit::Decor) -> String { /// The text `decor` puts after a value: the spaces and comment that end its /// line. -fn decor_suffix(decor: &toml_edit::Decor) -> String { +pub fn decor_suffix(decor: &toml_edit::Decor) -> String { decor .suffix() .and_then(|suffix| suffix.as_str()) @@ -645,7 +350,7 @@ fn has_commented_out_email_to(text: &str) -> bool { } /// Whether `line`, in the `[email]` section, is a commented-out `to` line. -fn is_commented_out_email_to(line: &str) -> bool { +pub fn is_commented_out_email_to(line: &str) -> bool { line.trim() .strip_prefix('#') .and_then(|comment| comment.trim_start().strip_prefix("to")) @@ -655,7 +360,7 @@ fn is_commented_out_email_to(line: &str) -> bool { /// The User config Setup writes with no answers: every key at its default, /// each with what it does and that default. `email.to` has no default, so it /// is the one key written commented out. -const DEFAULTS: &str = r#"[merge] +pub const DEFAULTS: &str = r#"[merge] always = false # every Run is a Merge run, without the merge word; default false [base] @@ -697,7 +402,7 @@ effort = "" # how hard that Model reasons; default blank, for Codex's own const NO_EMAIL_TO: &str = r#"# to = "you@example.com" # where email goes when the command names no address; no default"#; /// `DEFAULTS` with `email.to` set to `to`, if there is one. -fn with_email_to(to: Option) -> String { +pub fn with_email_to(to: Option) -> String { let Some(to) = to else { return DEFAULTS.to_string(); }; @@ -706,23 +411,6 @@ fn with_email_to(to: Option) -> String { DEFAULTS.replace(NO_EMAIL_TO, &format!("{line:<31} # {comment}")) } -/// The address Setup suggests for `email.to`: the public email of the user's -/// GitHub profile, else the global git `user.email`, unless that is a -/// `@users.noreply.github.com` address, which can't receive mail. Only Setup -/// looks it up; a Run never falls back to it. -fn suggested_address(home: &Path) -> Option { - let github = github::profile_email().ok().flatten(); - github.filter(|email| !email.is_empty()).or_else(|| { - let git = Git::new(home).run(&["config", "--global", "user.email"]); - git.ok().filter(|email| { - !email.is_empty() - && !email - .to_ascii_lowercase() - .ends_with("@users.noreply.github.com") - }) - }) -} - /// `value`, which `file` gives the setting `key`, as a whole number from 1 /// up, or an error naming both if it is anything else. fn whole_number_from_1(value: &Value, key: &str, file: &str) -> Result { @@ -755,7 +443,7 @@ fn model_and_effort(settings: &Table, harness: &str, file: &str) -> Result Result<(PathBuf, PathBuf)> { +pub fn home_and_path() -> Result<(PathBuf, PathBuf)> { let home = home()?; let path = home.join(".thirdshift/config.toml"); Ok((home, path)) @@ -806,6 +494,49 @@ mod tests { assert_eq!(config, UserConfig::defaults(Path::new("/home/me"))); } + #[test] + fn every_default_key_has_a_comment_giving_what_it_does_and_its_default() { + let mut section = ""; + let mut keys = Vec::new(); + for line in DEFAULTS.lines().filter(|line| !line.is_empty()) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + section = name; + continue; + } + let (setting, comment) = line.split_once(" # ").unwrap_or_else(|| { + panic!("no trailing comment on [{section}] {line:?}"); + }); + assert!(comment.contains("default"), "[{section}] {line:?}"); + let key = setting.trim_start_matches("# ").split(' ').next().unwrap(); + keys.push(format!("{section}.{key}")); + } + assert_eq!( + keys, + [ + "merge.always", + "base.fix", + "launch.pull", + "email.always", + "email.to", + "email.from", + "logs.dir", + "activity.quiet_skips", + "spec.parallel", + "pickup.limit", + "harness.default", + "harness.claude.model", + "harness.claude.effort", + "harness.codex.model", + "harness.codex.effort" + ] + ); + let commented_out: Vec<&str> = DEFAULTS + .lines() + .filter(|line| line.starts_with('#')) + .collect(); + assert_eq!(commented_out, [NO_EMAIL_TO]); + } + #[test] fn a_suggested_address_sets_email_to_in_place_of_the_commented_out_line() { assert!(DEFAULTS.contains(&format!("\n{NO_EMAIL_TO}\n"))); @@ -1173,55 +904,4 @@ mod tests { let completed = completed("[email]\nto = \"me@example.com\"\n"); assert!(!completed.contains("# to ="), "{completed}"); } - - fn notifications_to(to: &str) -> Answers { - Answers { - harness: None, - merge_always: false, - base_fix: false, - launch_pull: false, - notifications: Some(questions::Notifications { - to: to.to_string(), - from: crate::email::DEFAULT_FROM.to_string(), - key: None, - send_test: false, - }), - } - } - - #[test] - fn an_answered_address_takes_the_place_of_the_commented_out_line() { - let answered = with_answers(DEFAULTS, ¬ifications_to("me@example.com")).unwrap(); - let expected = with_email_to(Some("me@example.com".to_string())).replace( - "always = false #", - "always = true #", - ); - assert_eq!(answered, expected); - } - - #[test] - fn an_answered_address_keeps_the_lines_around_the_commented_out_one() { - let text = - "[email]\nalways = false\n\n# mine\n# to = \"x@y.z\"\n# more\nfrom = \"a@b.c\"\n"; - let answered = with_answers( - &complete(text).unwrap(), - ¬ifications_to("me@example.com"), - ) - .unwrap(); - let lines: Vec<&str> = answered.lines().collect(); - assert_eq!( - lines[..4], - ["[email]", "always = true", "", "# mine"], - "{answered}" - ); - assert!( - lines[4].starts_with("to = \"me@example.com\""), - "{answered}" - ); - assert_eq!( - lines[5..7], - ["# more", "from = \"onboarding@resend.dev\""], - "{answered}" - ); - } } diff --git a/src/harness.rs b/src/harness.rs index 94525a8b0..d865ccffa 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -320,7 +320,7 @@ impl Catalog { } /// The catalog `codex debug models` printed as `json`. - fn parse(json: &str) -> Result { + pub fn parse(json: &str) -> Result { let parsed: Value = serde_json::from_str(json) .context("codex debug models printed no JSON catalog of Models")?; let models = parsed["models"] diff --git a/src/main.rs b/src/main.rs index e0fc0020a..3faee51b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,12 +28,12 @@ mod progress; mod prompt; #[cfg(test)] mod prompts_page; -mod questions; mod ready; mod resend_key; mod run; mod run_ending; mod session; +mod setup; mod skills; mod spec_run; mod update; @@ -362,7 +362,7 @@ fn main() -> ExitCode { return ExitCode::SUCCESS; } Ok(Command::Update) => return outcome(update::update()), - Ok(Command::Setup) => return outcome(config::setup()), + Ok(Command::Setup) => return outcome(setup::setup()), Ok(Command::EmailTest(to)) => { return outcome( UserConfig::load().and_then(|config| email::send_test(to, &config.email)), diff --git a/src/resend_key.rs b/src/resend_key.rs index a96d4111e..b1e6fa60a 100644 --- a/src/resend_key.rs +++ b/src/resend_key.rs @@ -24,7 +24,7 @@ pub struct ResendKey { } /// Where a Resend API key came from. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Source { /// The `RESEND_API_KEY` environment variable. Environment, @@ -284,6 +284,28 @@ mod tests { } } + #[test] + fn a_saved_key_is_replaced_in_place_keeping_the_rest_of_the_credentials() { + let saved = "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_saved_456\" # from the dashboard\n"; + + assert_eq!( + with_key(saved, "re_secret_123").unwrap(), + "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_secret_123\" # from the dashboard\n" + ); + } + + #[test] + fn a_key_is_added_after_credentials_that_hold_none() { + let text = with_key("# Keys go here.\n", "re_secret_123").unwrap(); + + assert!(text.starts_with("# Keys go here.\n"), "{text}"); + let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml")); + assert_eq!( + parse(&text, &file).unwrap().as_deref(), + Some("re_secret_123") + ); + } + #[test] fn broken_credentials_are_an_error_naming_the_file_and_what_is_wrong() { let path = "/home/me/.thirdshift/credentials.toml"; diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 000000000..dfa16af69 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,2425 @@ +//! Setup: writing the User config by answering a few questions, from +//! `thirdshift setup` or the first Run's offer of it, and the Credentials +//! when a Resend API key is entered. +//! +//! Its rules are here: whether to ask, the questions and their defaults, the +//! suggested address, what is written and in what order, and which failures +//! the offer carries on through. Parsing, the defaults and completing a User +//! config are the User config module's. Everything Setup does outside +//! itself goes through [`Outside`]: [`OnMachine`] does each on the terminal, +//! the Harnesses' CLIs, `gh`, `git`, Resend and the files under the home +//! folder; `Scripted`, in tests, from a script, recording each call. + +mod questions; + +use std::io::{BufRead, IsTerminal, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; +use signal_hook::SigId; +use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; +use signal_hook::low_level; +use toml_edit::{DocumentMut, Item}; + +use crate::config::{self, EmailSettings, UserConfig}; +use crate::git::Git; +use crate::harness::{Catalog, Choice, ChosenBy, Harness, ModelAndEffort}; +use crate::resend_key::{Credentials, Source}; +use crate::{email, github, progress}; + +use questions::Answers; + +/// Setup: write the User config. From a terminal, the Setup questions come +/// first, each with the current value as its default answer, and the answers +/// are written, with `base.fix`, asked about only when every Run is a Merge +/// run, otherwise at its default; with no terminal, nothing is asked, and every setting is at +/// its default, with `email.to` as the suggested address, if there is one. +/// An existing User config is edited in place, once it parses as a Run would +/// parse it: its comments and key order stay, as do the values Setup didn't +/// ask about, and each key it lacks is added at its default, so Setup with no +/// terminal never resets a configured machine. A Resend API key the user +/// gave is saved in the Credentials once the User config is written; with no +/// terminal, the Credentials are never read or written. Nothing is written +/// until the last answer is in. A test email, if the user asked for one, goes +/// once the files are written, as `email-test` sends it. Returns the line +/// saying what was done last. +pub fn setup() -> Result { + let (home, path) = config::home_and_path()?; + run_setup(&mut OnMachine::new(&home), &home, &path) +} + +/// The first Run's offer of Setup, made only from a terminal and only when +/// there is no User config. Yes asks the Setup questions and writes the +/// answers, and the Resend API key to the Credentials if the user gave one; +/// no writes every setting at its default, as `setup` with no terminal does, +/// and leaves the Credentials alone. Either way the Run then loads what was +/// written. A User config or Credentials that can't be written, or a test +/// email that can't go, is a warning, so the Run carries on; Credentials a +/// Run would refuse, or stdin closing before the last answer, end the command +/// before any work, with nothing written. +pub fn offer() -> Result<()> { + let (home, path) = config::home_and_path()?; + run_offer(&mut OnMachine::new(&home), &home, &path) +} + +/// [`setup`], for the User config at `path` under `home`. +fn run_setup(outside: &mut impl Outside, home: &Path, path: &Path) -> Result { + let existing = outside + .read_user_config(path) + .with_context(|| format!("can't read {}", path.display()))?; + let mut text = match &existing { + Some(text) => { + UserConfig::parse(text, path, home)?; + config::complete(text)? + } + None => config::with_email_to(suggested_address(outside)), + }; + let mut answered = None; + if outside.has_terminal() { + let answers; + (text, answers) = ask(outside, &text, path, home)?; + answered = Some(answers); + } + let asked = answered.is_some(); + let mut written = match &existing { + None => { + write_new(outside, path, &text)?; + format!("wrote the User config {}", path.display()) + } + Some(existing) if *existing == text && asked => { + format!( + "the User config {} already holds your answers", + path.display() + ) + } + Some(existing) if *existing == text => format!( + "the User config {} already lists every setting", + path.display() + ), + Some(_) => { + outside + .replace_user_config(path, &text) + .with_context(|| format!("can't write {}", path.display()))?; + if asked { + format!("wrote your answers to the User config {}", path.display()) + } else { + format!( + "added the missing settings to the User config {}", + path.display() + ) + } + } + }; + let Some(answers) = &answered else { + return Ok(written); + }; + if let Some(key) = answers.key() { + outside.step(written); + written = outside.save_key(key)?; + } + if !answers.send_test() { + return Ok(written); + } + outside.step(written); + let config = UserConfig::parse(&text, path, home)?; + outside.send_test(&config.email) +} + +/// [`offer`], for the User config at `path` under `home`. +fn run_offer(outside: &mut impl Outside, home: &Path, path: &Path) -> Result<()> { + if !outside.has_terminal() || outside.user_config_exists(path) { + return Ok(()); + } + let accepted = questions::offer(outside, path)?; + let mut text = config::with_email_to(suggested_address(outside)); + let mut answered = None; + if accepted { + let answers; + (text, answers) = ask(outside, &text, path, home)?; + answered = Some(answers); + } + if let Err(error) = write_new(outside, path, &text) { + outside.step(format!("warning: {error:#}; carrying on with the defaults")); + return Ok(()); + } + let Some(answers) = &answered else { + outside.step(format!( + "wrote the User config {} with every setting at its default; \ + thirdshift setup changes it", + path.display() + )); + return Ok(()); + }; + outside.step(format!("wrote the User config {}", path.display())); + if let Some(key) = answers.key() { + match outside.save_key(key) { + Ok(wrote) => outside.step(wrote), + Err(error) => outside.step(format!("warning: {error:#}")), + } + } + if answers.send_test() { + let sent = UserConfig::parse(&text, path, home) + .and_then(|config| outside.send_test(&config.email)); + match sent { + Ok(sent) => outside.step(sent), + Err(error) => outside.step(format!("warning: {error:#}")), + } + } + Ok(()) +} + +/// Ask the Setup questions, with the settings in `text`, the User config at +/// `path` under `home`, and the key in the Credentials as the default +/// answers. Credentials a Run would refuse are refused before any question. +/// Returns `text` with the answers, and the answers. +fn ask( + outside: &mut impl Outside, + text: &str, + path: &Path, + home: &Path, +) -> Result<(String, Answers)> { + let current = UserConfig::parse(text, path, home)?; + let found = outside.find_key()?; + let answers = questions::ask(outside, ¤t, found)?; + let text = with_answers(text, &answers)?; + Ok((text, answers)) +} + +/// Write `text` as the User config at `path`, where there is none yet. +fn write_new(outside: &mut impl Outside, path: &Path, text: &str) -> Result<()> { + outside + .write_new_user_config(path, text) + .with_context(|| format!("can't write {}", path.display())) +} + +/// The address Setup suggests for `email.to`: the public email of the user's +/// GitHub profile, else the global git `user.email`, unless that is a +/// `@users.noreply.github.com` address, which can't receive mail. Only Setup +/// looks it up; a Run never falls back to it. +fn suggested_address(outside: &mut impl Outside) -> Option { + let github = outside.github_email().ok().flatten(); + github.filter(|email| !email.is_empty()).or_else(|| { + outside.git_email().ok().filter(|email| { + !email.is_empty() + && !email + .to_ascii_lowercase() + .ends_with("@users.noreply.github.com") + }) + }) +} + +/// `text`, a User config with every key, or a commented-out `email.to`, with +/// the values `answers` gives. Only those values change: the spacing and +/// comments around each stay, as does everything else in `text`. +fn with_answers(text: &str, answers: &Answers) -> Result { + let mut document: DocumentMut = text.parse().context("can't parse the User config")?; + set(&mut document, "merge", "always", answers.merge_always); + set(&mut document, "base", "fix", answers.base_fix); + set(&mut document, "launch", "pull", answers.launch_pull); + set( + &mut document, + "email", + "always", + answers.notifications.is_some(), + ); + if let Some(notifications) = &answers.notifications { + set(&mut document, "email", "from", notifications.from.as_str()); + set_email_to(&mut document, ¬ifications.to); + } + if let Some((harness, chosen)) = &answers.harness { + set(&mut document, "harness", "default", harness.name()); + let section = format!("harness.{}", harness.name()); + for (key, value) in [("model", &chosen.model), ("effort", &chosen.effort)] { + set(&mut document, §ion, key, value.as_deref().unwrap_or("")); + } + } + Ok(document.to_string()) +} + +/// Set `section.key`, which `document` holds, to `value`, keeping the +/// spacing and comment around the old value, and the comment in the same +/// column where the spaces before it allow. An equal value is left as it was +/// written. `section` may be a subsection, as `harness.claude`. +fn set(document: &mut DocumentMut, section: &str, key: &str, value: impl Into) { + let value = value.into(); + let old = section + .split('.') + .try_fold(document.as_item_mut(), |item, name| { + item.as_table_like_mut()?.get_mut(name) + }) + .and_then(Item::as_table_like_mut) + .and_then(|settings| settings.get_mut(key)) + .and_then(Item::as_value_mut) + .unwrap_or_else(|| panic!("a completed User config has {section}.{key}")); + let same = match (old.as_bool(), old.as_str()) { + (Some(old), _) => value.as_bool() == Some(old), + (_, Some(old)) => value.as_str() == Some(old), + _ => false, + }; + if same { + return; + } + let mut decor = old.decor().clone(); + let suffix = config::decor_suffix(&decor); + let spaces = suffix.len() - suffix.trim_start_matches(' ').len(); + if spaces > 0 && suffix[spaces..].starts_with('#') { + let width = |value: &toml_edit::Value| value.clone().decorated("", "").to_string().len(); + let spaces = (spaces + width(old)).saturating_sub(width(&value)).max(1); + decor.set_suffix(format!( + "{}{}", + " ".repeat(spaces), + suffix.trim_start_matches(' ') + )); + } + *old = value; + *old.decor_mut() = decor; +} + +/// Set `email.to` in `document` to `to`. With no `email.to` there yet, it +/// takes the place of the commented-out one, if there is one, written as +/// the defaults would write it, with its comment. +fn set_email_to(document: &mut DocumentMut, to: &str) { + let email = &mut document["email"]; + let has_to = email + .as_table_like() + .is_some_and(|settings| settings.contains_key("to")); + if has_to { + set(document, "email", "to", to); + return; + } + let Some(email) = email.as_table_mut().filter(|email| !email.is_dotted()) else { + let settings = email.as_table_like_mut().expect("[email] is a section"); + settings.insert("to", Item::Value(to.into())); + return; + }; + let example: DocumentMut = config::with_email_to(Some(to.to_string())) + .parse() + .expect("DEFAULTS is valid TOML"); + let (key, item) = example["email"] + .as_table() + .and_then(|example| example.get_key_value("to")) + .expect("the example sets email.to"); + let mut key = key.clone(); + key.leaf_decor_mut().set_prefix(""); + let keys: Vec = email.iter().map(|(key, _)| key.to_string()).collect(); + let mut place = keys.len(); + for (at, name) in keys.iter().enumerate() { + let mut next = email.key_mut(name).expect("the key is in [email]"); + let prefix = config::decor_prefix(next.leaf_decor()); + let mut end = 0; + let found = prefix.split_inclusive('\n').find_map(|line| { + let start = end; + end += line.len(); + config::is_commented_out_email_to(line).then_some((start, end)) + }); + let Some((start, end)) = found else { + continue; + }; + next.leaf_decor_mut().set_prefix(&prefix[end..]); + key.leaf_decor_mut().set_prefix(&prefix[..start]); + place = at; + break; + } + email.insert_formatted(&key, item.clone()); + // Each key keeps its place, and `to` goes just before the key at `place`, + // or last: odd ranks for the keys that were there, an even one for `to`. + let rank = |name: &str| match keys.iter().position(|key| key == name) { + Some(at) => 2 * at + 1, + None => 2 * place, + }; + email.sort_values_by(|a, _, b, _| rank(a.get()).cmp(&rank(b.get()))); +} + +/// What Setup does outside itself: the terminal it asks on, the Harnesses' +/// CLIs, the Credentials, the lookups behind the suggested address, the User +/// config file, the test email, and where its progress lines go. +trait Outside { + /// Whether there is someone to ask: stdin and stderr are both terminals. + fn has_terminal(&mut self) -> bool; + /// Show `prompt` and read one answer, trimmed, or `None` if stdin closed. + fn read(&mut self, prompt: &str) -> Result>; + /// [`Outside::read`], with what is typed never shown. + fn read_hidden(&mut self, prompt: &str) -> Result>; + /// Show `line` on the terminal. + fn say(&mut self, line: String); + /// Whether sessions can run on `harness` here. + fn installed(&mut self, harness: Harness) -> bool; + /// Make Claude's test call on the Model and Effort `chosen`, as a Run + /// checks them. + fn test_call(&mut self, chosen: &ModelAndEffort) -> Result<()>; + /// Read Codex's catalog of Models. + fn codex_catalog(&mut self) -> Result; + /// Read the Credentials, as strictly as a Run does: where a Resend API + /// key is found, if anywhere. + fn find_key(&mut self) -> Result>; + /// Save `key` in the Credentials: the line saying where. + fn save_key(&mut self, key: &str) -> Result; + /// The public email of the user's GitHub profile, if it has one. + fn github_email(&mut self) -> Result>; + /// The global git `user.email`. + fn git_email(&mut self) -> Result; + /// The User config at `path`, or `None` if there is none. + fn read_user_config(&mut self, path: &Path) -> Result>; + /// Whether anything is at `path`, where the User config goes. + fn user_config_exists(&mut self, path: &Path) -> bool; + /// Write `text` as the User config at `path`, where there is none yet, + /// creating its folder if it is missing. + fn write_new_user_config(&mut self, path: &Path, text: &str) -> Result<()>; + /// Replace the User config at `path` with `text`, all at once. + fn replace_user_config(&mut self, path: &Path, text: &str) -> Result<()>; + /// Send the test email, with `email`, as `email-test` sends it: the line + /// saying it went. + fn send_test(&mut self, email: &EmailSettings) -> Result; + /// Hand on the progress line `line`. + fn step(&mut self, line: String); +} + +/// This machine, with its home folder `home`: the terminal on stdin and +/// stderr, the Harnesses' CLIs, `gh`, `git`, Resend, and the files under +/// `home`. +struct OnMachine<'a> { + home: &'a Path, + /// The Credentials as read, kept so a key is saved into what was read. + credentials: Option, +} + +impl<'a> OnMachine<'a> { + fn new(home: &'a Path) -> Self { + OnMachine { + home, + credentials: None, + } + } +} + +impl Outside for OnMachine<'_> { + fn has_terminal(&mut self) -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + } + + fn read(&mut self, prompt: &str) -> Result> { + let mut stderr = std::io::stderr(); + let _ = write!(stderr, "{prompt}"); + let _ = stderr.flush(); + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line)? == 0 { + let _ = writeln!(stderr); + return Ok(None); + } + Ok(Some(line.trim().to_string())) + } + + /// With the terminal's echo off, so what is typed never shows. + fn read_hidden(&mut self, prompt: &str) -> Result> { + let echo_off = EchoOff::new()?; + let line = self.read(prompt)?; + drop(echo_off); + if line.is_some() { + let _ = writeln!(std::io::stderr()); + } + Ok(line) + } + + fn say(&mut self, line: String) { + let _ = writeln!(std::io::stderr(), "{line}"); + } + + fn installed(&mut self, harness: Harness) -> bool { + harness.installed() + } + + fn test_call(&mut self, chosen: &ModelAndEffort) -> Result<()> { + Choice { + harness: Harness::Claude, + model: chosen.model.clone(), + effort: chosen.effort.clone(), + chosen_by: ChosenBy::UserConfig, + } + .test_call() + } + + fn codex_catalog(&mut self) -> Result { + Catalog::read() + } + + fn find_key(&mut self) -> Result> { + let credentials = Credentials::read()?; + let found = credentials.lookup().map(|key| key.source); + self.credentials = Some(credentials); + Ok(found) + } + + fn save_key(&mut self, key: &str) -> Result { + let credentials = match self.credentials.take() { + Some(credentials) => credentials, + None => Credentials::read()?, + }; + credentials.save(key)?; + Ok(format!("wrote {credentials}")) + } + + fn github_email(&mut self) -> Result> { + github::profile_email() + } + + fn git_email(&mut self) -> Result { + Git::new(self.home).run(&["config", "--global", "user.email"]) + } + + fn read_user_config(&mut self, path: &Path) -> Result> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + + fn user_config_exists(&mut self, path: &Path) -> bool { + !matches!( + std::fs::symlink_metadata(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) + } + + fn write_new_user_config(&mut self, path: &Path, text: &str) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, text)?; + Ok(()) + } + + fn replace_user_config(&mut self, path: &Path, text: &str) -> Result<()> { + Ok(config::replace(path, text)?) + } + + fn send_test(&mut self, email: &EmailSettings) -> Result { + Ok(email::send_test(None, email)?.to_string()) + } + + fn step(&mut self, line: String) { + progress::step(line); + } +} + +/// The terminal on stdin with its echo off, until this is dropped. Ctrl-C, +/// SIGTERM or SIGHUP meanwhile turn echo back on, then end the command as +/// they would anywhere else in Setup, so it never leaves the shell blind. +struct EchoOff { + saved: libc::termios, + handlers: Vec, +} + +impl EchoOff { + fn new() -> Result { + // SAFETY: an all-zero termios is a valid value for tcgetattr to fill. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + // SAFETY: fd 0 is open, and `saved` is a valid termios to fill. + if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't read the terminal's settings"); + } + let mut echo_off = EchoOff { + saved, + handlers: Vec::new(), + }; + for signal in [SIGINT, SIGTERM, SIGHUP] { + // SAFETY: the handler only calls tcsetattr and signal-hook's + // emulate_default_handler, both async-signal-safe. + let handler = unsafe { + low_level::register(signal, move || { + libc::tcsetattr(0, libc::TCSANOW, &saved); + let _ = low_level::emulate_default_handler(signal); + }) + } + .context("could not install the signal handler")?; + echo_off.handlers.push(handler); + } + let mut hidden = saved; + hidden.c_lflag &= !libc::ECHO; + // SAFETY: fd 0 is open, and `hidden` is a valid termios. + if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't turn the terminal's echo off"); + } + Ok(echo_off) + } +} + +impl Drop for EchoOff { + fn drop(&mut self) { + // SAFETY: fd 0 is open, and `saved` is the termios read from it. + unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; + for handler in self.handlers.drain(..) { + low_level::unregister(handler); + } + } +} + +#[cfg(test)] +mod scripted { + use std::collections::VecDeque; + + use anyhow::{anyhow, bail}; + + use super::*; + + /// What `codex debug models` prints: a few Models, each with its display + /// name and the Efforts it supports. + pub const CATALOG: &str = r#"{"models": [ + {"slug": "gpt-6.1-sol", "display_name": "GPT-6.1-Sol", "visibility": "list", + "supported_reasoning_levels": [{"effort": "low"}, {"effort": "medium"}, {"effort": "high"}, {"effort": "xhigh"}, {"effort": "max"}, {"effort": "ultra"}]}, + {"slug": "gpt-6-luna", "display_name": "GPT-6-Luna", "visibility": "list", + "supported_reasoning_levels": [{"effort": "low"}, {"effort": "medium"}, {"effort": "high"}, {"effort": "xhigh"}, {"effort": "max"}]}, + {"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list", + "supported_reasoning_levels": [{"effort": "low"}, {"effort": "medium"}, {"effort": "high"}, {"effort": "xhigh"}]} + ]}"#; + + /// Where the Credentials are. + pub const CREDENTIALS: &str = "/home/me/.thirdshift/credentials.toml"; + + /// What the test email's send says once Resend accepts it. + pub const SENT: &str = "accepted by Resend; check your inbox"; + + /// A call Setup made, in the order it made it. + #[derive(Debug, PartialEq, Eq)] + pub enum Call { + /// It showed this prompt and read an answer. + Ask(String), + /// It showed this prompt and read a hidden answer. + AskHidden(String), + /// It showed this line on the terminal. + Say(String), + /// It made Claude's test call on this Model and Effort. + Tested(ModelAndEffort), + /// It wrote this new User config. + WriteNew(String), + /// It replaced the User config with this. + Replace(String), + /// It saved this key in the Credentials. + SaveKey(String), + /// It sent the test email to `to`, from `from`. + SendTest { + to: Option, + from: Option, + }, + /// It handed on this progress line. + Step(String), + } + + /// A write, save or send that can fail. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Fails { + WriteNew, + Replace, + SaveKey, + SendTest, + } + + /// The outside world from a script: the answers to give, in order, to + /// plain and hidden prompts alike, each with part of the prompt it + /// answers; which Harnesses are installed, and Claude's test calls' + /// results in turn; Codex's catalog; where a key is found; the two + /// emails; the User config, if any; and what fails. + pub struct Scripted { + pub terminal: bool, + /// Each answer, with part of the prompt it is for. Once they run + /// out, stdin is closed. + pub answers: VecDeque<(&'static str, &'static str)>, + pub installed: Vec, + /// Claude's refusal of each test call in turn, if it refuses it; + /// once they run out, it takes each. + pub test_calls: VecDeque>, + /// The catalog's JSON, or what reading it fails with. + pub catalog: Result<&'static str, &'static str>, + /// Where a key is found, or what reading the Credentials fails with. + pub key: Result, &'static str>, + pub github_email: Result, &'static str>, + pub git_email: Result<&'static str, &'static str>, + /// The User config's text, if there is one, which writes change. + pub user_config: Option, + pub failing: Vec, + /// Every call made, in order. + pub calls: Vec, + } + + impl Scripted { + /// A terminal answering `answers` in order, then closing stdin, + /// with both Harnesses installed, no key, no email to suggest and no + /// User config. + pub fn answering(answers: &[(&'static str, &'static str)]) -> Self { + Scripted { + terminal: true, + answers: answers.iter().copied().collect(), + installed: Harness::ALL.to_vec(), + test_calls: VecDeque::new(), + catalog: Ok(CATALOG), + key: Ok(None), + github_email: Ok(None), + git_email: Err("git config --global user.email: exit status 1"), + user_config: None, + failing: Vec::new(), + calls: Vec::new(), + } + } + + /// No terminal, with the rest as [`Scripted::answering`] has it. + pub fn unattended() -> Self { + Scripted { + terminal: false, + ..Scripted::answering(&[]) + } + } + + /// Every prompt shown, plain or hidden, in order. + pub fn prompts(&self) -> Vec<&str> { + self.calls + .iter() + .filter_map(|call| match call { + Call::Ask(prompt) | Call::AskHidden(prompt) => Some(prompt.as_str()), + _ => None, + }) + .collect() + } + + /// Every line said on the terminal, in order. + pub fn said(&self) -> Vec<&str> { + self.calls + .iter() + .filter_map(|call| match call { + Call::Say(line) => Some(line.as_str()), + _ => None, + }) + .collect() + } + + /// Every write, save, send and progress line, in order. + pub fn effects(&self) -> Vec<&Call> { + self.calls + .iter() + .filter(|call| { + !matches!( + call, + Call::Ask(_) | Call::AskHidden(_) | Call::Say(_) | Call::Tested(_) + ) + }) + .collect() + } + + /// Every test call Claude was given, in order. + pub fn test_calls(&self) -> Vec<&ModelAndEffort> { + self.calls + .iter() + .filter_map(|call| match call { + Call::Tested(chosen) => Some(chosen), + _ => None, + }) + .collect() + } + + /// The next answer, checking `prompt` is the one it is for, or + /// `None` if stdin is closed. + fn answer(&mut self, prompt: &str) -> Option { + let (expected, answer) = self.answers.pop_front()?; + assert!( + prompt.contains(expected), + "asked {prompt:?}, but the next answer is for {expected:?}; asked so far: {:#?}", + self.prompts() + ); + Some(answer.to_string()) + } + + /// Fail if `what` is to fail. + fn fail_if(&self, what: Fails) -> Result<()> { + if self.failing.contains(&what) { + bail!("Permission denied (os error 13)"); + } + Ok(()) + } + } + + impl Outside for Scripted { + fn has_terminal(&mut self) -> bool { + self.terminal + } + + fn read(&mut self, prompt: &str) -> Result> { + self.calls.push(Call::Ask(prompt.to_string())); + Ok(self.answer(prompt)) + } + + fn read_hidden(&mut self, prompt: &str) -> Result> { + self.calls.push(Call::AskHidden(prompt.to_string())); + Ok(self.answer(prompt)) + } + + fn say(&mut self, line: String) { + self.calls.push(Call::Say(line)); + } + + fn installed(&mut self, harness: Harness) -> bool { + self.installed.contains(&harness) + } + + fn test_call(&mut self, chosen: &ModelAndEffort) -> Result<()> { + self.calls.push(Call::Tested(chosen.clone())); + match self.test_calls.pop_front().flatten() { + Some(refusal) => Err(anyhow!("{refusal}")), + None => Ok(()), + } + } + + fn codex_catalog(&mut self) -> Result { + match self.catalog { + Ok(json) => Catalog::parse(json), + Err(error) => bail!("{error}"), + } + } + + fn find_key(&mut self) -> Result> { + self.key.clone().map_err(|error| anyhow!("{error}")) + } + + fn save_key(&mut self, key: &str) -> Result { + self.calls.push(Call::SaveKey(key.to_string())); + self.fail_if(Fails::SaveKey) + .with_context(|| format!("can't write {CREDENTIALS}"))?; + Ok(format!("wrote the Credentials {CREDENTIALS}")) + } + + fn github_email(&mut self) -> Result> { + match self.github_email { + Ok(email) => Ok(email.map(String::from)), + Err(error) => bail!("{error}"), + } + } + + fn git_email(&mut self) -> Result { + match self.git_email { + Ok(email) => Ok(email.to_string()), + Err(error) => bail!("{error}"), + } + } + + fn read_user_config(&mut self, _: &Path) -> Result> { + Ok(self.user_config.clone()) + } + + fn user_config_exists(&mut self, _: &Path) -> bool { + self.user_config.is_some() + } + + fn write_new_user_config(&mut self, _: &Path, text: &str) -> Result<()> { + self.calls.push(Call::WriteNew(text.to_string())); + self.fail_if(Fails::WriteNew)?; + self.user_config = Some(text.to_string()); + Ok(()) + } + + fn replace_user_config(&mut self, _: &Path, text: &str) -> Result<()> { + self.calls.push(Call::Replace(text.to_string())); + self.fail_if(Fails::Replace)?; + self.user_config = Some(text.to_string()); + Ok(()) + } + + fn send_test(&mut self, email: &EmailSettings) -> Result { + self.calls.push(Call::SendTest { + to: email.to.clone(), + from: email.from.clone(), + }); + self.fail_if(Fails::SendTest)?; + Ok(SENT.to_string()) + } + + fn step(&mut self, line: String) { + self.calls.push(Call::Step(line)); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::scripted::{CREDENTIALS, Call, Fails, SENT, Scripted}; + use super::*; + use crate::config::DEFAULTS; + + const HOME: &str = "/home/me"; + const PATH: &str = "/home/me/.thirdshift/config.toml"; + + const OFFER: &str = + "No User config at /home/me/.thirdshift/config.toml. Set your defaults now? [Y/n] "; + const HARNESS: &str = "Harness for every Run's sessions"; + const MODEL: &str = "Model for claude"; + const EFFORT: &str = "Effort for claude"; + const CODEX_MODEL: &str = "Model for codex"; + const CODEX_EFFORT: &str = "Effort for codex"; + const MERGE: &str = "Every Run a Merge run?"; + const BASE_FIX: &str = "Every Run may start a Base fix when the Base branch's CI is red?"; + const PULL: &str = "fast-forward"; + const NOTIFY: &str = "Run notifications, an email"; + const TO: &str = "Send Run notifications to"; + const FROM: &str = "Send them from"; + const TEST_EMAIL: &str = "Send a test email now? [y/N] "; + const KEY_QUESTION: &str = "Resend API key (input hidden"; + const KEY_PROMPT: &str = "Resend API key (input hidden, Enter to skip): "; + const KEPT: &str = "Resend API key (input hidden, Enter keeps the saved one): "; + const SKIPPED: &str = "No Resend API key, so no email can go yet. To add one later, rerun \ + `thirdshift setup`, or set RESEND_API_KEY in the environment the Run \ + starts from."; + const ENDED: &str = "Setup ended before its last answer; nothing written"; + const KEY: &str = "re_secret_123"; + + /// The answers that take every default and turn nothing on. + const ENTER_THROUGHOUT: [(&str, &str); 6] = [ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, ""), + ]; + + /// The answers after the Harness's that leave every setting as it is. + const NOTHING_ELSE: [(&str, &str); 3] = [(MERGE, ""), (PULL, ""), (NOTIFY, "")]; + + /// The answers that turn Run notifications on, to `me@example.com` from + /// the default sender, then `rest`. + fn notifications_on( + rest: &[(&'static str, &'static str)], + ) -> Vec<(&'static str, &'static str)> { + let mut answers = vec![ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, "me@example.com"), + (FROM, ""), + ]; + answers.extend_from_slice(rest); + answers + } + + /// `harness` answers, then [`NOTHING_ELSE`]. + fn then_nothing_else( + harness: &[(&'static str, &'static str)], + ) -> Vec<(&'static str, &'static str)> { + let mut answers = harness.to_vec(); + answers.extend(NOTHING_ELSE); + answers + } + + /// Run Setup, and check it took every answer it was given. + fn setup(outside: &mut Scripted) -> Result { + let done = run_setup(outside, Path::new(HOME), Path::new(PATH)); + if done.is_ok() { + assert!(outside.answers.is_empty(), "unasked: {:?}", outside.answers); + } + done + } + + /// Make the first Run's offer of Setup, and check it took every answer + /// it was given. + fn offer(outside: &mut Scripted) -> Result<()> { + let done = run_offer(outside, Path::new(HOME), Path::new(PATH)); + if done.is_ok() { + assert!(outside.answers.is_empty(), "unasked: {:?}", outside.answers); + } + done + } + + /// The User config as written, parsed. + fn table(outside: &Scripted) -> toml::Table { + let text = outside.user_config.as_deref().expect("no User config"); + text.parse().unwrap() + } + + /// Whether anything was written, saved or sent. + fn wrote_anything(outside: &Scripted) -> bool { + outside.calls.iter().any(|call| { + matches!( + call, + Call::WriteNew(_) | Call::Replace(_) | Call::SaveKey(_) | Call::SendTest { .. } + ) + }) + } + + fn step(line: &str) -> Call { + Call::Step(line.to_string()) + } + + fn model_and_effort(model: Option<&str>, effort: Option<&str>) -> ModelAndEffort { + ModelAndEffort { + model: model.map(String::from), + effort: effort.map(String::from), + } + } + + // What Setup writes. + + #[test] + fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { + let mut outside = Scripted::unattended(); + + let done = setup(&mut outside).unwrap(); + + assert_eq!(done, format!("wrote the User config {PATH}")); + assert_eq!(outside.effects(), [&Call::WriteNew(DEFAULTS.to_string())]); + assert!(outside.prompts().is_empty()); + } + + #[test] + fn with_no_terminal_the_credentials_are_never_read_or_written() { + let mut outside = Scripted { + key: Err("unknown key resend.kye in the Credentials"), + ..Scripted::unattended() + }; + + setup(&mut outside).unwrap(); + + assert_eq!(outside.effects(), [&Call::WriteNew(DEFAULTS.to_string())]); + } + + #[test] + fn setup_over_a_partial_user_config_keeps_it_and_adds_each_missing_key() { + let partial = "\ +# My machine: always merge. +[merge] +always = true # I trust the factory + +[email] +to = \"me@example.com\" # my inbox +"; + let mut outside = Scripted { + user_config: Some(partial.to_string()), + ..Scripted::unattended() + }; + + let done = setup(&mut outside).unwrap(); + + assert_eq!( + done, + format!("added the missing settings to the User config {PATH}") + ); + let completed = config::complete(partial).unwrap(); + assert_eq!(outside.effects(), [&Call::Replace(completed.clone())]); + assert!( + completed.starts_with( + "# My machine: always merge.\n[merge]\nalways = true # I trust the factory\n" + ), + "{completed}" + ); + assert!( + completed.contains("to = \"me@example.com\" # my inbox\n"), + "{completed}" + ); + assert!( + completed.contains( + "\n[pickup]\nlimit = 3 # how many open issues labelled in-progress stop a \ + Pickup run taking another; default 3\n" + ), + "{completed}" + ); + } + + #[test] + fn setup_over_a_complete_user_config_leaves_it_as_it_was() { + let edited = DEFAULTS + .replace("always = false #", "always = true #") + .replace("\"~/.thirdshift/logs\"", "\"/var/log/thirdshift\"") + + "# hand-written at the end\n"; + let mut outside = Scripted { + user_config: Some(edited.clone()), + ..Scripted::unattended() + }; + + let done = setup(&mut outside).unwrap(); + + assert_eq!( + done, + format!("the User config {PATH} already lists every setting") + ); + assert!(outside.effects().is_empty()); + assert_eq!(outside.user_config, Some(edited)); + } + + #[test] + fn setup_refuses_a_user_config_a_run_would_refuse_before_asking_and_leaves_it_alone() { + for (broken, named) in [ + ("[merge]\nalway = true\n", "unknown key merge.alway"), + ("[merge\nalways = true\n", "can't parse the User config"), + ( + "# mine\n[launch]\npull = \"yes\"\n", + "launch.pull must be true or false", + ), + ] { + for terminal in [false, true] { + let mut outside = Scripted { + terminal, + user_config: Some(broken.to_string()), + ..Scripted::answering(&ENTER_THROUGHOUT) + }; + + let error = format!("{:#}", setup(&mut outside).unwrap_err()); + + let refused = UserConfig::parse(broken, Path::new(PATH), Path::new(HOME)); + assert_eq!(error, format!("{:#}", refused.unwrap_err())); + for part in [named, PATH] { + assert!(error.contains(part), "{broken:?}: {error}"); + } + assert!(outside.prompts().is_empty(), "{broken:?}"); + assert!(outside.effects().is_empty(), "{broken:?}"); + } + } + } + + #[test] + fn on_a_terminal_pressing_enter_throughout_writes_what_setup_with_no_terminal_writes() { + let mut unattended = Scripted::unattended(); + setup(&mut unattended).unwrap(); + let mut outside = Scripted::answering(&ENTER_THROUGHOUT); + + let done = setup(&mut outside).unwrap(); + + assert_eq!(done, format!("wrote the User config {PATH}")); + assert_eq!(outside.user_config, unattended.user_config); + assert_eq!( + outside.prompts(), + [ + "Harness for every Run's sessions, claude or codex [claude]: ", + "Model for claude [claude's own default]: ", + "Effort for claude [claude's own default]: ", + "Every Run a Merge run? [y/N] ", + "Every Run first fast-forwards your checkout of the Base branch? [y/N] ", + "Run notifications, an email as each Run ends? [y/N] ", + ] + ); + } + + #[test] + fn on_a_terminal_the_answers_are_written_with_the_comments_on_each_key() { + let mut outside = Scripted { + key: Ok(Some(Source::Environment)), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "y"), + (BASE_FIX, "y"), + (PULL, "yes"), + (NOTIFY, "y"), + (TO, "me@example.com"), + (FROM, "ts@acme.dev"), + (TEST_EMAIL, ""), + ]) + }; + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!(config["merge"]["always"].as_bool(), Some(true)); + assert_eq!(config["base"]["fix"].as_bool(), Some(true)); + assert_eq!(config["launch"]["pull"].as_bool(), Some(true)); + assert_eq!(config["email"]["always"].as_bool(), Some(true)); + assert_eq!(config["email"]["to"].as_str(), Some("me@example.com")); + assert_eq!(config["email"]["from"].as_str(), Some("ts@acme.dev")); + let text = outside.user_config.unwrap(); + let expected = DEFAULTS + .replace( + "always = false # every Run is a Merge", + "always = true # every Run is a Merge", + ) + .replace("fix = false #", "fix = true #") + .replace("pull = false #", "pull = true #") + .replace( + "always = false #", + "always = true #", + ) + .replace( + "# to = \"you@example.com\" #", + "to = \"me@example.com\" #", + ) + .replace( + "from = \"onboarding@resend.dev\" #", + "from = \"ts@acme.dev\" #", + ); + assert_eq!(text, expected); + } + + #[test] + fn on_a_terminal_pressing_enter_throughout_keeps_an_existing_user_config() { + let mine = "\ +[merge] +always = true + +[base] +fix = true + +[launch] +pull = true + +[email] +always = true +to = \"mine@example.net\" +from = \"ts@acme.dev\" + +[logs] +dir = \"/var/log/thirdshift\" + +[activity] +quiet_skips = true + +[spec] +parallel = 5 + +[pickup] +limit = 5 + +[harness] +default = \"claude\" + +[harness.claude] +model = \"opus\" +effort = \"high\" + +[harness.codex] +model = \"gpt-6.1-sol\" +effort = \"max\" +"; + let mut outside = Scripted { + user_config: Some(mine.to_string()), + key: Ok(Some(Source::Environment)), + github_email: Ok(Some("octo@example.com")), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (BASE_FIX, ""), + (PULL, ""), + (NOTIFY, ""), + (TO, ""), + (FROM, ""), + (TEST_EMAIL, ""), + ]) + }; + + let done = setup(&mut outside).unwrap(); + + assert_eq!( + done, + format!("the User config {PATH} already holds your answers") + ); + assert!(outside.effects().is_empty()); + assert_eq!( + outside.prompts(), + [ + "Harness for every Run's sessions, claude or codex [claude]: ", + "Model for claude, - for claude's own default [opus]: ", + "Effort for claude, - for claude's own default [high]: ", + "Every Run a Merge run? [Y/n] ", + "Every Run may start a Base fix when the Base branch's CI is red? [Y/n] ", + "Every Run first fast-forwards your checkout of the Base branch? [Y/n] ", + "Run notifications, an email as each Run ends? [Y/n] ", + "Send Run notifications to [mine@example.net]: ", + "Send them from [ts@acme.dev]: ", + "Send a test email now? [y/N] ", + ] + ); + } + + #[test] + fn on_a_terminal_changed_answers_are_written_over_the_user_config() { + let mut outside = Scripted { + user_config: Some(DEFAULTS.to_string()), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, "y"), + (NOTIFY, ""), + ]) + }; + + let done = setup(&mut outside).unwrap(); + + assert_eq!( + done, + format!("wrote your answers to the User config {PATH}") + ); + let answered = DEFAULTS.replace("pull = false #", "pull = true #"); + assert_eq!(outside.effects(), [&Call::Replace(answered)]); + } + + #[test] + fn on_a_terminal_setup_over_a_hand_commented_user_config_keeps_the_comments() { + let mine = "\ +# My machine. +[merge] +# Merging is for later. +always = false # not yet + +[email] +# to = \"someday@example.com\" +always = false # quiet, please +"; + let mut outside = Scripted { + user_config: Some(mine.to_string()), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "y"), + (BASE_FIX, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, "me@example.com"), + (FROM, ""), + (KEY_PROMPT, ""), + ]) + }; + + setup(&mut outside).unwrap(); + + let text = outside.user_config.clone().unwrap(); + for kept in [ + "# My machine.\n[merge]\n# Merging is for later.\nalways = true # not yet\n", + "always = true # quiet, please\n", + ] { + assert!(text.contains(kept), "{text}"); + } + assert!(!text.contains("# to = "), "{text}"); + let config = table(&outside); + assert_eq!(config["merge"]["always"].as_bool(), Some(true)); + assert_eq!(config["email"]["to"].as_str(), Some("me@example.com")); + } + + #[test] + fn the_credentials_are_saved_after_the_user_config_and_the_test_email_goes_last() { + let mut outside = + Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])); + + let done = setup(&mut outside).unwrap(); + + assert_eq!(done, SENT); + assert_eq!( + outside.effects()[1..], + [ + &step(&format!("wrote the User config {PATH}")), + &Call::SaveKey(KEY.to_string()), + &step(&format!("wrote the Credentials {CREDENTIALS}")), + &Call::SendTest { + to: Some("me@example.com".to_string()), + from: Some(crate::email::DEFAULT_FROM.to_string()), + }, + ] + ); + assert!(matches!(outside.effects()[0], Call::WriteNew(_))); + } + + #[test] + fn an_entered_key_alone_ends_with_the_line_saying_where_it_was_saved() { + let mut outside = + Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "n")])); + + let done = setup(&mut outside).unwrap(); + + assert_eq!(done, format!("wrote the Credentials {CREDENTIALS}")); + assert!(!outside.user_config.unwrap().contains(KEY)); + } + + #[test] + fn stdin_closing_before_the_last_answer_writes_nothing() { + for answers in [ + vec![], + vec![ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "y"), + (BASE_FIX, ""), + ], + notifications_on(&[]), + notifications_on(&[(KEY_PROMPT, KEY)]), + ] { + for existing in [None, Some("[launch]\npull = true # mine\n")] { + let mut outside = Scripted { + user_config: existing.map(String::from), + ..Scripted::answering(&answers) + }; + + let error = setup(&mut outside).unwrap_err(); + + assert_eq!(error.to_string(), ENDED, "{answers:?}"); + assert!(!wrote_anything(&outside), "{answers:?}"); + assert_eq!(outside.user_config.as_deref(), existing); + } + } + } + + #[test] + fn on_a_terminal_setup_refuses_broken_credentials_before_asking() { + let mut outside = Scripted { + key: Err("unknown key resend.kye in the Credentials"), + ..Scripted::answering(&ENTER_THROUGHOUT) + }; + + let error = setup(&mut outside).unwrap_err(); + + assert_eq!( + error.to_string(), + "unknown key resend.kye in the Credentials" + ); + assert!(outside.prompts().is_empty()); + assert!(outside.effects().is_empty()); + } + + // The questions. + + #[test] + fn with_merging_on_setup_asks_about_base_fixes_and_writes_the_answer() { + for (answer, allowed) in [("y", true), ("", false), ("n", false)] { + let mut outside = Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "y"), + (BASE_FIX, answer), + (PULL, ""), + (NOTIFY, ""), + ]); + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!(config["merge"]["always"].as_bool(), Some(true)); + assert_eq!(config["base"]["fix"].as_bool(), Some(allowed), "{answer:?}"); + assert!( + outside + .prompts() + .contains(&format!("{BASE_FIX} [y/N] ").as_str()), + "{:?}", + outside.prompts() + ); + } + } + + #[test] + fn with_merging_off_setup_asks_nothing_about_base_fixes_and_writes_the_default() { + for answer in ["", "n"] { + let mut outside = Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, answer), + (PULL, ""), + (NOTIFY, ""), + ]); + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!(config["merge"]["always"].as_bool(), Some(false)); + assert_eq!(config["base"]["fix"].as_bool(), Some(false)); + } + } + + #[test] + fn the_base_fix_question_defaults_to_the_user_configs_base_fix() { + let mut outside = Scripted { + user_config: Some("[merge]\nalways = true\n\n[base]\nfix = true # mine\n".to_string()), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (BASE_FIX, ""), + (PULL, ""), + (NOTIFY, ""), + ]) + }; + + setup(&mut outside).unwrap(); + + assert!( + outside + .prompts() + .contains(&format!("{BASE_FIX} [Y/n] ").as_str()), + "{:?}", + outside.prompts() + ); + let text = outside.user_config.unwrap(); + assert!(text.contains("fix = true # mine\n"), "{text}"); + } + + #[test] + fn turning_merging_off_writes_base_fix_at_its_default() { + let mut outside = Scripted { + user_config: Some("[merge]\nalways = true\n\n[base]\nfix = true # mine\n".to_string()), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "n"), + (PULL, ""), + (NOTIFY, ""), + ]) + }; + + setup(&mut outside).unwrap(); + + let text = outside.user_config.clone().unwrap(); + assert!(text.contains("fix = false # mine\n"), "{text}"); + assert_eq!(table(&outside)["merge"]["always"].as_bool(), Some(false)); + } + + #[test] + fn a_yes_or_no_question_is_asked_again_until_the_answer_is_one() { + let mut outside = Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "maybe"), + (MERGE, "YES"), + (BASE_FIX, "No"), + (PULL, ""), + (NOTIFY, ""), + ]); + + setup(&mut outside).unwrap(); + + assert_eq!(table(&outside)["merge"]["always"].as_bool(), Some(true)); + } + + #[test] + fn an_address_without_an_at_is_asked_again() { + let mut outside = Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, ""), + (TO, "me.example.com"), + (TO, "me@example.com"), + (FROM, ""), + (KEY_PROMPT, ""), + ]); + + setup(&mut outside).unwrap(); + + let asked = outside.prompts(); + assert_eq!( + asked.iter().filter(|prompt| prompt.contains(TO)).count(), + 3, + "{asked:?}" + ); + assert!(asked.contains(&"Send Run notifications to: "), "{asked:?}"); + assert_eq!( + table(&outside)["email"]["to"].as_str(), + Some("me@example.com") + ); + } + + #[test] + fn with_notifications_off_nothing_about_email_is_asked() { + let mut outside = Scripted { + key: Ok(Some(Source::Credentials(CREDENTIALS.into()))), + github_email: Ok(Some("octo@example.com")), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "n"), + ]) + }; + + setup(&mut outside).unwrap(); + + for prompt in outside.prompts() { + for asked in [TO, FROM, KEY_QUESTION, TEST_EMAIL] { + assert!(!prompt.contains(asked), "{prompt}"); + } + } + assert!(outside.said().is_empty(), "{:?}", outside.said()); + assert_eq!(outside.effects().len(), 1, "{:?}", outside.effects()); + assert_eq!(table(&outside)["email"]["always"].as_bool(), Some(false)); + } + + #[test] + fn with_no_key_anywhere_skipping_it_saves_nothing_offers_no_test_email_and_says_how_to_add_one() + { + let mut outside = Scripted::answering(¬ifications_on(&[(KEY_PROMPT, "")])); + + let done = setup(&mut outside).unwrap(); + + assert_eq!(done, format!("wrote the User config {PATH}")); + assert_eq!(outside.said(), [SKIPPED]); + assert!(!outside.prompts().contains(&TEST_EMAIL)); + assert_eq!(outside.effects().len(), 1, "{:?}", outside.effects()); + assert_eq!(table(&outside)["email"]["always"].as_bool(), Some(true)); + } + + #[test] + fn the_key_prompt_is_hidden_and_a_key_not_starting_with_re_is_asked_again() { + let mut outside = Scripted::answering(¬ifications_on(&[ + (KEY_PROMPT, "sk_not_resend"), + (KEY_PROMPT, KEY), + (TEST_EMAIL, ""), + ])); + + setup(&mut outside).unwrap(); + + let hidden: Vec<&Call> = outside + .calls + .iter() + .filter(|call| matches!(call, Call::AskHidden(_))) + .collect(); + assert_eq!(hidden, [&Call::AskHidden(KEY_PROMPT.to_string()); 2]); + assert_eq!( + outside.said(), + ["That isn't a Resend API key, which starts with re_."] + ); + assert!(outside.calls.contains(&Call::SaveKey(KEY.to_string()))); + assert!( + !outside + .calls + .contains(&Call::SaveKey("sk_not_resend".to_string())) + ); + } + + #[test] + fn with_a_saved_key_pressing_enter_keeps_it() { + let mut outside = Scripted { + key: Ok(Some(Source::Credentials(CREDENTIALS.into()))), + ..Scripted::answering(¬ifications_on(&[(KEPT, ""), (TEST_EMAIL, "")])) + }; + + setup(&mut outside).unwrap(); + + assert!(outside.said().is_empty(), "{:?}", outside.said()); + assert!( + !outside + .calls + .iter() + .any(|call| matches!(call, Call::SaveKey(_))) + ); + } + + #[test] + fn with_a_saved_key_a_new_one_replaces_it() { + let mut outside = Scripted { + key: Ok(Some(Source::Credentials(CREDENTIALS.into()))), + ..Scripted::answering(¬ifications_on(&[(KEPT, KEY), (TEST_EMAIL, "")])) + }; + + setup(&mut outside).unwrap(); + + assert!(outside.calls.contains(&Call::SaveKey(KEY.to_string()))); + } + + #[test] + fn with_resend_api_key_set_no_key_is_asked_and_its_source_is_said() { + let mut outside = Scripted { + key: Ok(Some(Source::Environment)), + ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, "")])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.said(), + ["The Resend API key comes from RESEND_API_KEY."] + ); + for prompt in outside.prompts() { + assert!(!prompt.contains(KEY_QUESTION), "{prompt}"); + } + } + + #[test] + fn accepting_the_test_email_sends_one_and_declining_sends_none() { + for (answer, sent) in [("y", true), ("n", false), ("", false)] { + let mut outside = Scripted { + key: Ok(Some(Source::Environment)), + ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, answer)])) + }; + + let done = setup(&mut outside).unwrap(); + + let sends: Vec<&Call> = outside + .calls + .iter() + .filter(|call| matches!(call, Call::SendTest { .. })) + .collect(); + if sent { + assert_eq!(done, SENT); + assert_eq!( + sends, + [&Call::SendTest { + to: Some("me@example.com".to_string()), + from: Some(crate::email::DEFAULT_FROM.to_string()), + }] + ); + } else { + assert!(sends.is_empty(), "{answer:?}"); + } + assert!(outside.user_config.is_some()); + } + } + + // The Harness, Model and Effort. + + #[test] + fn the_harness_question_lists_both_harnesses_and_defaults_to_claude() { + let mut outside = Scripted::answering(&ENTER_THROUGHOUT); + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.prompts()[0], + "Harness for every Run's sessions, claude or codex [claude]: " + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("claude")); + for harness in ["claude", "codex"] { + for key in ["model", "effort"] { + assert_eq!(config["harness"][harness][key].as_str(), Some("")); + } + } + assert!(outside.test_calls().is_empty()); + } + + #[test] + fn a_harness_not_installed_or_unknown_is_refused_and_asked_again() { + let mut outside = Scripted { + installed: vec![Harness::Codex], + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, "claude"), + (HARNESS, "gemini"), + (HARNESS, "codex"), + (CODEX_MODEL, ""), + (CODEX_EFFORT, ""), + ])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.prompts()[0], + "Harness for every Run's sessions, claude (not installed) or codex [codex]: " + ); + assert_eq!( + outside.said()[..2], + [ + "claude is not installed: it isn't on PATH.", + "Choose claude or codex." + ] + ); + assert_eq!( + table(&outside)["harness"]["default"].as_str(), + Some("codex") + ); + } + + #[test] + fn with_neither_harness_installed_the_harness_settings_are_left_as_they_are() { + let mut outside = Scripted { + installed: Vec::new(), + user_config: Some("[harness.claude]\nmodel = \"opus\"\n".to_string()), + ..Scripted::answering(&NOTHING_ELSE) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.said(), + [ + "Harness for every Run's sessions: claude (not installed) or codex (not \ + installed). Sessions can run on neither here, so the harness settings stay as \ + they are; install claude or codex, then rerun `thirdshift setup`." + ] + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("claude")); + assert_eq!(config["harness"]["claude"]["model"].as_str(), Some("opus")); + } + + #[test] + fn the_harness_default_is_the_configured_one_if_installed_else_the_first_installed() { + for (installed, default) in [ + (vec![Harness::Claude, Harness::Codex], "codex"), + (vec![Harness::Claude], "claude"), + ] { + let mut outside = Scripted { + installed, + user_config: Some("[harness]\ndefault = \"codex\"\n".to_string()), + ..Scripted::answering(&[(HARNESS, "")]) + }; + + setup(&mut outside).unwrap_err(); + + assert!( + outside.prompts()[0].ends_with(&format!(" [{default}]: ")), + "{:?}", + outside.prompts() + ); + } + } + + #[test] + fn claudes_model_and_effort_are_written_after_a_test_call_with_them() { + let mut outside = Scripted::answering(&then_nothing_else(&[ + (HARNESS, ""), + (MODEL, "claude-opus-5-5"), + (EFFORT, "high"), + ])); + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!( + config["harness"]["claude"]["model"].as_str(), + Some("claude-opus-5-5") + ); + assert_eq!(config["harness"]["claude"]["effort"].as_str(), Some("high")); + assert_eq!(config["harness"]["codex"]["model"].as_str(), Some("")); + let text = outside.user_config.clone().unwrap(); + assert!( + text.contains("model = \"claude-opus-5-5\" # the Model Claude Code's sessions run on"), + "{text}" + ); + assert_eq!( + outside.test_calls(), + [&model_and_effort(Some("claude-opus-5-5"), Some("high"))] + ); + } + + #[test] + fn the_model_and_effort_default_to_the_current_ones_and_a_dash_clears_one() { + let mut outside = Scripted { + user_config: Some( + "[harness.claude]\nmodel = \"opus\" # mine\neffort = \"high\"\n\n\ + [harness.codex]\nmodel = \"gpt-6.1-sol\"\n" + .to_string(), + ), + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, "-"), + ])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.prompts()[1..3], + [ + "Model for claude, - for claude's own default [opus]: ", + "Effort for claude, - for claude's own default [high]: ", + ] + ); + let text = outside.user_config.clone().unwrap(); + assert!(text.contains("model = \"opus\" # mine\n"), "{text}"); + let config = table(&outside); + assert_eq!(config["harness"]["claude"]["effort"].as_str(), Some("")); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-6.1-sol") + ); + assert_eq!( + outside.test_calls(), + [&model_and_effort(Some("opus"), None)] + ); + } + + #[test] + fn a_model_claude_refuses_is_asked_again_with_claudes_error() { + let refusal = "claude refused a test call on the Model Opus 5.5: There's an issue with \ + the selected model (Opus 5.5)."; + let mut outside = Scripted { + test_calls: [Some(refusal)].into(), + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, ""), + (MODEL, "Opus 5.5"), + (EFFORT, ""), + (MODEL, "opus"), + (EFFORT, ""), + ])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!(outside.said(), [refusal]); + assert_eq!( + table(&outside)["harness"]["claude"]["model"].as_str(), + Some("opus") + ); + assert_eq!( + outside.test_calls(), + [ + &model_and_effort(Some("Opus 5.5"), None), + &model_and_effort(Some("opus"), None) + ] + ); + } + + #[test] + fn codex_is_offered_with_its_models_and_the_chosen_models_efforts() { + let mut outside = Scripted::answering(&then_nothing_else(&[ + (HARNESS, "codex"), + (CODEX_MODEL, "gpt-5.5"), + (CODEX_EFFORT, "high"), + ])); + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.said(), + [ + "Codex's Models: gpt-6.1-sol (GPT-6.1-Sol), gpt-6-luna (GPT-6-Luna), gpt-5.5 \ + (GPT-5.5)", + "Efforts gpt-5.5 supports: low, medium, high, xhigh", + ] + ); + assert_eq!( + outside.prompts()[1..3], + [ + "Model for codex [codex's own default]: ", + "Effort for codex [codex's own default]: ", + ] + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("codex")); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-5.5") + ); + assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("high")); + assert_eq!(config["harness"]["claude"]["model"].as_str(), Some("")); + assert!(outside.test_calls().is_empty()); + } + + #[test] + fn with_no_codex_model_the_efforts_any_model_supports_are_listed() { + let mut outside = Scripted { + installed: vec![Harness::Codex], + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, ""), + (CODEX_MODEL, ""), + (CODEX_EFFORT, ""), + ])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.said()[1], + "Efforts Codex's Models support: low, medium, high, xhigh, max, ultra" + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("codex")); + assert_eq!(config["harness"]["codex"]["model"].as_str(), Some("")); + assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("")); + } + + #[test] + fn a_codex_display_name_and_capitalised_effort_are_written_as_codex_names_them() { + let mut outside = Scripted::answering(&then_nothing_else(&[ + (HARNESS, "codex"), + (CODEX_MODEL, "GPT-6.1-Sol"), + (CODEX_EFFORT, "Max"), + ])); + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-6.1-sol") + ); + assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("max")); + } + + #[test] + fn an_unknown_codex_model_or_unsupported_effort_is_asked_again_with_the_choices() { + let mut outside = Scripted::answering(&then_nothing_else(&[ + (HARNESS, "codex"), + (CODEX_MODEL, "gpt-7"), + (CODEX_MODEL, "GPT-5.5"), + (CODEX_EFFORT, "max"), + (CODEX_EFFORT, "XHigh"), + ])); + + setup(&mut outside).unwrap(); + + let said = outside.said(); + for refusal in [ + "the Model gpt-7 is not in Codex's catalog: choose one of gpt-6.1-sol, gpt-6-luna, \ + gpt-5.5", + "the Effort max is not one the Codex Model gpt-5.5 supports: choose one of low, \ + medium, high, xhigh", + ] { + assert!(said.contains(&refusal), "{said:#?}"); + } + let config = table(&outside); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-5.5") + ); + assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("xhigh")); + } + + #[test] + fn the_codex_model_and_effort_default_to_the_current_ones_as_codex_names_them() { + let mut outside = Scripted { + user_config: Some( + "[harness.codex]\nmodel = \"GPT-6-Luna\"\neffort = \"High\"\n".to_string(), + ), + ..Scripted::answering(&then_nothing_else(&[ + (HARNESS, "codex"), + (CODEX_MODEL, ""), + (CODEX_EFFORT, ""), + ])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.prompts()[1..3], + [ + "Model for codex, - for codex's own default [GPT-6-Luna]: ", + "Effort for codex, - for codex's own default [High]: ", + ] + ); + assert_eq!( + outside.said()[1], + "Efforts gpt-6-luna supports: low, medium, high, xhigh, max" + ); + let config = table(&outside); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-6-luna") + ); + assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("high")); + } + + #[test] + fn when_codexs_catalog_cant_be_read_the_harness_settings_are_left_as_they_are() { + let mut outside = Scripted { + catalog: Err( + "codex debug models failed, so Codex's Models can't be read: not logged in", + ), + user_config: Some("[harness.codex]\nmodel = \"gpt-5.5\"\n".to_string()), + ..Scripted::answering(&then_nothing_else(&[(HARNESS, "codex")])) + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.said(), + [ + "codex debug models failed, so Codex's Models can't be read: not logged in\n\ + The harness settings stay as they are; rerun `thirdshift setup` once codex \ + debug models works." + ] + ); + let config = table(&outside); + assert_eq!(config["harness"]["default"].as_str(), Some("claude")); + assert_eq!( + config["harness"]["codex"]["model"].as_str(), + Some("gpt-5.5") + ); + } + + // The suggested address. + + /// The `email.to` Setup with no terminal writes, with `github` and `git` + /// the two emails it can suggest. + fn suggested( + github: Result, &'static str>, + git: Result<&'static str, &'static str>, + ) -> Option { + let mut outside = Scripted { + github_email: github, + git_email: git, + ..Scripted::unattended() + }; + setup(&mut outside).unwrap(); + let text = outside.user_config.clone().unwrap(); + let config = table(&outside); + let to = config["email"] + .get("to") + .map(|to| to.as_str().unwrap().to_string()); + assert_eq!( + text.contains("\n# to = \"you@example.com\""), + to.is_none(), + "{text}" + ); + to + } + + #[test] + fn the_github_profiles_public_email_is_suggested_first() { + let to = suggested(Ok(Some("octo@example.com")), Ok("me@example.org")); + + assert_eq!(to.as_deref(), Some("octo@example.com")); + } + + #[test] + fn with_no_public_github_email_or_none_readable_the_git_email_is_suggested() { + for github in [Ok(None), Ok(Some("")), Err("gh: HTTP 401")] { + let to = suggested(github, Ok("me@example.org")); + + assert_eq!(to.as_deref(), Some("me@example.org"), "{github:?}"); + } + } + + #[test] + fn a_noreply_git_email_is_never_suggested() { + for noreply in [ + "123+octo@users.noreply.github.com", + "123+octo@Users.NoReply.GitHub.com", + ] { + assert_eq!(suggested(Ok(None), Ok(noreply)), None, "{noreply}"); + } + } + + #[test] + fn with_neither_email_email_to_is_written_commented_out() { + assert_eq!(suggested(Err("gh: HTTP 401"), Err("no user.email")), None); + assert_eq!(suggested(Ok(None), Ok("")), None); + } + + #[test] + fn the_suggested_address_written_has_the_comment_of_the_line_it_replaces() { + let mut outside = Scripted { + github_email: Ok(Some("octo@example.com")), + ..Scripted::unattended() + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + outside.user_config, + Some(config::with_email_to(Some("octo@example.com".to_string()))) + ); + } + + #[test] + fn setup_never_replaces_an_existing_email_to() { + let mine = "[email]\nto = \"mine@example.net\"\n"; + let mut outside = Scripted { + github_email: Ok(Some("octo@example.com")), + user_config: Some(mine.to_string()), + ..Scripted::unattended() + }; + + setup(&mut outside).unwrap(); + + assert_eq!( + table(&outside)["email"]["to"].as_str(), + Some("mine@example.net") + ); + } + + #[test] + fn on_a_terminal_the_address_defaults_to_the_suggested_github_email() { + let mut outside = Scripted { + github_email: Ok(Some("octo@example.com")), + ..Scripted::answering(&[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, ""), + (FROM, ""), + (KEY_PROMPT, ""), + ]) + }; + + setup(&mut outside).unwrap(); + + assert!( + outside + .prompts() + .contains(&"Send Run notifications to [octo@example.com]: "), + "{:?}", + outside.prompts() + ); + let config = table(&outside); + assert_eq!(config["email"]["always"].as_bool(), Some(true)); + assert_eq!(config["email"]["to"].as_str(), Some("octo@example.com")); + assert_eq!( + config["email"]["from"].as_str(), + Some(crate::email::DEFAULT_FROM) + ); + } + + // The first Run's offer. + + #[test] + fn with_no_terminal_a_run_offers_nothing_and_writes_nothing() { + let mut outside = Scripted::unattended(); + + offer(&mut outside).unwrap(); + + assert!(outside.calls.is_empty(), "{:?}", outside.calls); + } + + #[test] + fn with_a_user_config_a_run_offers_nothing() { + let mut outside = Scripted { + user_config: Some(String::new()), + ..Scripted::answering(&[]) + }; + + offer(&mut outside).unwrap(); + + assert!(outside.calls.is_empty(), "{:?}", outside.calls); + } + + #[test] + fn declining_writes_the_defaults_and_asks_nothing_more() { + let mut outside = Scripted { + key: Err("unknown key resend.kye in the Credentials"), + ..Scripted::answering(&[(OFFER, "n")]) + }; + + offer(&mut outside).unwrap(); + + assert_eq!(outside.prompts(), [OFFER]); + assert_eq!( + outside.effects(), + [ + &Call::WriteNew(DEFAULTS.to_string()), + &step(&format!( + "wrote the User config {PATH} with every setting at its default; \ + thirdshift setup changes it" + )), + ] + ); + } + + #[test] + fn declining_writes_the_suggested_address() { + let mut outside = Scripted { + github_email: Ok(Some("octo@example.com")), + ..Scripted::answering(&[(OFFER, "n")]) + }; + + offer(&mut outside).unwrap(); + + assert_eq!( + outside.user_config, + Some(config::with_email_to(Some("octo@example.com".to_string()))) + ); + } + + #[test] + fn accepting_asks_the_setup_questions_and_writes_the_answers() { + let mut outside = Scripted::answering(&[ + (OFFER, ""), + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, "y"), + (BASE_FIX, ""), + (PULL, ""), + (NOTIFY, ""), + ]); + + offer(&mut outside).unwrap(); + + assert_eq!( + outside.effects()[1..], + [&step(&format!("wrote the User config {PATH}"))] + ); + assert_eq!(table(&outside)["merge"]["always"].as_bool(), Some(true)); + } + + #[test] + fn accepting_saves_the_key_after_the_user_config_and_sends_the_test_email_last() { + let mut outside = Scripted::answering(&[ + (OFFER, "y"), + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, "me@example.com"), + (FROM, ""), + (KEY_PROMPT, KEY), + (TEST_EMAIL, "y"), + ]); + + offer(&mut outside).unwrap(); + + assert_eq!( + outside.effects()[1..], + [ + &step(&format!("wrote the User config {PATH}")), + &Call::SaveKey(KEY.to_string()), + &step(&format!("wrote the Credentials {CREDENTIALS}")), + &Call::SendTest { + to: Some("me@example.com".to_string()), + from: Some(crate::email::DEFAULT_FROM.to_string()), + }, + &step(SENT), + ] + ); + } + + #[test] + fn an_unwritable_user_config_is_a_warning_and_the_run_carries_on_with_the_defaults() { + let mut outside = Scripted { + failing: vec![Fails::WriteNew], + ..Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])) + }; + outside.answers.push_front((OFFER, "y")); + + offer(&mut outside).unwrap(); + + assert_eq!( + outside.effects()[1..], + [&step(&format!( + "warning: can't write {PATH}: Permission denied (os error 13); carrying on \ + with the defaults" + ))] + ); + assert_eq!(outside.user_config, None); + } + + #[test] + fn credentials_that_cant_be_saved_or_a_test_email_that_cant_go_are_warnings() { + let mut outside = Scripted { + failing: vec![Fails::SaveKey, Fails::SendTest], + ..Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])) + }; + outside.answers.push_front((OFFER, "y")); + + offer(&mut outside).unwrap(); + + let steps: Vec<&Call> = outside + .calls + .iter() + .filter(|call| matches!(call, Call::Step(_))) + .collect(); + assert_eq!( + steps, + [ + &step(&format!("wrote the User config {PATH}")), + &step(&format!( + "warning: can't write {CREDENTIALS}: Permission denied (os error 13)" + )), + &step("warning: Permission denied (os error 13)"), + ] + ); + } + + #[test] + fn accepting_with_broken_credentials_ends_the_command_before_any_question() { + let mut outside = Scripted { + key: Err("unknown key resend.kye in the Credentials"), + ..Scripted::answering(&[(OFFER, "y")]) + }; + + let error = offer(&mut outside).unwrap_err(); + + assert_eq!( + error.to_string(), + "unknown key resend.kye in the Credentials" + ); + assert_eq!(outside.prompts(), [OFFER]); + assert!(!wrote_anything(&outside)); + } + + #[test] + fn stdin_closing_at_the_offer_or_during_the_questions_writes_nothing() { + for answers in [ + vec![], + vec![(OFFER, "y"), (HARNESS, ""), (MODEL, ""), (EFFORT, "")], + vec![ + (OFFER, "y"), + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (NOTIFY, "y"), + (TO, "me@example.com"), + (FROM, ""), + ], + ] { + let mut outside = Scripted::answering(&answers); + + let error = offer(&mut outside).unwrap_err(); + + assert_eq!(error.to_string(), ENDED, "{answers:?}"); + assert!(!wrote_anything(&outside), "{answers:?}"); + } + } + + // Writing the answers into the text. + + fn notifications_to(to: &str) -> Answers { + Answers { + harness: None, + merge_always: false, + base_fix: false, + launch_pull: false, + notifications: Some(questions::Notifications { + to: to.to_string(), + from: crate::email::DEFAULT_FROM.to_string(), + key: None, + send_test: false, + }), + } + } + + #[test] + fn an_answered_address_takes_the_place_of_the_commented_out_line() { + let answered = with_answers(DEFAULTS, ¬ifications_to("me@example.com")).unwrap(); + let expected = config::with_email_to(Some("me@example.com".to_string())).replace( + "always = false #", + "always = true #", + ); + assert_eq!(answered, expected); + } + + #[test] + fn an_answered_address_keeps_the_lines_around_the_commented_out_one() { + let text = + "[email]\nalways = false\n\n# mine\n# to = \"x@y.z\"\n# more\nfrom = \"a@b.c\"\n"; + let answered = with_answers( + &config::complete(text).unwrap(), + ¬ifications_to("me@example.com"), + ) + .unwrap(); + let lines: Vec<&str> = answered.lines().collect(); + assert_eq!( + lines[..4], + ["[email]", "always = true", "", "# mine"], + "{answered}" + ); + assert!( + lines[4].starts_with("to = \"me@example.com\""), + "{answered}" + ); + assert_eq!( + lines[5..7], + ["# more", "from = \"onboarding@resend.dev\""], + "{answered}" + ); + } +} diff --git a/src/questions.rs b/src/setup/questions.rs similarity index 50% rename from src/questions.rs rename to src/setup/questions.rs index 36bae4812..36de315f7 100644 --- a/src/questions.rs +++ b/src/setup/questions.rs @@ -1,20 +1,17 @@ -//! Setup's questions, asked on the terminal: on stderr, with the answers read -//! from stdin, so stdout stays empty. Each question's default answer, taken -//! by pressing Enter, is the current value. The Resend API key is read with -//! echo off, and never shown. +//! Setup's questions, asked through [`Outside`]: on the terminal, on stderr, +//! with the answers read from stdin, so stdout stays empty. Each question's +//! default answer, taken by pressing Enter, is the current value. The Resend +//! API key is read hidden, and never shown. -use std::io::{BufRead, IsTerminal, Write}; use std::path::Path; -use anyhow::{Context, Result, bail}; -use signal_hook::SigId; -use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; -use signal_hook::low_level; +use anyhow::{Result, bail}; +use super::{Outside, suggested_address}; use crate::config::UserConfig; use crate::email::DEFAULT_FROM; -use crate::harness::{self, Catalog, Choice, ChosenBy, Harness, ModelAndEffort}; -use crate::resend_key::{self, Credentials, Source}; +use crate::harness::{self, Harness, ModelAndEffort}; +use crate::resend_key::{self, Source}; /// What the user chose. pub struct Answers { @@ -62,15 +59,11 @@ pub struct Notifications { pub send_test: bool, } -/// Whether there is someone to ask: stdin and stderr are both terminals. -pub fn has_terminal() -> bool { - std::io::stdin().is_terminal() && std::io::stderr().is_terminal() -} - /// The first Run's offer of Setup, with no User config at `path`: whether /// the user wants to set their defaults now, yes unless they say no. -pub fn offer(path: &Path) -> Result { +pub fn offer(outside: &mut impl Outside, path: &Path) -> Result { yes_or_no( + outside, &format!( "No User config at {}. Set your defaults now?", path.display() @@ -80,31 +73,35 @@ pub fn offer(path: &Path) -> Result { } /// Ask the Setup questions, with the settings in `current` as the default -/// answers, and `suggested_address` for `email.to` when `current` has none. -/// The Harness, Model and Effort come first, as [`ask_harness`] asks them. -/// Base fixes are asked about only with every Run a Merge run; otherwise -/// the answer is `base.fix`'s default, no. +/// answers, and the suggested address for `email.to` when `current` has +/// none. The Harness, Model and Effort come first, as [`ask_harness`] asks +/// them. Base fixes are asked about only with every Run a Merge run; +/// otherwise the answer is `base.fix`'s default, no. /// The address is re-asked until it has an `@`. With Run notifications on, -/// the Resend API key is asked for too, unless `RESEND_API_KEY` gives it, -/// with Enter keeping the one in `credentials`, if any. Ends in an error if -/// stdin closes before the last answer. +/// the Resend API key is asked for too, unless `found`, where the key was +/// found, is `RESEND_API_KEY`, with Enter keeping the one in the +/// Credentials, if any. Ends in an error if stdin closes before the last +/// answer. pub fn ask( + outside: &mut impl Outside, current: &UserConfig, - credentials: &Credentials, - suggested_address: impl FnOnce() -> Option, + found: Option, ) -> Result { - let harness = ask_harness(¤t.harness)?; - let merge_always = yes_or_no("Every Run a Merge run?", current.merge_always)?; + let harness = ask_harness(outside, ¤t.harness)?; + let merge_always = yes_or_no(outside, "Every Run a Merge run?", current.merge_always)?; let base_fix = merge_always && yes_or_no( + outside, "Every Run may start a Base fix when the Base branch's CI is red?", current.base_fix, )?; let launch_pull = yes_or_no( + outside, "Every Run first fast-forwards your checkout of the Base branch?", current.launch_pull, )?; if !yes_or_no( + outside, "Run notifications, an email as each Run ends?", current.email.always, )? { @@ -116,32 +113,36 @@ pub fn ask( notifications: None, }); } - let suggested = current.email.to.clone().or_else(suggested_address); + let suggested = match ¤t.email.to { + Some(to) => Some(to.clone()), + None => suggested_address(outside), + }; let to = loop { - let to = answer("Send Run notifications to", suggested.as_deref())?; + let to = answer(outside, "Send Run notifications to", suggested.as_deref())?; if to.contains('@') { break to; } }; let from = answer( + outside, "Send them from", Some(current.email.from.as_deref().unwrap_or(DEFAULT_FROM)), )?; - let found = credentials.lookup().map(|key| key.source); - let key = match found { + let key = match &found { Some(Source::Environment) => { - say("The Resend API key comes from RESEND_API_KEY."); + outside.say("The Resend API key comes from RESEND_API_KEY.".to_string()); None } - Some(Source::Credentials(_)) => ask_key("Enter keeps the saved one")?, - None => ask_key("Enter to skip")?, + Some(Source::Credentials(_)) => ask_key(outside, "Enter keeps the saved one")?, + None => ask_key(outside, "Enter to skip")?, }; let send_test = if found.is_some() || key.is_some() { - yes_or_no("Send a test email now?", false)? + yes_or_no(outside, "Send a test email now?", false)? } else { - say( + outside.say( "No Resend API key, so no email can go yet. To add one later, rerun \ - `thirdshift setup`, or set RESEND_API_KEY in the environment the Run starts from.", + `thirdshift setup`, or set RESEND_API_KEY in the environment the Run starts from." + .to_string(), ); false }; @@ -167,11 +168,18 @@ pub fn ask( /// installed, so `claude` when both are. With no /// Harness to choose, or Codex chosen and its catalog unreadable, the answer /// is `None`. -fn ask_harness(current: &harness::Settings) -> Result> { +fn ask_harness( + outside: &mut impl Outside, + current: &harness::Settings, +) -> Result> { + let installed: Vec = Harness::ALL + .into_iter() + .filter(|harness| outside.installed(*harness)) + .collect(); let harnesses: Vec = Harness::ALL .iter() .map(|harness| { - if harness.installed() { + if installed.contains(harness) { harness.name().to_string() } else { format!("{} (not installed)", harness.name()) @@ -181,10 +189,10 @@ fn ask_harness(current: &harness::Settings) -> Result Result say(&format!("Choose {}.", harness::NAMES)), - Some(harness) if !harness.installed() => { - say(&format!("{name} is not installed: it isn't on PATH.")) + None => outside.say(format!("Choose {}.", harness::NAMES)), + Some(harness) if !installed.contains(&harness) => { + outside.say(format!("{name} is not installed: it isn't on PATH.")) } Some(harness) => break harness, } }; let set = current.of(harness); let chosen = match harness { - Harness::Claude => ask_claude(set)?, - Harness::Codex => match Catalog::read() { - Ok(catalog) => ask_codex(&catalog, set)?, + Harness::Claude => ask_claude(outside, set)?, + Harness::Codex => match outside.codex_catalog() { + Ok(catalog) => ask_codex(outside, &catalog, set)?, Err(error) => { - say(&format!( + outside.say(format!( "{error:#}\nThe harness settings stay as they are; rerun `thirdshift \ setup` once codex debug models works." )); @@ -224,24 +233,23 @@ fn ask_harness(current: &harness::Settings) -> Result Result { +fn ask_claude(outside: &mut impl Outside, current: &ModelAndEffort) -> Result { loop { - let model = ask_setting("Model", Harness::Claude, current.model.as_deref())?; - let effort = ask_setting("Effort", Harness::Claude, current.effort.as_deref())?; - let choice = Choice { - harness: Harness::Claude, - model, - effort, - chosen_by: ChosenBy::UserConfig, + let chosen = ModelAndEffort { + model: ask_setting(outside, "Model", Harness::Claude, current.model.as_deref())?, + effort: ask_setting( + outside, + "Effort", + Harness::Claude, + current.effort.as_deref(), + )?, }; - match choice.test_call() { - Ok(()) => { - return Ok(ModelAndEffort { - model: choice.model, - effort: choice.effort, - }); - } - Err(error) => say(&format!("{error:#}")), + if chosen.model.is_none() { + return Ok(chosen); + } + match outside.test_call(&chosen) { + Ok(()) => return Ok(chosen), + Err(error) => outside.say(format!("{error:#}")), } } } @@ -251,46 +259,59 @@ fn ask_claude(current: &ModelAndEffort) -> Result { /// Each is matched against the catalog as a Run matches it, and written as /// Codex names it; one the catalog doesn't have is asked again, with the /// valid choices. -fn ask_codex(catalog: &Catalog, current: &ModelAndEffort) -> Result { - say(&format!("Codex's Models: {}", catalog.listed().join(", "))); +fn ask_codex( + outside: &mut impl Outside, + catalog: &harness::Catalog, + current: &ModelAndEffort, +) -> Result { + outside.say(format!("Codex's Models: {}", catalog.listed().join(", "))); let model = loop { - let model = ask_setting("Model", Harness::Codex, current.model.as_deref())?; + let model = ask_setting(outside, "Model", Harness::Codex, current.model.as_deref())?; match catalog.settle(model.as_deref(), None) { Ok(settled) => break settled.model, - Err(error) => say(&format!("{error:#}")), + Err(error) => outside.say(format!("{error:#}")), } }; let efforts = catalog.efforts(model.as_deref()).join(", "); - say(&match &model { + outside.say(match &model { Some(model) => format!("Efforts {model} supports: {efforts}"), None => format!("Efforts Codex's Models support: {efforts}"), }); loop { - let effort = ask_setting("Effort", Harness::Codex, current.effort.as_deref())?; + let effort = ask_setting(outside, "Effort", Harness::Codex, current.effort.as_deref())?; match catalog.settle(model.as_deref(), effort.as_deref()) { Ok(settled) => return Ok(settled), - Err(error) => say(&format!("{error:#}")), + Err(error) => outside.say(format!("{error:#}")), } } } /// Ask for the `setting`, the Model or the Effort, of `harness`, with /// `current` as the default; `-` is none, the Harness's own default. -fn ask_setting(setting: &str, harness: Harness, current: Option<&str>) -> Result> { +fn ask_setting( + outside: &mut impl Outside, + setting: &str, + harness: Harness, + current: Option<&str>, +) -> Result> { let name = harness.name(); let own = format!("{name}'s own default"); let answer = match current { - Some(current) => answer(&format!("{setting} for {name}, - for {own}"), Some(current))?, - None => read(&format!("{setting} for {name} [{own}]: "))?, + Some(current) => answer( + outside, + &format!("{setting} for {name}, - for {own}"), + Some(current), + )?, + None => read(outside, &format!("{setting} for {name} [{own}]: "))?, }; Ok(Some(answer).filter(|answer| !answer.is_empty() && answer != "-")) } /// Ask `question` until the answer is yes, no or nothing, which is `default`. -fn yes_or_no(question: &str, default: bool) -> Result { +fn yes_or_no(outside: &mut impl Outside, question: &str, default: bool) -> Result { let choices = if default { "[Y/n]" } else { "[y/N]" }; loop { - match read(&format!("{question} {choices} "))? + match read(outside, &format!("{question} {choices} "))? .to_ascii_lowercase() .as_str() { @@ -304,13 +325,13 @@ fn yes_or_no(question: &str, default: bool) -> Result { /// Ask `question` for a word, showing `default`, which nothing takes, if /// there is one. With no default, nothing is asked again. -fn answer(question: &str, default: Option<&str>) -> Result { +fn answer(outside: &mut impl Outside, question: &str, default: Option<&str>) -> Result { let prompt = match default { Some(default) => format!("{question} [{default}]: "), None => format!("{question}: "), }; loop { - match (read(&prompt)?, default) { + match (read(outside, &prompt)?, default) { (answer, _) if !answer.is_empty() => return Ok(answer), (_, Some(default)) => return Ok(default.to_string()), (_, None) => {} @@ -320,94 +341,27 @@ fn answer(question: &str, default: Option<&str>) -> Result { /// Ask for a Resend API key, hidden, until the answer is one or is nothing. /// `on_enter` says, in the prompt, what nothing does; it is `None`. -fn ask_key(on_enter: &str) -> Result> { +fn ask_key(outside: &mut impl Outside, on_enter: &str) -> Result> { + let prompt = format!("Resend API key (input hidden, {on_enter}): "); loop { - match read_hidden(&format!("Resend API key (input hidden, {on_enter}): "))? { - key if key.is_empty() => return Ok(None), - key if resend_key::is_key(&key) => return Ok(Some(key)), - _ => say("That isn't a Resend API key, which starts with re_."), - } - } -} - -/// Show `prompt` on stderr and read one line from stdin, trimmed, with the -/// terminal's echo off, so what is typed never shows. -fn read_hidden(prompt: &str) -> Result { - let echo_off = EchoOff::new()?; - let line = read(prompt)?; - drop(echo_off); - let _ = writeln!(std::io::stderr()); - Ok(line) -} - -/// The terminal on stdin with its echo off, until this is dropped. Ctrl-C, -/// SIGTERM or SIGHUP meanwhile turn echo back on, then end the command as -/// they would anywhere else in Setup, so it never leaves the shell blind. -struct EchoOff { - saved: libc::termios, - handlers: Vec, -} - -impl EchoOff { - fn new() -> Result { - // SAFETY: an all-zero termios is a valid value for tcgetattr to fill. - let mut saved: libc::termios = unsafe { std::mem::zeroed() }; - // SAFETY: fd 0 is open, and `saved` is a valid termios to fill. - if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't read the terminal's settings"); - } - let mut echo_off = EchoOff { - saved, - handlers: Vec::new(), - }; - for signal in [SIGINT, SIGTERM, SIGHUP] { - // SAFETY: the handler only calls tcsetattr and signal-hook's - // emulate_default_handler, both async-signal-safe. - let handler = unsafe { - low_level::register(signal, move || { - libc::tcsetattr(0, libc::TCSANOW, &saved); - let _ = low_level::emulate_default_handler(signal); - }) + match outside.read_hidden(&prompt)? { + None => bail!(ENDED), + Some(key) if key.is_empty() => return Ok(None), + Some(key) if resend_key::is_key(&key) => return Ok(Some(key)), + Some(_) => { + outside.say("That isn't a Resend API key, which starts with re_.".to_string()) } - .context("could not install the signal handler")?; - echo_off.handlers.push(handler); - } - let mut hidden = saved; - hidden.c_lflag &= !libc::ECHO; - // SAFETY: fd 0 is open, and `hidden` is a valid termios. - if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't turn the terminal's echo off"); } - Ok(echo_off) } } -impl Drop for EchoOff { - fn drop(&mut self) { - // SAFETY: fd 0 is open, and `saved` is the termios read from it. - unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; - for handler in self.handlers.drain(..) { - low_level::unregister(handler); - } +/// Show `prompt` and read one answer, trimmed, or end Setup if stdin closes. +fn read(outside: &mut impl Outside, prompt: &str) -> Result { + match outside.read(prompt)? { + Some(line) => Ok(line), + None => bail!(ENDED), } } -/// Show `prompt` on stderr and read one line from stdin, trimmed. -fn read(prompt: &str) -> Result { - let mut stderr = std::io::stderr(); - let _ = write!(stderr, "{prompt}"); - let _ = stderr.flush(); - let mut line = String::new(); - if std::io::stdin().lock().read_line(&mut line)? == 0 { - let _ = writeln!(stderr); - bail!("Setup ended before its last answer; nothing written"); - } - Ok(line.trim().to_string()) -} - -/// Show `text` on stderr. -fn say(text: &str) { - let _ = writeln!(std::io::stderr(), "{text}"); -} +/// Why Setup ends when stdin closes before the last answer. +const ENDED: &str = "Setup ended before its last answer; nothing written"; diff --git a/tests/setup.rs b/tests/setup.rs index 932742e38..f830ad39c 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -1,21 +1,15 @@ -//! `thirdshift setup`: Setup writes a complete User config. With no terminal -//! and no User config, it writes every setting at its default without asking, -//! and `email.to` as the GitHub email it suggests, if it finds one; over an -//! existing one, it keeps its values and comments and adds the keys it lacks. -//! From a terminal, it first asks the Setup questions on stderr, each with the -//! current value as its default answer. Base fixes are asked about only -//! with every Run a Merge run. The Harness question lists both Harnesses, -//! refusing one that isn't installed, and the Model and Effort are asked for -//! the Harness chosen: a Claude Model checked with a test call, and a Codex -//! Model and Effort chosen from Codex's catalog and written as Codex names -//! them. +//! `thirdshift setup`, wired to the command line and a real terminal: with no +//! terminal it writes the defaults, and a Run with them behaves as one with +//! none; on a terminal, pressing Enter throughout writes the same, an +//! entered key is saved in the Credentials and never shown, and Ctrl-C +//! anywhere in the questions writes nothing. Setup's rules are tested +//! in-process, in the Setup module. mod support; use std::fs; use std::os::unix::fs::PermissionsExt; -use support::resend::ResendStandIn; use support::{CTRL_C, Keystrokes, Scenario, TerminalResult}; /// The agent commits its work and opens a PR that closes issue #7, into @@ -33,28 +27,6 @@ fn user_config(scenario: &Scenario) -> Option { fs::read_to_string(scenario.path("home/.thirdshift/config.toml")).ok() } -/// The `email.to` the User config sets, if it sets one. -fn email_to(scenario: &Scenario) -> Option { - let config: toml::Table = user_config(scenario)?.parse().unwrap(); - Some(config["email"].get("to")?.as_str()?.to_string()) -} - -/// The lines of `text` that set a key, commented out or not, as -/// `(section, line)`. -fn key_lines(text: &str) -> Vec<(String, String)> { - let mut section = String::new(); - let mut keys = Vec::new(); - for line in text.lines() { - let line = line.trim(); - if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { - section = name.to_string(); - } else if line.trim_start_matches("# ").contains(" = ") && !section.is_empty() { - keys.push((section.clone(), line.to_string())); - } - } - keys -} - #[test] fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { let scenario = Scenario::new(); @@ -110,61 +82,6 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { ); } -#[test] -fn every_key_is_written_with_a_comment_giving_what_it_does_and_its_default() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - - scenario.run(&["setup"]); - - let text = user_config(&scenario).unwrap(); - let keys = key_lines(&text); - let names: Vec = keys - .iter() - .map(|(section, line)| { - let key = line.trim_start_matches("# ").split(' ').next().unwrap(); - format!("{section}.{key}") - }) - .collect(); - assert_eq!( - names, - [ - "merge.always", - "base.fix", - "launch.pull", - "email.always", - "email.to", - "email.from", - "logs.dir", - "activity.quiet_skips", - "spec.parallel", - "pickup.limit", - "harness.default", - "harness.claude.model", - "harness.claude.effort", - "harness.codex.model", - "harness.codex.effort" - ], - "{text}" - ); - for (section, line) in &keys { - let Some((_, comment)) = line.trim_start_matches("# ").split_once(" # ") else { - panic!("no trailing comment on [{section}] {line:?}"); - }; - assert!( - comment.contains("default"), - "the comment on [{section}] {line:?} doesn't give the default" - ); - } - let commented_out: Vec<&String> = keys - .iter() - .filter(|(_, line)| line.starts_with('#')) - .map(|(_, line)| line) - .collect(); - assert_eq!(commented_out.len(), 1, "{text}"); - assert!(commented_out[0].starts_with("# to = "), "{text}"); -} - #[test] fn a_run_with_the_written_user_config_behaves_as_with_none() { let scenario = Scenario::new(); @@ -190,30 +107,6 @@ fn a_run_with_the_written_user_config_behaves_as_with_none() { ); } -#[test] -fn a_run_with_the_written_user_config_still_needs_an_address_for_email() { - let scenario = Scenario::new(); - scenario.git_email_is(Some("123+runner@users.noreply.github.com")); - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - assert!(email_to(&scenario).is_none()); - let setup_calls = scenario.gh_calls().len(); - - let result = scenario.run_with_env( - &["--email", &scenario.issue_url(7)], - &[("RESEND_API_KEY", "re_test_123")], - ); - - assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); - assert!( - result.stderr.contains("email.to"), - "stderr: {}", - result.stderr - ); - assert!(scenario.claude_calls().is_empty(), "the Run started"); - let run_calls = &scenario.gh_calls()[setup_calls..]; - assert!(run_calls.is_empty(), "the Run asked GitHub: {run_calls:?}"); -} - #[test] fn setup_with_an_argument_is_an_argument_error_and_writes_nothing() { let scenario = Scenario::new(); @@ -241,304 +134,6 @@ fn setup_with_an_argument_is_an_argument_error_and_writes_nothing() { } } -/// Every key this version knows, as `section.key`, sorted. -const EVERY_KEY: [&str; 15] = [ - "activity.quiet_skips", - "base.fix", - "email.always", - "email.from", - "email.to", - "harness.claude.effort", - "harness.claude.model", - "harness.codex.effort", - "harness.codex.model", - "harness.default", - "launch.pull", - "logs.dir", - "merge.always", - "pickup.limit", - "spec.parallel", -]; - -/// The `section.key` names of the keys `text` sets, commented out or not, in -/// the order they appear. -fn key_names(text: &str) -> Vec { - key_lines(text) - .iter() - .map(|(section, line)| { - let key = line.trim_start_matches("# ").split(' ').next().unwrap(); - format!("{section}.{key}") - }) - .collect() -} - -#[test] -fn setup_over_a_partial_user_config_keeps_it_and_adds_each_missing_key_with_its_comment() { - let scenario = Scenario::new(); - let partial = "\ -# My machine: always merge. -[merge] -always = true # I trust the factory - -[email] -to = \"me@example.com\" # my inbox -"; - scenario.user_config_is(partial); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(result.stdout, ""); - assert!( - result.stderr.contains(".thirdshift/config.toml"), - "stderr: {}", - result.stderr - ); - let text = user_config(&scenario).unwrap(); - assert!( - text.starts_with( - "# My machine: always merge.\n[merge]\nalways = true # I trust the factory\n" - ), - "{text}" - ); - assert!( - text.contains("to = \"me@example.com\" # my inbox\n"), - "{text}" - ); - let config: toml::Table = text.parse().unwrap(); - assert_eq!(config["merge"]["always"].as_bool(), Some(true), "{text}"); - assert_eq!(config["email"]["to"].as_str(), Some("me@example.com")); - assert_eq!(config["launch"]["pull"].as_bool(), Some(false), "{text}"); - assert_eq!(config["email"]["always"].as_bool(), Some(false), "{text}"); - assert_eq!( - config["email"]["from"].as_str(), - Some("onboarding@resend.dev") - ); - assert_eq!(config["logs"]["dir"].as_str(), Some("~/.thirdshift/logs")); - let mut names = key_names(&text); - names.sort(); - assert_eq!(names, EVERY_KEY, "{text}"); - for (section, line) in key_lines(&text) { - let Some((_, comment)) = line.split_once(" # ") else { - panic!("no trailing comment on [{section}] {line:?}"); - }; - // The two lines from the partial User config carry the user's own - // comments; every added key's comment gives its default. - if !line.contains("me@example.com") && !line.starts_with("always = true") { - assert!( - comment.contains("default"), - "the comment on [{section}] {line:?} doesn't give the default" - ); - } - } -} - -#[test] -fn setup_over_a_user_config_with_no_email_to_adds_it_commented_out_once() { - let scenario = Scenario::new(); - scenario.user_config_is("[launch]\npull = true\n"); - - for _ in 0..2 { - let result = scenario.run(&["setup"]); - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - } - - let text = user_config(&scenario).unwrap(); - let config: toml::Table = text.parse().unwrap(); - assert_eq!(config["launch"]["pull"].as_bool(), Some(true), "{text}"); - assert!(config["email"].get("to").is_none(), "{text}"); - let mut names = key_names(&text); - names.sort(); - assert_eq!(names, EVERY_KEY, "{text}"); -} - -#[test] -fn setup_writes_pickup_limit_at_3_with_its_comment_and_keeps_one_already_there() { - let scenario = Scenario::new(); - scenario.user_config_is("[launch]\npull = true\n"); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let text = user_config(&scenario).unwrap(); - let written = "\n[pickup]\nlimit = 3 # how many open issues labelled in-progress stop a \ - Pickup run taking another; default 3\n"; - assert!(text.contains(&format!("{written}\n[harness]\n")), "{text}"); - - let mine = "[pickup]\nlimit = 7 # I review quickly\n"; - scenario.user_config_is(mine); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let text = user_config(&scenario).unwrap(); - assert!(text.starts_with(mine), "{text}"); - assert_eq!(text.matches("limit =").count(), 1, "{text}"); -} - -#[test] -fn setup_over_a_complete_user_config_leaves_it_as_it_was() { - let scenario = Scenario::new(); - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - let edited = user_config(&scenario) - .unwrap() - .replace("always = false #", "always = true #") - .replace("\"~/.thirdshift/logs\"", "\"/var/log/thirdshift\"") - + "# hand-written at the end\n"; - scenario.user_config_is(&edited); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(result.stdout, ""); - assert_eq!(user_config(&scenario).as_deref(), Some(edited.as_str())); -} - -#[test] -fn setup_refuses_a_user_config_a_run_would_refuse_and_leaves_it_alone() { - for (broken, named) in [ - ("[merge]\nalway = true\n", "unknown key merge.alway"), - ("[merge\nalways = true\n", "can't parse the User config"), - ( - "# mine\n[launch]\npull = \"yes\"\n", - "launch.pull must be true or false", - ), - ] { - let scenario = Scenario::new(); - scenario.user_config_is(broken); - let run = scenario.run(&[&scenario.issue_url(7)]); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(1), "{broken:?}: {}", result.stderr); - assert_eq!(result.stdout, ""); - assert!(result.stderr.contains(named), "stderr: {}", result.stderr); - assert!( - result.stderr.contains(".thirdshift/config.toml"), - "stderr: {}", - result.stderr - ); - // The Run's first line says it is starting; the rest is the same. - let (_, refused) = run.stderr.split_once('\n').unwrap(); - assert_eq!(result.stderr, refused, "{broken:?}"); - assert_eq!(user_config(&scenario).as_deref(), Some(broken)); - } -} - -#[test] -fn a_run_reads_the_completed_user_config_with_the_settings_it_had_before() { - let scenario = Scenario::new(); - scenario.user_config_is("[merge]\nalways = true\n\n[logs]\ndir = \"~/elsewhere/logs\"\n"); - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - scenario.agent_does(AGENT_OPENS_PR); - - let result = scenario.run(&[&scenario.issue_url(7)]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(result.stdout, format!("{PR_URL}\n")); - assert_eq!( - result.stderr.lines().last(), - Some(format!("thirdshift: PR {PR_URL} is merged").as_str()), - "stderr: {}", - result.stderr - ); - assert_eq!( - scenario - .entries("home/elsewhere/logs/acme/widgets/sessions") - .len(), - 1 - ); - assert!(!scenario.path("home/.thirdshift/logs").exists()); -} - -#[test] -fn setup_writes_the_public_github_email_as_email_to() { - let scenario = Scenario::new(); - scenario.github_email_is(Some("octo@example.com")); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(email_to(&scenario).as_deref(), Some("octo@example.com")); - let text = user_config(&scenario).unwrap(); - assert!(!text.contains("# to = "), "{text}"); - let to = key_lines(&text) - .into_iter() - .find(|(_, line)| line.starts_with("to = ")) - .unwrap() - .1; - assert!(to.contains(" # ") && to.contains("default"), "{to}"); -} - -#[test] -fn with_no_public_github_email_setup_writes_the_git_email() { - let scenario = Scenario::new(); - scenario.github_email_is(None); - scenario.git_email_is(Some("me@example.org")); - - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - - assert_eq!(email_to(&scenario).as_deref(), Some("me@example.org")); -} - -#[test] -fn when_gh_api_user_fails_setup_writes_the_git_email() { - let scenario = Scenario::new(); - scenario.github_profile_fails(); - scenario.git_email_is(Some("me@example.org")); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(email_to(&scenario).as_deref(), Some("me@example.org")); - assert!( - scenario - .gh_calls() - .iter() - .any(|call| call[..2] == ["api", "user"]), - "{:?}", - scenario.gh_calls() - ); -} - -#[test] -fn a_noreply_git_email_is_never_written() { - let scenario = Scenario::new(); - scenario.git_email_is(Some("123+octo@users.noreply.github.com")); - - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - - let text = user_config(&scenario).unwrap(); - assert!(!text.contains("noreply"), "{text}"); - assert!(text.contains("\n# to = "), "{text}"); -} - -#[test] -fn with_neither_email_setup_writes_email_to_commented_out() { - let scenario = Scenario::new(); - scenario.github_profile_fails(); - scenario.git_email_is(None); - - assert_eq!(scenario.run(&["setup"]).code, Some(0)); - - let text = user_config(&scenario).unwrap(); - assert_eq!(email_to(&scenario), None, "{text}"); - assert!(text.contains("\n# to = \"you@example.com\""), "{text}"); -} - -#[test] -fn setup_never_replaces_an_existing_email_to() { - let scenario = Scenario::new(); - scenario.github_email_is(Some("octo@example.com")); - let mine = "[email]\nto = \"mine@example.net\"\n"; - scenario.user_config_is(mine); - - let result = scenario.run(&["setup"]); - - assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - assert_eq!(email_to(&scenario).as_deref(), Some("mine@example.net")); -} - // Setup from a terminal. const HARNESS: &str = "Harness for every Run's sessions"; @@ -551,14 +146,10 @@ const NOTIFY: &str = "Run notifications, an email"; const TO: &str = "Send Run notifications to"; const FROM: &str = "Send them from"; const TEST_EMAIL: &str = "test email now?"; -const KEY_QUESTION: &str = "Resend API key (input hidden"; const KEY_PROMPT: &str = "Resend API key (input hidden, Enter to skip):"; const KEPT: &str = "Resend API key (input hidden, Enter keeps the saved one):"; -const SKIPPED: &str = "No Resend API key, so no email can go yet."; -const OLD_KEY_HINT: &str = "export RESEND_API_KEY="; const WROTE_CREDENTIALS: &str = "wrote the Credentials"; const KEY: &str = "re_secret_123"; -const ACCEPTED: &str = r#"{"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}"#; /// Run `thirdshift setup` on a terminal, typing `keystrokes`, and check it /// succeeded with nothing on stdout. @@ -590,11 +181,6 @@ fn notifications_on<'a>(rest: &[Keystrokes<'a>]) -> Vec> { keystrokes } -fn table(result: &TerminalResult) -> toml::Table { - let text = result.user_config.as_ref().expect("no User config written"); - text.parse().unwrap() -} - #[test] fn on_a_terminal_pressing_enter_throughout_writes_what_setup_with_no_terminal_writes() { let unattended = Scenario::new(); @@ -627,729 +213,73 @@ fn on_a_terminal_pressing_enter_throughout_writes_what_setup_with_no_terminal_wr } #[test] -fn on_a_terminal_the_answers_are_written_with_the_comments_on_each_key() { +fn on_a_terminal_an_entered_key_is_written_to_the_credentials_and_never_shown() { let scenario = Scenario::new(); - scenario.git_email_is(None); let result = setup_on_terminal( &scenario, - &[("RESEND_API_KEY", KEY)], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, "y"), - (PULL, "yes"), - (NOTIFY, "y"), - (TO, "me@example.com"), - (FROM, "ts@acme.dev"), - (TEST_EMAIL, ""), - ], + &[], + ¬ifications_on(&[(KEY_PROMPT, &format!(" {KEY} ")), (TEST_EMAIL, "")]), ); - let config = table(&result); - assert_eq!(config["merge"]["always"].as_bool(), Some(true)); - assert_eq!(config["base"]["fix"].as_bool(), Some(true)); - assert_eq!(config["launch"]["pull"].as_bool(), Some(true)); - assert_eq!(config["email"]["always"].as_bool(), Some(true)); - assert_eq!(config["email"]["to"].as_str(), Some("me@example.com")); - assert_eq!(config["email"]["from"].as_str(), Some("ts@acme.dev")); - let text = result.user_config.unwrap(); - assert_eq!( - key_names(&text), - [ - "merge.always", - "base.fix", - "launch.pull", - "email.always", - "email.to", - "email.from", - "logs.dir", - "activity.quiet_skips", - "spec.parallel", - "pickup.limit", - "harness.default", - "harness.claude.model", - "harness.claude.effort", - "harness.codex.model", - "harness.codex.effort" - ], - "{text}" - ); - for (section, line) in key_lines(&text) { - assert!( - !line.starts_with('#'), - "[{section}] {line:?} is commented out" - ); - let Some((_, comment)) = line.split_once(" # ") else { - panic!("no trailing comment on [{section}] {line:?}"); - }; - assert!(comment.contains("default"), "[{section}] {line:?}"); - } - assert!(!text.contains(KEY), "{text}"); + let path = scenario.path("home/.thirdshift/credentials.toml"); assert!( - !result.stderr.contains(KEY_QUESTION), + result + .stderr + .contains(&format!("{WROTE_CREDENTIALS} {}", path.display())), "terminal: {}", result.stderr ); + let credentials: toml::Table = scenario.credentials().unwrap().parse().unwrap(); + assert_eq!(credentials["resend"]["key"].as_str(), Some(KEY)); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); + for part in [&KEY[..6], &KEY[KEY.len() - 6..]] { + assert!(!result.stderr.contains(part), "{part}: {}", result.stderr); + } + assert!(!result.user_config.unwrap().contains(KEY)); } #[test] -fn on_a_terminal_with_merging_on_setup_asks_about_base_fixes_and_writes_the_answer() { - for (answer, allowed) in [("y", true), ("", false), ("n", false)] { - let scenario = Scenario::new(); - scenario.git_email_is(None); +fn ctrl_c_at_the_key_prompt_writes_neither_file() { + let scenario = Scenario::new(); - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, answer), - (PULL, ""), - (NOTIFY, ""), - ], - ); + let result = + scenario.run_on_terminal(&["setup"], &[], ¬ifications_on(&[(KEY_PROMPT, CTRL_C)])); - let config = table(&result); - assert_eq!(config["merge"]["always"].as_bool(), Some(true)); - assert_eq!(config["base"]["fix"].as_bool(), Some(allowed), "{answer:?}"); - assert!( - result.stderr.contains(&format!("{BASE_FIX} [y/N]")), - "terminal: {}", - result.stderr - ); - } + assert_ne!(result.code, Some(0), "terminal: {}", result.stderr); + assert_eq!(result.stdout, ""); + assert_eq!(result.user_config, None); + assert_eq!(scenario.credentials(), None); } #[test] -fn on_a_terminal_with_merging_off_setup_asks_nothing_about_base_fixes_and_writes_the_default() { - for answer in ["", "n"] { - let scenario = Scenario::new(); - scenario.git_email_is(None); +fn ctrl_c_at_the_key_prompt_leaves_saved_credentials_unchanged() { + let scenario = Scenario::new(); + let saved = "[resend]\nkey = \"re_saved_456\"\n"; + scenario.credentials_are(saved); - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, answer), - (PULL, ""), - (NOTIFY, ""), - ], - ); + let result = scenario.run_on_terminal(&["setup"], &[], ¬ifications_on(&[(KEPT, CTRL_C)])); - assert!( - !result.stderr.contains(BASE_FIX), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["merge"]["always"].as_bool(), Some(false)); - assert_eq!(config["base"]["fix"].as_bool(), Some(false)); - } + assert_ne!(result.code, Some(0), "terminal: {}", result.stderr); + assert_eq!(result.user_config, None); + assert_eq!(scenario.credentials().as_deref(), Some(saved)); } #[test] -fn on_a_terminal_the_base_fix_question_defaults_to_the_user_configs_base_fix() { +fn ctrl_c_during_the_questions_writes_no_user_config() { let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is("[merge]\nalways = true\n\n[base]\nfix = true # mine\n"); - let result = setup_on_terminal( - &scenario, + let result = scenario.run_on_terminal( + &["setup"], &[], &[ (HARNESS, ""), (MODEL, ""), (EFFORT, ""), - (MERGE, ""), + (MERGE, "y"), (BASE_FIX, ""), - (PULL, ""), - (NOTIFY, ""), - ], - ); - - assert!( - result.stderr.contains(&format!("{BASE_FIX} [Y/n]")), - "terminal: {}", - result.stderr - ); - let text = result.user_config.clone().unwrap(); - assert!(text.contains("fix = true # mine\n"), "{text}"); -} - -#[test] -fn on_a_terminal_turning_merging_off_writes_base_fix_at_its_default() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is("[merge]\nalways = true\n\n[base]\nfix = true # mine\n"); - - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "n"), - (PULL, ""), - (NOTIFY, ""), - ], - ); - - assert!( - !result.stderr.contains(BASE_FIX), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["merge"]["always"].as_bool(), Some(false)); - assert_eq!(config["base"]["fix"].as_bool(), Some(false)); - let text = result.user_config.unwrap(); - assert!(text.contains("fix = false # mine\n"), "{text}"); -} - -#[test] -fn on_a_terminal_the_address_defaults_to_the_suggested_github_email() { - let scenario = Scenario::new(); - scenario.github_email_is(Some("octo@example.com")); - - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, ""), - (FROM, ""), - (KEY_PROMPT, ""), - ], - ); - - assert!( - result.stderr.contains("octo@example.com"), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["email"]["always"].as_bool(), Some(true)); - assert_eq!(config["email"]["to"].as_str(), Some("octo@example.com")); - assert_eq!( - config["email"]["from"].as_str(), - Some("onboarding@resend.dev") - ); -} - -#[test] -fn on_a_terminal_pressing_enter_throughout_keeps_an_existing_user_config() { - let scenario = Scenario::new(); - scenario.github_email_is(Some("octo@example.com")); - let mine = "\ -[merge] -always = true - -[base] -fix = true - -[launch] -pull = true - -[email] -always = true -to = \"mine@example.net\" -from = \"ts@acme.dev\" - -[logs] -dir = \"/var/log/thirdshift\" - -[activity] -quiet_skips = true - -[spec] -parallel = 5 - -[pickup] -limit = 5 - -[harness] -default = \"claude\" - -[harness.claude] -model = \"opus\" -effort = \"high\" - -[harness.codex] -model = \"gpt-6.1-sol\" -effort = \"max\" -"; - scenario.user_config_is(mine); - - let result = setup_on_terminal( - &scenario, - &[("RESEND_API_KEY", KEY)], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (BASE_FIX, ""), - (PULL, ""), - (NOTIFY, ""), - (TO, ""), - (FROM, ""), - (TEST_EMAIL, ""), - ], - ); - - assert_eq!(result.user_config.as_deref(), Some(mine)); - // Setup asks nothing about the Claim limit. - assert!( - !result.stderr.contains("limit"), - "terminal: {}", - result.stderr - ); - for current in [ - "[Y/n]", - "mine@example.net", - "ts@acme.dev", - "[opus]", - "[high]", - ] { - assert!( - result.stderr.contains(current), - "terminal: {}", - result.stderr - ); - } -} - -#[test] -fn on_a_terminal_setup_over_a_hand_commented_user_config_keeps_the_comments() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let mine = "\ -# My machine. -[merge] -# Merging is for later. -always = false # not yet - -[email] -# to = \"someday@example.com\" -always = false # quiet, please -"; - scenario.user_config_is(mine); - - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, "me@example.com"), - (FROM, ""), - (KEY_PROMPT, ""), - ], - ); - - let text = result.user_config.clone().unwrap(); - for kept in [ - "# My machine.\n[merge]\n# Merging is for later.\nalways = true # not yet\n", - "always = true # quiet, please\n", - ] { - assert!(text.contains(kept), "{text}"); - } - assert!(!text.contains("# to = "), "{text}"); - let config = table(&result); - assert_eq!(config["merge"]["always"].as_bool(), Some(true)); - assert_eq!(config["email"]["to"].as_str(), Some("me@example.com")); - let mut names = key_names(&text); - names.sort(); - assert_eq!(names, EVERY_KEY, "{text}"); -} - -#[test] -fn on_a_terminal_an_address_without_an_at_is_asked_again() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - - let result = setup_on_terminal( - &scenario, - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, ""), - (TO, "me.example.com"), - (TO, "me@example.com"), - (FROM, ""), - (KEY_PROMPT, ""), - ], - ); - - assert_eq!(result.stderr.matches(TO).count(), 3, "{}", result.stderr); - assert_eq!( - table(&result)["email"]["to"].as_str(), - Some("me@example.com") - ); -} - -#[test] -fn on_a_terminal_with_notifications_off_nothing_about_email_is_asked() { - let scenario = Scenario::new(); - let resend = ResendStandIn::replying(200, ACCEPTED); - scenario.git_email_is(None); - - let saved = "[resend]\nkey = \"re_saved_456\"\n"; - scenario.credentials_are(saved); - - let result = setup_on_terminal( - &scenario, - &[("THIRDSHIFT_RESEND_URL", resend.url())], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "n"), - ], - ); - - assert_eq!(scenario.credentials().as_deref(), Some(saved)); - for asked in [TO, FROM, KEY_QUESTION, "RESEND_API_KEY", TEST_EMAIL] { - assert!( - !result.stderr.contains(asked), - "terminal: {}", - result.stderr - ); - } - assert_eq!(table(&result)["email"]["always"].as_bool(), Some(false)); - assert!(resend.requests().is_empty()); -} - -#[test] -fn on_a_terminal_with_no_key_skipping_it_writes_no_credentials_and_says_how_to_add_one() { - for key in [None, Some("")] { - let scenario = Scenario::new(); - let env: Vec<(&str, &str)> = key.map(|key| ("RESEND_API_KEY", key)).into_iter().collect(); - - let result = setup_on_terminal(&scenario, &env, ¬ifications_on(&[(KEY_PROMPT, "")])); - - assert!( - result.stderr.contains(SKIPPED), - "{key:?}: {}", - result.stderr - ); - for said in ["rerun `thirdshift setup`", "set RESEND_API_KEY"] { - assert!(result.stderr.contains(said), "{key:?}: {}", result.stderr); - } - for unsaid in [OLD_KEY_HINT, TEST_EMAIL, WROTE_CREDENTIALS] { - assert!( - !result.stderr.contains(unsaid), - "{key:?}: {}", - result.stderr - ); - } - assert_eq!(scenario.credentials(), None, "{key:?}"); - assert_eq!(table(&result)["email"]["always"].as_bool(), Some(true)); - } -} - -#[test] -fn on_a_terminal_an_entered_key_is_written_to_the_credentials_and_never_shown() { - let scenario = Scenario::new(); - - let result = setup_on_terminal( - &scenario, - &[], - ¬ifications_on(&[(KEY_PROMPT, &format!(" {KEY} ")), (TEST_EMAIL, "")]), - ); - - let path = scenario.path("home/.thirdshift/credentials.toml"); - assert!( - result - .stderr - .contains(&format!("{WROTE_CREDENTIALS} {}", path.display())), - "terminal: {}", - result.stderr - ); - let credentials: toml::Table = scenario.credentials().unwrap().parse().unwrap(); - assert_eq!(credentials["resend"]["key"].as_str(), Some(KEY)); - let mode = fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600, "{mode:o}"); - for part in [&KEY[..6], &KEY[KEY.len() - 6..]] { - assert!(!result.stderr.contains(part), "{part}: {}", result.stderr); - } - assert!(!result.user_config.unwrap().contains(KEY)); -} - -#[test] -fn on_a_terminal_a_key_not_starting_with_re_is_asked_again() { - let scenario = Scenario::new(); - - let result = setup_on_terminal( - &scenario, - &[], - ¬ifications_on(&[ - (KEY_PROMPT, "sk_not_resend"), - (KEY_PROMPT, KEY), - (TEST_EMAIL, ""), - ]), - ); - - assert_eq!( - result.stderr.matches(KEY_PROMPT).count(), - 2, - "{}", - result.stderr - ); - assert!( - !result.stderr.contains("sk_not_resend"), - "{}", - result.stderr - ); - let credentials: toml::Table = scenario.credentials().unwrap().parse().unwrap(); - assert_eq!(credentials["resend"]["key"].as_str(), Some(KEY)); -} - -#[test] -fn on_a_terminal_pressing_enter_keeps_the_saved_key() { - let scenario = Scenario::new(); - let saved = "# Mine.\n[resend]\nkey = \"re_saved_456\"\n"; - scenario.credentials_are(saved); - - let result = setup_on_terminal( - &scenario, - &[], - ¬ifications_on(&[(KEPT, ""), (TEST_EMAIL, "")]), - ); - - assert_eq!(scenario.credentials().as_deref(), Some(saved)); - assert!( - !result.stderr.contains(WROTE_CREDENTIALS), - "{}", - result.stderr - ); - assert!(!result.stderr.contains("re_saved"), "{}", result.stderr); -} - -#[test] -fn on_a_terminal_a_new_key_replaces_the_saved_one_keeping_the_rest_of_the_file() { - let scenario = Scenario::new(); - scenario.credentials_are( - "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_saved_456\" # from the dashboard\n", - ); - - setup_on_terminal( - &scenario, - &[], - ¬ifications_on(&[(KEPT, KEY), (TEST_EMAIL, "")]), - ); - - assert_eq!( - scenario.credentials().as_deref(), - Some( - "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_secret_123\" # from the dashboard\n" - ) - ); -} - -#[test] -fn on_a_terminal_a_key_is_added_to_credentials_that_hold_none() { - let scenario = Scenario::new(); - scenario.credentials_are("# Keys go here.\n"); - - setup_on_terminal( - &scenario, - &[], - ¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "")]), - ); - - let text = scenario.credentials().unwrap(); - assert!(text.starts_with("# Keys go here.\n"), "{text}"); - assert!(!text.contains("# key"), "{text}"); - let credentials: toml::Table = text.parse().unwrap(); - assert_eq!(credentials["resend"]["key"].as_str(), Some(KEY)); -} - -#[test] -fn on_a_terminal_with_resend_api_key_set_no_key_is_asked_and_its_source_is_said() { - let scenario = Scenario::new(); - let saved = "[resend]\nkey = \"re_saved_456\"\n"; - scenario.credentials_are(saved); - - let result = setup_on_terminal( - &scenario, - &[("RESEND_API_KEY", KEY)], - ¬ifications_on(&[(TEST_EMAIL, "")]), - ); - - assert!( - result - .stderr - .contains("The Resend API key comes from RESEND_API_KEY"), - "{}", - result.stderr - ); - assert!(!result.stderr.contains(KEY_QUESTION), "{}", result.stderr); - assert!(!result.stderr.contains(KEY), "{}", result.stderr); - assert_eq!(scenario.credentials().as_deref(), Some(saved)); -} - -#[test] -fn on_a_terminal_the_test_email_goes_with_the_key_just_entered() { - let scenario = Scenario::new(); - let resend = ResendStandIn::replying(200, ACCEPTED); - - let result = setup_on_terminal( - &scenario, - &[("THIRDSHIFT_RESEND_URL", resend.url())], - ¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")]), - ); - - let requests = resend.requests(); - assert_eq!(requests.len(), 1, "{}", result.stderr); - assert_eq!( - requests[0].authorization.as_deref(), - Some(format!("Bearer {KEY}").as_str()) - ); - let wrote = result.stderr.find(WROTE_CREDENTIALS).unwrap(); - let sent = result - .stderr - .find("accepted by Resend; check your inbox") - .expect(&result.stderr); - assert!(wrote < sent, "{}", result.stderr); -} - -#[test] -fn ctrl_c_at_the_key_prompt_writes_neither_file() { - let scenario = Scenario::new(); - - let result = - scenario.run_on_terminal(&["setup"], &[], ¬ifications_on(&[(KEY_PROMPT, CTRL_C)])); - - assert_ne!(result.code, Some(0), "terminal: {}", result.stderr); - assert_eq!(result.stdout, ""); - assert_eq!(result.user_config, None); - assert_eq!(scenario.credentials(), None); -} - -#[test] -fn ctrl_c_at_the_key_prompt_leaves_saved_credentials_unchanged() { - let scenario = Scenario::new(); - let saved = "[resend]\nkey = \"re_saved_456\"\n"; - scenario.credentials_are(saved); - - let result = scenario.run_on_terminal(&["setup"], &[], ¬ifications_on(&[(KEPT, CTRL_C)])); - - assert_ne!(result.code, Some(0), "terminal: {}", result.stderr); - assert_eq!(result.user_config, None); - assert_eq!(scenario.credentials().as_deref(), Some(saved)); -} - -#[test] -fn on_a_terminal_setup_refuses_broken_credentials_before_asking() { - for broken in [ - "[resend]\nkye = \"re_saved_456\"\n", - "[resend\n", - "[resend]\nkey = 456\n", - ] { - let scenario = Scenario::new(); - scenario.credentials_are(broken); - - let result = scenario.run_on_terminal(&["setup"], &[("RESEND_API_KEY", KEY)], &[]); - - assert_eq!(result.code, Some(1), "{broken:?}: {}", result.stderr); - assert!( - result.stderr.contains("credentials.toml"), - "{broken:?}: {}", - result.stderr - ); - assert!( - !result.stderr.contains(MERGE), - "{broken:?}: {}", - result.stderr - ); - assert_eq!(result.user_config, None); - assert_eq!(scenario.credentials().as_deref(), Some(broken)); - } -} - -#[test] -fn on_a_terminal_accepting_the_test_email_sends_one_and_declining_sends_none() { - for (answer, sent) in [("y", 1), ("n", 0), ("", 0)] { - let scenario = Scenario::new(); - let resend = ResendStandIn::replying(200, ACCEPTED); - - let result = setup_on_terminal( - &scenario, - &[ - ("RESEND_API_KEY", KEY), - ("THIRDSHIFT_RESEND_URL", resend.url()), - ], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, "me@example.com"), - (FROM, ""), - (TEST_EMAIL, answer), - ], - ); - - let requests = resend.requests(); - assert_eq!(requests.len(), sent, "{answer:?}: {requests:?}"); - if sent == 1 { - assert_eq!(requests[0].body["to"], "me@example.com"); - assert!( - result - .stderr - .contains("accepted by Resend; check your inbox"), - "terminal: {}", - result.stderr - ); - } - assert!(result.user_config.is_some(), "{answer:?}"); - } -} - -#[test] -fn ctrl_c_during_the_questions_writes_no_user_config() { - let scenario = Scenario::new(); - - let result = scenario.run_on_terminal( - &["setup"], - &[], - &[ - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, ""), - (PULL, CTRL_C), + (PULL, CTRL_C), ], ); @@ -1381,451 +311,3 @@ fn ctrl_c_during_the_questions_leaves_an_existing_user_config_unchanged() { assert_ne!(result.code, Some(0), "terminal: {}", result.stderr); assert_eq!(result.user_config.as_deref(), Some(mine)); } - -#[test] -fn on_a_terminal_setup_still_refuses_a_broken_user_config_before_asking() { - let scenario = Scenario::new(); - let broken = "[merge]\nalway = true\n"; - scenario.user_config_is(broken); - - let result = scenario.run_on_terminal(&["setup"], &[], &[]); - - assert_eq!(result.code, Some(1), "terminal: {}", result.stderr); - assert!(result.stderr.contains("unknown key merge.alway")); - assert!( - !result.stderr.contains(MERGE), - "terminal: {}", - result.stderr - ); - assert_eq!(result.user_config.as_deref(), Some(broken)); -} - -// The Harness, Model and Effort. - -/// What Claude says, and how it exits, when it refuses the Model it is -/// asked to run on. -const CLAUDE_REFUSES_THE_MODEL: &str = r#" -echo "There's an issue with the selected model (Opus 5.5). It may not exist or you may not have access to it." -exit 1 -"#; - -/// The answers that leave every setting but the Harness's as it is. -const NOTHING_ELSE: [Keystrokes; 3] = [(MERGE, ""), (PULL, ""), (NOTIFY, "")]; - -/// `PATH` with the scenario's fakes and the system's tools, and so no other -/// `claude` or `codex`, whatever is installed on the machine the tests run -/// on. -fn fakes_only_path(scenario: &Scenario) -> String { - format!("{}:/usr/bin:/bin", scenario.path("bin").display()) -} - -/// Each call to `claude` that was the test call checking a Model, by its -/// arguments. -fn test_calls(scenario: &Scenario) -> Vec> { - scenario - .claude_calls() - .into_iter() - .map(|call| { - call["argv"] - .as_array() - .unwrap() - .iter() - .map(|arg| arg.as_str().unwrap().to_string()) - .collect() - }) - .collect() -} - -#[test] -fn on_a_terminal_the_harness_question_lists_both_harnesses_and_defaults_to_claude() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![(HARNESS, ""), (MODEL, ""), (EFFORT, "")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - assert!( - result - .stderr - .contains("Harness for every Run's sessions, claude or codex [claude]: "), - "terminal: {}", - result.stderr - ); - assert!( - !result.stderr.contains("for codex"), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - for harness in ["claude", "codex"] { - for key in ["model", "effort"] { - assert_eq!(config["harness"][harness][key].as_str(), Some("")); - } - } - assert!(test_calls(&scenario).is_empty()); -} - -#[test] -fn on_a_terminal_a_harness_not_installed_is_refused_and_asked_again() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - fs::remove_file(scenario.path("bin/claude")).unwrap(); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![ - (HARNESS, "claude"), - (HARNESS, "gemini"), - (HARNESS, "codex"), - (CODEX_MODEL, ""), - (CODEX_EFFORT, ""), - ]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - for refusal in [ - "claude is not installed: it isn't on PATH.", - "Choose claude or codex.", - ] { - assert!( - result.stderr.contains(refusal), - "terminal: {}", - result.stderr - ); - } - assert_eq!(table(&result)["harness"]["default"].as_str(), Some("codex")); -} - -#[test] -fn on_a_terminal_with_neither_harness_installed_the_harness_is_left_as_it_was() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is("[harness.claude]\nmodel = \"opus\"\n"); - fs::remove_file(scenario.path("bin/claude")).unwrap(); - fs::remove_file(scenario.path("bin/codex")).unwrap(); - let path = fakes_only_path(&scenario); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &NOTHING_ELSE); - - assert!( - result - .stderr - .contains("claude (not installed) or codex (not installed)"), - "terminal: {}", - result.stderr - ); - assert!( - !result.stderr.contains(MODEL), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - assert_eq!(config["harness"]["claude"]["model"].as_str(), Some("opus")); -} - -#[test] -fn on_a_terminal_the_model_and_effort_answers_are_written_after_a_test_call_with_them() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let mut keystrokes = vec![(HARNESS, ""), (MODEL, "claude-opus-5-5"), (EFFORT, "high")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[], &keystrokes); - - let config = table(&result); - assert_eq!( - config["harness"]["claude"]["model"].as_str(), - Some("claude-opus-5-5") - ); - assert_eq!(config["harness"]["claude"]["effort"].as_str(), Some("high")); - assert_eq!(config["harness"]["codex"]["model"].as_str(), Some("")); - let text = result.user_config.unwrap(); - assert!( - text.contains("model = \"claude-opus-5-5\" # the Model Claude Code's sessions run on"), - "{text}" - ); - assert_eq!( - test_calls(&scenario), - [["-p", "--model", "claude-opus-5-5", "--effort", "high"]] - ); -} - -#[test] -fn on_a_terminal_the_model_and_effort_default_to_the_current_ones_and_a_dash_clears_one() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is( - "[harness.claude]\nmodel = \"opus\" # mine\neffort = \"high\"\n\n\ - [harness.codex]\nmodel = \"gpt-6.1-sol\"\n", - ); - let mut keystrokes = vec![(HARNESS, ""), (MODEL, ""), (EFFORT, "-")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[], &keystrokes); - - for shown in [ - "Model for claude, - for claude's own default [opus]: ", - "Effort for claude, - for claude's own default [high]: ", - ] { - assert!(result.stderr.contains(shown), "terminal: {}", result.stderr); - } - let text = result.user_config.clone().unwrap(); - assert!(text.contains("model = \"opus\" # mine\n"), "{text}"); - let config = table(&result); - assert_eq!(config["harness"]["claude"]["effort"].as_str(), Some("")); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-6.1-sol") - ); - assert_eq!(test_calls(&scenario), [["-p", "--model", "opus"]]); -} - -#[test] -fn on_a_terminal_a_model_claude_refuses_is_asked_again_with_claudes_error() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.agent_does_in_session(1, CLAUDE_REFUSES_THE_MODEL); - let mut keystrokes = vec![ - (HARNESS, ""), - (MODEL, "Opus 5.5"), - (EFFORT, ""), - (MODEL, "opus"), - (EFFORT, ""), - ]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[], &keystrokes); - - assert!( - result.stderr.contains( - "claude refused a test call on the Model Opus 5.5: There's an issue with the \ - selected model (Opus 5.5)." - ), - "terminal: {}", - result.stderr - ); - assert_eq!( - table(&result)["harness"]["claude"]["model"].as_str(), - Some("opus") - ); - assert_eq!(test_calls(&scenario).len(), 2); -} - -// Codex. - -const CODEX_MODEL: &str = "Model for codex"; -const CODEX_EFFORT: &str = "Effort for codex"; - -/// How Setup lists the fake `codex`'s catalog of Models. -const CODEX_MODELS: &str = - "Codex's Models: gpt-6.1-sol (GPT-6.1-Sol), gpt-6-luna (GPT-6-Luna), gpt-5.5 (GPT-5.5)"; - -#[test] -fn on_a_terminal_codex_is_offered_with_its_models_and_the_chosen_models_efforts() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![ - (HARNESS, "codex"), - (CODEX_MODEL, "gpt-5.5"), - (CODEX_EFFORT, "high"), - ]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - for shown in [ - "Harness for every Run's sessions, claude or codex [claude]: ", - CODEX_MODELS, - "Efforts gpt-5.5 supports: low, medium, high, xhigh", - ] { - assert!(result.stderr.contains(shown), "terminal: {}", result.stderr); - } - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("codex")); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-5.5") - ); - assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("high")); - assert_eq!(config["harness"]["claude"]["model"].as_str(), Some("")); - assert!(test_calls(&scenario).is_empty()); - assert!(scenario.codex_calls().is_empty()); -} - -#[test] -fn on_a_terminal_with_only_codex_installed_codex_is_the_default_answer() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - fs::remove_file(scenario.path("bin/claude")).unwrap(); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![(HARNESS, ""), (CODEX_MODEL, ""), (CODEX_EFFORT, "")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - for shown in [ - "Harness for every Run's sessions, claude (not installed) or codex [codex]: ", - "Efforts Codex's Models support: low, medium, high, xhigh, max, ultra", - ] { - assert!(result.stderr.contains(shown), "terminal: {}", result.stderr); - } - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("codex")); - assert_eq!(config["harness"]["codex"]["model"].as_str(), Some("")); - assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("")); -} - -#[test] -fn on_a_terminal_rerunning_setup_defaults_to_the_harness_already_configured() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is( - "[harness]\ndefault = \"codex\"\n\n[harness.codex]\nmodel = \"gpt-6.1-sol\"\n", - ); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![(HARNESS, ""), (CODEX_MODEL, ""), (CODEX_EFFORT, "")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - assert!( - result - .stderr - .contains("Harness for every Run's sessions, claude or codex [codex]: "), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("codex")); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-6.1-sol") - ); -} - -#[test] -fn on_a_terminal_a_codex_display_name_and_capitalised_effort_are_written_as_codex_names_them() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![ - (HARNESS, "codex"), - (CODEX_MODEL, "GPT-6.1-Sol"), - (CODEX_EFFORT, "Max"), - ]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - let config = table(&result); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-6.1-sol") - ); - assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("max")); -} - -#[test] -fn on_a_terminal_an_unknown_codex_model_or_unsupported_effort_is_asked_again_with_the_choices() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![ - (HARNESS, "codex"), - (CODEX_MODEL, "gpt-7"), - (CODEX_MODEL, "GPT-5.5"), - (CODEX_EFFORT, "max"), - (CODEX_EFFORT, "XHigh"), - ]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - for refusal in [ - "the Model gpt-7 is not in Codex's catalog: choose one of gpt-6.1-sol, gpt-6-luna, \ - gpt-5.5", - "the Effort max is not one the Codex Model gpt-5.5 supports: choose one of low, \ - medium, high, xhigh", - ] { - assert!( - result.stderr.contains(refusal), - "terminal: {}", - result.stderr - ); - } - let config = table(&result); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-5.5") - ); - assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("xhigh")); -} - -#[test] -fn on_a_terminal_the_codex_model_and_effort_default_to_the_current_ones_as_codex_names_them() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is("[harness.codex]\nmodel = \"GPT-6-Luna\"\neffort = \"High\"\n"); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![(HARNESS, "codex"), (CODEX_MODEL, ""), (CODEX_EFFORT, "")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - for shown in [ - "Model for codex, - for codex's own default [GPT-6-Luna]: ", - "Efforts gpt-6-luna supports: low, medium, high, xhigh, max", - "Effort for codex, - for codex's own default [High]: ", - ] { - assert!(result.stderr.contains(shown), "terminal: {}", result.stderr); - } - let config = table(&result); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-6-luna") - ); - assert_eq!(config["harness"]["codex"]["effort"].as_str(), Some("high")); -} - -#[test] -fn on_a_terminal_when_codexs_catalog_cant_be_read_the_harness_is_left_as_it_was() { - let scenario = Scenario::new(); - scenario.git_email_is(None); - scenario.user_config_is("[harness.codex]\nmodel = \"gpt-5.5\"\n"); - // The fake is a link to the build every test shares, so it's replaced, - // not written through. - let codex = scenario.path("bin/codex"); - fs::remove_file(&codex).unwrap(); - fs::write(&codex, "#!/bin/sh\necho 'not logged in' >&2\nexit 1\n").unwrap(); - fs::set_permissions(&codex, fs::Permissions::from_mode(0o755)).unwrap(); - let path = fakes_only_path(&scenario); - let mut keystrokes = vec![(HARNESS, "codex")]; - keystrokes.extend(NOTHING_ELSE); - - let result = setup_on_terminal(&scenario, &[("PATH", &path)], &keystrokes); - - assert!( - result.stderr.contains( - "codex debug models failed, so Codex's Models can't be read: not logged in\n\ - The harness settings stay as they are; rerun `thirdshift setup` once codex debug \ - models works." - ), - "terminal: {}", - result.stderr - ); - assert!( - !result.stderr.contains(CODEX_MODEL), - "terminal: {}", - result.stderr - ); - let config = table(&result); - assert_eq!(config["harness"]["default"].as_str(), Some("claude")); - assert_eq!( - config["harness"]["codex"]["model"].as_str(), - Some("gpt-5.5") - ); -} diff --git a/tests/setup_offer.rs b/tests/setup_offer.rs index 113054753..15e9706d8 100644 --- a/tests/setup_offer.rs +++ b/tests/setup_offer.rs @@ -1,13 +1,12 @@ -//! The first Run's offer of Setup: a Run from a terminal with no User config -//! asks, on stderr, whether to set the defaults now, before any work. Yes runs -//! the Setup questions and the Run carries on with the answers; no writes the -//! all-defaults User config. With no terminal, a Run offers nothing and writes -//! nothing. +//! The first Run's offer of Setup, wired into a Run: a Run from a terminal +//! with no User config asks, on stderr, whether to set the defaults now, +//! before any work, and carries on with the answers. With no terminal, a Run +//! offers nothing and writes nothing; no other command offers it; and Ctrl-C +//! at the offer or during the questions ends the command with no work done. +//! The offer's rules are tested in-process, in the Setup module. mod support; -use std::fs; - use support::resend::ResendStandIn; use support::{CTRL_C, Scenario, TerminalResult}; @@ -30,11 +29,6 @@ const MERGE: &str = "Merge run?"; const BASE_FIX: &str = "Every Run may start a Base fix when the Base branch's CI is red?"; const PULL: &str = "fast-forward"; const NOTIFY: &str = "Run notifications, an email"; -const TO: &str = "Send Run notifications to"; -const FROM: &str = "Send them from"; -const TEST_EMAIL: &str = "test email now?"; -const KEY_QUESTION: &str = "Resend API key (input hidden"; -const KEY_PROMPT: &str = "Resend API key (input hidden, Enter to skip):"; const KEY: &str = "re_secret_123"; /// The line a Run prints as it starts its work, after the one saying it is @@ -97,212 +91,6 @@ fn accepting_and_choosing_merge_always_makes_that_run_a_merge_run() { assert_eq!(config["merge"]["always"].as_bool(), Some(true)); } -#[test] -fn accepting_and_allowing_base_fixes_lets_that_run_start_a_base_fix() { - let scenario = Scenario::new(); - scenario.agent_does(&format!( - "{AGENT_OPENS_PR}{}{}", - "gh fake checks \"$(git rev-parse HEAD)\" '[{\"name\": \"test\", \"conclusion\": \"failure\"}]'\n", - "gh fake checks \"$(git rev-parse origin/main)\" '[{\"name\": \"test\", \"conclusion\": \"failure\"}]'\n", - )); - scenario.agent_does_for( - 8, - r#" -echo "fixed" > ci-fix.txt -git add ci-fix.txt -git commit -q -m "Fix CI on main" -gh pr create --base main --head issue-8 --title "Fix CI on main" --body "Closes #8" -gh fake checks "$(git rev-parse HEAD)" '[{"name": "test", "conclusion": "success"}]' -"#, - ); - - let result = scenario.run_on_terminal( - &[&scenario.issue_url(7)], - &[], - &[ - (OFFER, ""), - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, "y"), - (PULL, ""), - (NOTIFY, ""), - ], - ); - - assert_ended(&scenario, &result, "merged", "MERGED"); - let config: toml::Table = result.user_config.unwrap().parse().unwrap(); - assert_eq!(config["base"]["fix"].as_bool(), Some(true)); - let gh = scenario.gh_state(); - assert_eq!(gh["titles"]["8"], "CI red on main: test"); - assert_eq!(gh["prs"][1]["state"], "MERGED"); -} - -#[test] -fn no_merge_in_the_command_wins_over_a_fresh_merge_always() { - let scenario = Scenario::new(); - scenario.agent_does(AGENT_OPENS_PR); - - let result = scenario.run_on_terminal( - &["--no-merge", &scenario.issue_url(7)], - &[], - &[ - (OFFER, "y"), - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, ""), - (PULL, ""), - (NOTIFY, ""), - ], - ); - - assert_ended(&scenario, &result, "ready for review", "OPEN"); - let config: toml::Table = result.user_config.unwrap().parse().unwrap(); - assert_eq!(config["merge"]["always"].as_bool(), Some(true)); -} - -#[test] -fn no_email_in_the_command_wins_over_fresh_run_notifications() { - let scenario = Scenario::new(); - let resend = ResendStandIn::replying(200, r#"{"id":"1"}"#); - scenario.agent_does(AGENT_OPENS_PR); - - let result = scenario.run_on_terminal( - &["--no-email", &scenario.issue_url(7)], - &[ - ("RESEND_API_KEY", KEY), - ("THIRDSHIFT_RESEND_URL", resend.url()), - ], - &[ - (OFFER, "y"), - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, "me@example.com"), - (FROM, ""), - (TEST_EMAIL, "n"), - ], - ); - - assert_ended(&scenario, &result, "ready for review", "OPEN"); - let config: toml::Table = result.user_config.unwrap().parse().unwrap(); - assert_eq!(config["email"]["always"].as_bool(), Some(true)); - assert!(resend.requests().is_empty(), "{:?}", resend.requests()); -} - -#[test] -fn accepting_asks_for_the_key_saves_it_and_the_run_notification_goes_with_it() { - let scenario = Scenario::new(); - let resend = ResendStandIn::replying(200, r#"{"id":"1"}"#); - scenario.agent_does(AGENT_OPENS_PR); - - let result = scenario.run_on_terminal( - &[&scenario.issue_url(7)], - &[("THIRDSHIFT_RESEND_URL", resend.url())], - &[ - (OFFER, "y"), - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, ""), - (PULL, ""), - (NOTIFY, "y"), - (TO, "me@example.com"), - (FROM, ""), - (KEY_PROMPT, KEY), - (TEST_EMAIL, "n"), - ], - ); - - assert_ended(&scenario, &result, "ready for review", "OPEN"); - let path = scenario.path("home/.thirdshift/credentials.toml"); - let wrote = result - .stderr - .find(&format!("wrote the Credentials {}", path.display())) - .expect(&result.stderr); - assert!( - wrote < result.stderr.find("creating worktree").unwrap(), - "terminal: {}", - result.stderr - ); - assert!(!result.stderr.contains(KEY), "terminal: {}", result.stderr); - let credentials: toml::Table = scenario.credentials().unwrap().parse().unwrap(); - assert_eq!(credentials["resend"]["key"].as_str(), Some(KEY)); - let requests = resend.requests(); - assert_eq!(requests.len(), 1, "{requests:?}"); - assert_eq!( - requests[0].authorization.as_deref(), - Some(format!("Bearer {KEY}").as_str()) - ); -} - -#[test] -fn accepting_with_broken_credentials_ends_the_command_before_any_question_or_work() { - let scenario = Scenario::new(); - scenario.agent_does(AGENT_OPENS_PR); - let broken = "[resend]\nkye = \"re_saved_456\"\n"; - scenario.credentials_are(broken); - - let result = scenario.run_on_terminal(&[&scenario.issue_url(7)], &[], &[(OFFER, "y")]); - - assert_eq!(result.code, Some(1), "terminal: {}", result.stderr); - assert!( - result.stderr.contains("unknown key resend.kye"), - "terminal: {}", - result.stderr - ); - assert!( - !result.stderr.contains(MERGE), - "terminal: {}", - result.stderr - ); - assert_eq!(result.user_config, None); - assert_eq!(scenario.credentials().as_deref(), Some(broken)); - assert!(scenario.claude_calls().is_empty(), "the Run started"); -} - -#[test] -fn declining_writes_the_defaults_and_a_second_run_does_not_offer() { - let unattended = Scenario::new(); - assert_eq!(unattended.run(&["setup"]).code, Some(0)); - let defaults = fs::read_to_string(unattended.path("home/.thirdshift/config.toml")).unwrap(); - let scenario = Scenario::new(); - scenario.agent_does(AGENT_OPENS_PR); - - let first = scenario.run_on_terminal(&[&scenario.issue_url(7)], &[], &[(OFFER, "n")]); - - assert_ended(&scenario, &first, "ready for review", "OPEN"); - assert_eq!(first.user_config.as_deref(), Some(defaults.as_str())); - assert!( - first.stderr.contains("thirdshift setup"), - "terminal: {}", - first.stderr - ); - assert!(!first.stderr.contains(MERGE), "terminal: {}", first.stderr); - assert!( - !first.stderr.contains(KEY_QUESTION), - "terminal: {}", - first.stderr - ); - assert_eq!(scenario.credentials(), None); - - scenario.issue_is(8, "OPEN"); - let second = scenario.run_on_terminal(&[&scenario.issue_url(8)], &[], &[]); - - assert!( - !second.stderr.contains(OFFER), - "terminal: {}", - second.stderr - ); - assert_eq!(second.user_config.as_deref(), Some(defaults.as_str())); -} - #[test] fn a_bad_command_exits_2_with_no_offer_and_no_file() { let scenario = Scenario::new(); @@ -335,41 +123,6 @@ fn with_no_terminal_a_run_offers_nothing_and_writes_nothing() { assert_eq!(scenario.gh_state()["prs"][0]["state"], "OPEN"); } -#[test] -fn an_unwritable_user_config_is_a_warning_and_the_run_completes_on_the_defaults() { - use std::os::unix::fs::PermissionsExt; - - let scenario = Scenario::new(); - scenario.agent_does(AGENT_OPENS_PR); - let dir = scenario.path("home/.thirdshift"); - fs::create_dir_all(dir.join("logs")).unwrap(); - fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap(); - - let result = scenario.run_on_terminal( - &[&scenario.issue_url(7)], - &[], - &[ - (OFFER, "y"), - (HARNESS, ""), - (MODEL, ""), - (EFFORT, ""), - (MERGE, "y"), - (BASE_FIX, ""), - (PULL, ""), - (NOTIFY, ""), - ], - ); - - fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); - assert_ended(&scenario, &result, "ready for review", "OPEN"); - assert!( - result.stderr.contains("thirdshift: warning: "), - "terminal: {}", - result.stderr - ); - assert_eq!(result.user_config, None); -} - #[test] fn ctrl_c_at_the_offer_writes_no_file_and_does_no_work() { let scenario = Scenario::new(); From b85370281641783fba57bc8c7e1ff228e0ea0da6 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 5 Oct 2026 04:04:33 -0400 Subject: [PATCH 2/2] Review clean-ups for #411: one ask step and one write error for Setup and its offer Co-Authored-By: Claude Opus 5.5 --- src/setup.rs | 58 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/src/setup.rs b/src/setup.rs index dfa16af69..518dd6e7e 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -32,8 +32,9 @@ use questions::Answers; /// Setup: write the User config. From a terminal, the Setup questions come /// first, each with the current value as its default answer, and the answers /// are written, with `base.fix`, asked about only when every Run is a Merge -/// run, otherwise at its default; with no terminal, nothing is asked, and every setting is at -/// its default, with `email.to` as the suggested address, if there is one. +/// run, otherwise at its default; with no terminal, nothing is asked, and +/// every setting is at its default, with `email.to` as the suggested +/// address, if there is one. /// An existing User config is edited in place, once it parses as a Run would /// parse it: its comments and key order stay, as do the values Setup didn't /// ask about, and each key it lacks is added at its default, so Setup with no @@ -67,19 +68,15 @@ fn run_setup(outside: &mut impl Outside, home: &Path, path: &Path) -> Result { UserConfig::parse(text, path, home)?; config::complete(text)? } None => config::with_email_to(suggested_address(outside)), }; - let mut answered = None; - if outside.has_terminal() { - let answers; - (text, answers) = ask(outside, &text, path, home)?; - answered = Some(answers); - } + let asking = outside.has_terminal(); + let (text, answered) = ask_if(asking, outside, text, path, home)?; let asked = answered.is_some(); let mut written = match &existing { None => { @@ -97,9 +94,7 @@ fn run_setup(outside: &mut impl Outside, home: &Path, path: &Path) -> Result { - outside - .replace_user_config(path, &text) - .with_context(|| format!("can't write {}", path.display()))?; + replace(outside, path, &text)?; if asked { format!("wrote your answers to the User config {}", path.display()) } else { @@ -131,13 +126,8 @@ fn run_offer(outside: &mut impl Outside, home: &Path, path: &Path) -> Result<()> return Ok(()); } let accepted = questions::offer(outside, path)?; - let mut text = config::with_email_to(suggested_address(outside)); - let mut answered = None; - if accepted { - let answers; - (text, answers) = ask(outside, &text, path, home)?; - answered = Some(answers); - } + let text = config::with_email_to(suggested_address(outside)); + let (text, answered) = ask_if(accepted, outside, text, path, home)?; if let Err(error) = write_new(outside, path, &text) { outside.step(format!("warning: {error:#}; carrying on with the defaults")); return Ok(()); @@ -168,6 +158,22 @@ fn run_offer(outside: &mut impl Outside, home: &Path, path: &Path) -> Result<()> Ok(()) } +/// `text` with the answers to the Setup questions, and the answers, if +/// `asking`, as [`ask`] asks them; otherwise `text` as it is. +fn ask_if( + asking: bool, + outside: &mut impl Outside, + text: String, + path: &Path, + home: &Path, +) -> Result<(String, Option)> { + if !asking { + return Ok((text, None)); + } + let (text, answers) = ask(outside, &text, path, home)?; + Ok((text, Some(answers))) +} + /// Ask the Setup questions, with the settings in `text`, the User config at /// `path` under `home`, and the key in the Credentials as the default /// answers. Credentials a Run would refuse are refused before any question. @@ -189,7 +195,19 @@ fn ask( fn write_new(outside: &mut impl Outside, path: &Path, text: &str) -> Result<()> { outside .write_new_user_config(path, text) - .with_context(|| format!("can't write {}", path.display())) + .with_context(|| cant_write(path)) +} + +/// Replace the User config at `path` with `text`. +fn replace(outside: &mut impl Outside, path: &Path, text: &str) -> Result<()> { + outside + .replace_user_config(path, text) + .with_context(|| cant_write(path)) +} + +/// What a write of the User config at `path` that failed says. +fn cant_write(path: &Path) -> String { + format!("can't write {}", path.display()) } /// The address Setup suggests for `email.to`: the public email of the user's