use anyhow::Result;
use clap::Args as ClapArgs;
use console::style;
use stout_cask::InstalledCasks;
use stout_install::cask_scan::scan_caskroom;
use stout_install::cellar::{scan_cellar, scan_cellar_package, timestamp_to_iso, CellarPackage};
use stout_install::relocate_bottle;
use stout_state::{InstalledPackages, Paths};
#[derive(ClapArgs)]
pub struct Args {
pub packages: Vec<String>,
#[arg(short = 'n', long)]
pub dry_run: bool,
#[arg(long)]
pub overwrite: bool,
#[arg(short, long)]
pub verbose: bool,
}
pub async fn run(args: Args) -> Result<()> {
let paths = Paths::default();
paths.ensure_dirs()?;
let mut installed = InstalledPackages::load(&paths)?;
println!("\n{}...", style("Scanning Homebrew").cyan());
let cellar_packages = if args.packages.is_empty() {
scan_cellar(&paths.cellar)?
} else {
let mut pkgs = Vec::new();
for name in &args.packages {
match scan_cellar_package(&paths.cellar, name)? {
Some(pkg) => pkgs.push(pkg),
None => {
eprintln!(" {} {} not found in Cellar", style("✗").red(), name);
}
}
}
pkgs
};
if cellar_packages.is_empty() {
println!("\n{}", style("No packages found in Cellar.").dim());
return Ok(());
}
println!(
"\n{} {} packages:\n",
style("Importing").cyan(),
cellar_packages.len()
);
let mut imported = 0u32;
let mut skipped = 0u32;
for pkg in &cellar_packages {
if installed.is_installed(&pkg.name) && !args.overwrite {
if args.verbose {
println!(
" {} {} {} {}",
style("⊘").dim(),
pkg.name,
style(&pkg.version).dim(),
style("(already tracked)").dim()
);
}
skipped += 1;
continue;
}
if args.dry_run {
println!(
" {} {} {}",
style("✓").green(),
pkg.name,
style(&pkg.version).dim()
);
imported += 1;
continue;
}
import_cellar_package(&mut installed, pkg);
match relocate_bottle(&pkg.path, &paths.prefix) {
Ok(count) if count > 0 && args.verbose => {
println!(
" {} {} {} {}",
style("✓").green(),
pkg.name,
style(&pkg.version).dim(),
style(format!("(relocated {} files)", count)).dim()
);
}
Ok(_) => {
println!(
" {} {} {}",
style("✓").green(),
pkg.name,
style(&pkg.version).dim()
);
}
Err(e) => {
eprintln!(
" {} {} {} {}",
style("✓").green(),
pkg.name,
style(&pkg.version).dim(),
style(format!("(relocation warning: {})", e)).yellow()
);
}
}
imported += 1;
}
if !args.dry_run && imported > 0 {
installed.save(&paths)?;
}
println!(
"\n{} {} packages ({} already tracked)",
if args.dry_run {
style("Would import").yellow()
} else {
style("Imported").green().bold()
},
imported,
skipped,
);
if args.packages.is_empty() {
import_brew_casks(&paths, args.dry_run, args.overwrite, args.verbose)?;
}
Ok(())
}
fn import_brew_casks(paths: &Paths, dry_run: bool, overwrite: bool, verbose: bool) -> Result<()> {
let brew_casks = scan_caskroom(&paths.prefix).unwrap_or_default();
if brew_casks.is_empty() {
return Ok(());
}
let cask_state_path = paths.stout_dir.join("casks.json");
let mut cask_state = InstalledCasks::load(&cask_state_path).unwrap_or_default();
println!("\n{}...", style("Scanning Homebrew casks").cyan());
let mut cask_imported = 0u32;
let mut cask_skipped = 0u32;
for cask in &brew_casks {
if cask_state.is_installed(&cask.token) && !overwrite {
if verbose {
println!(
" {} {} {}",
style("⊘").dim(),
cask.token,
style("(already tracked)").dim()
);
}
cask_skipped += 1;
continue;
}
if dry_run {
println!(" {} {}", style("✓").green(), cask.token);
cask_imported += 1;
continue;
}
let timestamp = timestamp_now_iso();
let imported_cask = stout_cask::InstalledCask {
version: cask
.version
.clone()
.unwrap_or_else(|| "unknown".to_string()),
installed_at: timestamp,
artifact_path: std::path::PathBuf::from(""),
auto_updates: false,
artifacts: Vec::new(),
};
cask_state.add(&cask.token, imported_cask);
cask_imported += 1;
println!(" {} {}", style("✓").green(), cask.token);
}
if !dry_run && cask_imported > 0 {
cask_state.save(&cask_state_path)?;
}
println!(
"\n{} {} casks ({} already tracked)",
if dry_run {
style("Would import").yellow()
} else {
style("Imported").green().bold()
},
cask_imported,
cask_skipped,
);
Ok(())
}
pub fn import_cellar_package(installed: &mut InstalledPackages, pkg: &CellarPackage) {
let (requested, installed_by, installed_at, dependencies) = match &pkg.receipt {
Some(receipt) => {
let deps: Vec<String> = receipt
.runtime_dependencies
.iter()
.map(|d| d.full_name.clone())
.collect();
let at = receipt
.install_time
.map(timestamp_to_iso)
.unwrap_or_else(timestamp_now_iso);
(receipt.installed_on_request, "brew", at, deps)
}
None => {
(true, "unknown", timestamp_now_iso(), Vec::new())
}
};
installed.add_imported(
&pkg.name,
&pkg.version,
0, requested,
installed_by,
&installed_at,
dependencies,
);
}
pub fn timestamp_now_iso() -> String {
jiff::Timestamp::now()
.strftime("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}