use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use console::style;
use std::cmp::Ordering;
use stout_audit::compare_versions;
use stout_index::{Database, IndexSync};
use stout_state::{Config, InstalledPackages, Paths};
#[derive(ClapArgs)]
pub struct Args {
pub formulas: Vec<String>,
#[arg(long, short = 'v')]
pub verbose: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub formula: bool,
#[arg(long)]
pub cask: bool,
#[arg(long)]
pub greedy: bool,
#[arg(long = "fetch-HEAD")]
pub fetch_head: bool,
}
#[derive(Debug, serde::Serialize)]
struct OutdatedPackage {
name: String,
installed_version: String,
current_version: String,
pinned: bool,
}
#[derive(Debug, serde::Serialize)]
struct OutdatedCask {
token: String,
installed_version: String,
current_version: String,
}
#[derive(Debug, serde::Serialize)]
struct HeadUpdate {
name: String,
installed_sha: String,
latest_sha: String,
}
pub async fn run(args: Args) -> Result<()> {
let paths = Paths::default();
let installed = InstalledPackages::load(&paths)?;
let config = Config::load(&paths)?;
let db = Database::open(paths.index_db())
.context("Failed to open index. Run 'stout update' first.")?;
if !db.is_initialized()? {
eprintln!(
"{} Index not initialized. Run 'stout update' first.",
style("error:").red().bold()
);
std::process::exit(1);
}
let mut outdated: Vec<OutdatedPackage> = Vec::new();
let mut head_updates: Vec<HeadUpdate> = Vec::new();
if !args.cask {
let packages_to_check: Vec<String> = if args.formulas.is_empty() {
installed.names().map(|s| s.to_string()).collect()
} else {
args.formulas.clone()
};
for name in packages_to_check {
let pkg = match installed.get(&name) {
Some(p) => p,
None => continue,
};
if pkg.is_head_install() {
continue;
}
if let Ok(Some(info)) = db.get_formula(&name) {
if compare_versions(&pkg.version, &info.version) == Ordering::Less {
outdated.push(OutdatedPackage {
name: name.clone(),
installed_version: pkg.version.clone(),
current_version: info.version,
pinned: pkg.pinned,
});
}
}
}
}
if !args.greedy {
outdated.retain(|p| !p.pinned);
}
let mut outdated_casks: Vec<OutdatedCask> = Vec::new();
if !args.formula {
let cask_state_path = paths.stout_dir.join("casks.json");
if let Ok(installed_casks) = stout_cask::InstalledCasks::load(&cask_state_path) {
for (token, cask) in installed_casks.iter() {
if cask.version == "unknown" {
continue;
}
if let Ok(Some(info)) = db.get_cask(token) {
if compare_versions(&cask.version, &info.version) == Ordering::Less {
outdated_casks.push(OutdatedCask {
token: token.to_string(),
installed_version: cask.version.clone(),
current_version: info.version,
});
}
}
}
}
}
if args.fetch_head && !args.cask {
let sync = IndexSync::with_security_policy(
Some(&config.index.base_url),
&paths.stout_dir,
config.security.to_security_policy(),
)?;
for (name, pkg) in installed.iter() {
if !pkg.is_head_install() {
continue;
}
let formula = match sync.fetch_formula_cached(name, None).await {
Ok(f) => f,
Err(_) => continue,
};
let head_url = match &formula.urls.head {
Some(url) => url,
None => continue,
};
let remote_sha = get_remote_head_sha(&head_url.url, &head_url.branch).ok();
if let (Some(current), Some(remote)) = (&pkg.head_sha, remote_sha) {
if current != &remote {
let short_remote: String = remote.chars().take(7).collect();
head_updates.push(HeadUpdate {
name: name.clone(),
installed_sha: pkg.short_sha().unwrap_or("?").to_string(),
latest_sha: short_remote,
});
}
}
}
}
if args.json {
let output = serde_json::json!({
"formulas": outdated,
"casks": outdated_casks,
"head_updates": head_updates,
});
let json = serde_json::to_string_pretty(&output)?;
println!("{}", json);
} else if outdated.is_empty() && outdated_casks.is_empty() && head_updates.is_empty() {
if args.formulas.is_empty() {
println!("{}", style("All packages are up to date.").green());
} else {
println!("{}", style("Specified packages are up to date.").green());
}
} else {
if !outdated.is_empty() {
for pkg in &outdated {
if args.verbose {
println!(
"{} {} -> {}{}",
style(&pkg.name).cyan(),
style(&pkg.installed_version).yellow(),
style(&pkg.current_version).green(),
if pkg.pinned {
style(" [pinned]").dim().to_string()
} else {
String::new()
}
);
} else {
print!("{}", pkg.name);
if pkg.pinned {
print!(" {}", style("[pinned]").dim());
}
println!();
}
}
println!(
"\n{} {} outdated formula{}",
style("==>").blue().bold(),
outdated.len(),
if outdated.len() == 1 { "" } else { "s" }
);
}
if !outdated_casks.is_empty() {
if !outdated.is_empty() {
println!();
}
for cask in &outdated_casks {
if args.verbose {
println!(
"{} {} -> {}",
style(&cask.token).magenta(),
style(&cask.installed_version).yellow(),
style(&cask.current_version).green(),
);
} else {
println!("{} (cask)", cask.token);
}
}
println!(
"\n{} {} outdated cask{}",
style("==>").blue().bold(),
outdated_casks.len(),
if outdated_casks.len() == 1 { "" } else { "s" }
);
}
if !head_updates.is_empty() {
if !outdated.is_empty() || !outdated_casks.is_empty() {
println!();
}
for update in &head_updates {
println!(
"{} {} (HEAD) {} → {}",
style("~").yellow(),
style(&update.name).cyan(),
style(&update.installed_sha).dim(),
style(&update.latest_sha).green()
);
}
println!(
"\n{} {} HEAD package{} have updates",
style("==>").blue().bold(),
head_updates.len(),
if head_updates.len() == 1 { "" } else { "s" }
);
println!(
"\n {}",
style("Use 'stout reinstall <package>' to update HEAD packages").dim()
);
}
}
Ok(())
}
fn get_remote_head_sha(url: &str, branch: &Option<String>) -> Result<String> {
let branch = branch.as_deref().unwrap_or("HEAD");
let output = std::process::Command::new("git")
.args(["ls-remote", url, branch])
.output()
.context("git ls-remote failed")?;
if !output.status.success() {
anyhow::bail!("git ls-remote returned non-zero exit code");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sha = stdout
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("No SHA in git ls-remote output"))?;
Ok(sha.to_string())
}