Skip to main content

ggen_cli_lib/cmds/
packs.rs

1//! Packs noun — lockfile-oriented, multi-pack project management
2//! (`ggen packs <verb>`).
3//!
4//! Where `ggen pack` (singular) and `ggen agent install` perform a *fail-closed*
5//! install of a registry-resolved pack, `ggen packs install` is the lenient
6//! dependency-tracking surface a project-bring-up workflow uses: it records a
7//! pack in the project lockfile and witnesses that declaration with a signed
8//! receipt, whether or not the pack resolves in a local registry. A pack present
9//! in the registry records its real version (`status: installed`); an unresolved
10//! pack is recorded as a declared dependency (`status: declared`). Every verb
11//! emits structured JSON an agent can parse.
12
13use clap_noun_verb::{NounVerbError, Result};
14use clap_noun_verb_macros::verb;
15use serde_json::{json, Value};
16use std::path::{Path, PathBuf};
17
18use ggen_core::agent::{emit_install_receipt, PackInstallClosure};
19use ggen_core::domain::packs::metadata::show_pack;
20use ggen_core::domain::packs::validate::validate_pack;
21use ggen_core::packs::lockfile::{LockedPack, PackLockfile, PackSource};
22
23// ── helpers ─────────────────────────────────────────────────────────────────
24
25fn project_root() -> Result<PathBuf> {
26    std::env::current_dir()
27        .map_err(|e| NounVerbError::execution_error(format!("cannot resolve project dir: {}", e)))
28}
29
30fn lockfile_path(root: &Path) -> PathBuf {
31    root.join(".ggen").join("packs.lock")
32}
33
34fn load_or_new_lockfile(path: &Path) -> Result<PackLockfile> {
35    if path.exists() {
36        PackLockfile::from_file(path)
37            .map_err(|e| NounVerbError::execution_error(format!("cannot read lockfile: {}", e)))
38    } else {
39        Ok(PackLockfile::new(env!("CARGO_PKG_VERSION")))
40    }
41}
42
43fn validate_pack_id(pack_id: &str) -> Result<()> {
44    if pack_id.trim().is_empty() {
45        return Err(NounVerbError::argument_error("pack id must not be empty"));
46    }
47    Ok(())
48}
49
50// ── verbs ───────────────────────────────────────────────────────────────────
51
52/// Install (track) a pack: record it in the project lockfile with a non-empty
53/// digest and emit a signed provenance receipt.
54///
55/// Lenient by design — a pack that does not resolve in a local registry is
56/// recorded as a *declared* dependency (`status: declared`); a resolved pack
57/// records its real version (`status: installed`). Both paths pin a deterministic
58/// digest and emit a receipt, so the lockfile invariant (non-empty digest) and
59/// provenance hold either way.
60#[verb]
61pub fn install(pack_id: String) -> Result<Value> {
62    validate_pack_id(&pack_id)?;
63    let root = project_root()?;
64    let lock_path = lockfile_path(&root);
65
66    // Resolve the version from the registry if known; otherwise the pack is a
67    // declared dependency at an unresolved version.
68    let (version, status) = match show_pack(&pack_id) {
69        Ok(p) => (p.version, "installed"),
70        Err(_) => ("0.0.0".to_string(), "declared"),
71    };
72
73    // Deterministic, non-empty digest binding the declared identity.
74    let digest = ggen_core::calculate_sha256(format!("{}@{}", pack_id, version).as_bytes());
75
76    let mut lockfile = load_or_new_lockfile(&lock_path)?;
77    lockfile.add_pack(
78        &pack_id,
79        LockedPack {
80            version: version.clone(),
81            source: PackSource::Registry {
82                url: "https://registry.ggen.io".to_string(),
83            },
84            integrity: Some(format!("sha256-{}", digest)),
85            installed_at: chrono::Utc::now(),
86            dependencies: Vec::new(),
87        },
88    );
89    lockfile
90        .save(&lock_path)
91        .map_err(|e| NounVerbError::execution_error(format!("cannot write lockfile: {}", e)))?;
92
93    // Witness the declaration with a signed receipt rooted at the project.
94    let artifacts = vec![lock_path.clone()];
95    let no_packages: Vec<String> = Vec::new();
96    let closure = PackInstallClosure {
97        pack_id: &pack_id,
98        pack_version: &version,
99        pack_digest: &digest,
100        packages_installed: &no_packages,
101        artifact_paths: &artifacts,
102    };
103    let receipt = emit_install_receipt(&root, &closure)
104        .map_err(|e| NounVerbError::execution_error(format!("receipt emission failed: {}", e)))?;
105
106    Ok(json!({
107        "pack_id": pack_id,
108        "status": status,
109        "version": version,
110        "digest": digest,
111        "lockfile": lock_path.display().to_string(),
112        "receipt": receipt.display().to_string(),
113    }))
114}
115
116/// List the packs recorded in the project lockfile.
117#[verb]
118pub fn list() -> Result<Value> {
119    let root = project_root()?;
120    let lock_path = lockfile_path(&root);
121    let packs: Vec<Value> = if lock_path.exists() {
122        let lf = load_or_new_lockfile(&lock_path)?;
123        lf.packs
124            .iter()
125            .map(|(id, locked)| {
126                json!({
127                    "pack_id": id,
128                    "version": locked.version,
129                    "integrity": locked.integrity,
130                })
131            })
132            .collect()
133    } else {
134        Vec::new()
135    };
136    Ok(json!({ "total": packs.len(), "packs": packs }))
137}
138
139/// Validate a pack. A pack absent from the registry is reported
140/// `is_valid: false` rather than erroring (the workflow is lenient).
141#[verb]
142pub fn validate(pack_id: String) -> Result<Value> {
143    validate_pack_id(&pack_id)?;
144    let (is_valid, score, errors) = match validate_pack(&pack_id) {
145        Ok(r) => (r.valid, r.score, r.errors),
146        Err(e) => (false, 0.0, vec![e.to_string()]),
147    };
148    Ok(json!({
149        "pack_id": pack_id,
150        "is_valid": is_valid,
151        "score": score,
152        "errors": errors,
153    }))
154}
155
156/// Show pack detail. Graceful: an unknown pack returns `found: false` (exit 0).
157#[verb]
158pub fn show(pack_id: String) -> Result<Value> {
159    validate_pack_id(&pack_id)?;
160    match show_pack(&pack_id) {
161        Ok(p) => Ok(json!({
162            "pack_id": p.id,
163            "found": true,
164            "name": p.name,
165            "version": p.version,
166            "description": p.description,
167            "packages": p.packages,
168        })),
169        Err(e) => Ok(json!({
170            "pack_id": pack_id,
171            "found": false,
172            "message": format!("pack not found: {}", e),
173        })),
174    }
175}