From 59ae40aac5e6907f39585903269b12818903f1a6 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 19:05:41 -0600 Subject: [PATCH] test(cli): the terminal helpers quote each word (a macOS root has a space) The portable macOS job: the command line handed to `script` was the words joined by spaces, and `Library/Application Support/wanderbooks` split into two arguments ("unknown argument: Support/wanderbooks"). Each word is single-quoted now, on every OS; a word that is a shell operator a test means as one (the consent test's `>`) stays bare. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/format_upgrade.rs | 26 ++++++++++++++++++---- crates/ledger-cli/tests/open_while_held.rs | 19 +++++++++++++++- crates/ledger-cli/tests/setup.rs | 26 ++++++++++++++++++---- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index b5a9dba..c5b5d44 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -139,10 +139,11 @@ impl Home { /// asked for (a prompt ending ": " or "] "): its exit code and what /// the terminal showed. fn at_terminal(&self, words: &[String], lines: &[&str]) -> (i32, String) { - let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) - .chain(words.iter().cloned()) - .collect::>() - .join(" "); + let command = shell_join( + &std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) + .chain(words.iter().cloned()) + .collect::>(), + ); let mut child = Command::new("script") .args(script_args(&command)) .env_clear() @@ -985,3 +986,20 @@ fn script_args(command: &str) -> Vec { }; args.iter().map(|a| a.to_string()).collect() } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index d1275c8..8bb5c28 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -1944,7 +1944,7 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() { args.push(a.to_string()); } let mut child = Command::new("script") - .args(script_args(&args.join(" "))) + .args(script_args(&shell_join(&args))) .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", home.dir.path()) @@ -2960,3 +2960,20 @@ fn script_args(command: &str) -> Vec { }; args.iter().map(|a| a.to_string()).collect() } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/ledger-cli/tests/setup.rs b/crates/ledger-cli/tests/setup.rs index 8e2dddb..e7f5868 100644 --- a/crates/ledger-cli/tests/setup.rs +++ b/crates/ledger-cli/tests/setup.rs @@ -176,10 +176,11 @@ fn script_args(command: &str) -> Vec { /// showed. fn at_terminal(args: &[String], lines: &[&str]) -> (i32, String) { use std::io::Write; - let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) - .chain(args.iter().cloned()) - .collect::>() - .join(" "); + let command = shell_join( + &std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) + .chain(args.iter().cloned()) + .collect::>(), + ); let mut child = Command::new("script") .args(script_args(&command)) .env_remove("XDG_DATA_HOME") @@ -323,3 +324,20 @@ fn the_passphrase_is_typed_by_a_person_and_changed_only_with_the_one_in_force() let kept = std::fs::read_to_string(auth.join("owner.json")).unwrap(); assert!(!kept.contains("staple"), "the text itself was kept"); } + +/// `words` as one shell command line, each word single-quoted (a macOS +/// root has a space in it: `Library/Application Support`), except a +/// word that is a shell operator a test means as one (`>`, `2>&1`, `|`). +fn shell_join(words: &[String]) -> String { + words + .iter() + .map(|w| { + if matches!(w.as_str(), ">" | ">>" | "<" | "|" | "2>&1") { + w.clone() + } else { + format!("'{}'", w.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +}