Move status rendering and prompt parsing into the library
Deleting the "y" | "yes" arm made satisfy silently never upgrade, and
no test caught it because the logic sat in the binary. main.rs is now
I/O only and excluded from mutants the way bin/coverage already ignores
it.

Assisted-by: Claude Opus 5 via Claude Code
change pyxxouutrvumoposulrwxytnyoywtmuy
commit f06cda1c8a89b9c0ddad5446aae07f39739df456
author Alpha Chen <alpha@kejadlen.dev>
date
parent zxkpypwu
diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
index f44584c..c876203 100644
--- a/.cargo/mutants.toml
+++ b/.cargo/mutants.toml
@@ -14,3 +14,10 @@ exclude_re = [
     "impl AssertionType for BrewBundle<R>>::status",
     "impl AssertionType for BrewBundle<R>>::install",
 ]
+
+# main.rs is I/O only — argument parsing, println!, and the stdin prompt.
+# "Function does nothing" cannot be caught there without capturing stdout, so
+# it is excluded here the same way bin/coverage ignores it. Anything with a
+# decision in it belongs in a library module instead; report.rs holds the
+# wording and the prompt parsing for exactly this reason.
+exclude_globs = ["frork-cli/src/main.rs"]
diff --git a/frork-cli/src/lib.rs b/frork-cli/src/lib.rs
index da66d2c..29b4f15 100644
--- a/frork-cli/src/lib.rs
+++ b/frork-cli/src/lib.rs
@@ -14,5 +14,6 @@
 pub mod assertions;
 pub mod error;
 pub mod registry;
+pub mod report;
 pub mod runtime;
 pub mod utils;
diff --git a/frork-cli/src/main.rs b/frork-cli/src/main.rs
index 15727e7..f61611b 100644
--- a/frork-cli/src/main.rs
+++ b/frork-cli/src/main.rs
@@ -4,6 +4,7 @@ use clap::Subcommand;
 use clap_complete::Shell;
 use frork_cli::assertions::AssertionType;
 use frork_cli::assertions::Status;
+use frork_cli::report;
 use frork_cli::runtime::run_code;
 use frork_cli::runtime::run_script;
 use miette::IntoDiagnostic as _;
@@ -64,53 +65,48 @@ fn run(command: &Commands) -> Result<()> {
 }
 
 fn status(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
-    match status {
-        Status::Ok => println!("ok: {}", assertion),
-        Status::Missing => println!("missing: {}", assertion),
-        // TODO: show a nicer diff?
-        Status::ConflictUpgrade(conflict) => {
-            println!("conflict (upgradable): {}", assertion);
-            println!("  expected: {}", conflict.expected);
-            println!("    actual: {}", conflict.actual);
-        }
-    }
+    print_lines(&report::render(status, assertion));
     Ok(())
 }
 
 fn satisfy(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
+    print_lines(&report::render(status, assertion));
+
     match status {
-        Status::Ok => println!("ok: {}", assertion),
+        Status::Ok => {}
         Status::Missing => {
-            println!("missing: {}", assertion);
             assertion.install()?;
-            println!("ok: {}", assertion);
+            println!("ok: {assertion}");
         }
-        Status::ConflictUpgrade(conflict) => {
-            println!("conflict (upgradable): {}", assertion);
-            println!("  expected: {}", conflict.expected);
-            println!("    actual: {}", conflict.actual);
-
-            use std::io::Write as _;
-            print!("Upgrade? [y/N]: ");
-            std::io::stdout()
-                .flush()
-                .map_err(|e| miette!("Failed to flush stdout: {e}"))?;
-
-            let mut input = String::new();
-            std::io::stdin()
-                .read_line(&mut input)
-                .map_err(|e| miette!("Failed to read input: {e}"))?;
-
-            match input.trim().to_lowercase().as_str() {
-                "y" | "yes" => {
-                    assertion.upgrade()?;
-                    println!("ok: {}", assertion);
-                }
-                _ => {
-                    println!("skipped: {}", assertion);
-                }
+        Status::ConflictUpgrade(_) => {
+            if report::wants_upgrade(&prompt("Upgrade? [y/N]: ")?) {
+                assertion.upgrade()?;
+                println!("ok: {assertion}");
+            } else {
+                println!("skipped: {assertion}");
             }
         }
     }
     Ok(())
 }
+
+fn print_lines(lines: &[String]) {
+    for line in lines {
+        println!("{line}");
+    }
+}
+
+fn prompt(question: &str) -> Result<String> {
+    use std::io::Write as _;
+
+    print!("{question}");
+    std::io::stdout()
+        .flush()
+        .map_err(|e| miette!("Failed to flush stdout: {e}"))?;
+
+    let mut input = String::new();
+    std::io::stdin()
+        .read_line(&mut input)
+        .map_err(|e| miette!("Failed to read input: {e}"))?;
+    Ok(input)
+}
diff --git a/frork-cli/src/report.rs b/frork-cli/src/report.rs
new file mode 100644
index 0000000..f4a715e
--- /dev/null
+++ b/frork-cli/src/report.rs
@@ -0,0 +1,80 @@
+use crate::assertions::AssertionType;
+use crate::assertions::Status;
+
+/// Renders an assertion's status as the lines the CLI prints. Kept apart from
+/// the printing itself so the wording is testable.
+pub fn render(status: &Status, assertion: &dyn AssertionType) -> Vec<String> {
+    match status {
+        Status::Ok => vec![format!("ok: {assertion}")],
+        Status::Missing => vec![format!("missing: {assertion}")],
+        // TODO: show a nicer diff?
+        Status::ConflictUpgrade(conflict) => vec![
+            format!("conflict (upgradable): {assertion}"),
+            format!("  expected: {}", conflict.expected),
+            format!("    actual: {}", conflict.actual),
+        ],
+    }
+}
+
+/// Whether a reply to the upgrade prompt means yes. Anything else declines,
+/// so an unrecognised answer never upgrades.
+pub fn wants_upgrade(input: &str) -> bool {
+    matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::assertions::Conflict;
+    use crate::utils::ExpandedPath;
+
+    fn directory() -> crate::assertions::Directory {
+        crate::assertions::Directory {
+            path: ExpandedPath::try_from("/tmp").unwrap(),
+        }
+    }
+
+    #[test]
+    fn test_render_ok() {
+        assert_eq!(render(&Status::Ok, &directory()), ["ok: directory /tmp"]);
+    }
+
+    #[test]
+    fn test_render_missing() {
+        assert_eq!(
+            render(&Status::Missing, &directory()),
+            ["missing: directory /tmp"]
+        );
+    }
+
+    #[test]
+    fn test_render_conflict_includes_both_sides() {
+        let status = Status::ConflictUpgrade(Conflict {
+            expected: "a".to_string(),
+            actual: "b".to_string(),
+        });
+
+        assert_eq!(
+            render(&status, &directory()),
+            [
+                "conflict (upgradable): directory /tmp",
+                "  expected: a",
+                "    actual: b",
+            ]
+        );
+    }
+
+    #[test]
+    fn test_wants_upgrade_accepts_y_and_yes() {
+        for input in ["y", "yes", "Y", "YES", "  yes  ", "Yes\n"] {
+            assert!(wants_upgrade(input), "expected {input:?} to upgrade");
+        }
+    }
+
+    #[test]
+    fn test_wants_upgrade_declines_everything_else() {
+        for input in ["", "n", "no", "ye", "yess", "yep", "1", "true"] {
+            assert!(!wants_upgrade(input), "expected {input:?} to decline");
+        }
+    }
+}