diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index 7f43c19..f4ef71b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -11,8 +11,16 @@ pub struct DepReport { pub fn check_deps(required: &[String], optional: &[String]) -> Result { Ok(DepReport { - missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), - warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(), + missing: required + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), + warnings: optional + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), }) } @@ -99,14 +107,24 @@ pub fn install_hint(missing: &[String]) -> String { /// Print a formatted doctor report for a package's system deps. /// Returns true if all *required* deps are satisfied. -pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { +pub fn report( + package_name: &str, + required: &[String], + optional: &[String], + name_width: usize, +) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); + ui::check_row(true, package_name, name_width, "no system deps required"); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); + ui::check_row( + false, + package_name, + name_width, + &format!("error running doctor: {e}"), + ); false } Ok(rep) => { @@ -123,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b ); } if rep.missing.is_empty() { - println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); + ui::check_row( + true, + package_name, + name_width, + "all required system deps satisfied", + ); true } else { + ui::check_row( + false, + package_name, + name_width, + &format!("missing: {}", rep.missing.join(", ")), + ); eprintln!( " {}", - ui::fail(&format!( - "{package_name}: missing system deps: {}", - rep.missing.join(", ") - )) + ui::dim(&format!("install with: {}", install_hint(&rep.missing))) ); - eprintln!(" install with: {}", install_hint(&rep.missing)); false } } @@ -187,22 +212,14 @@ mod tests { #[test] fn missing_required_dep_detected() { - let rep = check_deps( - &["this-package-does-not-exist-xyzzy42".to_string()], - &[], - ) - .unwrap(); + let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap(); assert_eq!(rep.missing.len(), 1); assert!(rep.warnings.is_empty()); } #[test] fn missing_optional_dep_becomes_warning_not_error() { - let rep = check_deps( - &[], - &["this-package-does-not-exist-xyzzy42".to_string()], - ) - .unwrap(); + let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap(); assert!(rep.missing.is_empty()); assert_eq!(rep.warnings.len(), 1); } diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 14a19fd..d59bf67 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -3,6 +3,7 @@ use sha2::{Digest, Sha256}; use std::path::Path; use crate::manifest::{fetch_binary, Binary}; +use crate::ui; /// Download a binary, verify its SHA-256, then atomically write it into /// place (fsynced, temp-in-same-dir-with-unique-name then rename — see @@ -12,7 +13,7 @@ use crate::manifest::{fetch_binary, Binary}; /// bytes a second time — `verify_sha256` already confirmed `bytes` matches /// `binary.sha256`, so that's the value to return. pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { - println!(" downloading {}…", binary.name); + ui::step("downloading", &binary.name); let bytes = fetch_binary(&binary.dl_url, &binary.github_url) .with_context(|| format!("downloading {}", binary.name))?; @@ -21,7 +22,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755)) .with_context(|| format!("placing binary at {}", dest.display()))?; - println!(" installed {}", dest.display()); + ui::step("placed", &dest.display().to_string()); Ok(binary.sha256.clone()) } diff --git a/bakery/src/install.rs b/bakery/src/install.rs index c046a00..19a7c76 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -9,6 +9,7 @@ use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; use crate::track::Track; +use crate::ui; /// Rejects a filename that isn't a safe single path component — no `/`, /// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/ @@ -52,7 +53,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool { return false; } use std::io::Write; - print!("{prompt} [y/N] "); + print!("{prompt} {} ", ui::dim("[y/N]")); std::io::stdout().flush().ok(); let mut buf = String::new(); std::io::stdin().read_line(&mut buf).ok(); @@ -74,7 +75,6 @@ pub fn install_package( assume_yes: bool, ) -> Result<()> { ensure_safe_component(&pkg.name, "package name")?; - println!("installing {}@{}…", pkg.name, pkg.version); // 1. Download and verify all binaries. On an update (not a fresh // install), back up the current binary first — best-effort, feeding @@ -126,10 +126,13 @@ pub fn install_package( // confirmation rather than running unconditionally. if !pkg.post_install.is_empty() { if no_hooks { - println!( - " note: skipped {} post_install hook(s) for {} (--no-hooks)", - pkg.post_install.len(), - pkg.name + eprintln!( + " {}", + ui::note(&format!( + "skipped {} post_install hook(s) for {} (--no-hooks)", + pkg.post_install.len(), + pkg.name + )) ); } else if confirm( &format!( @@ -143,7 +146,13 @@ pub fn install_package( run_hook(cmd, &pkg.name)?; } } else { - println!(" skipped post_install hooks for {} (declined)", pkg.name); + eprintln!( + " {}", + ui::note(&format!( + "skipped post_install hooks for {} (declined)", + pkg.name + )) + ); } } @@ -163,7 +172,7 @@ pub fn install_package( Ok(()) })?; - println!(" {} installed successfully", pkg.name); + println!(" {}", ui::ok(&format!("{} installed", pkg.name))); warn_path_if_needed(bin_dir); Ok(()) } @@ -184,13 +193,21 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa } if let Err(e) = std::fs::create_dir_all(backup_dir) { eprintln!( - " warning: could not create backup dir {} ({e}) — rollback won't be available for this update", - backup_dir.display() + " {}", + ui::warn(&format!( + "could not create backup dir {} ({e}) — rollback won't be available for this update", + backup_dir.display() + )) ); return; } if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) { - eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update"); + eprintln!( + " {}", + ui::warn(&format!( + "could not back up {binary_name} before update ({e}) — rollback won't be available for this update" + )) + ); } } @@ -199,10 +216,11 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let installed = match installed { Some(p) => p, None => { - eprintln!("{pkg_name} is not installed"); + eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed"))); return Ok(()); } }; + ui::action("Removing", pkg_name, Some(&installed.version)); // State is already committed by with_lock above — everything from here // is best-effort file cleanup, and must all run even if part of it fails. @@ -214,7 +232,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let path = bin_dir.join(bin); if path.exists() { match std::fs::remove_file(&path) { - Ok(()) => println!(" removed {}", path.display()), + Ok(()) => ui::step("removed", &path.display().to_string()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -235,7 +253,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) .status(); - println!(" removed unit {unit}"); + ui::step("removed", &format!("unit {unit}")); } } } @@ -247,7 +265,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b // surprise no flag should cause. if let Some(cfg_dir) = guess_config_dir(pkg_name) { if cfg_dir.exists() { - println!(" config preserved at {}", cfg_dir.display()); + ui::step("preserved", &format!("config {}", cfg_dir.display())); } } @@ -258,23 +276,34 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let license_dir = share_dir.join("licenses").join(pkg_name); remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures); - let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop")); - remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures); + let desktop_file = share_dir + .join("applications") + .join(format!("{pkg_name}.desktop")); + remove_purged_path( + &desktop_file, + "desktop entry", + false, + assume_yes, + &mut failures, + ); remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures); } else if data_dir.exists() { - println!(" data preserved at {}", data_dir.display()); + ui::step("preserved", &format!("data {}", data_dir.display())); } if !failures.is_empty() { - eprintln!(" failed to remove {} item(s):", failures.len()); + eprintln!( + " {}", + ui::fail(&format!("failed to remove {} item(s):", failures.len())) + ); for f in &failures { eprintln!(" {f}"); } bail!("{pkg_name} removed from state, but some files could not be deleted"); } - println!(" {pkg_name} removed"); + println!(" {}", ui::ok(&format!("{pkg_name} removed"))); Ok(()) } @@ -284,12 +313,21 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b /// in place and prints the same "preserved at" wording the non-purge path /// already uses. Shared by `remove_package`'s three `--purge` targets /// (license dir, desktop entry, data dir). -fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec) { +fn remove_purged_path( + path: &Path, + label: &str, + recursive: bool, + assume_yes: bool, + failures: &mut Vec, +) { if !path.exists() { return; } - if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) { - println!(" {label} preserved at {}", path.display()); + if !confirm( + &format!(" remove {label} at {}?", path.display()), + assume_yes, + ) { + ui::step("preserved", &format!("{label} {}", path.display())); return; } let result = if recursive { @@ -298,7 +336,7 @@ fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: boo std::fs::remove_file(path) }; match result { - Ok(()) => println!(" removed {}", path.display()), + Ok(()) => ui::step("removed", &path.display().to_string()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -318,36 +356,48 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) => { std::fs::write(&dest, &bytes) .with_context(|| format!("writing {}", dest.display()))?; - println!(" installed example config at {}", dest.display()); + ui::step("config", &dest.display().to_string()); } Err(e) => { eprintln!( - " warning: checksum mismatch for example config {example}: {e} — not installed" + " {}", + ui::warn(&format!( + "checksum mismatch for example config {example}: {e} — not installed" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, None => { eprintln!( - " warning: index.json has no sha256 for example config \ - {example} — refusing to install an unverified download" + " {}", + ui::warn(&format!( + "index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, Err(e) => { - eprintln!(" warning: could not download example config {example}: {e}"); - println!(" config dir created at {}", dir.display()); + eprintln!( + " {}", + ui::warn(&format!("could not download example config {example}: {e}")) + ); + ui::step("config", &dir.display().to_string()); } } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } } else { - println!(" config at {} already exists, skipping", dest.display()); + ui::step( + "config", + &format!("{} already exists, skipping", dest.display()), + ); } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } Ok(()) } @@ -366,32 +416,46 @@ fn fetch_verify_write( label: &str, ) -> Result<()> { let Some((primary, fallback)) = pkg.artifact_urls(filename) else { - eprintln!(" warning: no artifact URL to download {label} ({filename})"); + eprintln!( + " {}", + ui::warn(&format!("no artifact URL to download {label} ({filename})")) + ); return Ok(()); }; let bytes = match fetch_binary(&primary, &fallback) { Ok(b) => b, Err(e) => { - eprintln!(" warning: could not download {label} {filename}: {e}"); + eprintln!( + " {}", + ui::warn(&format!("could not download {label} {filename}: {e}")) + ); return Ok(()); } }; let Some(expected) = sha256 else { eprintln!( - " warning: index.json has no sha256 for {label} {filename} — \ - refusing to install an unverified download" + " {}", + ui::warn(&format!( + "index.json has no sha256 for {label} {filename} — \ + refusing to install an unverified download" + )) ); return Ok(()); }; if let Err(e) = verify_sha256(&bytes, expected) { - eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed"); + eprintln!( + " {}", + ui::warn(&format!( + "checksum mismatch for {label} {filename}: {e} — not installed" + )) + ); return Ok(()); } if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; - println!(" installed {label} at {}", dest.display()); + ui::step("installed", &format!("{label} {}", dest.display())); Ok(()) } @@ -411,7 +475,13 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("applications") .join(format!("{}.desktop", pkg.name)); - fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry") + fetch_verify_write( + pkg, + filename, + &pkg.desktop_file_sha256, + &dest, + "desktop entry", + ) } fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { @@ -468,9 +538,15 @@ fn fetch_extract_archive( // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. if status.success() { - println!(" extracted {filename} to {}", dest_dir.display()); + ui::step( + "extracted", + &format!("{filename} → {}", dest_dir.display()), + ); } else { - eprintln!(" warning: tar exited with {status} extracting {filename}"); + eprintln!( + " {}", + ui::warn(&format!("tar exited with {status} extracting {filename}")) + ); } Ok(()) } @@ -552,18 +628,24 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { Ok(bytes) => { std::fs::write(&unit_path, &bytes) .with_context(|| format!("writing {}", unit_path.display()))?; - println!(" downloaded unit {}", unit_path.display()); + ui::step("unit", &unit_path.display().to_string()); } Err(e) => { if had_existing { eprintln!( - " warning: could not refresh unit {} ({e}) — keeping existing copy", - svc.unit + " {}", + ui::warn(&format!( + "could not refresh unit {} ({e}) — keeping existing copy", + svc.unit + )) ); } else { eprintln!( - " warning: unit file {} not found ({e}) — skipping service setup", - svc.unit + " {}", + ui::warn(&format!( + "unit file {} not found ({e}) — skipping service setup", + svc.unit + )) ); return Ok(()); } @@ -578,7 +660,7 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - eprintln!(" warning: systemctl daemon-reload failed"); + eprintln!(" {}", ui::warn("systemctl daemon-reload failed")); } if svc.enable { @@ -595,9 +677,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - println!(" {} restarted", svc.unit); + ui::step("restarted", &svc.unit); } else { - eprintln!(" warning: failed to restart {}", svc.unit); + eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit))); } } else if Command::new("systemctl") .args(["--user", "enable", "--now", &svc.unit]) @@ -605,9 +687,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - println!(" {} enabled and started", svc.unit); + ui::step("enabled", &svc.unit); } else { - eprintln!(" warning: failed to enable {}", svc.unit); + eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit))); } } @@ -650,13 +732,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { } fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> { - println!(" running post_install hook: {cmd}"); + ui::step("hook", cmd); let status = Command::new("sh") .args(["-c", cmd]) .status() .with_context(|| format!("running post_install hook for {pkg_name}"))?; if !status.success() { - eprintln!(" warning: hook exited with {status}"); + eprintln!(" {}", ui::warn(&format!("hook exited with {status}"))); } Ok(()) } @@ -699,11 +781,14 @@ fn warn_path_if_needed(bin_dir: &Path) { let path_var = std::env::var("PATH").unwrap_or_default(); let bin_str = bin_dir.to_string_lossy(); if !path_var.split(':').any(|p| p == bin_str) { - println!( - "\n note: {} is not in PATH — add to your shell profile:", - bin_str + eprintln!(); + eprintln!( + " {}", + ui::note(&format!( + "{bin_str} is not in PATH — add to your shell profile:" + )) ); - println!(" export PATH=\"{}:$PATH\"", bin_str); + println!(" export PATH=\"{bin_str}:$PATH\""); } } @@ -728,10 +813,7 @@ mod tests { if let Ok((mut stream, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", - body.len() - ); + let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(body); } @@ -748,10 +830,7 @@ mod tests { if let Ok((mut stream, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", - body.len() - ); + let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(&body); } @@ -797,8 +876,14 @@ mod tests { let dir = tempdir().unwrap(); let dest = dir.path().join("LICENSE"); - fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") - .unwrap(); + fetch_verify_write( + &pkg, + "LICENSE", + &pkg.license_file_sha256.clone(), + &dest, + "license", + ) + .unwrap(); assert_eq!(fs::read(&dest).unwrap(), license_bytes); } @@ -836,8 +921,14 @@ mod tests { let dir = tempdir().unwrap(); let dest = dir.path().join("LICENSE"); - fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") - .unwrap(); + fetch_verify_write( + &pkg, + "LICENSE", + &pkg.license_file_sha256.clone(), + &dest, + "license", + ) + .unwrap(); // Refused, not erred (matches scaffold_config's warn-and-continue // posture) — the file must not have been written. @@ -885,8 +976,7 @@ mod tests { let pkg = test_package(&base_url); let dest_dir = tempdir().unwrap(); - fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()) - .unwrap(); + fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()).unwrap(); let extracted = dest_dir.path().join("content/tours/onboarding.toml"); assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); @@ -1043,7 +1133,10 @@ mod tests { backup_current_binary(&backup_dir, "mypkg", ¤t); - assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes"); + assert_eq!( + fs::read(backup_dir.join("mypkg")).unwrap(), + b"old version bytes" + ); } #[test] diff --git a/bakery/src/main.rs b/bakery/src/main.rs index fc7e7d1..d3d09b4 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -14,7 +14,12 @@ use std::path::{Path, PathBuf}; use track::Track; #[derive(Parser)] -#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] +#[command( + name = "bakery", + about = "Package manager for the bread ecosystem", + version, + styles = ui::CLAP_STYLES +)] struct Cli { #[command(subcommand)] command: Cmd, @@ -63,13 +68,9 @@ enum Cmd { installed: bool, }, /// Show details for a package - Info { - package: String, - }, + Info { package: String }, /// Search package names and descriptions - Search { - query: String, - }, + Search { query: String }, /// Check system dependencies for installed or requested packages Doctor { /// Package to check; omit to check all installed packages @@ -82,15 +83,11 @@ enum Cmd { }, /// Roll back a package to its previously installed version, from a /// local pre-update backup (not a re-download) - Rollback { - package: String, - }, + Rollback { package: String }, /// Update bakery itself SelfUpdate, /// Generate a shell completion script - Completions { - shell: clap_complete::Shell, - }, + Completions { shell: clap_complete::Shell }, /// View or switch which build track bakery follows (stable/beta/dev) Track { #[command(subcommand)] @@ -130,9 +127,15 @@ fn main() -> Result<()> { Ok(()) } Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge), - Cmd::Update { package, all } => { - cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run) - } + Cmd::Update { package, all } => cmd_update( + package.as_deref(), + all, + &bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + ), Cmd::List { installed } => cmd_list(installed, track), Cmd::Info { package } => cmd_info(&package, track), Cmd::Search { query } => cmd_search(&query, track), @@ -144,7 +147,15 @@ fn main() -> Result<()> { // bakery's own running binary via a normal update already works // (rename-over-running-binary is safe on Linux) but wasn't a real // first-class command. - Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run), + Cmd::SelfUpdate => cmd_update( + Some("bakery"), + false, + &bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + ), Cmd::Completions { shell } => cmd_completions(shell), Cmd::Track { action } => cmd_track(action), } @@ -159,11 +170,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> { let state = state::State::load()?; match action { TrackCmd::Show => { - println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); + ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]); } TrackCmd::Set { track } => { if state.track == track { - println!("already on track {track}"); + println!("{}", ui::unchanged(&format!("already on track {track}"))); return Ok(()); } // Fail fast on a bad/unreachable track rather than silently @@ -174,10 +185,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> { state.set_track(track); Ok(()) })?; - println!( - "switched to {} — run 'bakery update --all' to install {} builds", - ui::style(track.as_str(), ui::CYAN), - track + ui::action("Switched", track.as_str(), None); + ui::step( + "next", + &format!("bakery update --all to install {track} builds"), ); } } @@ -195,7 +206,16 @@ fn cmd_install( dry_run: bool, ) -> Result<()> { let mut visited = HashSet::new(); - install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited) + install_with_deps( + index, + name, + bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + &mut visited, + ) } /// Recursively installs `name` and any bread_deps, skipping already-installed @@ -223,8 +243,10 @@ fn install_with_deps( let state = state::State::load()?; for dep in pkg.bread_deps.clone() { if !state.is_installed(&dep) { - println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" }); - install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?; + ui::step(if dry_run { "would need" } else { "dependency" }, &dep); + install_with_deps( + index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited, + )?; } } @@ -237,26 +259,59 @@ fn install_with_deps( if let Some(installed) = previous { if !is_newer(&installed.version, &pkg.version) { println!( - "{name} already installed at {} (index has {})", - installed.version, pkg.version + " {}", + ui::unchanged(&format!( + "{name} already at {} (index has {})", + installed.version, pkg.version + )) ); return Ok(()); } } - println!("checking system dependencies for {name}…"); + ui::action( + if dry_run { + if previous.is_some() { + "Would update" + } else { + "Would install" + } + } else if previous.is_some() { + "Updating" + } else { + "Installing" + }, + name, + Some(&pkg.version), + ); + ui::step("checking", "system dependencies"); let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?; for warn in &rep.warnings { - eprintln!(" note: optional dep not installed: {warn}"); + eprintln!( + " {}", + ui::note(&format!("optional dep not installed: {warn}")) + ); } if !rep.missing.is_empty() { - eprintln!("missing system deps for {name}: {}", rep.missing.join(", ")); - eprintln!("install with: {}", doctor::install_hint(&rep.missing)); + eprintln!( + " {}", + ui::fail(&format!( + "missing system deps for {name}: {}", + rep.missing.join(", ") + )) + ); + eprintln!( + " {}", + ui::dim(&format!( + "install with: {}", + doctor::install_hint(&rep.missing) + )) + ); bail!("system deps not satisfied"); } if dry_run { - print_dry_run_plan(pkg, previous); + print_dry_run_plan(pkg); return Ok(()); } @@ -268,22 +323,23 @@ fn install_with_deps( /// already been made — this only renders that decision, it never /// recomputes it, so dry-run and real runs can't drift apart on "would this /// update happen at all". -fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) { - let verb = if previous.is_some() { "update" } else { "install" }; - println!( - " {} would {verb} {} to {}", - ui::style("dry-run:", ui::DIM), - pkg.name, - ui::style(&pkg.version, ui::BOLD) - ); - println!( - " binaries: {}", - pkg.binaries.iter().map(|b| b.name.as_str()).collect::>().join(", ") +fn print_dry_run_plan(pkg: &manifest::Package) { + ui::kv( + "binaries", + &pkg.binaries + .iter() + .map(|b| b.name.as_str()) + .collect::>() + .join(", "), ); if !pkg.services.is_empty() { - println!( - " services: {}", - pkg.services.iter().map(|s| s.unit.as_str()).collect::>().join(", ") + ui::kv( + "services", + &pkg.services + .iter() + .map(|s| s.unit.as_str()) + .collect::>() + .join(", "), ); } } @@ -311,10 +367,16 @@ fn cmd_update( }; if targets.is_empty() { - println!("no packages installed"); + println!("{}", ui::dim("no packages installed")); return Ok(()); } + let mut targets = targets; + targets.sort(); + if all { + ui::heading("Update", &[&format!("{} packages", targets.len())]); + } + let mut any_failed = false; let mut updated = 0u32; let mut unchanged = 0u32; @@ -322,7 +384,10 @@ fn cmd_update( let installed = match state.packages.get(pkg_name.as_str()) { Some(p) => p, None => { - eprintln!("{pkg_name} is not installed, skipping"); + eprintln!( + " {}", + ui::fail(&format!("{pkg_name} is not installed, skipping")) + ); any_failed = true; continue; } @@ -330,7 +395,10 @@ fn cmd_update( let latest = match index.get(pkg_name) { Some(p) => p, None => { - eprintln!("{pkg_name} not found in index, skipping"); + eprintln!( + " {}", + ui::fail(&format!("{pkg_name} not found in index, skipping")) + ); any_failed = true; continue; } @@ -350,57 +418,86 @@ fn cmd_update( // under a terminal palette that maps ANSI colors unusually. println!( " {}", - ui::unchanged(&format!("{pkg_name} is already at {}", installed.version)) + ui::unchanged(&format!("{pkg_name} already at {}", installed.version)) ); unchanged += 1; continue; } + ui::action( + if dry_run { "Would update" } else { "Updating" }, + pkg_name, + Some(&latest.version), + ); if track_switch { - println!( - "{pkg_name} switching track {} {} {}, installing {}", - ui::style(installed.track.as_str(), ui::DIM), - ui::style("→", ui::CYAN), - ui::style(track.as_str(), ui::BOLD), - ui::style(&latest.version, ui::BOLD) + ui::step( + "track", + &format!( + "{} {} {}", + ui::dim(installed.track.as_str()), + ui::style("→", ui::CYAN), + ui::bold(track.as_str()), + ), ); } else { - println!( - "updating {pkg_name} {} {} {}", - ui::style(&installed.version, ui::DIM), - ui::style("→", ui::CYAN), - ui::style(&latest.version, ui::BOLD) + ui::step( + "version", + &format!( + "{} {} {}", + ui::dim(&installed.version), + ui::style("→", ui::CYAN), + ui::bold(&latest.version) + ), ); } let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { Ok(r) => r, Err(e) => { - eprintln!(" doctor check failed for {pkg_name}: {e}"); + eprintln!( + " {}", + ui::fail(&format!("doctor check failed for {pkg_name}: {e}")) + ); any_failed = true; continue; } }; for warn in &rep.warnings { - eprintln!(" note: optional dep not installed: {warn}"); + eprintln!( + " {}", + ui::note(&format!("optional dep not installed: {warn}")) + ); } if !rep.missing.is_empty() { eprintln!( - " missing deps for {pkg_name}: {} — skipping update", - rep.missing.join(", ") + " {}", + ui::fail(&format!( + "missing deps for {pkg_name}: {} — skipping update", + rep.missing.join(", ") + )) ); any_failed = true; continue; } if dry_run { - print_dry_run_plan(latest, Some(installed)); + print_dry_run_plan(latest); updated += 1; continue; } - if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) { - eprintln!(" failed to update {pkg_name}: {e}"); + if let Err(e) = install::install_package( + latest, + bin_dir, + track, + Some(installed), + no_hooks, + assume_yes, + ) { + eprintln!( + " {}", + ui::fail(&format!("failed to update {pkg_name}: {e}")) + ); any_failed = true; } else { updated += 1; @@ -410,12 +507,14 @@ fn cmd_update( // Only for --all: a single named update already makes its own outcome // obvious, and "1 updated, 0 already up to date" isn't a useful takeaway. if all { - let mut parts = Vec::new(); + let updated_s = format!("{updated} updated"); + let unchanged_s = format!("{unchanged} already current"); + let mut parts: Vec<&str> = Vec::new(); if updated > 0 { - parts.push(format!("{updated} updated")); + parts.push(&updated_s); } - parts.push(format!("{unchanged} already up to date")); - println!("{}", ui::style(&parts.join(", "), ui::BOLD)); + parts.push(&unchanged_s); + ui::summary(&parts); } if any_failed { @@ -427,7 +526,12 @@ fn cmd_update( /// Whether `pkg_name` should be updated: always true on a track switch /// (an explicit user action that must take effect regardless of version /// ordering), otherwise a real semver comparison via [`is_newer`]. -fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool { +fn should_update( + installed_version: &str, + installed_track: Track, + active_track: Track, + latest_version: &str, +) -> bool { if installed_track != active_track { return true; } @@ -441,7 +545,10 @@ fn should_update(installed_version: &str, installed_track: Track, active_track: /// (with a warning) for any version string that isn't valid semver, rather /// than hard-erroring on packages built before this convention existed. fn is_newer(installed: &str, latest: &str) -> bool { - match (semver::Version::parse(installed), semver::Version::parse(latest)) { + match ( + semver::Version::parse(installed), + semver::Version::parse(latest), + ) { (Ok(i), Ok(l)) => l > i, _ => { if installed != latest { @@ -455,15 +562,14 @@ fn is_newer(installed: &str, latest: &str) -> bool { } } -/// Prints one index entry in the shared `list`/`search` format: name, -/// version, description, and an `[installed ]` tag when applicable. -fn print_index_entry(pkg: &manifest::Package, state: &state::State) { - let tag = if state.is_installed(&pkg.name) { - ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN) - } else { - String::new() - }; - println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); +fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow { + ui::CatalogRow { + name: pkg.name.clone(), + version: pkg.version.clone(), + installed: state.is_installed(&pkg.name), + detail: pkg.description.clone(), + aside: String::new(), + } } fn cmd_list(installed_only: bool, track: Track) -> Result<()> { @@ -471,24 +577,44 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> { if installed_only { if state.packages.is_empty() { - println!("no packages installed"); - } - for pkg in state.packages.values() { - println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at); + println!("{}", ui::dim("no packages installed")); + return Ok(()); } + let mut pkgs: Vec<_> = state.packages.values().collect(); + pkgs.sort_by(|a, b| a.name.cmp(&b.name)); + ui::heading("Installed", &[&pkgs.len().to_string()]); + let rows: Vec = pkgs + .iter() + .map(|pkg| ui::CatalogRow { + name: pkg.name.clone(), + version: pkg.version.clone(), + installed: true, + detail: String::new(), + aside: ui::short_date(&pkg.installed_at), + }) + .collect(); + ui::print_catalog(&rows); return Ok(()); } - if !matches!(track, Track::Stable) { - println!("tracking:{}\n", ui::track_badge(track)); - } - let index = manifest::load(false, track)?; let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); names.sort(); - for name in names { - print_index_entry(&index.packages[name], &state); - } + let installed = names.iter().filter(|n| state.is_installed(n)).count(); + let track_part = ui::track_tag(track); + ui::heading( + "Packages", + &[ + &format!("{} in index", names.len()), + &format!("{installed} installed"), + &track_part, + ], + ); + let rows: Vec = names + .iter() + .map(|name| catalog_row(&index.packages[*name], &state)) + .collect(); + ui::print_catalog(&rows); Ok(()) } @@ -513,13 +639,19 @@ fn cmd_search(query: &str, track: Track) -> Result<()> { names.sort(); if names.is_empty() { - println!("no packages matched '{query}'"); + println!("{}", ui::dim(&format!("no packages matched '{query}'"))); return Ok(()); } - for name in names { - print_index_entry(&index.packages[name], &state); - } + ui::heading( + "Search", + &[&format!("'{query}'"), &format!("{} matches", names.len())], + ); + let rows: Vec = names + .iter() + .map(|name| catalog_row(&index.packages[*name], &state)) + .collect(); + ui::print_catalog(&rows); Ok(()) } @@ -531,41 +663,45 @@ fn cmd_info(name: &str, track: Track) -> Result<()> { let state = state::State::load()?; let status = if let Some(inst) = state.packages.get(name) { - ui::style(&format!("installed ({})", inst.version), ui::GREEN) + ui::style(&format!("installed {}", inst.version), ui::GREEN) } else { ui::style("not installed", ui::DIM) }; - println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); - println!(" {}", pkg.description); - println!(" status: {status}"); - println!( - " binaries: {}", - pkg.binaries + let track_part = ui::track_tag(track); + ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]); + println!(" {}\n", pkg.description); + ui::kv("status", &status); + ui::kv( + "binaries", + &pkg.binaries .iter() .map(|b| b.name.as_str()) .collect::>() - .join(", ") + .join(", "), ); if !pkg.system_deps.is_empty() { - println!(" system deps: {}", pkg.system_deps.join(", ")); + ui::kv("system deps", &pkg.system_deps.join(", ")); } if !pkg.optional_system_deps.is_empty() { - println!(" optional deps: {}", pkg.optional_system_deps.join(", ")); + ui::kv("optional", &pkg.optional_system_deps.join(", ")); } if !pkg.bread_deps.is_empty() { - println!(" bread deps: {}", pkg.bread_deps.join(", ")); + ui::kv("bread deps", &pkg.bread_deps.join(", ")); } if !pkg.services.is_empty() { - println!( - " services: {}", - pkg.services + ui::kv( + "services", + &pkg.services .iter() .map(|s| s.unit.as_str()) .collect::>() - .join(", ") + .join(", "), ); } + if let Some(inst) = state.packages.get(name) { + ui::kv("installed", &ui::short_date(&inst.installed_at)); + } Ok(()) } @@ -584,18 +720,33 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re }; if targets.is_empty() { - println!("no packages installed — nothing to check"); + println!("{}", ui::dim("no packages installed — nothing to check")); return Ok(()); } + let mut targets = targets; + targets.sort(); + ui::heading("Doctor", &[&format!("{} packages", targets.len())]); + let name_w = ui::name_width(&targets); + let mut all_ok = true; for pkg_name in &targets { if let Some(pkg) = index.get(pkg_name) { - if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { + if !doctor::report( + pkg_name, + &pkg.system_deps, + &pkg.optional_system_deps, + name_w, + ) { all_ok = false; } } else { - eprintln!(" {pkg_name}: not found in index (removed from registry?)"); + ui::check_row( + false, + pkg_name, + name_w, + "not found in index (removed from registry?)", + ); all_ok = false; } @@ -621,7 +772,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re } if all_ok { - println!("{}", ui::ok("all checks passed")); + ui::summary(&[&ui::ok("all checks passed")]); } Ok(()) } @@ -674,38 +825,47 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { }; if targets.is_empty() { - println!("no packages installed — nothing to verify"); + println!("{}", ui::dim("no packages installed — nothing to verify")); return Ok(()); } + let mut targets = targets; + targets.sort(); + ui::heading("Verify", &[&format!("{} packages", targets.len())]); + let name_w = ui::name_width(&targets); + let mut any_bad = false; for pkg_name in &targets { let installed = &state.packages[pkg_name]; if installed.binary_sha256.is_empty() { - println!( - " {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)", - ui::style("?", ui::DIM) + ui::unknown_row( + pkg_name, + name_w, + "no recorded checksums (installed before verify support)", ); continue; } for bin in &installed.binaries { match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) { - VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))), + VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin), VerifyStatus::Missing => { - eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING"))); + ui::check_row(false, pkg_name, name_w, &format!("{bin} missing")); any_bad = true; } VerifyStatus::Tampered => { - eprintln!( - " {}", - ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)")) + ui::check_row( + false, + pkg_name, + name_w, + &format!("{bin} tampered (checksum mismatch)"), ); any_bad = true; } VerifyStatus::Unknown => { - println!( - " {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)", - ui::style("?", ui::DIM) + ui::unknown_row( + pkg_name, + name_w, + &format!("{bin} no recorded checksum for this binary"), ); } } @@ -715,7 +875,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { if any_bad { bail!("verification failed for one or more binaries"); } - println!("{}", ui::ok("all recorded checksums match")); + ui::summary(&[&ui::ok("all recorded checksums match")]); Ok(()) } @@ -728,12 +888,19 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { /// why rollback is backup-based rather than a network re-pin in the first /// place. Pure with respect to global state (caller supplies both dirs), so /// this is the piece of `bakery rollback` that's directly unit-testable. -fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result> { +fn restore_binaries( + backup_dir: &Path, + binaries: &[String], + bin_dir: &Path, +) -> Result> { let mut sha256 = HashMap::new(); for bin in binaries { let backup_path = backup_dir.join(bin); if !backup_path.exists() { - bail!("backup for binary '{bin}' is missing at {}", backup_path.display()); + bail!( + "backup for binary '{bin}' is missing at {}", + backup_path.display() + ); } let bytes = std::fs::read(&backup_path) .with_context(|| format!("reading backup {}", backup_path.display()))?; @@ -767,6 +934,8 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to") })?; + ui::action("Rolling back", pkg_name, Some(&target_version)); + let backup_dir = state::backup_dir(pkg_name, &target_version); if !backup_dir.exists() { bail!( @@ -794,7 +963,10 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { println!( " {}", - ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}")) + ui::ok(&format!( + "rolled back {pkg_name} {} → {target_version}", + ui::dim(&from_version) + )) ); Ok(()) } @@ -838,18 +1010,33 @@ mod tests { fn should_update_true_on_track_switch_even_if_not_newer_by_semver() { // "bakery track set stable && bakery update --all" from beta must // always take effect, even though 0.3.0 < 0.4.0-beta by strict semver. - assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0")); + assert!(should_update( + "0.4.0-beta", + Track::Beta, + Track::Stable, + "0.3.0" + )); } #[test] fn should_update_false_when_same_track_and_not_newer() { - assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1")); + assert!(!should_update( + "0.3.1", + Track::Stable, + Track::Stable, + "0.3.1" + )); assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1")); } #[test] fn should_update_true_when_same_track_and_newer() { - assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2")); + assert!(should_update( + "0.3.1", + Track::Stable, + Track::Stable, + "0.3.2" + )); } #[test] @@ -902,7 +1089,10 @@ mod tests { let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable"); let mut packages = std::collections::HashMap::new(); packages.insert(name.to_string(), pkg); - let index = manifest::Index { version: "1".to_string(), packages }; + let index = manifest::Index { + version: "1".to_string(), + packages, + }; let bin_dir = tempdir().unwrap(); let mut visited = HashSet::new(); @@ -926,7 +1116,10 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"good bytes").unwrap(); let hash = hex::encode(Sha256::digest(b"good bytes")); - assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok); + assert_eq!( + verify_binary(dir.path(), "mypkg", Some(&hash)), + VerifyStatus::Ok + ); } #[test] @@ -934,21 +1127,30 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap(); let wrong_hash = "0".repeat(64); - assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered); + assert_eq!( + verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), + VerifyStatus::Tampered + ); } #[test] fn verify_binary_missing_when_file_absent() { let dir = tempdir().unwrap(); let hash = "0".repeat(64); - assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing); + assert_eq!( + verify_binary(dir.path(), "nope", Some(&hash)), + VerifyStatus::Missing + ); } #[test] fn verify_binary_unknown_when_no_recorded_hash() { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"bytes").unwrap(); - assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown); + assert_eq!( + verify_binary(dir.path(), "mypkg", None), + VerifyStatus::Unknown + ); } #[test] @@ -962,8 +1164,14 @@ mod tests { let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap(); - assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes"); - assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes"))); + assert_eq!( + fs::read(bin_dir.join("mypkg")).unwrap(), + b"old version bytes" + ); + assert_eq!( + hashes["mypkg"], + hex::encode(Sha256::digest(b"old version bytes")) + ); } #[test] diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5b5f5aa..5cf4e9c 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -52,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> { /// exercise the verification logic with a throwaway keypair instead of the /// real production key. fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { - let public_key = - PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; + let public_key = PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; let signature = Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; public_key @@ -183,9 +182,7 @@ pub fn load(force_refresh: bool, track: Track) -> Result { match read_and_verify_cache(&cache_path, &sig_cache_path, track) { Ok(index) => return Ok(index), Err(err) => { - eprintln!( - " warning: cached index.json failed verification ({err}), re-fetching…" - ); + eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…"); } } } @@ -211,17 +208,12 @@ pub fn load(force_refresh: bool, track: Track) -> Result { } } -fn read_and_verify_cache( - cache_path: &Path, - sig_cache_path: &Path, - track: Track, -) -> Result { +fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result { let bytes = std::fs::read(cache_path).context("reading cached index")?; let sig_text = std::fs::read_to_string(sig_cache_path) .context("reading cached index.json.minisig (cache predates signing support)")?; - verify_index_signature(&bytes, &sig_text).with_context(|| { - format!("cached {track} index failed signature verification") - })?; + verify_index_signature(&bytes, &sig_text) + .with_context(|| format!("cached {track} index failed signature verification"))?; serde_json::from_slice(&bytes).context("parsing cached index") } @@ -285,8 +277,10 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result> { Ok(bytes) => Ok(bytes), Err(primary_err) => { eprintln!( - " primary URL failed ({}), trying GitHub fallback…", - primary_err + " {}", + crate::ui::note(&format!( + "primary URL failed ({primary_err}), trying GitHub fallback…" + )) ); fetch_bytes(fallback_url).context("both primary and GitHub fallback failed") } @@ -305,9 +299,7 @@ const CHUNK_SIZE: usize = 64 * 1024; fn fetch_bytes(url: &str) -> Result> { use std::io::{IsTerminal, Read}; - let resp = ureq::get(url) - .call() - .map_err(|e| anyhow::anyhow!("{e}"))?; + let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?; let status = resp.status(); if status != 200 { bail!("HTTP {status} from {url}"); @@ -320,7 +312,11 @@ fn fetch_bytes(url: &str) -> Result> { // is what makes printing partway through the download possible, without // pulling in a progress-bar crate for what's meant to just be reassurance. let content_length: Option = resp.header("Content-Length").and_then(|v| v.parse().ok()); - let show_progress = content_length.is_some() && std::io::stderr().is_terminal(); + // Progress is reassurance for multi-MB binaries. A 4 KB index fetch + // drawing a 100% / 0.0 MB bar is noise, not feedback. + const MIN_PROGRESS_BYTES: u64 = 256 * 1024; + let show_progress = + content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal(); let mut buf = Vec::new(); let mut reader = resp.into_reader(); @@ -336,27 +332,17 @@ fn fetch_bytes(url: &str) -> Result> { bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit"); } if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE { - print_progress(buf.len() as u64, content_length.unwrap()); + crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); last_print = std::time::Instant::now(); } } if show_progress { - print_progress(buf.len() as u64, content_length.unwrap()); - eprintln!(); + crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); + crate::ui::finish_progress(); } Ok(buf) } -fn print_progress(downloaded: u64, total: u64) { - use std::io::Write; - eprint!( - "\r ⇣ {:.1}/{:.1} MB", - downloaded as f64 / 1_048_576.0, - total as f64 / 1_048_576.0 - ); - let _ = std::io::stderr().flush(); -} - #[cfg(test)] mod tests { use super::*; @@ -408,10 +394,7 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ fn stable_cache_path_matches_pre_track_filename() { // Must stay exactly "index.json" so an existing warm cache from a // pre-track bakery binary is still used after an upgrade. - assert_eq!( - cache_path(Track::Stable).file_name().unwrap(), - "index.json" - ); + assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json"); } #[test] @@ -428,13 +411,22 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ #[test] fn stable_url_has_no_track_prefix() { - assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url())); + assert_eq!( + primary_url(Track::Stable), + format!("{}/index.json", base_url()) + ); } #[test] fn beta_and_dev_urls_are_track_prefixed() { - assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); - assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); + assert_eq!( + primary_url(Track::Beta), + format!("{}/beta/index.json", base_url()) + ); + assert_eq!( + primary_url(Track::Dev), + format!("{}/dev/index.json", base_url()) + ); } fn minimal_package_json() -> &'static str { diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs index c1408f3..0a90390 100644 --- a/bakery/src/ui.rs +++ b/bakery/src/ui.rs @@ -1,5 +1,6 @@ use crate::track::Track; -use std::io::IsTerminal; +use clap::builder::styling::{AnsiColor, Effects, Styles}; +use std::io::{IsTerminal, Write}; pub const RESET: &str = "\x1b[0m"; pub const BOLD: &str = "\x1b[1m"; @@ -9,6 +10,19 @@ pub const GREEN: &str = "\x1b[32m"; pub const YELLOW: &str = "\x1b[33m"; pub const CYAN: &str = "\x1b[36m"; pub const MAGENTA: &str = "\x1b[35m"; +pub const BOLD_CYAN: &str = "\x1b[1;36m"; + +/// Clap help styling — same cyan headers / green literals / dim placeholders +/// as the rest of bakery, so `bakery --help` doesn't look like a different +/// program from `bakery list`. +pub const CLAP_STYLES: Styles = Styles::styled() + .header(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::BrightBlack.on_default()) + .error(AnsiColor::Red.on_default().effects(Effects::BOLD)) + .valid(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD)); /// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't /// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI @@ -18,21 +32,52 @@ pub fn colors_enabled() -> bool { std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } +pub fn colors_enabled_err() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() +} + pub fn style(s: &str, code: &str) -> String { - if colors_enabled() { + paint(s, code, colors_enabled()) +} + +fn style_err(s: &str, code: &str) -> String { + paint(s, code, colors_enabled_err()) +} + +fn paint(s: &str, code: &str, on: bool) -> String { + if on { format!("{code}{s}{RESET}") } else { s.to_string() } } +pub fn bold(s: &str) -> String { + style(s, BOLD) +} + +pub fn dim(s: &str) -> String { + style(s, DIM) +} + /// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the /// common-case output is unchanged. +#[allow(dead_code)] pub fn track_badge(track: Track) -> String { + let tag = track_tag(track); + if tag.is_empty() { + tag + } else { + format!(" {tag}") + } +} + +/// `[beta]` / `[dev]` with no leading space; empty for `Stable`. +pub fn track_tag(track: Track) -> String { match track { Track::Stable => String::new(), - Track::Beta => format!(" {}", style("[beta]", YELLOW)), - Track::Dev => format!(" {}", style("[dev]", MAGENTA)), + Track::Beta => style("[beta]", YELLOW), + Track::Dev => style("[dev]", MAGENTA), } } @@ -54,6 +99,278 @@ pub fn unchanged(s: &str) -> String { style(&format!("· {s}"), DIM) } +pub fn warn(s: &str) -> String { + style(&format!("warning: {s}"), YELLOW) +} + +pub fn note(s: &str) -> String { + style(&format!("note: {s}"), DIM) +} + +/// Cyan verb + bold name + dim version — the install/update/remove banner. +pub fn action(verb: &str, name: &str, version: Option<&str>) { + let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD)); + if let Some(v) = version { + line.push_str(" "); + line.push_str(&style(v, DIM)); + } + println!("{line}"); +} + +/// Section title plus dim meta (`Packages 16 · 15 installed`). +pub fn heading(title: &str, parts: &[&str]) { + let mut line = style(title, BOLD_CYAN); + let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect(); + for (i, part) in visible.iter().enumerate() { + line.push_str(" "); + if i > 0 { + line.push_str(&style("·", DIM)); + line.push_str(" "); + } + line.push_str(part); + } + println!("{line}"); + println!(); +} + +pub fn summary(parts: &[&str]) { + let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect(); + if visible.is_empty() { + return; + } + println!(); + println!("{}", style(&visible.join(" · "), BOLD)); +} + +/// Left-aligned verb column so install chatter (`downloading` / `placed` / +/// `unit`) lines up instead of drifting with the verb length. +pub fn step(verb: &str, detail: &str) { + println!(" {:<12} {}", dim(verb), detail); +} + +pub fn kv(key: &str, value: &str) { + println!(" {:<12} {}", dim(key), value); +} + +pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) { + let glyph = if ok_flag { + style("✓", GREEN) + } else { + style("✗", RED) + }; + println!(" {glyph} {: Vec { + if rows.is_empty() { + return Vec::new(); + } + let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0); + let indent = 5; // " ✓ " / " " + let detail_width = width.saturating_sub(indent).max(24); + + let mut lines = Vec::new(); + for row in rows { + let glyph = if row.installed { + style("✓", GREEN) + } else { + " ".to_string() + }; + let name = style(&format!("{: Vec { + if width == 0 { + return vec![text.to_string()]; + } + let mut lines = Vec::new(); + let mut cur = String::new(); + for word in text.split_whitespace() { + if cur.is_empty() { + cur = word.to_string(); + } else if cur.len() + 1 + word.len() <= width { + cur.push(' '); + cur.push_str(word); + } else { + lines.push(std::mem::take(&mut cur)); + cur = word.to_string(); + } + } + if !cur.is_empty() { + lines.push(cur); + } + lines +} + +pub fn short_date(rfc3339: &str) -> String { + chrono::DateTime::parse_from_rfc3339(rfc3339) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|_| rfc3339.to_string()) +} + +pub fn name_width>(names: impl IntoIterator) -> usize { + names + .into_iter() + .map(|s| s.as_ref().len()) + .max() + .unwrap_or(0) +} + +/// `\r`-overwritten download bar on stderr. Pads to a stable width so a +/// shorter later frame doesn't leave leftover characters from a longer one. +pub fn print_progress(downloaded: u64, total: u64) { + let width = term_width().clamp(40, 72); + let line = progress_line(downloaded, total, 20); + let padded = fit_width(&line, width); + eprint!("\r{padded}"); + let _ = std::io::stderr().flush(); +} + +pub fn finish_progress() { + eprintln!(); +} + +pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String { + let dl = downloaded as f64 / 1_048_576.0; + let tot = total as f64 / 1_048_576.0; + let frac = if total == 0 { + 0.0 + } else { + (downloaded as f64 / total as f64).clamp(0.0, 1.0) + }; + let filled = ((bar_width as f64) * frac).round() as usize; + let filled = filled.min(bar_width); + let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled)); + let pct = (frac * 100.0).round() as u32; + format!( + " ⇣ {} {:>3}% {:.1}/{:.1} MB", + style_err(&bar, CYAN), + pct, + dl, + tot + ) +} + +fn fit_width(s: &str, width: usize) -> String { + let visible = visible_len(s); + if visible >= width { + return s.to_string(); + } + format!("{s}{}", " ".repeat(width - visible)) +} + +fn visible_len(s: &str) -> usize { + let mut n = 0; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + if chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + n += 1; + } + n +} + +pub fn term_width() -> usize { + if let Ok(w) = std::env::var("COLUMNS") { + if let Ok(n) = w.parse::() { + if n >= 40 { + return n; + } + } + } + ioctl_width().filter(|&n| n >= 40).unwrap_or(80) +} + +#[cfg(unix)] +fn ioctl_width() -> Option { + use std::os::fd::AsRawFd; + + #[repr(C)] + struct WinSize { + row: u16, + col: u16, + x: u16, + y: u16, + } + + unsafe extern "C" { + fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32; + } + + let mut ws = WinSize { + row: 0, + col: 0, + x: 0, + y: 0, + }; + // TIOCGWINSZ on Linux. + let fd = std::io::stdout().as_raw_fd(); + let ret = unsafe { ioctl(fd, 0x5413, &mut ws) }; + if ret == 0 && ws.col > 0 { + Some(ws.col as usize) + } else { + None + } +} + +#[cfg(not(unix))] +fn ioctl_width() -> Option { + None +} + #[cfg(test)] mod tests { use super::*; @@ -77,4 +394,64 @@ mod tests { assert!(!ok("foo").contains('·')); assert!(!fail("foo").contains('·')); } + + #[test] + fn catalog_aligns_names_and_versions() { + let lines = format_catalog( + &[ + CatalogRow { + name: "bakery".into(), + version: "0.7.2-dev.20260815142350+30517f1".into(), + installed: true, + detail: "Package manager".into(), + aside: String::new(), + }, + CatalogRow { + name: "breadarr".into(), + version: "0.1.2".into(), + installed: false, + detail: "Homelab arr stack".into(), + aside: String::new(), + }, + ], + 80, + ); + assert_eq!(lines.len(), 4); + assert!(lines[0].contains("bakery")); + assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1")); + assert!(lines[1].contains("Package manager")); + // Shorter version is padded so the columns stay a block, not a + // ragged list — the long bakery version used to overflow `{: <10}`. + // Compare display columns, not byte offsets: the installed glyph + // is a 3-byte checkmark sitting in a 1-column slot. + let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]); + let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]); + assert_eq!(bakery_col, breadarr_col); + } + + #[test] + fn wrap_words_breaks_on_width() { + let lines = wrap_words("one two three four", 9); + assert_eq!(lines, vec!["one two", "three", "four"]); + } + + #[test] + fn progress_line_has_bar_and_percent() { + let line = progress_line(1_048_576, 2_097_152, 10); + assert!(line.contains('█')); + assert!(line.contains('░')); + assert!(line.contains("50%")); + assert!(line.contains("1.0/2.0 MB")); + } + + #[test] + fn visible_len_ignores_ansi() { + assert_eq!(visible_len("hello"), 5); + assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5); + } + + #[test] + fn short_date_from_rfc3339() { + assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15"); + } }