Skip to main content

vanta_cli/
lib.rs

1//! `vanta-cli` — the command surface behind the `vanta` binary.
2//!
3//! Implements the commands documented in `docs/04-cli.md`, wiring the CLI to the
4//! resolver, registry, install engine, environment, and diagnostics subsystems.
5#![forbid(unsafe_code)]
6
7use std::collections::{BTreeMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use vanta_core::{Area, ExitCode, Platform, Request, StoreKey, VersionReq, VtaError, VtaResult};
11use vanta_install::Engine;
12use vanta_registry::Registry;
13use vanta_resolve::{artifact_for, Resolver};
14
15/// The crate version, surfaced by `vanta --version`.
16pub const VERSION: &str = env!("CARGO_PKG_VERSION");
17
18/// Dispatch a parsed argv (without the program name). Returns the process exit code.
19pub fn run(args: &[String]) -> VtaResult<ExitCode> {
20    let cmd = args.first().map(String::as_str).unwrap_or("help");
21    let rest: &[String] = args.get(1..).unwrap_or(&[]);
22    match cmd {
23        "--version" | "-V" | "version" => {
24            println!("vanta {VERSION}");
25            Ok(ExitCode::Ok)
26        }
27        "--help" | "-h" | "help" => {
28            print_help();
29            Ok(ExitCode::Ok)
30        }
31        "add" => cmd_add(rest),
32        "search" => cmd_search(rest),
33        "info" => cmd_info(rest),
34        "activate" => cmd_activate(rest),
35        "list" | "ls" => cmd_list(),
36        "which" => cmd_which(rest),
37        "doctor" => cmd_doctor(),
38        "sync" => cmd_sync(),
39        "generations" | "gen" => cmd_generations(),
40        "rollback" => cmd_rollback(rest),
41        "gc" => cmd_gc(),
42        "init" | "migrate" => cmd_import(has_flag(rest, "--force") || has_flag(rest, "-f")),
43        "exec" => cmd_exec(rest),
44        "x" => cmd_x(rest),
45        "remove" | "rm" => cmd_remove(rest),
46        "run" => cmd_run(rest),
47        "bundle" => cmd_bundle(rest),
48        "restore" => cmd_restore(rest),
49        "use" => cmd_add(rest),
50        "update" | "up" => cmd_sync(),
51        "outdated" => cmd_outdated(),
52        "cache" => cmd_cache(rest),
53        "config" => cmd_config(),
54        "completions" => cmd_completions(rest),
55        "trust" => cmd_trust(rest),
56        "registry" => cmd_registry(rest),
57        "shell" => cmd_shell(rest),
58        "self" => cmd_self(rest),
59        other => {
60            eprintln!("vanta: unknown command `{other}` (try `vanta help`)");
61            Ok(ExitCode::Usage)
62        }
63    }
64}
65
66/// `vanta add <tool>[@version] ...` — resolve and install each tool.
67fn cmd_add(rest: &[String]) -> VtaResult<ExitCode> {
68    let tools: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
69    if tools.is_empty() {
70        eprintln!("usage: vanta add <tool>[@version] ...");
71        return Ok(ExitCode::Usage);
72    }
73
74    let registry = load_registry()?;
75    let resolver = Resolver::new(&registry);
76    let platform = Platform::current();
77
78    // Resolve everything first (fail fast, no side effects on disk).
79    let mut resolutions = Vec::new();
80    for tool in &tools {
81        let request = Request::parse(tool)?;
82        resolutions.push(resolver.resolve(&request, &[platform])?);
83    }
84
85    // Install.
86    let engine = Engine::open(home()?)?;
87    for resolution in &resolutions {
88        let artifact = artifact_for(resolution, &platform).ok_or_else(|| {
89            VtaError::new(
90                Area::Res,
91                5,
92                format!(
93                    "no artifact for `{}` on {}",
94                    resolution.tool,
95                    platform.token()
96                ),
97            )
98        })?;
99        println!("installing {} {}", resolution.tool, resolution.version);
100        let key = engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
101        println!("  ✓ {} {} → {}", resolution.tool, resolution.version, key);
102    }
103    Ok(ExitCode::Ok)
104}
105
106/// `vanta search <query>` — search the registry.
107fn cmd_search(rest: &[String]) -> VtaResult<ExitCode> {
108    let query = rest
109        .iter()
110        .find(|a| !a.starts_with('-'))
111        .cloned()
112        .unwrap_or_default();
113    let registry = load_registry()?;
114    for name in registry.search(&query) {
115        println!("{name}");
116    }
117    Ok(ExitCode::Ok)
118}
119
120/// `vanta info <tool>` — show a tool's provider and available versions.
121fn cmd_info(rest: &[String]) -> VtaResult<ExitCode> {
122    let name = match rest.iter().find(|a| !a.starts_with('-')) {
123        Some(n) => n,
124        None => {
125            eprintln!("usage: vanta info <tool>");
126            return Ok(ExitCode::Usage);
127        }
128    };
129    let registry = load_registry()?;
130    let entry = registry
131        .tool(name)
132        .ok_or_else(|| VtaError::new(Area::Res, 3, format!("unknown tool `{name}`")))?;
133    println!("{name}  (provider: {})", entry.provider.id);
134    if let Some(summary) = &entry.summary {
135        println!("  {summary}");
136    }
137    println!("  versions:");
138    for v in &entry.versions {
139        let chan = v.channel.as_deref().unwrap_or("");
140        println!("    {} {}", v.version, chan);
141    }
142    Ok(ExitCode::Ok)
143}
144
145/// `vanta activate <shell>` — print the shell hook for `eval`.
146fn cmd_activate(rest: &[String]) -> VtaResult<ExitCode> {
147    let shell = match rest.iter().find(|a| !a.starts_with('-')) {
148        Some(s) => s,
149        None => {
150            eprintln!("usage: vanta activate <bash|zsh|fish|pwsh>");
151            return Ok(ExitCode::Usage);
152        }
153    };
154    match vanta_env::activate_hook(shell) {
155        Some(hook) => {
156            print!("{hook}");
157            Ok(ExitCode::Ok)
158        }
159        None => {
160            eprintln!("vanta: unsupported shell `{shell}`");
161            Ok(ExitCode::Usage)
162        }
163    }
164}
165
166/// `vanta list` — show the tools in the active generation.
167fn cmd_list() -> VtaResult<ExitCode> {
168    let engine = Engine::open(home()?)?;
169    match engine.state().current()? {
170        Some(id) => match engine.state().get_generation(id)? {
171            Some(gen) if !gen.tools.is_empty() => {
172                for (tool, key) in &gen.tools {
173                    println!("{tool}  ({key})");
174                }
175            }
176            _ => println!("(no tools installed)"),
177        },
178        None => println!("(no tools installed)"),
179    }
180    Ok(ExitCode::Ok)
181}
182
183/// `vanta which <tool>` — print the store path of the active tool.
184fn cmd_which(rest: &[String]) -> VtaResult<ExitCode> {
185    let name = match rest.iter().find(|a| !a.starts_with('-')) {
186        Some(n) => n,
187        None => {
188            eprintln!("usage: vanta which <tool>");
189            return Ok(ExitCode::Usage);
190        }
191    };
192    let engine = Engine::open(home()?)?;
193    let id = engine
194        .state()
195        .current()?
196        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
197    let gen = engine
198        .state()
199        .get_generation(id)?
200        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
201    let (_, key) = gen
202        .tools
203        .iter()
204        .find(|(t, _)| t == name)
205        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
206    let store_key = StoreKey::new(key.clone())?;
207    println!("{}", engine.store().entry_path(&store_key).display());
208    Ok(ExitCode::Ok)
209}
210
211/// `vanta generations` — list the generation history (`*` marks the active one).
212fn cmd_generations() -> VtaResult<ExitCode> {
213    let engine = Engine::open(home()?)?;
214    match engine.state().current()? {
215        None => println!("(no generations)"),
216        Some(current) => {
217            for id in 1..=current {
218                if let Some(gen) = engine.state().get_generation(id)? {
219                    let mark = if id == current { "*" } else { " " };
220                    println!("{mark} {id:04}  {}  [{}]", gen.command, gen.reason);
221                }
222            }
223        }
224    }
225    Ok(ExitCode::Ok)
226}
227
228/// `vanta rollback [gen]` — switch the active generation (defaults to the previous).
229fn cmd_rollback(rest: &[String]) -> VtaResult<ExitCode> {
230    let engine = Engine::open(home()?)?;
231    let current = engine
232        .state()
233        .current()?
234        .ok_or_else(|| VtaError::new(Area::Env, 2, "no generations to roll back".to_string()))?;
235    let target = match rest
236        .iter()
237        .find(|a| !a.starts_with('-'))
238        .and_then(|s| s.parse::<u64>().ok())
239    {
240        Some(n) => n,
241        None if current > 1 => current - 1,
242        None => {
243            return Err(VtaError::new(
244                Area::Env,
245                2,
246                "already at the earliest generation".to_string(),
247            ))
248        }
249    };
250    if engine.state().get_generation(target)?.is_none() {
251        return Err(VtaError::new(
252            Area::Env,
253            2,
254            format!("generation {target} not found"),
255        ));
256    }
257    engine.state().set_current(target)?;
258    println!("rolled back to generation {target:04}");
259    Ok(ExitCode::Ok)
260}
261
262/// `vanta gc` — remove store entries unreachable from the retained generations
263/// (the active one plus the previous few, per the retention policy).
264fn cmd_gc() -> VtaResult<ExitCode> {
265    const RETAIN: u64 = 5;
266    let engine = Engine::open(home()?)?;
267    let mut roots: HashSet<StoreKey> = HashSet::new();
268    if let Some(current) = engine.state().current()? {
269        let start = current.saturating_sub(RETAIN - 1).max(1);
270        for id in start..=current {
271            if let Some(gen) = engine.state().get_generation(id)? {
272                for (_, key) in &gen.tools {
273                    if let Ok(k) = StoreKey::new(key.clone()) {
274                        roots.insert(k);
275                    }
276                }
277            }
278        }
279    }
280    let removed = engine.store().gc(&roots)?;
281    println!(
282        "removed {removed} unreferenced store entr{}",
283        if removed == 1 { "y" } else { "ies" }
284    );
285    Ok(ExitCode::Ok)
286}
287
288/// `vanta doctor` — run health checks and print fixes.
289fn cmd_doctor() -> VtaResult<ExitCode> {
290    let home = home()?;
291    let checks = vanta_diag::run(&home);
292    for c in &checks {
293        let mark = if c.ok { "✓" } else { "✗" };
294        println!("{mark} {} — {}", c.name, c.detail);
295    }
296    Ok(if vanta_diag::all_ok(&checks) {
297        ExitCode::Ok
298    } else {
299        ExitCode::Failure
300    })
301}
302
303/// `vanta sync` — reconcile to the nearest `vanta.toml`: install each tool for the
304/// current platform and write a **cross-platform** `vanta.lock` pinning every
305/// declared target the registry can serve (`docs/11-reproducibility.md`).
306fn cmd_sync() -> VtaResult<ExitCode> {
307    let manifest_path = find_manifest()?;
308    let manifest = vanta_config::load_file(&manifest_path)?;
309    if manifest.tools.is_empty() {
310        println!(
311            "nothing to sync ({} has no [tools])",
312            manifest_path.display()
313        );
314        return Ok(ExitCode::Ok);
315    }
316
317    // Target platforms: the manifest's `[settings] targets`, else a default set;
318    // always include the current platform so this machine can install.
319    let current = Platform::current();
320    let mut platforms: Vec<Platform> = manifest
321        .settings
322        .targets
323        .clone()
324        .unwrap_or_else(default_targets)
325        .iter()
326        .filter_map(|t| Platform::parse(t).ok())
327        .collect();
328    if !platforms.contains(&current) {
329        platforms.push(current);
330    }
331
332    let registry = load_registry()?;
333    let resolver = Resolver::new(&registry);
334    let engine = Engine::open(home()?)?;
335    let mut lock = vanta_lock::Lock::new(
336        format!("vanta {VERSION}"),
337        platforms.iter().map(|p| p.token()).collect(),
338    );
339
340    for (tool, spec) in &manifest.tools {
341        let request_str = spec.version().to_string();
342        let request = Request {
343            tool: tool.clone(),
344            version: VersionReq::parse(&request_str),
345        };
346        let resolution = resolver.resolve(&request, &platforms)?;
347
348        // Install only the current platform; lock pins all resolved platforms.
349        let current_artifact = artifact_for(&resolution, &current).ok_or_else(|| {
350            VtaError::new(
351                Area::Res,
352                5,
353                format!("no artifact for `{tool}` on {}", current.token()),
354            )
355        })?;
356        println!("syncing {} {}", resolution.tool, resolution.version);
357        let key =
358            engine.install_artifact(&resolution.tool, &resolution.version, current_artifact)?;
359
360        let mut platform_map = BTreeMap::new();
361        for (plat, art) in &resolution.per_platform {
362            // Materialized only for the current platform; others pin url+hash and
363            // get a store key when that platform later syncs.
364            let store_key = if *plat == current {
365                key.as_str().to_string()
366            } else {
367                String::new()
368            };
369            platform_map.insert(
370                plat.token(),
371                vanta_lock::PlatformPin {
372                    store_key,
373                    url: art.url.clone(),
374                    size: art.size,
375                    sha256: art.checksum.value.clone(),
376                    blake3: None,
377                    signature: art.signature.clone(),
378                    bin: art.bin.clone(),
379                },
380            );
381        }
382        lock.tools.push(vanta_lock::LockedTool {
383            name: tool.clone(),
384            request: request_str,
385            version: resolution.version.clone(),
386            provider: resolution.provider.clone(),
387            platform: platform_map,
388        });
389    }
390
391    let lock_path = manifest_path
392        .parent()
393        .unwrap_or(Path::new("."))
394        .join("vanta.lock");
395    lock.write_file(&lock_path)?;
396    println!(
397        "✓ wrote {} ({} targets)",
398        lock_path.display(),
399        platforms.len()
400    );
401    Ok(ExitCode::Ok)
402}
403
404/// The default lock target set when a manifest declares none.
405fn default_targets() -> Vec<String> {
406    [
407        "macos/aarch64",
408        "macos/x86_64",
409        "linux/x86_64/gnu",
410        "linux/aarch64/gnu",
411        "windows/x86_64",
412    ]
413    .iter()
414    .map(|s| s.to_string())
415    .collect()
416}
417
418/// Find the nearest `vanta.toml`, walking up from the current directory.
419fn find_manifest() -> VtaResult<PathBuf> {
420    let mut dir = std::env::current_dir()
421        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
422    loop {
423        let candidate = dir.join("vanta.toml");
424        if candidate.is_file() {
425            return Ok(candidate);
426        }
427        if !dir.pop() {
428            return Err(VtaError::new(
429                Area::Cfg,
430                1,
431                "no vanta.toml found in this directory or any parent".to_string(),
432            ));
433        }
434    }
435}
436
437/// `vanta exec -- <cmd>` — run a command with `~/.vanta/bin` on PATH.
438fn cmd_exec(rest: &[String]) -> VtaResult<ExitCode> {
439    let cmdv: &[String] = match rest.iter().position(|a| a == "--") {
440        Some(i) => &rest[i + 1..],
441        None => rest,
442    };
443    if cmdv.is_empty() {
444        eprintln!("usage: vanta exec -- <command> [args]");
445        return Ok(ExitCode::Usage);
446    }
447    let status = Command::new(&cmdv[0])
448        .args(&cmdv[1..])
449        .env("PATH", env_path_with_bin()?)
450        .status()
451        .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", cmdv[0])))?;
452    Ok(status_exit(status))
453}
454
455/// `vanta x <tool>[@ver] [args]` — resolve+install if needed, then run it.
456fn cmd_x(rest: &[String]) -> VtaResult<ExitCode> {
457    let spec = match rest.iter().find(|a| !a.starts_with('-')) {
458        Some(s) => s.clone(),
459        None => {
460            eprintln!("usage: vanta x <tool>[@version] [args]");
461            return Ok(ExitCode::Usage);
462        }
463    };
464    let request = Request::parse(&spec)?;
465    let registry = load_registry()?;
466    let resolver = Resolver::new(&registry);
467    let platform = Platform::current();
468    let resolution = resolver.resolve(&request, &[platform])?;
469    let engine = Engine::open(home()?)?;
470    let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
471        VtaError::new(
472            Area::Res,
473            5,
474            format!("no artifact for `{}`", resolution.tool),
475        )
476    })?;
477    engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
478
479    let idx = rest.iter().position(|a| a == &spec).unwrap_or(0);
480    let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
481    let tool_bin = home()?.join("bin").join(&resolution.tool);
482    let status = Command::new(&tool_bin)
483        .args(args)
484        .env("PATH", env_path_with_bin()?)
485        .status()
486        .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", resolution.tool)))?;
487    Ok(status_exit(status))
488}
489
490/// `vanta remove <tool>` — drop a tool (new generation) and unlink it.
491fn cmd_remove(rest: &[String]) -> VtaResult<ExitCode> {
492    let tool = match rest.iter().find(|a| !a.starts_with('-')) {
493        Some(t) => t,
494        None => {
495            eprintln!("usage: vanta remove <tool>");
496            return Ok(ExitCode::Usage);
497        }
498    };
499    let engine = Engine::open(home()?)?;
500    if engine.remove(tool)? {
501        println!("removed {tool}");
502        Ok(ExitCode::Ok)
503    } else {
504        Err(VtaError::new(
505            Area::Env,
506            2,
507            format!("`{tool}` is not installed"),
508        ))
509    }
510}
511
512/// `vanta run <task|tool> [args]` — run a manifest task, else a tool binary.
513fn cmd_run(rest: &[String]) -> VtaResult<ExitCode> {
514    let name = match rest.iter().find(|a| !a.starts_with('-')) {
515        Some(n) => n.clone(),
516        None => {
517            eprintln!("usage: vanta run <task|tool> [args]");
518            return Ok(ExitCode::Usage);
519        }
520    };
521    // A defined task wins over a tool of the same name.
522    if let Ok(manifest_path) = find_manifest() {
523        if let Ok(manifest) = vanta_config::load_file(&manifest_path) {
524            if let Some(task) = manifest.tasks.get(&name) {
525                let cmd = match task {
526                    vanta_config::model::Task::Command(s) => s.clone(),
527                    vanta_config::model::Task::Detailed(d) => d.run.clone(),
528                };
529                let status = shell_command(&cmd)
530                    .env("PATH", env_path_with_bin()?)
531                    .status()
532                    .map_err(|e| {
533                        VtaError::new(Area::Env, 1, format!("running task `{name}`: {e}"))
534                    })?;
535                return Ok(status_exit(status));
536            }
537        }
538    }
539    let idx = rest.iter().position(|a| a == &name).unwrap_or(0);
540    let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
541    let tool_bin = home()?.join("bin").join(&name);
542    let status = Command::new(&tool_bin)
543        .args(args)
544        .env("PATH", env_path_with_bin()?)
545        .status()
546        .map_err(|e| VtaError::new(Area::Env, 1, format!("running `{name}`: {e}")))?;
547    Ok(status_exit(status))
548}
549
550/// `vanta bundle [--out file]` — pack the active generation for offline transfer.
551fn cmd_bundle(rest: &[String]) -> VtaResult<ExitCode> {
552    let out = rest
553        .iter()
554        .position(|a| a == "--out")
555        .and_then(|i| rest.get(i + 1))
556        .cloned()
557        .unwrap_or_else(|| "vanta-bundle.vbundle".to_string());
558    let engine = Engine::open(home()?)?;
559    let n = engine.bundle_current(Path::new(&out))?;
560    println!("bundled {n} store entries → {out}");
561    Ok(ExitCode::Ok)
562}
563
564/// `vanta restore <file>` — import a bundle (verifying integrity).
565fn cmd_restore(rest: &[String]) -> VtaResult<ExitCode> {
566    let file = match rest.iter().find(|a| !a.starts_with('-')) {
567        Some(f) => f,
568        None => {
569            eprintln!("usage: vanta restore <file>");
570            return Ok(ExitCode::Usage);
571        }
572    };
573    let engine = Engine::open(home()?)?;
574    let n = engine.restore(Path::new(file))?;
575    println!("restored {n} store entries");
576    Ok(ExitCode::Ok)
577}
578
579/// `vanta outdated` — show current (locked) vs allowed vs latest per manifest tool.
580#[allow(clippy::print_literal)] // aligned header columns read clearer as args
581fn cmd_outdated() -> VtaResult<ExitCode> {
582    let manifest_path = find_manifest()?;
583    let manifest = vanta_config::load_file(&manifest_path)?;
584    let registry = load_registry()?;
585    let resolver = Resolver::new(&registry);
586    let platform = Platform::current();
587
588    let lock_path = manifest_path
589        .parent()
590        .unwrap_or(Path::new("."))
591        .join("vanta.lock");
592    let locked: BTreeMap<String, String> = if lock_path.exists() {
593        vanta_lock::Lock::load_file(&lock_path)
594            .map(|l| l.tools.into_iter().map(|t| (t.name, t.version)).collect())
595            .unwrap_or_default()
596    } else {
597        BTreeMap::new()
598    };
599
600    println!(
601        "{:<16} {:<12} {:<12} {}",
602        "tool", "current", "allowed", "latest"
603    );
604    for (tool, spec) in &manifest.tools {
605        let allowed = resolver
606            .resolve(
607                &Request {
608                    tool: tool.clone(),
609                    version: VersionReq::parse(spec.version()),
610                },
611                &[platform],
612            )
613            .map(|r| r.version)
614            .unwrap_or_else(|_| "-".to_string());
615        let latest = resolver
616            .resolve(
617                &Request {
618                    tool: tool.clone(),
619                    version: VersionReq::Latest,
620                },
621                &[platform],
622            )
623            .map(|r| r.version)
624            .unwrap_or_else(|_| "-".to_string());
625        let current = locked.get(tool).cloned().unwrap_or_else(|| "-".to_string());
626        println!("{tool:<16} {current:<12} {allowed:<12} {latest}");
627    }
628    Ok(ExitCode::Ok)
629}
630
631/// `vanta cache <stats|prune>` — inspect or clear the download cache.
632fn cmd_cache(rest: &[String]) -> VtaResult<ExitCode> {
633    let sub = rest
634        .iter()
635        .find(|a| !a.starts_with('-'))
636        .map(|s| s.as_str())
637        .unwrap_or("stats");
638    let downloads = home()?.join("cache").join("downloads");
639    match sub {
640        "prune" => {
641            let mut n = 0;
642            if let Ok(rd) = std::fs::read_dir(&downloads) {
643                for e in rd.flatten() {
644                    if std::fs::remove_file(e.path()).is_ok() {
645                        n += 1;
646                    }
647                }
648            }
649            println!("pruned {n} cached downloads");
650        }
651        _ => {
652            let (mut files, mut bytes) = (0u64, 0u64);
653            if let Ok(rd) = std::fs::read_dir(&downloads) {
654                for e in rd.flatten() {
655                    if let Ok(m) = e.metadata() {
656                        if m.is_file() {
657                            files += 1;
658                            bytes += m.len();
659                        }
660                    }
661                }
662            }
663            println!("download cache: {files} files, {} KB", bytes / 1024);
664        }
665    }
666    Ok(ExitCode::Ok)
667}
668
669/// `vanta config` — show the global config path and contents.
670fn cmd_config() -> VtaResult<ExitCode> {
671    let path = home()?.join("config.toml");
672    println!("config: {}", path.display());
673    match std::fs::read_to_string(&path) {
674        Ok(contents) => {
675            println!("---");
676            print!("{contents}");
677        }
678        Err(_) => println!("(no global config; create it to set [tools]/[settings])"),
679    }
680    Ok(ExitCode::Ok)
681}
682
683/// `vanta completions <shell>` — emit a basic completion script.
684fn cmd_completions(rest: &[String]) -> VtaResult<ExitCode> {
685    let shell = rest
686        .iter()
687        .find(|a| !a.starts_with('-'))
688        .map(|s| s.as_str())
689        .unwrap_or("bash");
690    let cmds = "add remove update sync list which search info outdated init migrate doctor activate gc rollback generations run exec x bundle restore cache config completions use";
691    match shell {
692        "bash" => println!("complete -W \"{cmds}\" vanta vt"),
693        "zsh" => println!("#compdef vanta vt\n_values 'vanta command' {cmds}"),
694        "fish" => {
695            for c in cmds.split(' ') {
696                println!("complete -c vanta -a {c}");
697            }
698        }
699        other => {
700            eprintln!("vanta: no completions for `{other}`");
701            return Ok(ExitCode::Usage);
702        }
703    }
704    Ok(ExitCode::Ok)
705}
706
707/// `vanta trust [path]` — record a manifest's content hash as trusted (TOFU).
708fn cmd_trust(rest: &[String]) -> VtaResult<ExitCode> {
709    let trust_dir = home()?.join("trust");
710    if has_flag(rest, "--list") {
711        match std::fs::read_dir(&trust_dir) {
712            Ok(rd) => {
713                for e in rd.flatten() {
714                    if let Ok(target) = std::fs::read_to_string(e.path()) {
715                        println!("{}  {}", e.file_name().to_string_lossy(), target);
716                    }
717                }
718            }
719            Err(_) => println!("(nothing trusted yet)"),
720        }
721        return Ok(ExitCode::Ok);
722    }
723    let path = match rest.iter().find(|a| !a.starts_with('-')) {
724        Some(p) => PathBuf::from(p),
725        None => find_manifest()?,
726    };
727    let hash = vanta_security::sha256_file(&path)?;
728    std::fs::create_dir_all(&trust_dir)
729        .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
730    std::fs::write(trust_dir.join(&hash), path.display().to_string())
731        .map_err(|e| VtaError::new(Area::Vrf, 3, format!("recording trust: {e}")))?;
732    println!("trusted {} ({hash})", path.display());
733    Ok(ExitCode::Ok)
734}
735
736/// `vanta registry <list|add <name> <url>>` — manage configured registries.
737fn cmd_registry(rest: &[String]) -> VtaResult<ExitCode> {
738    let nonflags: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
739    let cfg = home()?.join("config.toml");
740    match nonflags.first().map(|s| s.as_str()) {
741        Some("add") => {
742            if nonflags.len() < 3 {
743                eprintln!("usage: vanta registry add <name> <url>");
744                return Ok(ExitCode::Usage);
745            }
746            let (name, url) = (nonflags[1], nonflags[2]);
747            let block = format!("\n[registries.{name}]\nurl = \"{url}\"\n");
748            if let Some(parent) = cfg.parent() {
749                let _ = std::fs::create_dir_all(parent);
750            }
751            let mut existing = std::fs::read_to_string(&cfg).unwrap_or_default();
752            existing.push_str(&block);
753            std::fs::write(&cfg, existing)
754                .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing config: {e}")))?;
755            println!("added registry {name} → {url}");
756            Ok(ExitCode::Ok)
757        }
758        _ => {
759            if cfg.exists() {
760                let manifest = vanta_config::load_file(&cfg)?;
761                if manifest.registries.is_empty() {
762                    println!("(no registries configured; the official registry is used)");
763                } else {
764                    for (name, reg) in &manifest.registries {
765                        println!("{name}  {}", reg.url);
766                    }
767                }
768            } else {
769                println!("(no config; the official registry is used by default)");
770            }
771            Ok(ExitCode::Ok)
772        }
773    }
774}
775
776/// `vanta shell <tool>@<ver> ...` — install (if needed) and start a subshell with
777/// the tools on PATH.
778fn cmd_shell(rest: &[String]) -> VtaResult<ExitCode> {
779    let specs: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
780    if specs.is_empty() {
781        eprintln!("usage: vanta shell <tool>[@version] ...");
782        return Ok(ExitCode::Usage);
783    }
784    let registry = load_registry()?;
785    let resolver = Resolver::new(&registry);
786    let platform = Platform::current();
787    let engine = Engine::open(home()?)?;
788    for spec in &specs {
789        let request = Request::parse(spec)?;
790        let resolution = resolver.resolve(&request, &[platform])?;
791        let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
792            VtaError::new(
793                Area::Res,
794                5,
795                format!("no artifact for `{}`", resolution.tool),
796            )
797        })?;
798        engine.install_artifact(&resolution.tool, &resolution.version, artifact)?;
799    }
800    let shell = std::env::var("SHELL").unwrap_or_else(|_| {
801        if cfg!(windows) {
802            "cmd".to_string()
803        } else {
804            "/bin/sh".to_string()
805        }
806    });
807    println!(
808        "entering vanta subshell ({}); type `exit` to leave",
809        specs.len()
810    );
811    let status = Command::new(shell)
812        .env("PATH", env_path_with_bin()?)
813        .status()
814        .map_err(|e| VtaError::new(Area::Env, 1, format!("starting subshell: {e}")))?;
815    Ok(status_exit(status))
816}
817
818/// `vanta self <uninstall|update>` — manage the Vanta installation itself.
819fn cmd_self(rest: &[String]) -> VtaResult<ExitCode> {
820    match rest
821        .iter()
822        .find(|a| !a.starts_with('-'))
823        .map(|s| s.as_str())
824    {
825        Some("uninstall") => {
826            let h = home()?;
827            if !has_flag(rest, "--yes") {
828                eprintln!(
829                    "this will permanently remove {} — re-run with --yes",
830                    h.display()
831                );
832                return Ok(ExitCode::Usage);
833            }
834            std::fs::remove_dir_all(&h).map_err(|e| {
835                VtaError::new(Area::Sys, 2, format!("removing {}: {e}", h.display()))
836            })?;
837            println!("removed {}", h.display());
838            Ok(ExitCode::Ok)
839        }
840        Some("update") => {
841            println!(
842                "self-update is handled by the channel you installed from; \
843                 see docs/32-release-engineering.md"
844            );
845            Ok(ExitCode::Ok)
846        }
847        _ => {
848            eprintln!("usage: vanta self <uninstall|update>");
849            Ok(ExitCode::Usage)
850        }
851    }
852}
853
854fn env_path_with_bin() -> VtaResult<String> {
855    let bin = home()?.join("bin");
856    let sep = if cfg!(windows) { ';' } else { ':' };
857    Ok(format!(
858        "{}{}{}",
859        bin.display(),
860        sep,
861        std::env::var("PATH").unwrap_or_default()
862    ))
863}
864
865fn shell_command(cmd: &str) -> Command {
866    if cfg!(windows) {
867        let mut c = Command::new("cmd");
868        c.arg("/C").arg(cmd);
869        c
870    } else {
871        let mut c = Command::new("sh");
872        c.arg("-c").arg(cmd);
873        c
874    }
875}
876
877fn status_exit(status: std::process::ExitStatus) -> ExitCode {
878    if status.success() {
879        ExitCode::Ok
880    } else {
881        ExitCode::Failure
882    }
883}
884
885/// `vanta init` / `vanta migrate` — detect foreign version files and write a
886/// `vanta.toml` (`docs/30-migration.md`).
887fn cmd_import(force: bool) -> VtaResult<ExitCode> {
888    let cwd = std::env::current_dir()
889        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
890    let imported = vanta_migrate::import_dir(&cwd);
891    if imported.is_empty() {
892        println!("no version files detected in {}", cwd.display());
893        return Ok(ExitCode::Ok);
894    }
895    let target = cwd.join("vanta.toml");
896    if target.exists() && !force {
897        eprintln!("vanta.toml already exists (use --force to overwrite)");
898        return Ok(ExitCode::Usage);
899    }
900    println!("detected:");
901    for i in &imported {
902        println!("  {} = \"{}\"  (from {})", i.tool, i.version, i.source);
903    }
904    let body = vanta_migrate::to_manifest_toml(&imported);
905    std::fs::write(&target, body)
906        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing {}: {e}", target.display())))?;
907    println!(
908        "✓ wrote {} — run `vanta sync` to install + lock",
909        target.display()
910    );
911    Ok(ExitCode::Ok)
912}
913
914fn has_flag(rest: &[String], flag: &str) -> bool {
915    rest.iter().any(|a| a == flag)
916}
917
918/// Resolve `$VANTA_HOME` (or `~/.vanta`).
919fn home() -> VtaResult<PathBuf> {
920    if let Ok(h) = std::env::var("VANTA_HOME") {
921        return Ok(PathBuf::from(h));
922    }
923    let base = std::env::var("HOME")
924        .or_else(|_| std::env::var("USERPROFILE"))
925        .map_err(|_| {
926            VtaError::new(
927                Area::Sys,
928                2,
929                "cannot determine home directory; set VANTA_HOME".to_string(),
930            )
931        })?;
932    Ok(PathBuf::from(base).join(".vanta"))
933}
934
935/// Load the registry: an HTTP(S) URL or a local file via `$VANTA_REGISTRY`, else
936/// the built-in index. A network registry is fetched (mirror/retry-aware) and parsed.
937fn load_registry() -> VtaResult<Registry> {
938    match std::env::var("VANTA_REGISTRY") {
939        Ok(loc) if loc.starts_with("http://") || loc.starts_with("https://") => {
940            let tmp =
941                std::env::temp_dir().join(format!("vanta-registry-{}.toml", std::process::id()));
942            vanta_net::Downloader::new()?.download(&loc, &tmp)?;
943            let registry = Registry::load_file(&tmp);
944            let _ = std::fs::remove_file(&tmp);
945            registry
946        }
947        Ok(path) => Registry::load_file(Path::new(&path)),
948        Err(_) => Ok(Registry::builtin()),
949    }
950}
951
952fn print_help() {
953    println!(
954        "vanta — every developer tool, one command\n\
955         \n\
956         USAGE:\n    vanta <command> [args]\n\
957         \n\
958         COMMON COMMANDS:\n\
959         \x20   add <tool>[@ver]    resolve and install a tool\n\
960         \x20   search <query>      search the registry\n\
961         \x20   info <tool>         show a tool's versions\n\
962         \x20   remove <tool>       remove a tool\n\
963         \x20   update [tool]       update within constraints\n\
964         \x20   sync                reconcile to vanta.toml + vanta.lock\n\
965         \x20   doctor              diagnose the installation\n\
966         \n\
967         See docs/04-cli.md for the full reference."
968    );
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    #[test]
976    fn version_ok() {
977        assert_eq!(run(&["--version".into()]).unwrap(), ExitCode::Ok);
978    }
979
980    #[test]
981    fn unknown_is_usage() {
982        assert_eq!(run(&["frobnicate".into()]).unwrap(), ExitCode::Usage);
983    }
984
985    #[test]
986    fn add_no_args_is_usage() {
987        assert_eq!(run(&["add".into()]).unwrap(), ExitCode::Usage);
988    }
989
990    #[test]
991    fn add_unknown_tool_resolves_to_error() {
992        // Resolution fails for an unknown tool before any disk/network side effect.
993        let err = run(&["add".into(), "totally-unknown-tool".into()]).unwrap_err();
994        assert_eq!(err.area, Area::Res);
995    }
996
997    #[test]
998    fn search_succeeds() {
999        assert_eq!(
1000            run(&["search".into(), "node".into()]).unwrap(),
1001            ExitCode::Ok
1002        );
1003    }
1004}