Skip to main content

assay/install/
mod.rs

1//! `assay install` — fetch + verify + extract the binaries and Lua libraries
2//! declared in a `Manifest.lua`.
3//!
4//! See `.claude/plans/21-libs-folder-and-install.md`.
5//!
6//! ## Pipeline
7//!
8//! 1. Read `Manifest.lua`, parse in a sandboxed mlua VM (`manifest`).
9//! 2. Resolve dirs (cache / bin / lib) from CLI flags + per-user XDG
10//!    fallbacks (root falls back to `/var/cache/assay`, `/usr/local/bin`,
11//!    `/opt/assay/libs`).
12//! 3. Build a [`fetch::FetchPlan`] per declared dep.
13//! 4. With `--dry-run`: print the resolved plan and exit.
14//! 5. Otherwise: fetch all deps in parallel via `tokio::spawn` (cache
15//!    hits skip HTTP; sha256 verified for every artifact).
16//! 6. Extract sequentially: extension binaries via
17//!    `extract::install_extension_binary`; lib trees via
18//!    `extract::install_lib_tree`.
19//! 7. Write `Manifest.lock` next to the input manifest.
20//!
21//! Failures abort early. Cached entries from successful fetches are
22//! preserved; bin / lib trees from earlier extractions are NOT rolled
23//! back (matching plan 21's design, finalised treatment in phase 5).
24
25pub mod extract;
26pub mod fetch;
27pub mod lock;
28pub mod manifest;
29
30use std::path::{Path, PathBuf};
31use std::process::ExitCode;
32use std::time::Duration;
33
34use clap::Args;
35use thiserror::Error;
36
37#[derive(Args, Debug)]
38pub struct InstallArgs {
39    /// Path to the `Manifest.lua` declaring extensions + libs to install.
40    #[arg(short = 'f', long, default_value = "./Manifest.lua")]
41    pub manifest: PathBuf,
42
43    /// Cache directory for downloaded artifacts.
44    /// Default: `/var/cache/assay/` (root) or `$XDG_CACHE_HOME/assay/` (per-user).
45    #[arg(long)]
46    pub cache_dir: Option<PathBuf>,
47
48    /// Where to install extension binaries.
49    /// Default: `/usr/local/bin/` (root) or `$HOME/.local/bin/` (per-user).
50    #[arg(long)]
51    pub bin_dir: Option<PathBuf>,
52
53    /// Where to extract Lua libraries.
54    /// Default: `/opt/assay/libs/` (root) or `$XDG_DATA_HOME/assay/libs/` (per-user).
55    #[arg(long)]
56    pub lib_dir: Option<PathBuf>,
57
58    /// Skip network fetch; require every dep already present in the cache.
59    #[arg(long)]
60    pub offline: bool,
61
62    /// Resolve and report only; do not write any files.
63    #[arg(long)]
64    pub dry_run: bool,
65
66    /// Suppress per-dep progress output to stderr.
67    #[arg(long)]
68    pub no_progress: bool,
69}
70
71#[derive(Debug, Error)]
72pub enum InstallError {
73    #[error("read {path}: {source}")]
74    ReadManifest {
75        path: PathBuf,
76        #[source]
77        source: std::io::Error,
78    },
79
80    #[error(transparent)]
81    Manifest(#[from] manifest::ManifestError),
82
83    #[error(transparent)]
84    Fetch(#[from] fetch::FetchError),
85
86    #[error(transparent)]
87    Extract(#[from] extract::ExtractError),
88
89    #[error("{count} dep(s) failed to fetch; see errors above")]
90    FetchFailed { count: usize },
91
92    #[error("write {path}: {source}")]
93    WriteLock {
94        path: PathBuf,
95        #[source]
96        source: std::io::Error,
97    },
98}
99
100/// Top-level entry point invoked by the `Install` clap subcommand.
101pub async fn run(args: InstallArgs) -> ExitCode {
102    match execute(args).await {
103        Ok(()) => ExitCode::SUCCESS,
104        Err(e) => {
105            eprintln!("assay install: {e}");
106            let mut src = std::error::Error::source(&e);
107            while let Some(s) = src {
108                eprintln!("  caused by: {s}");
109                src = std::error::Error::source(s);
110            }
111            ExitCode::from(1)
112        }
113    }
114}
115
116/// Run the install pipeline, returning a typed result. Useful for tests
117/// that need to inspect the error path; the CLI entry [`run`] converts
118/// this into an `ExitCode`.
119pub async fn execute(args: InstallArgs) -> Result<(), InstallError> {
120    // 1. Read + parse manifest.
121    let source =
122        std::fs::read_to_string(&args.manifest).map_err(|e| InstallError::ReadManifest {
123            path: args.manifest.clone(),
124            source: e,
125        })?;
126    let m = manifest::parse(&source, &args.manifest)?;
127
128    // 2. Resolve dirs.
129    let cache_dir = args.cache_dir.clone().unwrap_or_else(default_cache_dir);
130    let bin_dir = args.bin_dir.clone().unwrap_or_else(default_bin_dir);
131    let lib_dir = args.lib_dir.clone().unwrap_or_else(default_lib_dir);
132    let arch = std::env::consts::ARCH.to_string();
133
134    // 3. Build plans.
135    let mut ext_plans: Vec<(manifest::Extension, fetch::FetchPlan)> = Vec::new();
136    for e in &m.extensions {
137        let plan = fetch::FetchPlan::for_extension(e, &arch, &cache_dir)?;
138        ext_plans.push((e.clone(), plan));
139    }
140    let mut lib_plans: Vec<(manifest::Lib, fetch::FetchPlan)> = Vec::new();
141    for l in &m.libs {
142        lib_plans.push((l.clone(), fetch::FetchPlan::for_lib(l, &cache_dir)));
143    }
144
145    // 4. Dry-run: print plan and bail.
146    if args.dry_run {
147        print_plan(&m, &ext_plans, &lib_plans, &cache_dir, &bin_dir, &lib_dir);
148        return Ok(());
149    }
150
151    // 5. Fetch everything in parallel.
152    let client = reqwest::Client::builder()
153        .timeout(Duration::from_secs(60))
154        .build()
155        .expect("build reqwest client");
156
157    let mut handles = Vec::with_capacity(ext_plans.len() + lib_plans.len());
158    let all_plans = ext_plans
159        .iter()
160        .map(|(_, p)| p)
161        .chain(lib_plans.iter().map(|(_, p)| p));
162    for plan in all_plans {
163        let plan = plan.clone();
164        let client = client.clone();
165        let offline = args.offline;
166        handles.push(tokio::spawn(async move {
167            let result = fetch::fetch(&plan, &client, offline).await;
168            (plan.display_name.clone(), result)
169        }));
170    }
171
172    let mut failures = 0;
173    for h in handles {
174        let (name, res) = h.await.expect("fetch task panicked");
175        match res {
176            Ok(()) => {
177                if !args.no_progress {
178                    eprintln!("  ✓ fetched {name}");
179                }
180            }
181            Err(e) => {
182                eprintln!("  ✗ {name}: {e}");
183                failures += 1;
184            }
185        }
186    }
187    if failures > 0 {
188        return Err(InstallError::FetchFailed { count: failures });
189    }
190
191    // 6. Extract.
192    for (e, plan) in &ext_plans {
193        extract::install_extension_binary(&plan.cache_path, &bin_dir, &e.name)?;
194        if !args.no_progress {
195            eprintln!("  → installed {} → {}", e.name, bin_dir.join(&e.name).display());
196        }
197    }
198    for (l, plan) in &lib_plans {
199        extract::install_lib_tree(&plan.cache_path, &lib_dir, &l.name)?;
200        if !args.no_progress {
201            eprintln!("  → installed {} → {}/", l.name, lib_dir.join(&l.name).display());
202        }
203    }
204
205    // 7. Write Manifest.lock alongside the input manifest.
206    let lockfile = lock::Lockfile {
207        assay: m.assay.clone(),
208        extensions: ext_plans
209            .iter()
210            .map(|(e, p)| lock::LockExtension::new(e, p))
211            .collect(),
212        libs: lib_plans
213            .iter()
214            .map(|(l, p)| lock::LockLib::new(l, p))
215            .collect(),
216    };
217    let lock_path = args.manifest.with_file_name("Manifest.lock");
218    std::fs::write(&lock_path, lockfile.to_lua()).map_err(|e| InstallError::WriteLock {
219        path: lock_path.clone(),
220        source: e,
221    })?;
222    if !args.no_progress {
223        eprintln!("  → wrote {}", lock_path.display());
224    }
225
226    Ok(())
227}
228
229fn print_plan(
230    m: &manifest::Manifest,
231    ext_plans: &[(manifest::Extension, fetch::FetchPlan)],
232    lib_plans: &[(manifest::Lib, fetch::FetchPlan)],
233    cache_dir: &Path,
234    bin_dir: &Path,
235    lib_dir: &Path,
236) {
237    println!("# assay install (dry run)");
238    if let Some(v) = &m.assay {
239        println!("assay = {v}");
240    }
241    println!("cache_dir = {}", cache_dir.display());
242    println!("bin_dir   = {}", bin_dir.display());
243    println!("lib_dir   = {}", lib_dir.display());
244    println!();
245    println!("extensions:");
246    for (_, p) in ext_plans {
247        println!("  {} (sha256 {})", p.display_name, p.expected_sha256);
248        println!("    url   = {}", p.url);
249        println!("    cache = {}", p.cache_path.display());
250    }
251    println!();
252    println!("libs:");
253    for (_, p) in lib_plans {
254        println!("  {} (sha256 {})", p.display_name, p.expected_sha256);
255        println!("    url   = {}", p.url);
256        println!("    cache = {}", p.cache_path.display());
257    }
258}
259
260// --- default dir resolution ------------------------------------------
261
262fn default_cache_dir() -> PathBuf {
263    if is_root() {
264        PathBuf::from("/var/cache/assay")
265    } else if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
266        PathBuf::from(xdg).join("assay")
267    } else if let Some(home) = std::env::var_os("HOME") {
268        PathBuf::from(home).join(".cache/assay")
269    } else {
270        PathBuf::from("/tmp/assay-cache")
271    }
272}
273
274fn default_bin_dir() -> PathBuf {
275    if is_root() {
276        PathBuf::from("/usr/local/bin")
277    } else if let Some(home) = std::env::var_os("HOME") {
278        PathBuf::from(home).join(".local/bin")
279    } else {
280        PathBuf::from("/usr/local/bin")
281    }
282}
283
284fn default_lib_dir() -> PathBuf {
285    if is_root() {
286        PathBuf::from("/opt/assay/libs")
287    } else if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
288        PathBuf::from(xdg).join("assay/libs")
289    } else if let Some(home) = std::env::var_os("HOME") {
290        PathBuf::from(home).join(".local/share/assay/libs")
291    } else {
292        PathBuf::from("/opt/assay/libs")
293    }
294}
295
296#[cfg(unix)]
297fn is_root() -> bool {
298    // SAFETY: getuid is always safe — no preconditions, no shared state.
299    unsafe { libc::getuid() == 0 }
300}
301
302#[cfg(not(unix))]
303fn is_root() -> bool {
304    false
305}