diff --git a/src/cli.rs b/src/cli.rs index 6060b37..a0b1b41 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,6 +14,7 @@ use crate::pagerduty::PagerDutyCommand; use crate::pipeline::PipelineCommand; use crate::read::ReadArgs; use crate::sentry::SentryCommand; +use crate::setup::SetupCommand; use crate::shell::ShellCommand; use crate::slack::SlackCommands; use crate::utils::UtilsCommand; @@ -129,4 +130,10 @@ pub enum Command { #[command(subcommand)] cmd: Option, }, + + /// Universal fresh-host bootstrap (packages, dotfiles, ssh) + Setup { + #[command(subcommand)] + cmd: Option, + }, } diff --git a/src/main.rs b/src/main.rs index 795ef4a..56ab888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod pagerduty; mod pipeline; mod read; mod sentry; +mod setup; mod shell; mod slack; mod util; @@ -138,6 +139,12 @@ async fn run_command(cmd: Command) -> anyhow::Result<()> { Command::Mcp { cmd: None } => { print_subcommand_help("mcp")?; } + Command::Setup { cmd: Some(cmd) } => { + return setup::run_command(cmd).await; + } + Command::Setup { cmd: None } => { + print_subcommand_help("setup")?; + } } Ok(()) } diff --git a/src/setup/bootstrap.rs b/src/setup/bootstrap.rs new file mode 100644 index 0000000..5a5ffb2 --- /dev/null +++ b/src/setup/bootstrap.rs @@ -0,0 +1,239 @@ +//! T0 bootstrap — make the host capable of running the installers. +//! +//! On macOS this just verifies `brew` is on PATH. On Linux we additionally +//! ensure the apt prereqs that linuxbrew needs (`build-essential`, `curl`, +//! `git`, `procps`, `file`). +//! +//! Per doctrine §9: every step is `check → skip-or-act → re-verify`. Per §1: +//! Shell chokepoint covers all I/O, no extra trait wrappers per binary. + +// reason: bootstrap functions wired by Phase 1 chunk 1.3 (`hu setup pkgs`) +// and Phase 5 (`hu setup run`). Tests cover the surface now. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::os::Os; +use crate::setup::packages::InstallResult; +use crate::util::shell::Shell; + +/// Official Homebrew installer command. +/// +/// Runs the upstream install script via `bash -c "$(curl …)"`. NONINTERACTIVE +/// flag skips the press-RETURN prompt the script normally requires. +pub(crate) const BREW_INSTALL: &str = "NONINTERACTIVE=1 /bin/bash -c \ + \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""; + +/// Linuxbrew apt prereqs (per the official Homebrew on Linux requirements). +pub const LINUXBREW_APT_PREREQS: &[&str] = &["build-essential", "curl", "git", "procps", "file"]; + +/// Ensure Homebrew is installed. Idempotent. +/// +/// On macOS: checks `which brew`, installs via the upstream script if missing. +/// On Linux: same flow; the caller is responsible for ensuring apt prereqs +/// first via [`ensure_linuxbrew_prereqs`]. +pub async fn ensure_brew(shell: &S) -> InstallResult { + if shell.which("brew").await { + return InstallResult::already("brew"); + } + let result = shell.run("bash", &["-c", BREW_INSTALL]).await; + match result { + Ok(out) if out.is_success() => {} + Ok(out) => { + return InstallResult::failed( + "brew", + &format!( + "brew install script exited {:?}: {}", + out.status.code(), + out.stderr.trim() + ), + ); + } + Err(e) => { + return InstallResult::failed("brew", &format!("brew install failed: {}", e)); + } + } + if shell.which("brew").await { + InstallResult::installed("brew") + } else { + InstallResult::failed( + "brew", + "install reported success but `which brew` still fails", + ) + } +} + +/// Ensure linuxbrew apt prereqs are installed. Idempotent. Skips on non-Linux. +pub async fn ensure_linuxbrew_prereqs(shell: &S, os: &Os) -> Vec { + if !os.is_linux() { + return vec![InstallResult::skipped( + "linuxbrew-prereqs", + "not on linux — skipped", + )]; + } + let mut results = Vec::with_capacity(LINUXBREW_APT_PREREQS.len()); + for pkg in LINUXBREW_APT_PREREQS { + results.push(ensure_apt_pkg(shell, pkg).await); + } + results +} + +/// Ensure one apt package is installed via `dpkg -s` check + `apt-get install`. +async fn ensure_apt_pkg(shell: &S, pkg: &str) -> InstallResult { + if apt_check(shell, pkg).await { + return InstallResult::already(pkg); + } + if let Err(e) = apt_install(shell, pkg).await { + return InstallResult::failed(pkg, &format!("apt-get install failed: {}", e)); + } + if apt_check(shell, pkg).await { + InstallResult::installed(pkg) + } else { + InstallResult::failed(pkg, "install reported success but dpkg -s still fails") + } +} + +async fn apt_check(shell: &S, pkg: &str) -> bool { + match shell.run("dpkg", &["-s", pkg]).await { + Ok(out) => out.is_success(), + Err(_) => false, + } +} + +async fn apt_install(shell: &S, pkg: &str) -> Result<()> { + let out = shell + .run("sudo", &["apt-get", "install", "-y", pkg]) + .await?; + if !out.is_success() { + anyhow::bail!( + "apt-get install -y {} exited {:?}: {}", + pkg, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::types::Status; + use crate::util::shell::FakeShell; + + #[tokio::test] + async fn ensure_brew_skips_when_already_present() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Already); + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn ensure_brew_installs_when_missing_and_re_verifies_green() { + let shell = FakeShell::new(); + shell.expect_sequence( + "which", + &["brew"], + &[("", 1), ("/opt/homebrew/bin/brew\n", 0)], + ); + shell.expect("bash", &["-c", BREW_INSTALL], "Homebrew installed\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Installed); + // 3 calls: check, install, re-check + assert_eq!(shell.calls().len(), 3); + } + + #[tokio::test] + async fn ensure_brew_marks_failed_when_install_lies() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "", 1); + shell.expect("bash", &["-c", BREW_INSTALL], "Homebrew installed\n", 0); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Failed); + assert!(r.note.contains("install reported success")); + } + + #[tokio::test] + async fn ensure_brew_marks_failed_when_install_errors() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "", 1); + shell.expect("bash", &["-c", BREW_INSTALL], "", 1); + let r = ensure_brew(&shell).await; + assert_eq!(r.status, Status::Failed); + assert!(r.note.contains("brew install script exited")); + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_skips_on_macos() { + let shell = FakeShell::new(); + let results = ensure_linuxbrew_prereqs(&shell, &Os::Mac).await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].status, Status::Skipped); + // No shell calls — short-circuited + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_returns_one_result_per_pkg_on_linux() { + let shell = FakeShell::new(); + for pkg in LINUXBREW_APT_PREREQS { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results.len(), LINUXBREW_APT_PREREQS.len()); + for r in &results { + assert_eq!(r.status, Status::Already); + } + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_installs_missing_pkg() { + let shell = FakeShell::new(); + // first prereq missing → install path; subsequent ones present + let first = LINUXBREW_APT_PREREQS[0]; + shell.expect_sequence( + "dpkg", + &["-s", first], + &[("", 1), ("Status: install ok installed\n", 0)], + ); + shell.expect("sudo", &["apt-get", "install", "-y", first], "ok\n", 0); + for pkg in &LINUXBREW_APT_PREREQS[1..] { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results[0].status, Status::Installed); + for r in &results[1..] { + assert_eq!(r.status, Status::Already); + } + } + + #[tokio::test] + async fn ensure_linuxbrew_prereqs_marks_failed_when_apt_fails() { + let shell = FakeShell::new(); + let first = LINUXBREW_APT_PREREQS[0]; + shell.expect("dpkg", &["-s", first], "", 1); + shell.expect( + "sudo", + &["apt-get", "install", "-y", first], + "E: locked\n", + 1, + ); + for pkg in &LINUXBREW_APT_PREREQS[1..] { + shell.expect("dpkg", &["-s", pkg], "Status: install ok installed\n", 0); + } + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let results = ensure_linuxbrew_prereqs(&shell, &os).await; + assert_eq!(results[0].status, Status::Failed); + assert!(results[0].note.contains("apt-get install failed")); + } +} diff --git a/src/setup/cli.rs b/src/setup/cli.rs new file mode 100644 index 0000000..0ed4464 --- /dev/null +++ b/src/setup/cli.rs @@ -0,0 +1,137 @@ +//! CLI argument types for `hu setup`. + +use clap::{Args, Subcommand}; + +#[derive(Subcommand, Debug)] +pub enum SetupCommand { + /// Run full setup: packages, dotfiles, ssh + Run(RunArgs), + + /// Show what would be installed without making changes (alias of status) + Preview, + + /// Show package and config status with ✓/✗ icons + Status, + + /// Install/refresh packages only + Pkgs(PkgsArgs), + + /// Clone and apply dotfiles only + Dotfiles, + + /// Sync SSH keys from 1Password only + Ssh, + + /// Manage the setup config file + Config { + #[command(subcommand)] + cmd: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommand { + /// Write a default setup.toml to the config dir + Init, + + /// Print the resolved config path + Path, +} + +#[derive(Args, Debug, Default)] +pub struct RunArgs { + /// Restrict run to a single phase + #[arg(long, value_enum)] + pub only: Option, + + /// Print intended actions without executing + #[arg(long)] + pub dry_run: bool, + + /// Suppress interactive confirmations + #[arg(short, long)] + pub yes: bool, +} + +#[derive(Args, Debug, Default)] +pub struct PkgsArgs { + /// Comma-separated package names to install (default: all configured) + #[arg(long, value_delimiter = ',')] + pub only: Vec, + + /// Print intended actions without executing + #[arg(long)] + pub dry_run: bool, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum RunPhase { + Pkgs, + Dotfiles, + Ssh, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct TestCli { + #[command(subcommand)] + cmd: SetupCommand, + } + + #[test] + fn parses_status() { + let cli = TestCli::try_parse_from(["test", "status"]).unwrap(); + assert!(matches!(cli.cmd, SetupCommand::Status)); + } + + #[test] + fn parses_run_with_dry_run() { + let cli = TestCli::try_parse_from(["test", "run", "--dry-run"]).unwrap(); + match cli.cmd { + SetupCommand::Run(args) => { + assert!(args.dry_run); + assert!(!args.yes); + assert!(args.only.is_none()); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn parses_run_with_only_filter() { + let cli = TestCli::try_parse_from(["test", "run", "--only", "ssh", "--yes"]).unwrap(); + match cli.cmd { + SetupCommand::Run(args) => { + assert_eq!(args.only, Some(RunPhase::Ssh)); + assert!(args.yes); + } + _ => panic!("expected Run"), + } + } + + #[test] + fn parses_pkgs_with_only_csv() { + let cli = TestCli::try_parse_from(["test", "pkgs", "--only", "gh,jq,op"]).unwrap(); + match cli.cmd { + SetupCommand::Pkgs(args) => { + assert_eq!(args.only, vec!["gh", "jq", "op"]); + } + _ => panic!("expected Pkgs"), + } + } + + #[test] + fn parses_config_init() { + let cli = TestCli::try_parse_from(["test", "config", "init"]).unwrap(); + assert!(matches!( + cli.cmd, + SetupCommand::Config { + cmd: Some(ConfigCommand::Init) + } + )); + } +} diff --git a/src/setup/config.rs b/src/setup/config.rs new file mode 100644 index 0000000..55892b0 --- /dev/null +++ b/src/setup/config.rs @@ -0,0 +1,294 @@ +//! Config types and TOML loader for `hu setup`. +//! +//! Path resolution follows the existing `hu` convention via +//! `directories::ProjectDirs::from("", "", "hu")`: +//! +//! - macOS: `~/Library/Application Support/hu/setup.toml` +//! - Linux: `~/.config/hu/setup.toml` +//! +//! Per project doctrine §1 — TOML serialize/deserialize is unit-tested via +//! round-trip; `fs::write` and `fs::read_to_string` are I/O glue and use +//! `#[coverage(off)]` carve-outs. + +// reason: config types wired to `hu setup config init/path` (this chunk) and +// `hu setup status / pkgs / dotfiles / ssh` (Phases 1–4). Suppress dead_code +// until each consumer lands; serialize / deserialize is verified by tests now. +#![allow(dead_code)] + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +const CONFIG_FILENAME: &str = "setup.toml"; + +/// Top-level setup config. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SetupConfig { + #[serde(default)] + pub dotfiles: DotfilesConfig, + #[serde(default)] + pub ssh: SshConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub host: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DotfilesConfig { + pub repo: String, + pub branch: String, + pub clone_to: String, + pub strategy: String, + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshConfig { + pub op_vault: String, + pub op_items: Vec, + pub key_dir: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackagesConfig { + pub brew: Vec, + pub mise: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct HostOverride { + #[serde(default)] + pub brew_extra: Vec, + #[serde(default)] + pub mise_extra: Vec, +} + +impl Default for DotfilesConfig { + fn default() -> Self { + Self { + repo: "aladac/dotfiles".into(), + branch: "main".into(), + clone_to: "~/Projects/dotfiles".into(), + strategy: "stow".into(), + packages: vec![ + "zsh".into(), + "git".into(), + "kitty".into(), + "starship".into(), + "zellij".into(), + ], + } + } +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + op_vault: "Personal".into(), + op_items: vec!["SSH/id_ed25519".into()], + key_dir: "~/.ssh".into(), + } + } +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + brew: vec![ + "mise".into(), + "uv".into(), + "hf".into(), + "op".into(), + "gh".into(), + "jq".into(), + "stow".into(), + "starship".into(), + "zellij".into(), + "kitty".into(), + "kitten".into(), + "flarectl".into(), + "cloudflared".into(), + "hcloud".into(), + "postgresql".into(), + ], + mise: vec![ + "node@lts".into(), + "ruby@latest".into(), + "python@latest".into(), + "rust@latest".into(), + ], + } + } +} + +/// Serialize config to a TOML string. Pure function — testable without I/O. +pub fn serialize(config: &SetupConfig) -> Result { + toml::to_string_pretty(config).context("serialize SetupConfig to TOML") +} + +/// Deserialize config from a TOML string. Pure function — testable without I/O. +pub fn deserialize(raw: &str) -> Result { + toml::from_str(raw).context("parse setup.toml") +} + +/// Resolve the config path via `ProjectDirs`. Returns `None` if no usable +/// config directory exists for this platform. +pub fn config_path() -> Option { + ProjectDirs::from("", "", "hu").map(|dirs| dirs.config_dir().join(CONFIG_FILENAME)) +} + +/// Read the current setup.toml. Returns the default config when the file is +/// absent (so `hu setup status` works on a fresh host before `config init`). +pub fn load() -> Result { + let Some(path) = config_path() else { + return Ok(SetupConfig::default()); + }; + if !path.exists() { + return Ok(SetupConfig::default()); + } + let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + deserialize(&raw) +} + +/// Write the default config to disk if absent. Idempotent — returns the path +/// either way and an `existed` flag indicating whether a file already lived +/// there. +pub fn init_default() -> Result { + let path = config_path().context("could not resolve config directory for hu")?; + if path.exists() { + return Ok(InitOutcome { + path, + existed: true, + }); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create config dir {}", parent.display()))?; + } + let raw = serialize(&SetupConfig::default())?; + std::fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?; + Ok(InitOutcome { + path, + existed: false, + }) +} + +#[derive(Debug, Clone)] +pub struct InitOutcome { + pub path: PathBuf, + pub existed: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_dotfiles_repo_is_aladac() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.dotfiles.repo, "aladac/dotfiles"); + assert_eq!(cfg.dotfiles.strategy, "stow"); + } + + #[test] + fn default_brew_includes_mise_and_stow() { + let cfg = SetupConfig::default(); + assert!(cfg.packages.brew.contains(&"mise".to_string())); + assert!(cfg.packages.brew.contains(&"stow".to_string())); + assert!(cfg.packages.brew.contains(&"op".to_string())); + assert!(cfg.packages.brew.contains(&"gh".to_string())); + } + + #[test] + fn default_mise_includes_all_t2() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.packages.mise.len(), 4); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("node@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("ruby@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("python@"))); + assert!(cfg.packages.mise.iter().any(|p| p.starts_with("rust@"))); + } + + #[test] + fn default_ssh_uses_op_with_personal_vault() { + let cfg = SetupConfig::default(); + assert_eq!(cfg.ssh.op_vault, "Personal"); + assert_eq!(cfg.ssh.key_dir, "~/.ssh"); + assert_eq!(cfg.ssh.op_items, vec!["SSH/id_ed25519"]); + } + + #[test] + fn round_trips_default_config() { + let cfg = SetupConfig::default(); + let raw = serialize(&cfg).unwrap(); + let parsed = deserialize(&raw).unwrap(); + assert_eq!(parsed, cfg); + } + + #[test] + fn round_trips_with_host_override() { + let mut cfg = SetupConfig::default(); + cfg.host.insert( + "marauder".to_string(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let raw = serialize(&cfg).unwrap(); + let parsed = deserialize(&raw).unwrap(); + assert_eq!(parsed, cfg); + assert_eq!( + parsed.host.get("marauder").unwrap().brew_extra, + vec!["nvtop"] + ); + } + + #[test] + fn deserialize_accepts_minimal_toml() { + let raw = r#" +[dotfiles] +repo = "x/y" +branch = "main" +clone_to = "~/dot" +strategy = "stow" +packages = ["zsh"] + +[ssh] +op_vault = "V" +op_items = ["a"] +key_dir = "~/.ssh" + +[packages] +brew = ["gh"] +mise = ["node@lts"] +"#; + let cfg = deserialize(raw).unwrap(); + assert_eq!(cfg.dotfiles.repo, "x/y"); + assert_eq!(cfg.packages.brew, vec!["gh"]); + assert!(cfg.host.is_empty()); + } + + #[test] + fn deserialize_rejects_invalid_toml() { + let err = deserialize("not = valid = toml").unwrap_err(); + assert!(err.to_string().contains("setup.toml")); + } + + #[test] + fn config_path_returns_setup_toml() { + let path = config_path().expect("ProjectDirs should resolve on test platform"); + assert_eq!(path.file_name().unwrap().to_str().unwrap(), CONFIG_FILENAME); + } + + #[test] + fn host_override_default_is_empty() { + let h = HostOverride::default(); + assert!(h.brew_extra.is_empty()); + assert!(h.mise_extra.is_empty()); + } +} diff --git a/src/setup/display.rs b/src/setup/display.rs new file mode 100644 index 0000000..bd0679a --- /dev/null +++ b/src/setup/display.rs @@ -0,0 +1,136 @@ +//! Status table rendering for `hu setup status`. +//! +//! Pure-function renderer: takes a `Vec` and produces a string +//! using `comfy_table` with the project-standard `UTF8_FULL_CONDENSED` preset. +//! Tested without I/O via snapshot-style equality on the rendered output. + +// reason: render is invoked only by `hu setup status` (this chunk) and `preview`. +// Tests cover the rendered output directly. +#![allow(dead_code)] + +use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, Table}; + +use crate::setup::types::Status; + +/// One row of the status table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusRow { + pub category: String, + pub name: String, + pub status: Status, + pub note: String, +} + +impl StatusRow { + pub fn new(category: &str, name: &str, status: Status) -> Self { + Self { + category: category.to_string(), + name: name.to_string(), + status, + note: String::new(), + } + } + + pub fn with_note(mut self, note: &str) -> Self { + self.note = note.to_string(); + self + } +} + +/// Render a status table to a string. +pub fn render(rows: &[StatusRow]) -> String { + let mut table = Table::new(); + table.load_preset(UTF8_FULL_CONDENSED); + table.set_header(vec!["", "Category", "Name", "Note"]); + for row in rows { + let icon_cell = Cell::new(row.status.icon()).fg(status_color(row.status)); + table.add_row(vec![ + icon_cell, + Cell::new(&row.category), + Cell::new(&row.name), + Cell::new(&row.note), + ]); + } + table.to_string() +} + +/// Summary line: "X/Y satisfied". +pub fn summary(rows: &[StatusRow]) -> String { + let total = rows.len(); + let satisfied = rows.iter().filter(|r| r.status.is_satisfied()).count(); + format!("{}/{} satisfied", satisfied, total) +} + +fn status_color(s: Status) -> Color { + match s { + Status::Already | Status::Installed => Color::Green, + Status::Skipped => Color::Yellow, + Status::Failed => Color::Red, + Status::Unknown => Color::DarkGrey, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_empty_table() { + let out = render(&[]); + // header still present + assert!(out.contains("Category")); + assert!(out.contains("Name")); + } + + #[test] + fn renders_single_row_with_icon() { + let rows = vec![StatusRow::new("brew", "gh", Status::Already)]; + let out = render(&rows); + assert!(out.contains("brew")); + assert!(out.contains("gh")); + assert!(out.contains("✓")); + } + + #[test] + fn renders_failed_with_x_icon() { + let rows = vec![StatusRow::new("brew", "missing", Status::Failed)]; + let out = render(&rows); + assert!(out.contains("✗")); + } + + #[test] + fn summary_counts_satisfied_only() { + let rows = vec![ + StatusRow::new("brew", "a", Status::Already), + StatusRow::new("brew", "b", Status::Installed), + StatusRow::new("brew", "c", Status::Failed), + StatusRow::new("brew", "d", Status::Unknown), + ]; + assert_eq!(summary(&rows), "2/4 satisfied"); + } + + #[test] + fn summary_handles_empty() { + assert_eq!(summary(&[]), "0/0 satisfied"); + } + + #[test] + fn with_note_attaches_string() { + let row = StatusRow::new("ssh", "id_ed25519", Status::Already).with_note("chmod 600"); + assert_eq!(row.note, "chmod 600"); + } + + #[test] + fn unknown_status_renders_open_circle() { + let rows = vec![StatusRow::new("brew", "?", Status::Unknown)]; + let out = render(&rows); + assert!(out.contains("○")); + } + + #[test] + fn skipped_status_renders_half_circle() { + let rows = vec![StatusRow::new("brew", "x", Status::Skipped)]; + let out = render(&rows); + assert!(out.contains("◐")); + } +} diff --git a/src/setup/dotfiles.rs b/src/setup/dotfiles.rs new file mode 100644 index 0000000..884aa28 --- /dev/null +++ b/src/setup/dotfiles.rs @@ -0,0 +1,352 @@ +//! Dotfiles phase — clone (or refresh) + stow apply. +//! +//! Phase 3 splits into two responsibilities: +//! 1. **Clone** — fetch the dotfiles repo via `gh repo clone `, +//! or detect an existing clone and skip. +//! 2. **Apply** — for each configured package directory, run +//! `stow -R -d -t ~ ` (restow is idempotent: re-creates +//! symlinks even if they already exist, handles partial state cleanly). +//! +//! Both steps go through the [`Shell`] chokepoint. No `Installer` trait +//! needed — this is a single-shot orchestration, not a registry walk. + +// reason: dotfiles flow wired by `hu setup dotfiles` (chunk 3.2) and +// `setup run` (Phase 5). Tests cover the surface. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use crate::setup::config::DotfilesConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Resolve a `~`-prefixed path to an absolute path. +/// +/// Pure-function alternative to `shellexpand` — keeps the dependency surface +/// thin. `$HOME` lookup is the only env touch and only happens for paths +/// that actually start with `~/`. +pub fn expand_tilde(raw: &str) -> PathBuf { + if let Some(rest) = raw.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(raw) +} + +/// True when `/.git` exists. Used to detect an already-cloned dotfiles +/// repo so we can skip the clone step. +pub fn is_git_repo(dir: &Path) -> bool { + dir.join(".git").exists() +} + +/// Idempotent clone of the dotfiles repo. If `/.git` already +/// exists, returns `Status::Already`. Otherwise runs `gh repo clone`. +pub async fn ensure_clone(shell: &S, config: &DotfilesConfig) -> StatusRow { + let dest = expand_tilde(&config.clone_to); + if is_git_repo(&dest) { + return StatusRow::new("dotfiles", &config.repo, Status::Already) + .with_note(&format!("clone exists: {}", dest.display())); + } + let dest_str = dest.to_string_lossy().into_owned(); + let result = shell + .run("gh", &["repo", "clone", &config.repo, &dest_str]) + .await; + match result { + Ok(out) if out.is_success() => { + // Re-verify the clone landed. + if is_git_repo(&dest) { + StatusRow::new("dotfiles", &config.repo, Status::Installed) + .with_note(&format!("cloned to {}", dest.display())) + } else { + StatusRow::new("dotfiles", &config.repo, Status::Failed) + .with_note("gh clone reported success but .git/ is missing") + } + } + Ok(out) => StatusRow::new("dotfiles", &config.repo, Status::Failed).with_note(&format!( + "gh clone failed (exit {:?}): {}", + out.status.code(), + out.stderr.trim() + )), + Err(e) => StatusRow::new("dotfiles", &config.repo, Status::Failed) + .with_note(&format!("gh clone errored: {}", e)), + } +} + +/// Apply one stow package: `stow -R -d -t ~ `. +/// +/// `-R` (restow) is idempotent — recreates symlinks each invocation, handles +/// partial state cleanly. Conflicts (existing non-symlink files in the way) +/// surface as exit-nonzero with stderr describing the path. +pub async fn stow_apply( + shell: &S, + clone_to: &str, + target: &str, + package: &str, +) -> StatusRow { + let result = shell + .run("stow", &["-R", "-d", clone_to, "-t", target, package]) + .await; + match result { + Ok(out) if out.is_success() => StatusRow::new("stow", package, Status::Installed) + .with_note(&format!("stowed → {}", target)), + Ok(out) => { + let note = parse_stow_conflicts(&out.stderr); + StatusRow::new("stow", package, Status::Failed).with_note(¬e) + } + Err(e) => StatusRow::new("stow", package, Status::Failed) + .with_note(&format!("stow errored: {}", e)), + } +} + +/// Orchestrate the full dotfiles phase: clone (or skip) → stow each package. +/// +/// Returns one `StatusRow` per step. If the clone fails the per-package +/// stow rows are skipped — there's nothing to stow from. +pub async fn run(shell: &S, config: &DotfilesConfig) -> Vec { + let mut rows = Vec::with_capacity(1 + config.packages.len()); + let clone_row = ensure_clone(shell, config).await; + let clone_satisfied = clone_row.status.is_satisfied(); + rows.push(clone_row); + if !clone_satisfied { + for pkg in &config.packages { + rows.push( + StatusRow::new("stow", pkg, Status::Skipped) + .with_note("clone failed — nothing to stow"), + ); + } + return rows; + } + let target = expand_tilde("~/").to_string_lossy().into_owned(); + let clone_to = expand_tilde(&config.clone_to) + .to_string_lossy() + .into_owned(); + for pkg in &config.packages { + rows.push(stow_apply(shell, &clone_to, &target, pkg).await); + } + rows +} + +/// Extract a concise conflict summary from stow stderr. +/// +/// stow's stderr lists conflicting files line by line. We keep the gist +/// (first conflicting path + count) without dumping the whole blob into a +/// table cell. +pub fn parse_stow_conflicts(stderr: &str) -> String { + let conflicts: Vec<&str> = stderr + .lines() + .filter(|l| { + l.contains("existing target") + || l.contains("CONFLICT") + || l.contains("would cause conflicts") + }) + .collect(); + if conflicts.is_empty() { + return format!("stow failed: {}", stderr.trim()); + } + let first = conflicts.first().copied().unwrap_or_default(); + if conflicts.len() == 1 { + first.trim().to_string() + } else { + format!("{} (+{} more conflicts)", first.trim(), conflicts.len() - 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn dotfiles_config() -> DotfilesConfig { + DotfilesConfig { + repo: "aladac/dotfiles".into(), + branch: "main".into(), + clone_to: "/tmp/hu-test-dotfiles".into(), + strategy: "stow".into(), + packages: vec!["zsh".into(), "kitty".into()], + } + } + + #[test] + fn expand_tilde_replaces_with_home() { + let original = std::env::var("HOME").ok(); + std::env::set_var("HOME", "/Users/test"); + let path = expand_tilde("~/Projects/dotfiles"); + assert_eq!(path, PathBuf::from("/Users/test/Projects/dotfiles")); + if let Some(h) = original { + std::env::set_var("HOME", h); + } + } + + #[test] + fn expand_tilde_passes_through_non_tilde() { + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!( + expand_tilde("relative/path"), + PathBuf::from("relative/path") + ); + } + + #[test] + fn is_git_repo_detects_dot_git_dir() { + let temp = std::env::temp_dir().join("hu-is-git-repo-test"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + assert!(is_git_repo(&temp)); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[test] + fn is_git_repo_returns_false_when_missing() { + assert!(!is_git_repo(Path::new("/nonexistent/directory"))); + } + + #[tokio::test] + async fn ensure_clone_skips_when_already_cloned() { + let temp = std::env::temp_dir().join("hu-ensure-clone-skip"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + let mut config = dotfiles_config(); + config.clone_to = temp.to_string_lossy().into_owned(); + let shell = FakeShell::new(); + let row = ensure_clone(&shell, &config).await; + assert_eq!(row.status, Status::Already); + assert!(shell.calls().is_empty()); + std::fs::remove_dir_all(&temp).unwrap(); + } + + #[tokio::test] + async fn ensure_clone_failed_when_gh_errors() { + let mut config = dotfiles_config(); + config.clone_to = "/tmp/hu-ensure-clone-fail-XXXX".into(); + let _ = std::fs::remove_dir_all(&config.clone_to); + let shell = FakeShell::new(); + shell.expect( + "gh", + &[ + "repo", + "clone", + "aladac/dotfiles", + "/tmp/hu-ensure-clone-fail-XXXX", + ], + "", + 1, + ); + let row = ensure_clone(&shell, &config).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("gh clone failed")); + } + + #[tokio::test] + async fn stow_apply_marks_installed_on_success() { + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", "/clone", "-t", "/home/u", "zsh"], + "", + 0, + ); + let row = stow_apply(&shell, "/clone", "/home/u", "zsh").await; + assert_eq!(row.status, Status::Installed); + assert!(row.note.contains("stowed")); + } + + #[tokio::test] + async fn stow_apply_marks_failed_with_conflict_summary() { + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", "/clone", "-t", "/home/u", "zsh"], + "", + 1, + ); + // FakeShell always returns empty stderr on expect; stuff a conflict via raw stdin + // — we test parse_stow_conflicts directly below to cover the parsing branch. + let row = stow_apply(&shell, "/clone", "/home/u", "zsh").await; + assert_eq!(row.status, Status::Failed); + } + + #[test] + fn parse_stow_conflicts_summarizes_one_conflict() { + let stderr = "existing target is not owned by stow: .zshrc\n"; + assert_eq!(parse_stow_conflicts(stderr), stderr.trim()); + } + + #[test] + fn parse_stow_conflicts_counts_extras() { + let stderr = "\ +existing target is not owned by stow: .zshrc +existing target is not owned by stow: .gitconfig +existing target is not owned by stow: .vimrc +"; + let summary = parse_stow_conflicts(stderr); + assert!(summary.contains(".zshrc")); + assert!(summary.contains("+2 more")); + } + + #[test] + fn parse_stow_conflicts_falls_back_when_no_keyword() { + let stderr = "stow: command not found\n"; + let summary = parse_stow_conflicts(stderr); + assert!(summary.starts_with("stow failed:")); + } + + #[tokio::test] + async fn run_skips_stow_when_clone_fails() { + let mut config = dotfiles_config(); + config.clone_to = "/tmp/hu-run-clone-fails".into(); + let _ = std::fs::remove_dir_all(&config.clone_to); + let shell = FakeShell::new(); + shell.expect( + "gh", + &[ + "repo", + "clone", + "aladac/dotfiles", + "/tmp/hu-run-clone-fails", + ], + "", + 1, + ); + let rows = run(&shell, &config).await; + assert_eq!(rows.len(), 1 + config.packages.len()); + assert_eq!(rows[0].status, Status::Failed); + for stow_row in &rows[1..] { + assert_eq!(stow_row.status, Status::Skipped); + assert!(stow_row.note.contains("clone failed")); + } + } + + #[tokio::test] + async fn run_stows_each_configured_package_when_clone_present() { + let temp = std::env::temp_dir().join("hu-run-clone-present"); + let _ = std::fs::remove_dir_all(&temp); + std::fs::create_dir_all(temp.join(".git")).unwrap(); + let mut config = dotfiles_config(); + config.clone_to = temp.to_string_lossy().into_owned(); + config.packages = vec!["zsh".into(), "kitty".into()]; + + let target = expand_tilde("~/").to_string_lossy().into_owned(); + let clone_to = temp.to_string_lossy().into_owned(); + let shell = FakeShell::new(); + shell.expect( + "stow", + &["-R", "-d", &clone_to, "-t", &target, "zsh"], + "", + 0, + ); + shell.expect( + "stow", + &["-R", "-d", &clone_to, "-t", &target, "kitty"], + "", + 0, + ); + let rows = run(&shell, &config).await; + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].status, Status::Already); + assert_eq!(rows[1].status, Status::Installed); + assert_eq!(rows[2].status, Status::Installed); + std::fs::remove_dir_all(&temp).unwrap(); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 0000000..fb2e473 --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,186 @@ +//! `hu setup` — universal fresh-host bootstrap. +//! +//! Runs on a clean macOS or Linux host and converges the system to the +//! configured desired state: packages, dotfiles, SSH keys. +//! +//! Each step follows the idempotency contract `check → skip-or-act → re-verify`. + +mod bootstrap; +mod cli; +mod config; +mod display; +mod dotfiles; +mod os; +mod packages; +mod pkgs; +mod post; +mod run; +mod ssh; +mod status; +mod types; + +pub use cli::SetupCommand; + +use anyhow::{bail, Context, Result}; +use owo_colors::OwoColorize; + +use cli::ConfigCommand; +use os::Os; + +use crate::util::shell::RealShell; + +/// Dispatch entry point — called from `main.rs`. +pub async fn run_command(cmd: SetupCommand) -> Result<()> { + match cmd { + SetupCommand::Status | SetupCommand::Preview => run_status().await, + SetupCommand::Run(args) => run_full(args).await, + SetupCommand::Pkgs(args) => run_pkgs(args).await, + SetupCommand::Dotfiles => run_dotfiles().await, + SetupCommand::Ssh => run_ssh().await, + SetupCommand::Config { cmd } => run_config(cmd).await, + } +} + +async fn run_config(cmd: Option) -> Result<()> { + let Some(cmd) = cmd else { + // Default action: show path + return show_config_path(); + }; + match cmd { + ConfigCommand::Init => init_config(), + ConfigCommand::Path => show_config_path(), + } +} + +fn init_config() -> Result<()> { + let outcome = config::init_default().context("init setup.toml")?; + if outcome.existed { + println!( + "{} setup.toml already exists at {}", + "◐".yellow(), + outcome.path.display() + ); + } else { + println!( + "{} wrote default setup.toml to {}", + "✓".green(), + outcome.path.display() + ); + } + Ok(()) +} + +async fn run_status() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + let rows = status::collect(&shell, &cfg).await?; + println!("{} host: {}", "◆".cyan(), os.label()); + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + Ok(()) +} + +async fn run_full(args: cli::RunArgs) -> Result<()> { + let os = Os::detect()?; + let cfg_base = config::load().context("load setup.toml")?; + let hostname = run::current_hostname(); + let cfg = run::apply_host_overrides(cfg_base, &hostname); + let shell = RealShell; + let op = ssh::RealOp::new(&shell); + println!("{} host: {} ({})", "◆".cyan(), os.label(), hostname); + if args.dry_run { + println!("{} dry-run — no changes will be made", "◐".yellow()); + } + if let Some(phase) = &args.only { + println!("{} only: {:?}", "◆".cyan(), phase); + } + let rows = run::run_full(&shell, &op, &cfg, &args, &os).await?; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("setup run had failures — see table above"); + } + Ok(()) +} + +async fn run_ssh() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + let op = ssh::RealOp::new(&shell); + println!("{} host: {}", "◆".cyan(), os.label()); + println!( + "{} ssh: vault={} items={}", + "◆".cyan(), + cfg.ssh.op_vault, + cfg.ssh.op_items.len() + ); + let rows = ssh::run(&op, &cfg.ssh).await; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("ssh phase had failures — see table above"); + } + Ok(()) +} + +async fn run_dotfiles() -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + println!("{} host: {}", "◆".cyan(), os.label()); + println!( + "{} dotfiles: {} → {}", + "◆".cyan(), + cfg.dotfiles.repo, + cfg.dotfiles.clone_to + ); + let rows = dotfiles::run(&shell, &cfg.dotfiles).await; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("dotfiles phase had failures — see table above"); + } + Ok(()) +} + +async fn run_pkgs(args: cli::PkgsArgs) -> Result<()> { + let os = Os::detect()?; + let cfg = config::load().context("load setup.toml")?; + let shell = RealShell; + println!("{} host: {}", "◆".cyan(), os.label()); + if args.dry_run { + println!("{} dry-run — no changes will be made", "◐".yellow()); + } + let rows = pkgs::run(&shell, &cfg, &args, &os).await?; + println!("{}", display::render(&rows)); + println!("{}", display::summary(&rows)); + let any_failed = rows.iter().any(|r| r.status == types::Status::Failed); + if any_failed { + bail!("one or more packages failed — see table above"); + } + Ok(()) +} + +fn show_config_path() -> Result<()> { + match config::config_path() { + Some(path) => { + let exists = path.exists(); + let icon = if exists { + "✓".green().to_string() + } else { + "○".dimmed().to_string() + }; + println!("{} {}", icon, path.display()); + if !exists { + println!(" (not yet created — run `hu setup config init`)"); + } + Ok(()) + } + None => bail!("could not resolve config directory for hu on this platform"), + } +} diff --git a/src/setup/os.rs b/src/setup/os.rs new file mode 100644 index 0000000..00a0590 --- /dev/null +++ b/src/setup/os.rs @@ -0,0 +1,172 @@ +//! Operating system detection for the setup module. +//! +//! Detects macOS via `cfg!(target_os)` and Linux distro via parsing +//! `/etc/os-release`. Distro string is lowercased for stable matching. + +// reason: items used by Phase 0 chunk 0.4 (status table) and Phase 1+ (per-OS install paths). +// Tests cover them now; suppress dead_code until the first runtime caller wires in. +#![allow(dead_code)] + +use std::path::Path; + +use anyhow::Result; + +/// Detected host operating system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Os { + Mac, + Linux { distro: String }, + Other { name: String }, +} + +impl Os { + /// Detect the current host's OS. + /// + /// On macOS this is a constant. On Linux it reads `/etc/os-release` and + /// extracts the `ID=` field. Falls back to `Os::Other` for unknown + /// platforms. + pub fn detect() -> Result { + if cfg!(target_os = "macos") { + return Ok(Os::Mac); + } + if cfg!(target_os = "linux") { + let raw = std::fs::read_to_string("/etc/os-release") + .unwrap_or_else(|_| String::from("ID=linux")); + return Ok(Os::Linux { + distro: parse_os_release_id(&raw), + }); + } + Ok(Os::Other { + name: std::env::consts::OS.to_string(), + }) + } + + /// Short label for status tables. + pub fn label(&self) -> String { + match self { + Os::Mac => "macOS".to_string(), + Os::Linux { distro } => format!("linux ({})", distro), + Os::Other { name } => name.clone(), + } + } + + /// True when the host is macOS. + pub fn is_macos(&self) -> bool { + matches!(self, Os::Mac) + } + + /// True when the host is any Linux distro. + pub fn is_linux(&self) -> bool { + matches!(self, Os::Linux { .. }) + } +} + +/// Parse the `ID=` value out of an `/etc/os-release` blob. +/// +/// Strips surrounding quotes and lowercases. Returns `"linux"` as a safe +/// fallback when no `ID=` line is present. +pub fn parse_os_release_id(raw: &str) -> String { + raw.lines() + .find_map(|line| line.strip_prefix("ID=")) + .map(strip_quotes) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "linux".to_string()) +} + +fn strip_quotes(s: &str) -> String { + let trimmed = s.trim(); + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + trimmed[1..trimmed.len() - 1].to_string() + } else { + trimmed.to_string() + } +} + +/// Whether `/etc/os-release` exists on disk (used by integration tests). +pub fn has_os_release_file() -> bool { + Path::new("/etc/os-release").exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ubuntu_id() { + let raw = r#"NAME="Ubuntu" +ID=ubuntu +VERSION_ID="24.04" +"#; + assert_eq!(parse_os_release_id(raw), "ubuntu"); + } + + #[test] + fn parses_quoted_id() { + let raw = "ID=\"debian\"\n"; + assert_eq!(parse_os_release_id(raw), "debian"); + } + + #[test] + fn parses_single_quoted_id() { + let raw = "ID='arch'\n"; + assert_eq!(parse_os_release_id(raw), "arch"); + } + + #[test] + fn lowercases_id() { + let raw = "ID=Fedora\n"; + assert_eq!(parse_os_release_id(raw), "fedora"); + } + + #[test] + fn falls_back_when_no_id() { + let raw = "NAME=Foo\nVERSION=1.0\n"; + assert_eq!(parse_os_release_id(raw), "linux"); + } + + #[test] + fn empty_input_falls_back() { + assert_eq!(parse_os_release_id(""), "linux"); + } + + #[test] + fn label_for_macos() { + assert_eq!(Os::Mac.label(), "macOS"); + } + + #[test] + fn label_for_linux_includes_distro() { + let os = Os::Linux { + distro: "ubuntu".into(), + }; + assert_eq!(os.label(), "linux (ubuntu)"); + } + + #[test] + fn label_for_other() { + let os = Os::Other { + name: "freebsd".into(), + }; + assert_eq!(os.label(), "freebsd"); + } + + #[test] + fn predicates_match_variant() { + assert!(Os::Mac.is_macos()); + assert!(!Os::Mac.is_linux()); + let linux = Os::Linux { + distro: "ubuntu".into(), + }; + assert!(linux.is_linux()); + assert!(!linux.is_macos()); + } + + #[test] + fn detect_returns_a_known_variant() { + let os = Os::detect().expect("detect should not fail on supported hosts"); + // We don't assert the specific variant — runs across mac + linux CI. + assert!(os.is_macos() || os.is_linux() || matches!(os, Os::Other { .. })); + } +} diff --git a/src/setup/packages.rs b/src/setup/packages.rs new file mode 100644 index 0000000..054196b --- /dev/null +++ b/src/setup/packages.rs @@ -0,0 +1,370 @@ +//! Installer trait + concrete implementations. +//! +//! The `Installer` trait is one of the two earned trait abstractions in +//! `hu setup` (per project doctrine §1 — ≥2 implementers exist or are likely): +//! +//! - [`BrewInstaller`] — wraps `brew list ` + `brew install ` +//! - `MiseInstaller` (Phase 2) — wraps `mise use -g ` +//! +//! Both use the [`Shell`] chokepoint for I/O. Installers consume `&impl Shell` +//! so callers stay generic (static dispatch). + +// reason: trait + impls wired by Phase 1 chunk 1.3 (`hu setup pkgs`) and +// Phase 5 (`hu setup run`). Tests cover the surface now. +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Outcome of an `Installer::ensure` call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallResult { + pub package: String, + pub status: Status, + pub note: String, +} + +impl InstallResult { + pub fn already(pkg: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Already, + note: "already present".into(), + } + } + + pub fn installed(pkg: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Installed, + note: "installed".into(), + } + } + + pub fn failed(pkg: &str, note: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Failed, + note: note.into(), + } + } + + pub fn skipped(pkg: &str, note: &str) -> Self { + Self { + package: pkg.to_string(), + status: Status::Skipped, + note: note.into(), + } + } +} + +/// A package installer for one delivery mechanism (brew, mise, apt, …). +#[async_trait] +pub trait Installer: Send + Sync { + /// Short id ("brew", "mise") for status reporting. + fn name(&self) -> &'static str; + + /// True when the package is currently installed. + async fn check(&self, shell: &S, package: &str) -> Result; + + /// Install the package. Implementations should be idempotent — calling + /// install on a present package should be a no-op. + async fn install(&self, shell: &S, package: &str) -> Result<()>; + + /// Idempotent ensure: `check → skip-or-install → re-verify`. + /// + /// This is the primary entry point. It enforces the doctrine §9 contract: + /// re-verifying after `install()` because exit 0 is not proof the side + /// effect happened. + async fn ensure(&self, shell: &S, package: &str) -> InstallResult { + match self.check(shell, package).await { + Ok(true) => return InstallResult::already(package), + Ok(false) => {} + Err(e) => return InstallResult::failed(package, &format!("check failed: {}", e)), + } + if let Err(e) = self.install(shell, package).await { + return InstallResult::failed(package, &format!("install failed: {}", e)); + } + match self.check(shell, package).await { + Ok(true) => InstallResult::installed(package), + Ok(false) => { + InstallResult::failed(package, "install reported success but check still fails") + } + Err(e) => InstallResult::failed(package, &format!("re-verify failed: {}", e)), + } + } +} + +/// Split a `lang@version` package id into `(lang, version)`. Defaults to +/// `latest` when no `@` is present. +pub fn split_lang_version(pkg: &str) -> (&str, &str) { + pkg.split_once('@').unwrap_or((pkg, "latest")) +} + +/// Homebrew installer. Works on macOS and Linux (linuxbrew). +pub struct BrewInstaller; + +#[async_trait] +impl Installer for BrewInstaller { + fn name(&self) -> &'static str { + "brew" + } + + async fn check(&self, shell: &S, package: &str) -> Result { + let out = shell + .run("brew", &["list", "--versions", package]) + .await + .with_context(|| format!("brew list {}", package))?; + Ok(out.is_success()) + } + + async fn install(&self, shell: &S, package: &str) -> Result<()> { + let out = shell + .run("brew", &["install", package]) + .await + .with_context(|| format!("brew install {}", package))?; + if !out.is_success() { + anyhow::bail!( + "brew install {} failed (exit {:?}): {}", + package, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) + } +} + +/// `mise` polyglot version manager. Manages node, ruby, python, rust, etc. +/// from a single tool — second concrete `Installer` impl that validates the +/// trait abstraction (per doctrine §1: trait earned when ≥2 implementers). +pub struct MiseInstaller; + +#[async_trait] +impl Installer for MiseInstaller { + fn name(&self) -> &'static str { + "mise" + } + + /// Check via `mise current `. Exit 0 + non-empty output means an + /// active version is set. We don't pin a specific version equality — + /// "any version of node managed by mise" satisfies a `node@lts` request, + /// because mise upgrades happen separately via `mise upgrade`. + async fn check(&self, shell: &S, package: &str) -> Result { + let (lang, _version) = split_lang_version(package); + let out = shell + .run("mise", &["current", lang]) + .await + .with_context(|| format!("mise current {}", lang))?; + Ok(out.is_success() && !out.stdout.trim().is_empty()) + } + + /// Install via `mise use -g `. Mise is idempotent itself, + /// but we still funnel through `ensure()` for the re-verify contract. + async fn install(&self, shell: &S, package: &str) -> Result<()> { + let out = shell + .run("mise", &["use", "-g", package]) + .await + .with_context(|| format!("mise use -g {}", package))?; + if !out.is_success() { + anyhow::bail!( + "mise use -g {} failed (exit {:?}): {}", + package, + out.status.code(), + out.stderr.trim() + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn brew() -> BrewInstaller { + BrewInstaller + } + + fn mise() -> MiseInstaller { + MiseInstaller + } + + #[tokio::test] + async fn name_is_brew() { + assert_eq!(brew().name(), "brew"); + } + + #[tokio::test] + async fn check_returns_true_when_brew_list_succeeds() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + assert!(brew().check(&shell, "gh").await.unwrap()); + } + + #[tokio::test] + async fn check_returns_false_when_brew_list_fails() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "missing"], "", 1); + assert!(!brew().check(&shell, "missing").await.unwrap()); + } + + #[tokio::test] + async fn install_succeeds_on_zero_exit() { + let shell = FakeShell::new(); + shell.expect("brew", &["install", "gh"], "Successfully installed gh\n", 0); + brew().install(&shell, "gh").await.unwrap(); + } + + #[tokio::test] + async fn install_errors_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("brew", &["install", "broken"], "", 1); + let err = brew().install(&shell, "broken").await.unwrap_err(); + assert!(err.to_string().contains("brew install broken failed")); + } + + #[tokio::test] + async fn ensure_skips_when_already_installed() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + let result = brew().ensure(&shell, "gh").await; + assert_eq!(result.status, Status::Already); + // exactly one call: just the check + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn ensure_marks_failed_when_install_lies() { + // Install reports success but post-install check still fails. + // Doctrine §9: re-verify catches lies — exit 0 ≠ side effect happened. + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "jq"], "", 1); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + let result = brew().ensure(&shell, "jq").await; + assert_eq!(result.status, Status::Failed); + assert!(result + .note + .contains("install reported success but check still fails")); + } + + #[tokio::test] + async fn ensure_installs_when_missing_then_re_verifies_green() { + // Happy path: first check fails, install succeeds, second check passes. + let shell = FakeShell::new(); + shell.expect_sequence( + "brew", + &["list", "--versions", "jq"], + &[("", 1), ("jq 1.7\n", 0)], + ); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + let result = brew().ensure(&shell, "jq").await; + assert_eq!(result.status, Status::Installed); + // Three calls: check → install → check + assert_eq!(shell.calls().len(), 3); + } + + #[tokio::test] + async fn ensure_marks_failed_when_install_errors() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "--versions", "broken"], "", 1); + shell.expect("brew", &["install", "broken"], "", 1); + let result = brew().ensure(&shell, "broken").await; + assert_eq!(result.status, Status::Failed); + assert!(result.note.contains("install failed")); + } + + #[test] + fn install_result_constructors() { + assert_eq!(InstallResult::already("x").status, Status::Already); + assert_eq!(InstallResult::installed("x").status, Status::Installed); + assert_eq!(InstallResult::failed("x", "boom").status, Status::Failed); + assert_eq!( + InstallResult::skipped("x", "filtered").status, + Status::Skipped + ); + } + + #[test] + fn split_lang_version_handles_versioned() { + assert_eq!(split_lang_version("node@lts"), ("node", "lts")); + assert_eq!(split_lang_version("rust@1.80"), ("rust", "1.80")); + assert_eq!(split_lang_version("python@latest"), ("python", "latest")); + } + + #[test] + fn split_lang_version_defaults_when_no_at() { + assert_eq!(split_lang_version("node"), ("node", "latest")); + assert_eq!(split_lang_version("ruby"), ("ruby", "latest")); + } + + #[tokio::test] + async fn mise_name_is_mise() { + assert_eq!(mise().name(), "mise"); + } + + #[tokio::test] + async fn mise_check_returns_true_on_active_version() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + assert!(mise().check(&shell, "node@lts").await.unwrap()); + } + + #[tokio::test] + async fn mise_check_returns_false_on_empty_stdout() { + // `mise current` can exit 0 with empty stdout when nothing is set + let shell = FakeShell::new(); + shell.expect("mise", &["current", "ruby"], "", 0); + assert!(!mise().check(&shell, "ruby@latest").await.unwrap()); + } + + #[tokio::test] + async fn mise_check_returns_false_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "rust"], "", 1); + assert!(!mise().check(&shell, "rust@latest").await.unwrap()); + } + + #[tokio::test] + async fn mise_install_runs_use_global() { + let shell = FakeShell::new(); + shell.expect( + "mise", + &["use", "-g", "node@lts"], + "installed node@20.10\n", + 0, + ); + mise().install(&shell, "node@lts").await.unwrap(); + } + + #[tokio::test] + async fn mise_install_errors_on_nonzero_exit() { + let shell = FakeShell::new(); + shell.expect("mise", &["use", "-g", "rust@nightly"], "", 1); + let err = mise().install(&shell, "rust@nightly").await.unwrap_err(); + assert!(err.to_string().contains("mise use -g rust@nightly failed")); + } + + #[tokio::test] + async fn mise_ensure_skips_when_already_present() { + let shell = FakeShell::new(); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + let r = mise().ensure(&shell, "node@lts").await; + assert_eq!(r.status, Status::Already); + assert_eq!(shell.calls().len(), 1); + } + + #[tokio::test] + async fn mise_ensure_installs_when_missing_re_verifies_green() { + let shell = FakeShell::new(); + shell.expect_sequence("mise", &["current", "rust"], &[("", 1), ("1.80.0\n", 0)]); + shell.expect("mise", &["use", "-g", "rust@latest"], "installed\n", 0); + let r = mise().ensure(&shell, "rust@latest").await; + assert_eq!(r.status, Status::Installed); + assert_eq!(shell.calls().len(), 3); + } +} diff --git a/src/setup/pkgs.rs b/src/setup/pkgs.rs new file mode 100644 index 0000000..a69c555 --- /dev/null +++ b/src/setup/pkgs.rs @@ -0,0 +1,311 @@ +//! Service layer for `hu setup pkgs`. +//! +//! Orchestrates T0 bootstrap (linuxbrew prereqs + brew) then walks the +//! configured brew package list, calling `BrewInstaller::ensure` per package. +//! Returns one `StatusRow` per step for the display module to render. + +// reason: collector wired by `hu setup pkgs` (this chunk) and `setup run` (Phase 5). +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::bootstrap::{ensure_brew, ensure_linuxbrew_prereqs}; +use crate::setup::cli::PkgsArgs; +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::os::Os; +use crate::setup::packages::{BrewInstaller, InstallResult, Installer, MiseInstaller}; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Plan + execute the brew package phase. +/// +/// Order of operations: +/// 1. Linuxbrew apt prereqs (skipped on macOS) +/// 2. T0: ensure brew itself is on PATH +/// 3. T1: ensure each filtered brew package is installed +/// +/// `--dry-run` short-circuits at step 1: every step reports `Skipped(dry-run)` +/// without touching the shell. +pub async fn run( + shell: &S, + config: &SetupConfig, + args: &PkgsArgs, + os: &Os, +) -> Result> { + let filtered_brew = filter_packages(&config.packages.brew, &args.only); + let filtered_mise = filter_packages(&config.packages.mise, &args.only); + + if args.dry_run { + return Ok(dry_run_rows(os, &filtered_brew, &filtered_mise)); + } + + let mut rows = Vec::new(); + + // 1. Linuxbrew prereqs (or skip on macOS) + for r in ensure_linuxbrew_prereqs(shell, os).await { + rows.push(install_result_to_row("linuxbrew-prereq", &r)); + } + + // 2. T0 brew bootstrap + let brew_result = ensure_brew(shell).await; + rows.push(install_result_to_row("bootstrap", &brew_result)); + if !brew_result.status.is_satisfied() { + // Brew missing → can't proceed with T1 / T2 packages (mise comes via brew). + return Ok(rows); + } + + // 3. T1 brew packages + let brew = BrewInstaller; + for pkg in &filtered_brew { + let r = brew.ensure(shell, pkg).await; + rows.push(install_result_to_row("brew", &r)); + } + + // 4. T2 mise-managed runtimes (only if mise itself is reachable) + if !filtered_mise.is_empty() && shell.which("mise").await { + let mise = MiseInstaller; + for pkg in &filtered_mise { + let r = mise.ensure(shell, pkg).await; + rows.push(install_result_to_row("mise", &r)); + } + } else if !filtered_mise.is_empty() { + // mise not on PATH yet — can happen when brew just installed it but + // shims aren't in this shell's environment. Surface as Failed so + // Pilot knows to re-run after rehashing PATH. + for pkg in &filtered_mise { + rows.push( + StatusRow::new("mise", pkg, Status::Failed) + .with_note("mise not on PATH — re-run after `eval \"$(mise activate)\"`"), + ); + } + } + + Ok(rows) +} + +/// Apply the `--only` filter to a package list (brew or mise). +/// +/// Empty filter → no filtering (all configured packages). Filter values that +/// don't match a configured package are silently dropped. Matches against the +/// full configured name (`node@lts` matches `node@lts`, not `node`). +pub fn filter_packages(configured: &[String], only: &[String]) -> Vec { + if only.is_empty() { + return configured.to_vec(); + } + configured + .iter() + .filter(|p| only.iter().any(|o| o == *p)) + .cloned() + .collect() +} + +fn dry_run_rows(os: &Os, brew: &[String], mise: &[String]) -> Vec { + let mut rows = Vec::new(); + if os.is_linux() { + for pkg in crate::setup::bootstrap::LINUXBREW_APT_PREREQS { + rows.push( + StatusRow::new("linuxbrew-prereq", pkg, Status::Skipped).with_note("dry-run"), + ); + } + } + rows.push(StatusRow::new("bootstrap", "brew", Status::Skipped).with_note("dry-run")); + for pkg in brew { + rows.push(StatusRow::new("brew", pkg, Status::Skipped).with_note("dry-run")); + } + for pkg in mise { + rows.push(StatusRow::new("mise", pkg, Status::Skipped).with_note("dry-run")); + } + rows +} + +fn install_result_to_row(category: &str, r: &InstallResult) -> StatusRow { + StatusRow::new(category, &r.package, r.status).with_note(&r.note) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::cli::PkgsArgs; + use crate::util::shell::FakeShell; + + fn args(only: Vec<&str>, dry_run: bool) -> PkgsArgs { + PkgsArgs { + only: only.into_iter().map(String::from).collect(), + dry_run, + } + } + + fn config_with_brew(pkgs: &[&str]) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = pkgs.iter().map(|s| s.to_string()).collect(); + cfg.packages.mise = vec![]; + cfg + } + + fn config_with_brew_and_mise(brew: &[&str], mise: &[&str]) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = brew.iter().map(|s| s.to_string()).collect(); + cfg.packages.mise = mise.iter().map(|s| s.to_string()).collect(); + cfg + } + + #[test] + fn filter_empty_returns_full_list() { + let configured = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let only: Vec = vec![]; + assert_eq!(filter_packages(&configured, &only), configured); + } + + #[test] + fn filter_keeps_matching_packages() { + let configured = vec!["gh".to_string(), "jq".to_string(), "op".to_string()]; + let only = vec!["gh".to_string(), "op".to_string()]; + assert_eq!(filter_packages(&configured, &only), vec!["gh", "op"]); + } + + #[test] + fn filter_drops_unconfigured_names() { + let configured = vec!["gh".to_string()]; + let only = vec!["gh".to_string(), "nonexistent".to_string()]; + assert_eq!(filter_packages(&configured, &only), vec!["gh"]); + } + + #[tokio::test] + async fn dry_run_on_macos_skips_prereqs() { + let shell = FakeShell::new(); + let cfg = config_with_brew(&["gh", "jq"]); + let rows = run(&shell, &cfg, &args(vec![], true), &Os::Mac) + .await + .unwrap(); + // No linuxbrew-prereq rows on macOS dry-run + assert!(!rows.iter().any(|r| r.category == "linuxbrew-prereq")); + // bootstrap + 2 brew packages + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r.status == Status::Skipped)); + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn dry_run_on_linux_includes_prereq_rows() { + let shell = FakeShell::new(); + let cfg = config_with_brew(&["gh"]); + let os = Os::Linux { + distro: "ubuntu".into(), + }; + let rows = run(&shell, &cfg, &args(vec![], true), &os).await.unwrap(); + let prereq_rows = rows + .iter() + .filter(|r| r.category == "linuxbrew-prereq") + .count(); + assert_eq!(prereq_rows, 5); // build-essential, curl, git, procps, file + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn run_skips_t1_when_brew_bootstrap_fails() { + let shell = FakeShell::new(); + // brew missing + install fails + shell.expect("which", &["brew"], "", 1); + shell.expect( + "bash", + &["-c", crate::setup::bootstrap::BREW_INSTALL], + "", + 1, + ); + let cfg = config_with_brew(&["gh"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + // bootstrap row marked Failed; no T1 rows added + let brew_rows = rows.iter().filter(|r| r.category == "brew").count(); + assert_eq!(brew_rows, 0); + let bootstrap_row = rows.iter().find(|r| r.category == "bootstrap").unwrap(); + assert_eq!(bootstrap_row.status, Status::Failed); + } + + #[tokio::test] + async fn run_walks_all_filtered_packages_when_brew_present() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + shell.expect("brew", &["list", "--versions", "jq"], "", 1); + shell.expect("brew", &["install", "jq"], "Successfully installed jq\n", 0); + shell.expect_sequence( + "brew", + &["list", "--versions", "jq"], + &[("", 1), ("jq 1.7\n", 0)], + ); + let cfg = config_with_brew(&["gh", "jq"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + let brew_rows: Vec<_> = rows.iter().filter(|r| r.category == "brew").collect(); + assert_eq!(brew_rows.len(), 2); + // gh already, jq installed + assert_eq!(brew_rows[0].name, "gh"); + assert_eq!(brew_rows[0].status, Status::Already); + assert_eq!(brew_rows[1].name, "jq"); + assert_eq!(brew_rows[1].status, Status::Installed); + } + + #[tokio::test] + async fn run_walks_brew_then_mise_when_both_configured() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + shell.expect("which", &["mise"], "/opt/homebrew/bin/mise\n", 0); + shell.expect("mise", &["current", "node"], "20.10.0\n", 0); + let cfg = config_with_brew_and_mise(&["gh"], &["node@lts"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + assert!(rows.iter().any(|r| r.category == "brew" && r.name == "gh")); + assert!(rows + .iter() + .any(|r| r.category == "mise" && r.name == "node@lts")); + let mise_row = rows.iter().find(|r| r.category == "mise").unwrap(); + assert_eq!(mise_row.status, Status::Already); + } + + #[tokio::test] + async fn run_marks_mise_failed_when_mise_not_on_path() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + // mise not yet on PATH + shell.expect("which", &["mise"], "", 1); + let cfg = config_with_brew_and_mise(&[], &["node@lts"]); + let rows = run(&shell, &cfg, &args(vec![], false), &Os::Mac) + .await + .unwrap(); + let mise_row = rows.iter().find(|r| r.category == "mise").unwrap(); + assert_eq!(mise_row.status, Status::Failed); + assert!(mise_row.note.contains("mise activate")); + } + + #[tokio::test] + async fn dry_run_includes_mise_rows() { + let shell = FakeShell::new(); + let cfg = config_with_brew_and_mise(&["gh"], &["node@lts", "rust@latest"]); + let rows = run(&shell, &cfg, &args(vec![], true), &Os::Mac) + .await + .unwrap(); + let mise_count = rows.iter().filter(|r| r.category == "mise").count(); + assert_eq!(mise_count, 2); + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn run_with_only_filter_walks_subset() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + shell.expect("brew", &["list", "--versions", "gh"], "gh 2.50.0\n", 0); + let cfg = config_with_brew(&["gh", "jq", "op"]); + let rows = run(&shell, &cfg, &args(vec!["gh"], false), &Os::Mac) + .await + .unwrap(); + let brew_rows: Vec<_> = rows.iter().filter(|r| r.category == "brew").collect(); + assert_eq!(brew_rows.len(), 1); + assert_eq!(brew_rows[0].name, "gh"); + } +} diff --git a/src/setup/post.rs b/src/setup/post.rs new file mode 100644 index 0000000..da51293 --- /dev/null +++ b/src/setup/post.rs @@ -0,0 +1,260 @@ +//! Phase 6 post-install checks. +//! +//! Runs after `pkgs → dotfiles → ssh` to verify the host is in a usable +//! state and start any services that need explicit kick. Each check +//! produces a `StatusRow`; nothing here is destructive. + +// reason: post checks called from `hu setup run` orchestrator. +#![allow(dead_code)] + +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Run all post-install checks. Currently: +/// - `brew services start postgresql` (only if configured + brew has it) +/// - `gh auth status` — read-only +/// - `ssh -T git@github.com` — smoke (exit 1 with greeting = success) +pub async fn run(shell: &S, config: &SetupConfig) -> Vec { + let mut rows = Vec::new(); + + if config.packages.brew.iter().any(|p| p == "postgresql") { + rows.push(start_postgres(shell).await); + } + rows.push(check_gh_auth(shell).await); + rows.push(smoke_github_ssh(shell).await); + + rows +} + +async fn start_postgres(shell: &S) -> StatusRow { + let out = shell + .run("brew", &["services", "info", "postgresql", "--json"]) + .await; + let already_running = match out { + Ok(out) if out.is_success() => { + out.stdout.contains("\"running\":true") || out.stdout.contains("\"running\": true") + } + _ => false, + }; + if already_running { + return StatusRow::new("post", "postgresql", Status::Already) + .with_note("brew service already running"); + } + match shell + .run("brew", &["services", "start", "postgresql"]) + .await + { + Ok(out) if out.is_success() => StatusRow::new("post", "postgresql", Status::Installed) + .with_note("brew service started"), + Ok(out) => StatusRow::new("post", "postgresql", Status::Failed).with_note(&format!( + "brew services start failed (exit {:?}): {}", + out.status.code(), + out.stderr.trim() + )), + Err(e) => StatusRow::new("post", "postgresql", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +async fn check_gh_auth(shell: &S) -> StatusRow { + match shell.run("gh", &["auth", "status"]).await { + Ok(out) if out.is_success() => { + StatusRow::new("post", "gh-auth", Status::Already).with_note("gh signed in") + } + Ok(_) => StatusRow::new("post", "gh-auth", Status::Failed) + .with_note("not signed in — run `gh auth login`"), + Err(e) => StatusRow::new("post", "gh-auth", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +async fn smoke_github_ssh(shell: &S) -> StatusRow { + // `ssh -T git@github.com` exits 1 with a greeting on success — odd but + // documented. We grep stderr for "successfully authenticated" instead. + match shell + .run( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + ) + .await + { + Ok(out) => { + let combined = format!("{}{}", out.stdout, out.stderr); + if combined.contains("successfully authenticated") { + StatusRow::new("post", "github-ssh", Status::Already) + .with_note("ssh -T git@github.com authenticated") + } else { + StatusRow::new("post", "github-ssh", Status::Failed).with_note(&format!( + "no auth confirmation (exit {:?})", + out.status.code() + )) + } + } + Err(e) => StatusRow::new("post", "github-ssh", Status::Failed) + .with_note(&format!("shell errored: {}", e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::shell::FakeShell; + + fn cfg_with_postgres(include: bool) -> SetupConfig { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = if include { + vec!["gh".into(), "postgresql".into()] + } else { + vec!["gh".into()] + }; + cfg + } + + #[tokio::test] + async fn run_skips_postgres_when_not_configured() { + let shell = FakeShell::new(); + // gh + ssh expected; postgres should NOT be invoked + shell.expect("gh", &["auth", "status"], "Logged in to github.com\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Hi user! You've successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(false); + let rows = run(&shell, &cfg).await; + assert_eq!(rows.len(), 2); + assert!(!rows.iter().any(|r| r.name == "postgresql")); + } + + #[tokio::test] + async fn run_starts_postgres_when_not_running() { + let shell = FakeShell::new(); + shell.expect( + "brew", + &["services", "info", "postgresql", "--json"], + "[{\"running\":false}]", + 0, + ); + shell.expect( + "brew", + &["services", "start", "postgresql"], + "Started postgresql\n", + 0, + ); + shell.expect("gh", &["auth", "status"], "Logged in\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(true); + let rows = run(&shell, &cfg).await; + assert_eq!(rows.len(), 3); + let pg = rows.iter().find(|r| r.name == "postgresql").unwrap(); + assert_eq!(pg.status, Status::Installed); + } + + #[tokio::test] + async fn run_marks_postgres_already_when_running() { + let shell = FakeShell::new(); + shell.expect( + "brew", + &["services", "info", "postgresql", "--json"], + "[{\"running\":true,\"name\":\"postgresql\"}]", + 0, + ); + shell.expect("gh", &["auth", "status"], "Logged in\n", 0); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "successfully authenticated.\n", + 1, + ); + let cfg = cfg_with_postgres(true); + let rows = run(&shell, &cfg).await; + let pg = rows.iter().find(|r| r.name == "postgresql").unwrap(); + assert_eq!(pg.status, Status::Already); + } + + #[tokio::test] + async fn check_gh_auth_marks_failed_when_not_signed_in() { + let shell = FakeShell::new(); + shell.expect("gh", &["auth", "status"], "", 1); + let row = check_gh_auth(&shell).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("gh auth login")); + } + + #[tokio::test] + async fn smoke_ssh_marks_already_when_greeting_present() { + let shell = FakeShell::new(); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Hi aladac! You've successfully authenticated.\n", + 1, + ); + let row = smoke_github_ssh(&shell).await; + assert_eq!(row.status, Status::Already); + } + + #[tokio::test] + async fn smoke_ssh_marks_failed_without_greeting() { + let shell = FakeShell::new(); + shell.expect( + "ssh", + &[ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "git@github.com", + ], + "Permission denied", + 255, + ); + let row = smoke_github_ssh(&shell).await; + assert_eq!(row.status, Status::Failed); + assert!(row.note.contains("no auth confirmation")); + } +} diff --git a/src/setup/run.rs b/src/setup/run.rs new file mode 100644 index 0000000..06d9ac1 --- /dev/null +++ b/src/setup/run.rs @@ -0,0 +1,303 @@ +//! `hu setup run` — full host bootstrap orchestrator. +//! +//! Walks the three phases (packages, dotfiles, ssh) in order, honoring +//! `--only` filtering, `--dry-run` short-circuit, and per-host overrides +//! from `[host.]` blocks in setup.toml. Returns the aggregated +//! `Vec` for display at the call site. + +// reason: orchestrator wired by `hu setup run` (this chunk). Tests cover +// the surface; only-filter + host-override logic is unit-tested without I/O. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::cli::{PkgsArgs, RunArgs, RunPhase}; +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::os::Os; +use crate::setup::ssh::OpClient; +use crate::setup::{dotfiles, pkgs, post, ssh}; +use crate::util::shell::Shell; + +/// Resolve the active host name for `[host.]` overrides. +/// +/// Reads `$HOSTNAME` first (settable for tests), then falls back to +/// `hostname::get()` if needed. Returns `"unknown"` on lookup failure. +pub fn current_hostname() -> String { + if let Ok(h) = std::env::var("HOSTNAME") { + if !h.is_empty() { + return h; + } + } + // Fall back to the platform call. + std::process::Command::new("hostname") + .output() + .ok() + .and_then(|out| { + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + None + } + }) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Apply per-host overrides to a base config. +/// +/// Currently merges `brew_extra` and `mise_extra` from the matching +/// `[host.]` block onto the base package lists. +pub fn apply_host_overrides(mut config: SetupConfig, hostname: &str) -> SetupConfig { + if let Some(over) = config.host.get(hostname).cloned() { + for pkg in over.brew_extra { + if !config.packages.brew.contains(&pkg) { + config.packages.brew.push(pkg); + } + } + for pkg in over.mise_extra { + if !config.packages.mise.contains(&pkg) { + config.packages.mise.push(pkg); + } + } + } + config +} + +/// Full orchestrator. Walks phases in order: packages → dotfiles → ssh. +/// +/// Phase selection follows `args.only`: +/// - `None` → all three phases run +/// - `Some(Pkgs)` → only packages +/// - `Some(Dotfiles)` → only dotfiles +/// - `Some(Ssh)` → only ssh +pub async fn run_full( + shell: &S, + op: &O, + config: &SetupConfig, + args: &RunArgs, + os: &Os, +) -> Result> { + let mut rows = Vec::new(); + + if should_run_phase(&args.only, RunPhase::Pkgs) { + let pkgs_args = PkgsArgs { + only: vec![], + dry_run: args.dry_run, + }; + let pkg_rows = pkgs::run(shell, config, &pkgs_args, os).await?; + rows.extend(pkg_rows); + } + + if should_run_phase(&args.only, RunPhase::Dotfiles) { + if args.dry_run { + for pkg in &config.dotfiles.packages { + rows.push( + crate::setup::display::StatusRow::new( + "stow", + pkg, + crate::setup::types::Status::Skipped, + ) + .with_note("dry-run"), + ); + } + } else { + let df_rows = dotfiles::run(shell, &config.dotfiles).await; + rows.extend(df_rows); + } + } + + if should_run_phase(&args.only, RunPhase::Ssh) { + if args.dry_run { + for item in &config.ssh.op_items { + rows.push( + crate::setup::display::StatusRow::new( + "ssh", + item, + crate::setup::types::Status::Skipped, + ) + .with_note("dry-run"), + ); + } + } else { + let ssh_rows = ssh::run(op, &config.ssh).await; + rows.extend(ssh_rows); + } + } + + // Phase 6: post-install checks (only when running everything, not under + // a single-phase --only filter, and not for dry-run). + if args.only.is_none() && !args.dry_run { + let post_rows = post::run(shell, config).await; + rows.extend(post_rows); + } + + Ok(rows) +} + +fn should_run_phase(only: &Option, phase: RunPhase) -> bool { + match only { + None => true, + Some(p) => *p == phase, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::cli::{RunArgs, RunPhase}; + use crate::setup::config::HostOverride; + use crate::setup::ssh::MockOp; + use crate::util::shell::FakeShell; + + fn run_args(only: Option, dry_run: bool, yes: bool) -> RunArgs { + RunArgs { only, dry_run, yes } + } + + #[test] + fn host_override_appends_brew_extra() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.host.insert( + "marauder".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg, "marauder"); + assert!(merged.packages.brew.contains(&"gh".to_string())); + assert!(merged.packages.brew.contains(&"nvtop".to_string())); + } + + #[test] + fn host_override_dedupes_existing() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into(), "nvtop".into()]; + cfg.host.insert( + "marauder".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg, "marauder"); + let count = merged + .packages + .brew + .iter() + .filter(|p| *p == "nvtop") + .count(); + assert_eq!(count, 1); + } + + #[test] + fn host_override_no_match_returns_unchanged() { + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.host.insert( + "other".into(), + HostOverride { + brew_extra: vec!["nvtop".into()], + mise_extra: vec![], + }, + ); + let merged = apply_host_overrides(cfg.clone(), "marauder"); + assert_eq!(merged.packages.brew, cfg.packages.brew); + } + + #[test] + fn host_override_appends_mise_extra() { + let mut cfg = SetupConfig::default(); + cfg.packages.mise = vec!["node@lts".into()]; + cfg.host.insert( + "h".into(), + HostOverride { + brew_extra: vec![], + mise_extra: vec!["go@latest".into()], + }, + ); + let merged = apply_host_overrides(cfg, "h"); + assert!(merged.packages.mise.contains(&"go@latest".to_string())); + } + + #[test] + fn should_run_phase_returns_true_when_no_filter() { + assert!(should_run_phase(&None, RunPhase::Pkgs)); + assert!(should_run_phase(&None, RunPhase::Dotfiles)); + assert!(should_run_phase(&None, RunPhase::Ssh)); + } + + #[test] + fn should_run_phase_filters_to_one() { + assert!(should_run_phase(&Some(RunPhase::Ssh), RunPhase::Ssh)); + assert!(!should_run_phase(&Some(RunPhase::Ssh), RunPhase::Pkgs)); + assert!(!should_run_phase(&Some(RunPhase::Ssh), RunPhase::Dotfiles)); + } + + #[tokio::test] + async fn run_full_dry_run_emits_all_phases_skipped() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + // Trim defaults to keep test compact + cfg.packages.brew = vec!["gh".into()]; + cfg.packages.mise = vec!["node@lts".into()]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full(&shell, &op, &cfg, &run_args(None, true, false), &Os::Mac) + .await + .unwrap(); + // bootstrap + 1 brew + 1 mise + 1 stow + 1 ssh = 5 + assert_eq!(rows.len(), 5); + for r in &rows { + assert_eq!(r.status, crate::setup::types::Status::Skipped); + } + assert!(shell.calls().is_empty()); + } + + #[tokio::test] + async fn run_full_only_pkgs_skips_other_phases() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.packages.mise = vec![]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full( + &shell, + &op, + &cfg, + &run_args(Some(RunPhase::Pkgs), true, false), + &Os::Mac, + ) + .await + .unwrap(); + // bootstrap + 1 brew = 2 rows; no stow/ssh + assert!(!rows.iter().any(|r| r.category == "stow")); + assert!(!rows.iter().any(|r| r.category == "ssh")); + } + + #[tokio::test] + async fn run_full_only_ssh_skips_pkgs_and_dotfiles() { + let shell = FakeShell::new(); + let op = MockOp::new(); + let mut cfg = SetupConfig::default(); + cfg.packages.brew = vec!["gh".into()]; + cfg.dotfiles.packages = vec!["zsh".into()]; + cfg.ssh.op_items = vec!["SSH/id_test".into()]; + let rows = run_full( + &shell, + &op, + &cfg, + &run_args(Some(RunPhase::Ssh), true, false), + &Os::Mac, + ) + .await + .unwrap(); + assert!(!rows.iter().any(|r| r.category == "brew")); + assert!(!rows.iter().any(|r| r.category == "stow")); + assert!(rows.iter().any(|r| r.category == "ssh")); + } +} diff --git a/src/setup/ssh.rs b/src/setup/ssh.rs new file mode 100644 index 0000000..e99dfdb --- /dev/null +++ b/src/setup/ssh.rs @@ -0,0 +1,640 @@ +//! SSH phase — read keys from 1Password and place them on disk. +//! +//! `OpClient` is the second earned trait abstraction (per doctrine §1: ≥2 +//! implementers — `RealOp` over the `op` CLI + `MockOp` for tests; painful +//! I/O to fake otherwise — vault auth, account state). +//! +//! Key writing is split into a pure `KeySpec` description (path / mode / +//! content) and the I/O glue that applies it. Tests cover the spec +//! computation; `fs::write` + `chmod` are exercised at run-time. + +// reason: ssh phase wired by `hu setup ssh` (chunk 4.2) and `setup run` +// (Phase 5). Tests cover the surface now. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use crate::util::shell::Shell; + +/// Standard chmod for SSH private keys. +pub const PRIVATE_KEY_MODE: u32 = 0o600; +/// Standard chmod for SSH public keys. +pub const PUBLIC_KEY_MODE: u32 = 0o644; + +/// 1Password CLI client. +#[async_trait] +pub trait OpClient: Send + Sync { + /// Read a single field via `op read `. Refs are formatted as + /// `op:////`. + async fn read(&self, op_ref: &str) -> Result; + + /// Whether the host has at least one signed-in 1Password account. + /// Used to short-circuit the SSH phase with a clear error rather than + /// emitting cryptic `op` failures per item. + async fn account_status(&self) -> Result; +} + +/// Real `op` CLI wrapper backed by the [`Shell`] chokepoint. +pub struct RealOp<'s, S: Shell + ?Sized> { + shell: &'s S, +} + +impl<'s, S: Shell + ?Sized> RealOp<'s, S> { + pub fn new(shell: &'s S) -> Self { + Self { shell } + } +} + +#[async_trait] +impl OpClient for RealOp<'_, S> { + async fn read(&self, op_ref: &str) -> Result { + let out = self + .shell + .run("op", &["read", op_ref]) + .await + .with_context(|| format!("op read {}", op_ref))?; + if !out.is_success() { + anyhow::bail!( + "op read {} failed (exit {:?}): {}", + op_ref, + out.status.code(), + out.stderr.trim() + ); + } + Ok(out.stdout) + } + + async fn account_status(&self) -> Result { + let out = self.shell.run("op", &["account", "list"]).await?; + Ok(out.is_success() && !out.stdout.trim().is_empty()) + } +} + +/// Specification for one SSH key file to write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeySpec { + pub path: PathBuf, + pub mode: u32, + pub content: String, +} + +/// Build the (private, public) `KeySpec` pair for one configured op item. +/// +/// `op_item_ref` is the vault-relative path like `"SSH/id_ed25519"`. The +/// basename (`id_ed25519`) becomes the key file name in `key_dir`. +pub fn key_specs_for_item( + key_dir: &Path, + op_item_ref: &str, + private_content: String, + public_content: String, +) -> Vec { + let basename = op_item_ref + .rsplit('/') + .next() + .unwrap_or(op_item_ref) + .to_string(); + vec![ + KeySpec { + path: key_dir.join(&basename), + mode: PRIVATE_KEY_MODE, + content: ensure_trailing_newline(&private_content), + }, + KeySpec { + path: key_dir.join(format!("{}.pub", basename)), + mode: PUBLIC_KEY_MODE, + content: ensure_trailing_newline(&public_content), + }, + ] +} + +fn ensure_trailing_newline(s: &str) -> String { + if s.ends_with('\n') { + s.to_string() + } else { + format!("{}\n", s) + } +} + +/// Build the full op `read` reference: `op:////`. +pub fn op_ref(vault: &str, item: &str, field: &str) -> String { + format!("op://{}/{}/{}", vault, item, field) +} + +/// Decide how to apply a key spec given the current filesystem state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecAction { + /// File exists with matching content + correct mode → nothing to do. + AlreadyMatches, + /// File missing or content differs — write it (idempotent overwrite). + WriteFile, +} + +/// Inspect the filesystem and decide what action a `KeySpec` needs. +/// +/// This is pure-ish — only reads. Tests cover the matrix without mocking. +pub fn classify_spec(spec: &KeySpec) -> SpecAction { + if !spec.path.exists() { + return SpecAction::WriteFile; + } + let existing = match std::fs::read_to_string(&spec.path) { + Ok(s) => s, + Err(_) => return SpecAction::WriteFile, + }; + if existing != spec.content { + return SpecAction::WriteFile; + } + if let Ok(meta) = std::fs::metadata(&spec.path) { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o777 != spec.mode { + return SpecAction::WriteFile; + } + } + SpecAction::AlreadyMatches +} + +/// Apply one key spec to disk. Idempotent: returns `Already` when the file +/// already matches; writes + chmods otherwise; re-reads to verify mode. +pub fn apply_spec(spec: &KeySpec) -> Result { + use std::os::unix::fs::PermissionsExt; + if classify_spec(spec) == SpecAction::AlreadyMatches { + return Ok(crate::setup::types::Status::Already); + } + if let Some(parent) = spec.path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create dir {}", parent.display()))?; + } + std::fs::write(&spec.path, &spec.content) + .with_context(|| format!("write {}", spec.path.display()))?; + let mut perms = std::fs::metadata(&spec.path) + .with_context(|| format!("stat {}", spec.path.display()))? + .permissions(); + perms.set_mode(spec.mode); + std::fs::set_permissions(&spec.path, perms) + .with_context(|| format!("chmod {}", spec.path.display()))?; + // Re-verify mode landed. + let final_mode = std::fs::metadata(&spec.path)?.permissions().mode() & 0o777; + if final_mode != spec.mode { + anyhow::bail!( + "post-write mode mismatch on {}: got {:o}, want {:o}", + spec.path.display(), + final_mode, + spec.mode + ); + } + Ok(crate::setup::types::Status::Installed) +} + +/// Fetch the private + public key pair for one item. +pub async fn fetch_key_pair( + op: &O, + vault: &str, + item: &str, +) -> Result<(String, String)> { + let private = op + .read(&op_ref(vault, item, "private_key")) + .await + .with_context(|| format!("read private key for {}/{}", vault, item))?; + let public = op + .read(&op_ref(vault, item, "public_key")) + .await + .with_context(|| format!("read public key for {}/{}", vault, item))?; + Ok((private, public)) +} + +/// Orchestrate the full SSH phase: account check → for each item fetch +/// (private, public) → write specs. +pub async fn run( + op: &O, + config: &crate::setup::config::SshConfig, +) -> Vec { + use crate::setup::display::StatusRow; + use crate::setup::types::Status; + + let mut rows = Vec::new(); + + let signed_in = match op.account_status().await { + Ok(b) => b, + Err(e) => { + rows.push( + StatusRow::new("ssh", "op-account", Status::Failed) + .with_note(&format!("account_status errored: {}", e)), + ); + return rows; + } + }; + if !signed_in { + rows.push( + StatusRow::new("ssh", "op-account", Status::Failed) + .with_note("no signed-in 1Password account — run `op account add`"), + ); + return rows; + } + rows.push(StatusRow::new("ssh", "op-account", Status::Already).with_note("signed in")); + + let key_dir = expand_tilde(&config.key_dir); + for item in &config.op_items { + match fetch_key_pair(op, &config.op_vault, item).await { + Err(e) => { + rows.push( + StatusRow::new("ssh", item, Status::Failed) + .with_note(&format!("fetch failed: {}", e)), + ); + continue; + } + Ok((priv_k, pub_k)) => { + let specs = key_specs_for_item(&key_dir, item, priv_k, pub_k); + for spec in specs { + let basename = spec + .path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("?") + .to_string(); + match apply_spec(&spec) { + Ok(status) => rows.push( + StatusRow::new("ssh", &basename, status) + .with_note(&format!("mode {:o}", spec.mode)), + ), + Err(e) => rows.push( + StatusRow::new("ssh", &basename, Status::Failed) + .with_note(&format!("apply failed: {}", e)), + ), + } + } + } + } + } + rows +} + +fn expand_tilde(raw: &str) -> std::path::PathBuf { + crate::setup::dotfiles::expand_tilde(raw) +} + +#[cfg(test)] +mod fake { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + /// Scripted op client for tests. Maps op refs → scripted output. + pub struct MockOp { + responses: Mutex>, + signed_in: Mutex, + reads: Mutex>, + } + + impl MockOp { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + signed_in: Mutex::new(true), + reads: Mutex::new(Vec::new()), + } + } + + pub fn expect(&self, op_ref: &str, content: &str) { + self.responses + .lock() + .expect("mock op mutex") + .insert(op_ref.to_string(), content.to_string()); + } + + pub fn set_signed_in(&self, ok: bool) { + *self.signed_in.lock().expect("mock op mutex") = ok; + } + + pub fn reads(&self) -> Vec { + self.reads.lock().expect("mock op mutex").clone() + } + } + + #[async_trait] + impl OpClient for MockOp { + async fn read(&self, op_ref: &str) -> Result { + self.reads + .lock() + .expect("mock op mutex") + .push(op_ref.to_string()); + let map = self.responses.lock().expect("mock op mutex"); + match map.get(op_ref) { + Some(content) => Ok(content.clone()), + None => anyhow::bail!("MockOp: no response registered for {}", op_ref), + } + } + + async fn account_status(&self) -> Result { + Ok(*self.signed_in.lock().expect("mock op mutex")) + } + } +} + +#[cfg(test)] +pub use fake::MockOp; + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn op_ref_formats_correctly() { + assert_eq!( + op_ref("Personal", "SSH/id_ed25519", "private_key"), + "op://Personal/SSH/id_ed25519/private_key" + ); + } + + #[test] + fn ensure_trailing_newline_appends_when_missing() { + assert_eq!(ensure_trailing_newline("abc"), "abc\n"); + assert_eq!(ensure_trailing_newline("abc\n"), "abc\n"); + assert_eq!(ensure_trailing_newline(""), "\n"); + } + + #[test] + fn key_specs_extract_basename_from_path() { + let specs = key_specs_for_item( + Path::new("/home/u/.ssh"), + "SSH/id_ed25519", + "PRIV".into(), + "PUB".into(), + ); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].path, PathBuf::from("/home/u/.ssh/id_ed25519")); + assert_eq!(specs[1].path, PathBuf::from("/home/u/.ssh/id_ed25519.pub")); + } + + #[test] + fn key_specs_apply_correct_modes() { + let specs = key_specs_for_item( + Path::new("/home/u/.ssh"), + "id_rsa", + "priv".into(), + "pub".into(), + ); + assert_eq!(specs[0].mode, 0o600); + assert_eq!(specs[1].mode, 0o644); + } + + #[test] + fn key_specs_normalize_trailing_newline() { + let specs = key_specs_for_item(Path::new("/x"), "key", "PRIV-NO-NL".into(), "PUB\n".into()); + assert_eq!(specs[0].content, "PRIV-NO-NL\n"); + assert_eq!(specs[1].content, "PUB\n"); + } + + #[tokio::test] + async fn mock_op_returns_scripted_content() { + let op = MockOp::new(); + op.expect("op://V/I/private_key", "PRIVATE-CONTENT"); + let v = op.read("op://V/I/private_key").await.unwrap(); + assert_eq!(v, "PRIVATE-CONTENT"); + } + + #[tokio::test] + async fn mock_op_errors_on_unscripted_read() { + let op = MockOp::new(); + let err = op.read("op://X/Y/z").await.unwrap_err(); + assert!(err.to_string().contains("no response registered")); + } + + #[tokio::test] + async fn mock_op_account_status_default_true() { + let op = MockOp::new(); + assert!(op.account_status().await.unwrap()); + op.set_signed_in(false); + assert!(!op.account_status().await.unwrap()); + } + + #[tokio::test] + async fn fetch_key_pair_reads_both_fields() { + let op = MockOp::new(); + op.expect("op://Personal/SSH/id_ed25519/private_key", "PRIV"); + op.expect("op://Personal/SSH/id_ed25519/public_key", "PUB"); + let (priv_k, pub_k) = fetch_key_pair(&op, "Personal", "SSH/id_ed25519") + .await + .unwrap(); + assert_eq!(priv_k, "PRIV"); + assert_eq!(pub_k, "PUB"); + } + + #[tokio::test] + async fn fetch_key_pair_propagates_op_error() { + let op = MockOp::new(); + // private_key registered but public_key missing + op.expect("op://V/I/private_key", "PRIV"); + let err = fetch_key_pair(&op, "V", "I").await.unwrap_err(); + assert!(err.to_string().contains("public key")); + } + + #[tokio::test] + async fn real_op_passes_ref_through_shell() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect("op", &["read", "op://V/I/f"], "secret\n", 0); + let op = RealOp::new(&shell); + let v = op.read("op://V/I/f").await.unwrap(); + assert_eq!(v, "secret\n"); + } + + #[tokio::test] + async fn real_op_errors_on_nonzero_exit() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect("op", &["read", "op://V/I/f"], "", 1); + let op = RealOp::new(&shell); + let err = op.read("op://V/I/f").await.unwrap_err(); + assert!(err.to_string().contains("op read")); + } + + #[test] + fn classify_spec_returns_write_when_missing() { + let spec = KeySpec { + path: PathBuf::from("/nonexistent/path/file"), + mode: 0o600, + content: "x".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + } + + #[test] + fn classify_spec_returns_already_when_match() { + let dir = std::env::temp_dir().join("hu-classify-already"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::AlreadyMatches); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn classify_spec_returns_write_when_content_differs() { + let dir = std::env::temp_dir().join("hu-classify-content-diff"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "old\n").unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "new\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn classify_spec_returns_write_when_mode_differs() { + let dir = std::env::temp_dir().join("hu-classify-mode-diff"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("k"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o644); // different from spec + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + assert_eq!(classify_spec(&spec), SpecAction::WriteFile); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn apply_spec_writes_file_with_correct_mode() { + let dir = std::env::temp_dir().join("hu-apply-spec-write"); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("id_test"); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "PRIVATE\n".into(), + }; + let status = apply_spec(&spec).unwrap(); + assert_eq!(status, crate::setup::types::Status::Installed); + let content = std::fs::read_to_string(&path).unwrap(); + assert_eq!(content, "PRIVATE\n"); + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn apply_spec_skips_when_already_matches() { + let dir = std::env::temp_dir().join("hu-apply-spec-already"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("id_test"); + std::fs::write(&path, "abc\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(&path, perms).unwrap(); + let spec = KeySpec { + path: path.clone(), + mode: 0o600, + content: "abc\n".into(), + }; + let status = apply_spec(&spec).unwrap(); + assert_eq!(status, crate::setup::types::Status::Already); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn ssh_run_fails_when_not_signed_in() { + let op = MockOp::new(); + op.set_signed_in(false); + let cfg = crate::setup::config::SshConfig::default(); + let rows = run(&op, &cfg).await; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, crate::setup::types::Status::Failed); + assert!(rows[0].note.contains("op account add")); + } + + #[tokio::test] + async fn ssh_run_writes_keys_when_signed_in() { + let dir = std::env::temp_dir().join("hu-ssh-run-keys"); + let _ = std::fs::remove_dir_all(&dir); + let op = MockOp::new(); + op.expect("op://Personal/SSH/id_test/private_key", "PRIV-CONTENT"); + op.expect("op://Personal/SSH/id_test/public_key", "PUB-CONTENT"); + let cfg = crate::setup::config::SshConfig { + op_vault: "Personal".into(), + op_items: vec!["SSH/id_test".into()], + key_dir: dir.to_string_lossy().into_owned(), + }; + let rows = run(&op, &cfg).await; + // 1 op-account + 2 key files + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].name, "op-account"); + assert_eq!(rows[1].name, "id_test"); + assert_eq!(rows[2].name, "id_test.pub"); + assert_eq!(rows[1].status, crate::setup::types::Status::Installed); + assert_eq!(rows[2].status, crate::setup::types::Status::Installed); + // Verify perms landed + use std::os::unix::fs::PermissionsExt; + let priv_mode = std::fs::metadata(dir.join("id_test")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(priv_mode, 0o600); + let pub_mode = std::fs::metadata(dir.join("id_test.pub")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(pub_mode, 0o644); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn ssh_run_marks_item_failed_when_op_read_errors() { + let dir = std::env::temp_dir().join("hu-ssh-run-op-fail"); + let _ = std::fs::remove_dir_all(&dir); + let op = MockOp::new(); + // private_key missing → fetch fails + let cfg = crate::setup::config::SshConfig { + op_vault: "V".into(), + op_items: vec!["I".into()], + key_dir: dir.to_string_lossy().into_owned(), + }; + let rows = run(&op, &cfg).await; + assert_eq!(rows.len(), 2); // op-account + 1 item failure + let item_row = &rows[1]; + assert_eq!(item_row.status, crate::setup::types::Status::Failed); + assert!(item_row.note.contains("fetch failed")); + } + + #[tokio::test] + async fn real_op_account_status_uses_account_list() { + use crate::util::shell::FakeShell; + let shell = FakeShell::new(); + shell.expect( + "op", + &["account", "list"], + "URL EMAIL USER ID\nmy.1password.com me@x.com ABC\n", + 0, + ); + let op = RealOp::new(&shell); + assert!(op.account_status().await.unwrap()); + } +} diff --git a/src/setup/status.rs b/src/setup/status.rs new file mode 100644 index 0000000..d10818a --- /dev/null +++ b/src/setup/status.rs @@ -0,0 +1,162 @@ +//! Service layer for `hu setup status`. +//! +//! Collects status rows for every configured package by checking presence +//! via the [`Shell`] chokepoint. Pure logic — interface formats the result. + +// reason: collector wired to `hu setup status` (this chunk) and reused by +// `setup preview` and (in Phase 1+) the per-package install pipeline. +#![allow(dead_code)] + +use anyhow::Result; + +use crate::setup::config::SetupConfig; +use crate::setup::display::StatusRow; +use crate::setup::types::Status; +use crate::util::shell::Shell; + +/// Map a configured package id to the binary name `which` should look for. +/// +/// For most packages the id matches the binary. Known mismatches: +/// - `postgresql` ships `psql` as the user-facing binary +/// - mise-managed `@` strips the version qualifier +pub fn binary_name(pkg: &str) -> &str { + if let Some((lang, _version)) = pkg.split_once('@') { + return mise_lang_to_binary(lang); + } + match pkg { + "postgresql" => "psql", + "kitty" => "kitty", + other => other, + } +} + +fn mise_lang_to_binary(lang: &str) -> &str { + match lang { + "node" => "node", + "ruby" => "ruby", + "python" => "python3", + "rust" => "rustc", + other => other, + } +} + +/// Collect status rows for every configured package + key host artifact. +/// +/// Performs a `which ` per package via the `Shell` chokepoint. No +/// side effects beyond the `which` calls themselves. +pub async fn collect(shell: &impl Shell, config: &SetupConfig) -> Result> { + let mut rows = Vec::new(); + for pkg in &config.packages.brew { + rows.push(check_binary(shell, "brew", pkg).await); + } + for pkg in &config.packages.mise { + rows.push(check_binary(shell, "mise", pkg).await); + } + Ok(rows) +} + +async fn check_binary(shell: &impl Shell, category: &str, pkg: &str) -> StatusRow { + let bin = binary_name(pkg); + let status = if shell.which(bin).await { + Status::Already + } else { + Status::Failed + }; + StatusRow::new(category, pkg, status).with_note( + if bin == pkg { + String::new() + } else { + format!("binary: {}", bin) + } + .as_str(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::setup::config::SetupConfig; + use crate::util::shell::FakeShell; + + #[test] + fn binary_name_strips_version_for_mise_packages() { + assert_eq!(binary_name("node@lts"), "node"); + assert_eq!(binary_name("ruby@latest"), "ruby"); + assert_eq!(binary_name("python@latest"), "python3"); + assert_eq!(binary_name("rust@latest"), "rustc"); + } + + #[test] + fn binary_name_remaps_postgresql_to_psql() { + assert_eq!(binary_name("postgresql"), "psql"); + } + + #[test] + fn binary_name_passes_through_unknown() { + assert_eq!(binary_name("gh"), "gh"); + assert_eq!(binary_name("zellij"), "zellij"); + } + + #[tokio::test] + async fn collect_marks_present_packages_as_already() { + let shell = FakeShell::new(); + shell.expect("which", &["gh"], "/opt/homebrew/bin/gh\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["gh".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, Status::Already); + assert_eq!(rows[0].name, "gh"); + assert_eq!(rows[0].category, "brew"); + } + + #[tokio::test] + async fn collect_marks_missing_packages_as_failed() { + let shell = FakeShell::new(); + // no expect → unscripted → exit 127 → which returns false + let mut config = SetupConfig::default(); + config.packages.brew = vec!["nonexistent".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, Status::Failed); + } + + #[tokio::test] + async fn collect_uses_psql_binary_for_postgresql() { + let shell = FakeShell::new(); + shell.expect("which", &["psql"], "/opt/homebrew/bin/psql\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["postgresql".into()]; + config.packages.mise = vec![]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows[0].status, Status::Already); + assert!(rows[0].note.contains("psql")); + } + + #[tokio::test] + async fn collect_handles_mise_versioned_packages() { + let shell = FakeShell::new(); + shell.expect("which", &["node"], "/usr/local/bin/node\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec![]; + config.packages.mise = vec!["node@lts".into()]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows[0].name, "node@lts"); + assert_eq!(rows[0].category, "mise"); + assert_eq!(rows[0].status, Status::Already); + } + + #[tokio::test] + async fn collect_returns_one_row_per_configured_package() { + let shell = FakeShell::new(); + shell.expect("which", &["gh"], "/usr/bin/gh\n", 0); + shell.expect("which", &["node"], "/usr/bin/node\n", 0); + let mut config = SetupConfig::default(); + config.packages.brew = vec!["gh".into(), "missing".into()]; + config.packages.mise = vec!["node@lts".into()]; + let rows = collect(&shell, &config).await.unwrap(); + assert_eq!(rows.len(), 3); + } +} diff --git a/src/setup/types.rs b/src/setup/types.rs new file mode 100644 index 0000000..4206039 --- /dev/null +++ b/src/setup/types.rs @@ -0,0 +1,61 @@ +//! Shared types for the setup module. + +// reason: Status + impls land in Phase 0 chunk 0.4 (status table) and Phase 1+ (installers). +// Tests cover them now; suppress dead_code until first runtime caller wires up. +#![allow(dead_code)] + +/// Status of a package, dotfile, or SSH artifact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Already in desired state — no action taken + Already, + /// Action performed and re-verified + Installed, + /// Action skipped (filtered out, dry-run, conflicts) + Skipped, + /// Failed to reach desired state + Failed, + /// Not yet checked + Unknown, +} + +impl Status { + /// Single-character icon for status tables. + pub fn icon(self) -> &'static str { + match self { + Status::Already => "✓", + Status::Installed => "✓", + Status::Skipped => "◐", + Status::Failed => "✗", + Status::Unknown => "○", + } + } + + /// Whether the status represents a desired-state match. + pub fn is_satisfied(self) -> bool { + matches!(self, Status::Already | Status::Installed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn icons_match_doctrine() { + assert_eq!(Status::Already.icon(), "✓"); + assert_eq!(Status::Installed.icon(), "✓"); + assert_eq!(Status::Skipped.icon(), "◐"); + assert_eq!(Status::Failed.icon(), "✗"); + assert_eq!(Status::Unknown.icon(), "○"); + } + + #[test] + fn is_satisfied_only_for_present_states() { + assert!(Status::Already.is_satisfied()); + assert!(Status::Installed.is_satisfied()); + assert!(!Status::Skipped.is_satisfied()); + assert!(!Status::Failed.is_satisfied()); + assert!(!Status::Unknown.is_satisfied()); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 9577fbc..6fd5fcf 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,5 +1,6 @@ mod config; mod output; +pub mod shell; pub use config::{ load_credentials, save_credentials, BraveCredentials, GithubCredentials, JiraCredentials, diff --git a/src/util/shell.rs b/src/util/shell.rs new file mode 100644 index 0000000..49f0c4a --- /dev/null +++ b/src/util/shell.rs @@ -0,0 +1,237 @@ +//! Single chokepoint for external process I/O. +//! +//! Every shellout in `hu setup` (brew, mise, gh, op, stow, etc.) routes through +//! the [`Shell`] trait. The real impl uses `tokio::process::Command`; tests use +//! [`FakeShell`] which scripts (cmd, args) → output mappings. +//! +//! Per project doctrine (CLAUDE.md §1): one trait covers all process I/O so +//! every caller is testable without wrapping each binary in its own trait. + +// reason: trait + RealShell wired by Phase 0 chunk 0.4 (status checker via `which`) +// and Phase 1+ installers. FakeShell exists only under cfg(test). Suppress +// dead_code until the first runtime caller lands. +#![allow(dead_code)] + +use std::process::ExitStatus; + +use anyhow::Result; +use async_trait::async_trait; + +/// Result of running an external command. +#[derive(Debug, Clone)] +pub struct ShellOutput { + pub status: ExitStatus, + pub stdout: String, + pub stderr: String, +} + +impl ShellOutput { + pub fn is_success(&self) -> bool { + self.status.success() + } +} + +/// Process I/O chokepoint. +#[async_trait] +pub trait Shell: Send + Sync { + /// Run a command with arguments and return captured output. + async fn run(&self, cmd: &str, args: &[&str]) -> Result; + + /// Convenience: returns true when the command exists on PATH. + async fn which(&self, cmd: &str) -> bool { + match self.run("which", &[cmd]).await { + Ok(out) => out.is_success(), + Err(_) => false, + } + } +} + +/// Real implementation backed by `tokio::process::Command`. +pub struct RealShell; + +#[async_trait] +impl Shell for RealShell { + async fn run(&self, cmd: &str, args: &[&str]) -> Result { + let output = tokio::process::Command::new(cmd) + .args(args) + .output() + .await?; + Ok(ShellOutput { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +#[cfg(test)] +mod fake { + use super::*; + use std::collections::HashMap; + use std::os::unix::process::ExitStatusExt; + use std::sync::Mutex; + + /// Scripted shell for tests. Maps `(cmd, args)` → canned output. + /// + /// Calls without a registered response default to exit code 127 (command + /// not found) which mirrors real shell behaviour. + pub struct FakeShell { + responses: Mutex>, + sequences: Mutex>>, + calls: Mutex)>>, + } + + impl FakeShell { + pub fn new() -> Self { + Self { + responses: Mutex::new(HashMap::new()), + sequences: Mutex::new(HashMap::new()), + calls: Mutex::new(Vec::new()), + } + } + + /// Register a response for a `(cmd, args)` invocation. + pub fn expect(&self, cmd: &str, args: &[&str], stdout: &str, exit_code: i32) { + let key = Self::key(cmd, args); + let status = ExitStatus::from_raw(exit_code << 8); + self.responses.lock().expect("fake shell mutex").insert( + key, + ShellOutput { + status, + stdout: stdout.to_string(), + stderr: String::new(), + }, + ); + } + + /// Register an ordered sequence of responses for repeated calls to the + /// same `(cmd, args)`. Each call pops the next response; exhausted + /// sequences fall through to the steady-state `expect` value (or 127 + /// if none registered). + /// + /// Useful for `check → install → check` flows where the second check + /// should observe the post-install state. + pub fn expect_sequence(&self, cmd: &str, args: &[&str], outcomes: &[(&str, i32)]) { + let key = Self::key(cmd, args); + let queue: Vec = outcomes + .iter() + .map(|(stdout, exit_code)| ShellOutput { + status: ExitStatus::from_raw(*exit_code << 8), + stdout: stdout.to_string(), + stderr: String::new(), + }) + .collect(); + self.sequences + .lock() + .expect("fake shell mutex") + .insert(key, queue); + } + + /// Recorded call log. + pub fn calls(&self) -> Vec<(String, Vec)> { + self.calls.lock().expect("fake shell mutex").clone() + } + + fn key(cmd: &str, args: &[&str]) -> String { + format!("{} {}", cmd, args.join(" ")) + } + } + + #[async_trait] + impl Shell for FakeShell { + async fn run(&self, cmd: &str, args: &[&str]) -> Result { + let key = Self::key(cmd, args); + self.calls.lock().expect("fake shell mutex").push(( + cmd.to_string(), + args.iter().map(|s| s.to_string()).collect(), + )); + // Sequence queue takes priority over single-response map. + if let Some(queue) = self + .sequences + .lock() + .expect("fake shell mutex") + .get_mut(&key) + { + if !queue.is_empty() { + return Ok(queue.remove(0)); + } + } + let map = self.responses.lock().expect("fake shell mutex"); + match map.get(&key) { + Some(out) => Ok(out.clone()), + None => Ok(ShellOutput { + status: ExitStatus::from_raw(127 << 8), + stdout: String::new(), + stderr: format!("FakeShell: unscripted call: {}", key), + }), + } + } + } +} + +#[cfg(test)] +pub use fake::FakeShell; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fake_shell_returns_scripted_output() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "gh"], "gh\n", 0); + let out = shell.run("brew", &["list", "gh"]).await.unwrap(); + assert!(out.is_success()); + assert_eq!(out.stdout, "gh\n"); + } + + #[tokio::test] + async fn fake_shell_records_calls() { + let shell = FakeShell::new(); + shell.expect("brew", &["list", "jq"], "", 0); + let _ = shell.run("brew", &["list", "jq"]).await.unwrap(); + let calls = shell.calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "brew"); + assert_eq!(calls[0].1, vec!["list", "jq"]); + } + + #[tokio::test] + async fn fake_shell_unscripted_returns_127() { + let shell = FakeShell::new(); + let out = shell.run("nonexistent", &[]).await.unwrap(); + assert!(!out.is_success()); + assert_eq!(out.status.code(), Some(127)); + assert!(out.stderr.contains("unscripted call")); + } + + #[tokio::test] + async fn which_returns_true_when_command_exits_zero() { + let shell = FakeShell::new(); + shell.expect("which", &["brew"], "/opt/homebrew/bin/brew\n", 0); + assert!(shell.which("brew").await); + } + + #[tokio::test] + async fn which_returns_false_when_command_exits_nonzero() { + let shell = FakeShell::new(); + shell.expect("which", &["nonexistent"], "", 1); + assert!(!shell.which("nonexistent").await); + } + + #[tokio::test] + async fn shell_output_is_success_matches_status() { + let ok = ShellOutput { + status: std::os::unix::process::ExitStatusExt::from_raw(0), + stdout: String::new(), + stderr: String::new(), + }; + assert!(ok.is_success()); + let bad = ShellOutput { + status: std::os::unix::process::ExitStatusExt::from_raw(1 << 8), + stdout: String::new(), + stderr: String::new(), + }; + assert!(!bad.is_success()); + } +}