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
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");
+ }
+ }
+}