use anyhow::{bail, Context, Result};
use stout_fetch::{BottleSpec, DownloadCache, DownloadClient, ProgressReporter};
use stout_index::{Database, Formula, IndexSync};
use stout_install::{
link_package, write_receipt, BottleInfo, BuildConfig, InstallReceipt,
ParallelInstaller, RuntimeDependency, SourceBuilder,
};
use stout_resolve::{DependencyGraph, InstallPlan, InstallStep};
use stout_state::{Config, InstalledPackages, Paths};
use clap::Args as ClapArgs;
use console::style;
use std::sync::Arc;
use std::time::Instant;
#[derive(ClapArgs)]
pub struct Args {
pub formulas: Vec<String>,
#[arg(long)]
pub ignore_dependencies: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, short = 's')]
pub build_from_source: bool,
#[arg(long)]
pub keep_bottles: bool,
#[arg(long, short = 'j')]
pub jobs: Option<usize>,
#[arg(long)]
pub cc: Option<String>,
#[arg(long)]
pub cxx: Option<String>,
}
pub async fn run(args: Args) -> Result<()> {
let start = Instant::now();
if args.formulas.is_empty() {
bail!("No formulas specified");
}
let paths = Paths::default();
paths.ensure_dirs()?;
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);
}
for name in &args.formulas {
if db.get_formula(name)?.is_none() {
let suggestions = db.find_similar(name, 3)?;
eprintln!(
"\n{} formula '{}' not found",
style("error:").red().bold(),
name
);
if !suggestions.is_empty() {
eprintln!("\n{}:", style("Did you mean?").yellow());
for s in suggestions {
eprintln!(" {} {}", style("•").dim(), s);
}
}
std::process::exit(1);
}
}
let mut installed = InstalledPackages::load(&paths)?;
println!("\n{}...", style("Resolving dependencies").cyan());
let formula_refs: Vec<&str> = args.formulas.iter().map(|s| s.as_str()).collect();
let graph = DependencyGraph::build_from_db(&db, &formula_refs, false)?;
let plan = InstallPlan::from_graph(
&graph,
&formula_refs,
|name| db.get_formula(name).ok().flatten(),
|name| installed.is_installed(name),
)?;
for step in plan.new_packages() {
if step.is_dependency {
println!(
" {} {} {} {}",
style("+").green(),
step.name,
style(&step.version).dim(),
style("(dependency)").dim()
);
} else {
println!(
" {} {} {}",
style("✓").green(),
step.name,
style(&step.version).dim()
);
}
}
if !plan.already_installed.is_empty() {
for name in &plan.already_installed {
println!(
" {} {} {}",
style("•").dim(),
name,
style("(already installed)").dim()
);
}
}
if plan.is_empty() {
println!("\n{}", style("Nothing to install.").dim());
return Ok(());
}
if args.dry_run {
println!("\n{}", style("Dry run - no changes made.").yellow());
return Ok(());
}
let platform = detect_platform();
println!("\n{}...", style("Fetching formula data").cyan());
let sync = IndexSync::with_security_policy(
Some(&config.index.base_url),
&paths.stout_dir,
config.security.to_security_policy(),
)?;
let mut bottle_installs: Vec<(InstallStep, Formula)> = Vec::new();
let mut source_installs: Vec<(InstallStep, Formula)> = Vec::new();
let mut bottle_specs: Vec<BottleSpec> = Vec::new();
for step in plan.new_packages() {
let formula = sync
.fetch_formula_cached(&step.name, None)
.await
.context(format!("Failed to fetch formula {}", step.name))?;
let use_source = args.build_from_source || formula.bottle_for_platform(&platform).is_none();
if use_source {
if formula.urls.stable.is_none() {
if !args.build_from_source {
bail!(
"No bottle for {} on {} and no source URL available",
step.name,
platform
);
} else {
bail!("No source URL available for {}", step.name);
}
}
source_installs.push((step.clone(), formula));
} else {
let bottle = formula.bottle_for_platform(&platform)
.expect("bottle_for_platform returned None after None check");
bottle_specs.push(BottleSpec {
name: step.name.clone(),
version: step.version.clone(),
platform: platform.clone(),
url: bottle.url.clone(),
sha256: bottle.sha256.clone(),
});
bottle_installs.push((step.clone(), formula));
}
}
let mut bottle_paths = Vec::new();
if !bottle_specs.is_empty() {
println!(
"\n{} {} packages...",
style("Downloading").cyan(),
bottle_specs.len()
);
let cache = DownloadCache::new(&paths.stout_dir);
let client = DownloadClient::new(cache, config.install.parallel_downloads as usize)?;
let progress = Arc::new(ProgressReporter::new());
bottle_paths = client
.download_bottles(bottle_specs, Arc::clone(&progress))
.await
.context("Failed to download bottles")?;
}
if !bottle_installs.is_empty() {
println!(
"\n{} (parallel)...",
style("Extracting and linking bottles").cyan()
);
let bottle_infos: Vec<BottleInfo> = bottle_installs
.iter()
.zip(bottle_paths.iter())
.map(|((step, _), bottle_path)| BottleInfo {
name: step.name.clone(),
bottle_path: bottle_path.clone(),
})
.collect();
let installer = ParallelInstaller::new();
let install_results = installer
.install_bottles(bottle_infos, &paths.cellar, &paths.prefix)
.await
.context("Parallel installation failed")?;
for ((step, formula), result) in bottle_installs.iter().zip(install_results.iter()) {
let runtime_deps: Vec<RuntimeDependency> = formula
.runtime_deps()
.iter()
.filter_map(|dep| {
db.get_formula(dep).ok().flatten().map(|info| RuntimeDependency {
full_name: dep.clone(),
version: info.version,
revision: Some(info.revision),
})
})
.collect();
let receipt = InstallReceipt::new_bottle(&formula.tap, !step.is_dependency, runtime_deps);
write_receipt(&result.install_path, &receipt)?;
installed.add(&step.name, &step.version, formula.revision, !step.is_dependency);
println!(
" {} {} {} ({} files linked)",
style("✓").green(),
step.name,
step.version,
result.linked_files.len()
);
}
}
if !source_installs.is_empty() {
println!("\n{}...", style("Building from source").cyan());
for (step, formula) in &source_installs {
let source = formula.urls.stable.as_ref()
.expect("stable URL should exist for source builds");
println!(
" {} {} {} (from source)",
style("Building").yellow(),
step.name,
step.version
);
let build_config = BuildConfig {
source_url: source.url.clone(),
sha256: source.sha256.clone(),
name: step.name.clone(),
version: step.version.clone(),
prefix: paths.prefix.clone(),
cellar: paths.cellar.clone(),
build_deps: formula.build_deps().to_vec(),
jobs: args.jobs,
cc: args.cc.clone(),
cxx: args.cxx.clone(),
};
let work_dir = paths.stout_dir.join("build").join(&step.name);
let builder = SourceBuilder::new(build_config, &work_dir);
let result = builder.build().await.context(format!(
"Failed to build {} from source",
step.name
))?;
link_package(&result.install_path, &paths.prefix)?;
let runtime_deps: Vec<RuntimeDependency> = formula
.runtime_deps()
.iter()
.filter_map(|dep| {
db.get_formula(dep).ok().flatten().map(|info| RuntimeDependency {
full_name: dep.clone(),
version: info.version,
revision: Some(info.revision),
})
})
.collect();
let receipt = InstallReceipt::new_source(&formula.tap, !step.is_dependency, runtime_deps);
write_receipt(&result.install_path, &receipt)?;
installed.add(&step.name, &step.version, formula.revision, !step.is_dependency);
let _ = std::fs::remove_dir_all(&work_dir);
println!(" {} {} {}", style("✓").green(), step.name, step.version);
}
}
installed.save(&paths)?;
if !args.keep_bottles && !bottle_paths.is_empty() {
let mut cleaned = 0u64;
for path in &bottle_paths {
if path.exists() {
if let Ok(meta) = std::fs::metadata(path) {
cleaned += meta.len();
}
let _ = std::fs::remove_file(path);
}
}
if cleaned > 0 {
println!(
" {} Cleaned up {} of downloaded bottles",
style("✓").dim(),
format_bytes(cleaned)
);
}
}
let elapsed = start.elapsed();
println!(
"\n{} {} packages in {:.1}s",
style("Installed").green().bold(),
plan.total_packages(),
elapsed.as_secs_f64()
);
Ok(())
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
fn detect_platform() -> String {
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
};
if cfg!(target_os = "macos") {
format!("{}_sonoma", arch)
} else {
format!("{}_linux", arch)
}
}