diff --git a/README.md b/README.md
index b5b583e53..5297d530d 100644
--- a/README.md
+++ b/README.md
@@ -130,7 +130,7 @@ The other commands:
```sh
thirdshift email-test [
] # send a test email through Resend (see Email)
-thirdshift setup # write the User config with every setting at its default (see User config)
+thirdshift setup # choose your defaults and write the User config with every setting (see User config)
thirdshift update # update to the latest release (see Updating)
thirdshift version # print thirdshift
thirdshift help # print every form of the command, each with a one-line description
@@ -178,7 +178,14 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi
dir = "~/.thirdshift/logs" # where session logs go; default ~/.thirdshift/logs
```
-Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so `setup` suggests one: the public email of your GitHub profile (from `gh api user`), else your global git `user.email`, unless that is a `@users.noreply.github.com` address, which can't receive mail. With neither, `email.to` is the only line written commented out, as above. `setup` never asks `gh` for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: `--email` with no address and no `email.to` still stops the Run. `setup` asks nothing, prints the file's path on stderr and exits `0` with stdout empty. Over a User config that is already there, `setup` edits it in place: its values, comments and key order stay, and each key it lacks is added at its default with its comment, so afterwards the file lists every setting this version knows. One that already does, down to the commented-out `email.to` line, is left byte for byte as it was. A key added to an inline table, such as `launch = { pull = true }`, gets no comment, since TOML has no place for one there. One a Run would refuse is refused the same way, exit `1`, and not touched. Any argument after `setup` is an argument error (exit `2`).
+Every key holds its real value, so a Run reading it does exactly what it does with no file. `email.to` has no default, so `setup` suggests one: the public email of your GitHub profile (from `gh api user`), else your global git `user.email`, unless that is a `@users.noreply.github.com` address, which can't receive mail. With neither, `email.to` is the only line written commented out, as above. `setup` never asks `gh` for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: `--email` with no address and no `email.to` still stops the Run. From a terminal (stdin and stderr both terminals), `setup` first asks, on stderr:
+
+1. Every Run a Merge run? (`merge.always`)
+2. Every Run first fast-forwards your checkout of the Base branch? (`launch.pull`)
+3. Run notifications? (`email.always`). If yes, the address (`email.to`), asked again until it has an `@`, then the sender (`email.from`).
+4. With notifications on and `RESEND_API_KEY` unset or empty, it prints the `export RESEND_API_KEY=...` line to add to your shell profile; the key is never written anywhere. With the key set, it offers to send a test email (default No), as `thirdshift email-test` does, once the file is written.
+
+Pressing Enter takes the default shown, which is the file's current value, or else the setting's default, and for the address the suggested email above. `logs.dir` is not asked about. The answers are written like everything else below: in place, keeping your comments. Ctrl-C during the questions writes nothing. With no terminal, as from cron or `thirdshift setup ` is a Merge run, and `thirdshift --no-merge ` (or `no-merge`, before or after the URL) leaves that one Run's pull request ready for review.
diff --git a/src/config.rs b/src/config.rs
index 63bc61d27..f6c62beed 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -10,9 +10,10 @@ use toml::{Table, Value};
use toml_edit::{DocumentMut, Item};
use crate::git::Git;
-use crate::github;
use crate::notification::NotificationAsk;
+use crate::questions::{self, Answers};
use crate::run::Goal;
+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.
@@ -136,38 +137,185 @@ impl EmailSettings {
}
}
-/// Setup with no terminal: write the User config with every setting at its
-/// default, asking nothing, and `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 values, comments and key order stay, and each key it
-/// lacks is added at its default, so Setup never resets a configured machine.
+/// 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 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. Nothing is written until the
+/// last answer is in. A test email, if the user asked for one, goes once the
+/// User config is written, as `email-test` sends it.
pub fn setup() -> Result {
let (home, path) = home_and_path()?;
- let text = match std::fs::read_to_string(&path) {
- Ok(text) => text,
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
- std::fs::create_dir_all(home.join(".thirdshift"))
- .and_then(|()| std::fs::write(&path, with_email_to(suggested_address(&home))))
- .with_context(|| format!("can't write {}", path.display()))?;
- return Ok(format!("wrote the User config {}", path.display()));
- }
+ 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()));
}
};
- UserConfig::parse(&text, &path, &home)?;
- let completed = complete(&text)?;
- if completed == text {
- return Ok(format!(
+ let mut text = match &existing {
+ Some(text) => {
+ UserConfig::parse(text, &path, &home)?;
+ complete(text)?
+ }
+ None => with_email_to(suggested_address(&home)),
+ };
+ let asked = questions::has_terminal();
+ let mut send_test = false;
+ if asked {
+ let current = UserConfig::parse(&text, &path, &home)?;
+ let answers = questions::ask(¤t, || suggested_address(&home))?;
+ send_test = answers.send_test();
+ text = with_answers(&text, &answers)?;
+ }
+ let written = match &existing {
+ None => {
+ std::fs::create_dir_all(home.join(".thirdshift"))
+ .and_then(|()| std::fs::write(&path, &text))
+ .with_context(|| format!("can't write {}", path.display()))?;
+ 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()
+ )
+ }
+ }
+ };
+ if !send_test {
+ return Ok(written);
+ }
+ progress::step(written);
+ let config = UserConfig::load()?;
+ Ok(email::send_test(None, &config.email)?.to_string())
+}
+
+/// `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, "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);
+ }
+ 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.
+fn set(document: &mut DocumentMut, section: &str, key: &str, value: impl Into) {
+ let value = value.into();
+ let old = document[section]
+ .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(' ')
));
}
- replace(&path, &completed).with_context(|| format!("can't write {}", path.display()))?;
- Ok(format!(
- "added the missing settings to the User config {}",
- path.display()
- ))
+ *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
@@ -270,6 +418,16 @@ fn decor_prefix(decor: &toml_edit::Decor) -> String {
.to_string()
}
+/// The text `decor` puts after a value: the spaces and comment that end its
+/// line.
+fn decor_suffix(decor: &toml_edit::Decor) -> String {
+ decor
+ .suffix()
+ .and_then(|suffix| suffix.as_str())
+ .unwrap_or("")
+ .to_string()
+}
+
/// Whether the `[email]` section of `text` holds a commented-out `to` line.
fn has_commented_out_email_to(text: &str) -> bool {
let mut in_email = false;
@@ -277,19 +435,21 @@ fn has_commented_out_email_to(text: &str) -> bool {
if let Some(header) = line.strip_prefix('[') {
let header = header.split('#').next().unwrap_or("").trim_end();
in_email = header.strip_suffix(']').map(str::trim) == Some("email");
- } else if in_email && let Some(comment) = line.strip_prefix('#') {
- let comment = comment.trim_start();
- if comment
- .strip_prefix("to")
- .is_some_and(|rest| rest.trim_start().starts_with('='))
- {
- return true;
- }
+ } else if in_email && is_commented_out_email_to(line) {
+ return true;
}
}
false
}
+/// Whether `line`, in the `[email]` section, is a commented-out `to` line.
+fn is_commented_out_email_to(line: &str) -> bool {
+ line.trim()
+ .strip_prefix('#')
+ .and_then(|comment| comment.trim_start().strip_prefix("to"))
+ .is_some_and(|rest| rest.trim_start().starts_with('='))
+}
+
/// 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.
@@ -586,4 +746,52 @@ mod tests {
let completed = completed("[email]\nto = \"me@example.com\"\n");
assert!(!completed.contains("# to ="), "{completed}");
}
+
+ fn notifications_to(to: &str) -> Answers {
+ Answers {
+ merge_always: false,
+ launch_pull: false,
+ notifications: Some(questions::Notifications {
+ to: to.to_string(),
+ from: crate::email::DEFAULT_FROM.to_string(),
+ 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/main.rs b/src/main.rs
index 311a4a353..db81d40d8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,6 +17,7 @@ mod progress;
mod prompt;
#[cfg(test)]
mod prompts_page;
+mod questions;
mod run;
mod session;
mod spec_run;
@@ -39,7 +40,7 @@ usage: thirdshift Run the factory on the issue, from th
thirdshift --no-merge Run the factory on the issue and leave its pull request for review
thirdshift --email Run the factory on the issue, then email how the Run ended
thirdshift email-test [] Send a test email through Resend, to check the email setup
- thirdshift setup Write the User config, with every setting and its default
+ thirdshift setup Choose your defaults, then write the User config with every setting
thirdshift update Update thirdshift to the latest release
thirdshift version Print thirdshift's version
thirdshift help Print this help
@@ -51,7 +52,7 @@ review, merged, failed or interrupted. --email sends it to ;
after --email is the address only if it has an @ and isn't a URL.
The User config, ~/.thirdshift/config.toml, sets defaults for every Run on this machine;
-thirdshift setup writes one listing every setting at its default, to edit.
+thirdshift setup asks for your defaults and writes one listing every setting, to edit.
With merge.always set, every Run is a Merge run unless given --no-merge:
[merge]
diff --git a/src/questions.rs b/src/questions.rs
new file mode 100644
index 000000000..e840bee2b
--- /dev/null
+++ b/src/questions.rs
@@ -0,0 +1,150 @@
+//! 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.
+
+use std::io::{BufRead, IsTerminal, Write};
+
+use anyhow::{Result, bail};
+
+use crate::config::UserConfig;
+use crate::email::DEFAULT_FROM;
+
+/// What the user chose.
+pub struct Answers {
+ /// `merge.always`.
+ pub merge_always: bool,
+ /// `launch.pull`.
+ pub launch_pull: bool,
+ /// With Run notifications on, `email.always`, their settings; with them
+ /// off, `None`, and the email settings stay as they were.
+ pub notifications: Option,
+}
+
+impl Answers {
+ /// Whether the user asked for a test email.
+ pub fn send_test(&self) -> bool {
+ self.notifications
+ .as_ref()
+ .is_some_and(|notifications| notifications.send_test)
+ }
+}
+
+/// The settings of Run notifications, when the user wants them.
+pub struct Notifications {
+ /// `email.to`.
+ pub to: String,
+ /// `email.from`.
+ pub from: String,
+ /// Send a test email once the User config is written.
+ 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()
+}
+
+/// Ask the Setup questions, with the settings in `current` as the default
+/// answers, and `suggested_address` for `email.to` when `current` has none.
+/// The address is re-asked until it has an `@`. Ends in an error if stdin
+/// closes before the last answer.
+pub fn ask(
+ current: &UserConfig,
+ suggested_address: impl FnOnce() -> Option,
+) -> Result {
+ let merge_always = yes_or_no("Every Run a Merge run?", current.merge_always)?;
+ let launch_pull = yes_or_no(
+ "Every Run first fast-forwards your checkout of the Base branch?",
+ current.launch_pull,
+ )?;
+ if !yes_or_no(
+ "Run notifications, an email as each Run ends?",
+ current.email.always,
+ )? {
+ return Ok(Answers {
+ merge_always,
+ launch_pull,
+ notifications: None,
+ });
+ }
+ let suggested = current.email.to.clone().or_else(suggested_address);
+ let to = loop {
+ let to = answer("Send Run notifications to", suggested.as_deref())?;
+ if to.contains('@') {
+ break to;
+ }
+ };
+ let from = answer(
+ "Send them from",
+ Some(current.email.from.as_deref().unwrap_or(DEFAULT_FROM)),
+ )?;
+ let has_key = std::env::var("RESEND_API_KEY").is_ok_and(|key| !key.is_empty());
+ let send_test = if has_key {
+ yes_or_no("Send a test email now?", false)?
+ } else {
+ say(
+ "RESEND_API_KEY is unset or empty, so no email can go yet. Add this line to your shell \
+ profile, with your Resend API key:\n\n export RESEND_API_KEY=re_...\n",
+ );
+ false
+ };
+ Ok(Answers {
+ merge_always,
+ launch_pull,
+ notifications: Some(Notifications {
+ to,
+ from,
+ send_test,
+ }),
+ })
+}
+
+/// Ask `question` until the answer is yes, no or nothing, which is `default`.
+fn yes_or_no(question: &str, default: bool) -> Result {
+ let choices = if default { "[Y/n]" } else { "[y/N]" };
+ loop {
+ match read(&format!("{question} {choices} "))?
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "" => return Ok(default),
+ "y" | "yes" => return Ok(true),
+ "n" | "no" => return Ok(false),
+ _ => {}
+ }
+ }
+}
+
+/// 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 {
+ let prompt = match default {
+ Some(default) => format!("{question} [{default}]: "),
+ None => format!("{question}: "),
+ };
+ loop {
+ match (read(&prompt)?, default) {
+ (answer, _) if !answer.is_empty() => return Ok(answer),
+ (_, Some(default)) => return Ok(default.to_string()),
+ (_, None) => {}
+ }
+ }
+}
+
+/// 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}");
+}
diff --git a/tests/setup.rs b/tests/setup.rs
index 38c0c5d7f..aac70a1d1 100644
--- a/tests/setup.rs
+++ b/tests/setup.rs
@@ -2,12 +2,15 @@
//! 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.
mod support;
use std::fs;
-use support::Scenario;
+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
/// `main`.
@@ -453,3 +456,383 @@ fn setup_never_replaces_an_existing_email_to() {
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 MERGE: &str = "Merge run?";
+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_HINT: &str = "export RESEND_API_KEY=";
+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.
+fn setup_on_terminal(
+ scenario: &Scenario,
+ env: &[(&str, &str)],
+ keystrokes: &[Keystrokes],
+) -> TerminalResult {
+ let result = scenario.run_on_terminal(&["setup"], env, keystrokes);
+ assert_eq!(result.code, Some(0), "terminal: {}", result.stderr);
+ assert_eq!(result.stdout, "");
+ result
+}
+
+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();
+ unattended.git_email_is(None);
+ assert_eq!(unattended.run(&["setup"]).code, Some(0));
+ let scenario = Scenario::new();
+ scenario.git_email_is(None);
+
+ let result = setup_on_terminal(&scenario, &[], &[(MERGE, ""), (PULL, ""), (NOTIFY, "")]);
+
+ assert_eq!(result.user_config, user_config(&unattended));
+ for prompt in [MERGE, PULL, NOTIFY] {
+ assert!(
+ result.stderr.contains(prompt),
+ "terminal: {}",
+ result.stderr
+ );
+ }
+}
+
+#[test]
+fn on_a_terminal_the_answers_are_written_with_the_comments_on_each_key() {
+ let scenario = Scenario::new();
+ scenario.git_email_is(None);
+
+ let result = setup_on_terminal(
+ &scenario,
+ &[("RESEND_API_KEY", KEY)],
+ &[
+ (MERGE, "y"),
+ (PULL, "yes"),
+ (NOTIFY, "y"),
+ (TO, "me@example.com"),
+ (FROM, "ts@acme.dev"),
+ (TEST_EMAIL, ""),
+ ],
+ );
+
+ let config = table(&result);
+ assert_eq!(config["merge"]["always"].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",
+ "launch.pull",
+ "email.always",
+ "email.to",
+ "email.from",
+ "logs.dir"
+ ],
+ "{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}");
+ assert!(
+ !result.stderr.contains(KEY_HINT),
+ "terminal: {}",
+ result.stderr
+ );
+}
+
+#[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,
+ &[],
+ &[(MERGE, ""), (PULL, ""), (NOTIFY, "y"), (TO, ""), (FROM, "")],
+ );
+
+ 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
+
+[launch]
+pull = true
+
+[email]
+always = true
+to = \"mine@example.net\"
+from = \"ts@acme.dev\"
+
+[logs]
+dir = \"/var/log/thirdshift\"
+";
+ scenario.user_config_is(mine);
+
+ let result = setup_on_terminal(
+ &scenario,
+ &[("RESEND_API_KEY", KEY)],
+ &[
+ (MERGE, ""),
+ (PULL, ""),
+ (NOTIFY, ""),
+ (TO, ""),
+ (FROM, ""),
+ (TEST_EMAIL, ""),
+ ],
+ );
+
+ assert_eq!(result.user_config.as_deref(), Some(mine));
+ for current in ["[Y/n]", "mine@example.net", "ts@acme.dev"] {
+ 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,
+ &[],
+ &[
+ (MERGE, "y"),
+ (PULL, ""),
+ (NOTIFY, "y"),
+ (TO, "me@example.com"),
+ (FROM, ""),
+ ],
+ );
+
+ 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,
+ &[],
+ &[
+ (MERGE, ""),
+ (PULL, ""),
+ (NOTIFY, "y"),
+ (TO, ""),
+ (TO, "me.example.com"),
+ (TO, "me@example.com"),
+ (FROM, ""),
+ ],
+ );
+
+ 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 result = setup_on_terminal(
+ &scenario,
+ &[("THIRDSHIFT_RESEND_URL", resend.url())],
+ &[(MERGE, ""), (PULL, ""), (NOTIFY, "n")],
+ );
+
+ for asked in [TO, FROM, KEY_HINT, 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_the_key_hint_shows_only_when_resend_api_key_is_unset_or_empty() {
+ for (key, hinted) in [(None, true), (Some(""), true), (Some(KEY), false)] {
+ let scenario = Scenario::new();
+ let env: Vec<(&str, &str)> = key.map(|key| ("RESEND_API_KEY", key)).into_iter().collect();
+ let mut keystrokes = vec![
+ (MERGE, ""),
+ (PULL, ""),
+ (NOTIFY, "y"),
+ (TO, "me@example.com"),
+ (FROM, ""),
+ ];
+ if !hinted {
+ keystrokes.push((TEST_EMAIL, "n"));
+ }
+
+ let result = setup_on_terminal(&scenario, &env, &keystrokes);
+
+ assert_eq!(
+ result.stderr.contains(KEY_HINT),
+ hinted,
+ "{key:?}: {}",
+ result.stderr
+ );
+ assert_eq!(result.stderr.contains(TEST_EMAIL), !hinted, "{key:?}");
+ assert!(!result.user_config.unwrap().contains(KEY));
+ assert!(!result.stderr.contains(KEY), "{key:?}: {}", result.stderr);
+ }
+}
+
+#[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()),
+ ],
+ &[
+ (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"], &[], &[(MERGE, "y"), (PULL, CTRL_C)]);
+
+ assert_ne!(result.code, Some(0), "terminal: {}", result.stderr);
+ assert_eq!(result.stdout, "");
+ assert_eq!(result.user_config, None);
+}
+
+#[test]
+fn ctrl_c_during_the_questions_leaves_an_existing_user_config_unchanged() {
+ let scenario = Scenario::new();
+ let mine = "[merge]\nalways = true # mine\n";
+ scenario.user_config_is(mine);
+
+ let result = scenario.run_on_terminal(
+ &["setup"],
+ &[],
+ &[(MERGE, "n"), (PULL, "y"), (NOTIFY, "y"), (TO, CTRL_C)],
+ );
+
+ 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));
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index ede78a0d5..ab5793d2c 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -51,6 +51,27 @@ pub struct Scenario {
root: PathBuf,
}
+/// What typing into thirdshift on a terminal does: wait until the terminal
+/// shows `prompt`, then type `line` and Enter, or with [`CTRL_C`] press
+/// Ctrl-C instead.
+pub type Keystrokes<'a> = (&'a str, &'a str);
+
+/// A `line` of [`Keystrokes`] that presses Ctrl-C.
+pub const CTRL_C: &str = "\x03";
+
+/// How a command run on a terminal ended.
+pub struct TerminalResult {
+ /// What it wrote to stdout, which is not the terminal.
+ pub stdout: String,
+ /// Everything the terminal showed, stderr and the echoed keystrokes,
+ /// with the terminal's `\r\n` as `\n`.
+ pub stderr: String,
+ /// `None` if a signal ended it.
+ pub code: Option,
+ /// The User config it left, if any.
+ pub user_config: Option,
+}
+
pub struct RunResult {
pub stdout: String,
pub stderr: String,
@@ -212,6 +233,108 @@ impl Scenario {
child.wait_with_output().unwrap().into()
}
+ /// Run thirdshift with stdin and stderr on a pseudo-terminal, as from an
+ /// interactive shell, typing `keystrokes` in order. stdout is a pipe, so
+ /// what it prints there stays apart from the terminal.
+ pub fn run_on_terminal(
+ &self,
+ args: &[&str],
+ env: &[(&str, &str)],
+ keystrokes: &[Keystrokes],
+ ) -> TerminalResult {
+ use std::io::{Read, Write};
+ use std::os::fd::{FromRawFd, OwnedFd};
+ use std::os::unix::process::CommandExt;
+ use std::sync::{Arc, Mutex};
+
+ let (mut master, slave) = {
+ let (mut master, mut slave) = (0, 0);
+ let opened = unsafe {
+ libc::openpty(
+ &mut master,
+ &mut slave,
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ )
+ };
+ assert_eq!(opened, 0, "openpty: {}", std::io::Error::last_os_error());
+ unsafe { (fs::File::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }
+ };
+ let mut command = self.command(args);
+ command
+ .envs(env.iter().copied())
+ .stdin(Stdio::from(slave.try_clone().unwrap()))
+ .stderr(Stdio::from(slave))
+ .stdout(Stdio::piped());
+ // The terminal becomes the child's controlling terminal, so Ctrl-C
+ // on it sends SIGINT, as in a shell.
+ unsafe {
+ command.pre_exec(|| {
+ if libc::setsid() == -1 || libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ let mut child = command.spawn().unwrap();
+ // Dropping the command closes this process's copies of the terminal,
+ // so reading it ends once the child has gone.
+ drop(command);
+
+ let shown = Arc::new(Mutex::new(Vec::new()));
+ let reader = {
+ let mut master = master.try_clone().unwrap();
+ let shown = Arc::clone(&shown);
+ std::thread::spawn(move || {
+ let mut buffer = [0; 4096];
+ // Linux ends a pseudo-terminal with EIO rather than EOF.
+ while let Ok(read @ 1..) = master.read(&mut buffer) {
+ shown.lock().unwrap().extend_from_slice(&buffer[..read]);
+ }
+ })
+ };
+ let stdout = {
+ let mut stdout = child.stdout.take().unwrap();
+ std::thread::spawn(move || {
+ let mut text = String::new();
+ stdout.read_to_string(&mut text).unwrap();
+ text
+ })
+ };
+ let shown_text = || String::from_utf8_lossy(&shown.lock().unwrap()).replace("\r\n", "\n");
+ let mut seen = 0;
+ for (prompt, line) in keystrokes {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ let text = shown_text();
+ if let Some(at) = text[seen..].find(prompt) {
+ seen += at + prompt.len();
+ break;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "the terminal never showed {prompt:?}; it shows:\n{text}"
+ );
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ if *line == CTRL_C {
+ master.write_all(line.as_bytes()).unwrap();
+ } else {
+ master.write_all(format!("{line}\n").as_bytes()).unwrap();
+ }
+ }
+ let status = child.wait().unwrap();
+ let stdout = stdout.join().unwrap();
+ reader.join().unwrap();
+ TerminalResult {
+ stdout,
+ stderr: shown_text(),
+ code: status.code(),
+ user_config: fs::read_to_string(self.path("home/.thirdshift/config.toml")).ok(),
+ }
+ }
+
fn command(&self, args: &[&str]) -> Command {
let path = format!(
"{}:{}",