Skip to main content

ggen_cli_lib/cmds/
pack.rs

1//! Pack Commands (singular alias for `packs`)
2//!
3//! This module provides the `ggen pack` noun as an alias for `ggen packs`,
4//! supporting the golden-path form: `ggen pack add <name>`.
5
6use clap_noun_verb::{NounVerbError, Result};
7use clap_noun_verb_macros::verb;
8use serde::Serialize;
9use std::path::PathBuf;
10
11use ggen_core::domain::packs::install::{install_pack, InstallInput};
12use ggen_core::domain::packs::metadata::{list_packs, load_pack_metadata, show_pack};
13use ggen_core::packs::lockfile::PackLockfile;
14
15// ============================================================================
16// Output Types
17// ============================================================================
18
19#[derive(Serialize)]
20pub struct AddOutput {
21    pub pack_name: String,
22    pub status: String,
23    pub message: String,
24}
25
26#[derive(Serialize)]
27pub struct RemoveOutput {
28    pub pack_name: String,
29    pub status: String,
30    pub message: String,
31}
32
33#[derive(Serialize)]
34pub struct ListOutput {
35    pub packs: Vec<PackSummary>,
36    pub total: usize,
37}
38
39#[derive(Serialize)]
40pub struct PackSummary {
41    pub id: String,
42    pub name: String,
43    pub description: String,
44    pub version: String,
45    pub category: String,
46    pub package_count: usize,
47    pub template_count: usize,
48    pub production_ready: bool,
49    pub registry_type: String,
50}
51
52#[derive(Serialize)]
53pub struct ShowOutput {
54    pub id: String,
55    pub name: String,
56    pub description: String,
57    pub version: String,
58    pub category: String,
59    pub package_count: usize,
60    pub packages: Vec<String>,
61    pub dependencies: Vec<String>,
62    pub registry_type: String,
63}
64
65#[derive(Serialize)]
66pub struct SearchOutput {
67    pub query: String,
68    pub results: Vec<SearchResult>,
69    pub total: usize,
70}
71
72#[derive(Serialize)]
73pub struct SearchResult {
74    pub pack_id: String,
75    pub name: String,
76    pub description: String,
77    pub score: f64,
78    pub registry_type: String,
79}
80
81#[derive(Serialize)]
82pub struct InstallOutput {
83    pub pack_id: String,
84    pub pack_name: String,
85    pub status: String,
86    pub message: String,
87}
88
89// ============================================================================
90// Verb Functions
91// ============================================================================
92
93/// Add (install) a pack by name
94#[verb]
95pub fn add(#[arg(index = 1)] pack_name: String, force: Option<bool>) -> Result<AddOutput> {
96    validate_pack_name(&pack_name)?;
97    // Verify the pack exists before attempting installation
98    if let Err(e) = load_pack_metadata(&pack_name) {
99        return Ok(AddOutput {
100            pack_name: pack_name.clone(),
101            status: "not_found".to_string(),
102            message: format!(
103                "Pack '{}' not found in local registry: {}. \
104                 Ensure marketplace/packs/{}.toml exists.",
105                pack_name, e, pack_name
106            ),
107        });
108    }
109
110    // Run the real installation via the domain layer
111    let input = InstallInput {
112        pack_id: pack_name.clone(),
113        target_dir: None,
114        force: force.unwrap_or(false),
115        dry_run: false,
116    };
117
118    let install_result = crate::runtime::block_on(install_pack(&input)).map_err(|e| {
119        NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
120    })?;
121    let output = install_result.map_err(|e| {
122        NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
123    })?;
124
125    // The install only reaches here on success. Emit a provenance receipt that
126    // binds the real pack closure (id+version+digest + packages) and the durable
127    // artifacts (install dir + lockfile). Emission is GATED on a non-empty
128    // digest — a failed install never reaches this point and never gets a
129    // receipt (no fail-open). A receipt failure is surfaced loudly, never
130    // swallowed.
131    let mut artifact_paths = vec![output.install_path.clone()];
132    if let Some(lock) = &output.lockfile_path {
133        artifact_paths.push(lock.clone());
134    }
135    let closure = crate::cmds::packs_receipt::PackInstallClosure {
136        pack_id: &output.pack_id,
137        pack_version: &output.pack_version,
138        pack_digest: &output.digest,
139        packages_installed: &output.packages_installed,
140        artifact_paths: &artifact_paths,
141    };
142    let receipt_path = crate::cmds::packs_receipt::generate_pack_install_receipt(&closure)
143        .map_err(|e| {
144            NounVerbError::execution_error(format!(
145                "Pack '{}' installed but receipt emission failed: {}",
146                pack_name, e
147            ))
148        })?;
149
150    Ok(AddOutput {
151        pack_name: output.pack_id.clone(),
152        status: "installed".to_string(),
153        message: format!(
154            "Pack '{}' ({}) installed successfully. {} package(s) recorded, {} template(s) available. Lockfile: .ggen/packs.lock. Receipt: {}",
155            output.pack_name,
156            output.pack_id,
157            output.packages_installed.len(),
158            output.templates_available.len(),
159            receipt_path.display()
160        ),
161    })
162}
163
164/// Remove an installed pack
165#[verb]
166pub fn remove(#[arg(index = 1)] pack_name: String) -> Result<RemoveOutput> {
167    validate_pack_name(&pack_name)?;
168
169    // Step 1: Resolve lock_path
170    let lock_path = std::env::current_dir()
171        .map_err(|e| {
172            NounVerbError::execution_error(format!("Cannot resolve project directory: {}", e))
173        })?
174        .join(".ggen")
175        .join("packs.lock");
176
177    // Step 2: Check if lockfile exists
178    if !lock_path.exists() {
179        return Err(NounVerbError::execution_error(
180            "No packs installed: .ggen/packs.lock not found",
181        ));
182    }
183
184    // Step 3: Load lockfile
185    let mut lockfile = PackLockfile::from_file(&lock_path)
186        .map_err(|e| NounVerbError::execution_error(format!("Failed to load lockfile: {}", e)))?;
187
188    // Step 4: Check if pack exists in lockfile
189    if lockfile.get_pack(&pack_name).is_none() {
190        return Err(NounVerbError::execution_error(format!(
191            "Pack '{}' is not installed",
192            pack_name
193        )));
194    }
195
196    // Step 5: Compute pack_dir and remove if exists
197    let pack_dir = resolve_cache_dir()?.join(&pack_name);
198
199    if pack_dir.exists() {
200        std::fs::remove_dir_all(&pack_dir).map_err(|e| {
201            NounVerbError::execution_error(format!("Failed to remove pack directory: {}", e))
202        })?;
203    }
204
205    // Step 6: Remove from lockfile
206    lockfile.remove_pack(&pack_name);
207
208    // Step 7: Save lockfile
209    lockfile.save(&lock_path).map_err(|e| {
210        NounVerbError::execution_error(format!(
211            "Failed to save lockfile (partial removal may have occurred): {}",
212            e
213        ))
214    })?;
215
216    Ok(RemoveOutput {
217        pack_name: pack_name.clone(),
218        status: "removed".to_string(),
219        message: format!(
220            "Pack '{}' removed successfully. \
221             Run `ggen pack list` to see remaining installed packs.",
222            pack_name
223        ),
224    })
225}
226
227/// List all available packs
228#[verb]
229pub fn list(verbose: Option<bool>, category: Option<String>) -> Result<ListOutput> {
230    let packages = list_packs(None)
231        .map_err(|e| NounVerbError::execution_error(format!("Failed to list packs: {}", e)))?;
232
233    let is_verbose = verbose.unwrap_or(false);
234    let filtered_packages: Vec<_> = if let Some(cat) = category.as_ref() {
235        packages
236            .into_iter()
237            .filter(|pkg| &pkg.category == cat)
238            .collect()
239    } else {
240        packages
241    };
242
243    let total = filtered_packages.len();
244    let default_category = category.unwrap_or_else(|| "marketplace".to_string());
245
246    let packs: Vec<PackSummary> = filtered_packages
247        .into_iter()
248        .map(|pkg| {
249            if is_verbose {
250                log::debug!("  - {} (v{})", pkg.id, pkg.version);
251            }
252
253            PackSummary {
254                id: pkg.id,
255                name: pkg.name,
256                description: pkg.description,
257                version: pkg.version,
258                category: default_category.clone(),
259                package_count: 0,
260                template_count: 0,
261                production_ready: pkg.production_ready,
262                registry_type: pkg.registry_type.unwrap_or_else(|| "local".to_string()),
263            }
264        })
265        .collect();
266
267    Ok(ListOutput { packs, total })
268}
269
270/// Show detailed pack information
271#[verb]
272pub fn show(#[arg(index = 1)] pack_id: String) -> Result<ShowOutput> {
273    let detail = show_pack(&pack_id).map_err(|e| {
274        NounVerbError::execution_error(format!("Failed to get pack '{}': {}", pack_id, e))
275    })?;
276
277    let dependencies: Vec<String> = detail
278        .dependencies
279        .iter()
280        .map(|d| format!("{} {}", d.pack_id, d.version))
281        .collect();
282
283    let package_count = detail.packages.len();
284    let packages: Vec<String> = detail.packages.iter().map(|p| p.to_string()).collect();
285
286    Ok(ShowOutput {
287        id: detail.id,
288        name: detail.name,
289        description: detail.description,
290        version: detail.version,
291        category: "marketplace".to_string(),
292        package_count,
293        packages,
294        dependencies,
295        registry_type: detail.registry_type.unwrap_or_else(|| "local".to_string()),
296    })
297}
298
299/// Search for packs
300#[verb]
301pub fn search(#[arg(index = 1)] query: String, limit: Option<usize>) -> Result<SearchOutput> {
302    let results = perform_search(&query, limit)?;
303    let total = results.len();
304    log::info!("Found {} result(s) for '{}'", total, query);
305
306    Ok(SearchOutput {
307        query,
308        results,
309        total,
310    })
311}
312
313/// Run health check on installed packs and lockfile
314#[verb]
315pub fn doctor() -> Result<serde_json::Value> {
316    use ggen_core::domain::utils::{execute_doctor, DoctorInput};
317
318    let result = crate::runtime::block_on(execute_doctor(DoctorInput {
319        verbose: true,
320        check: Some("cache".to_string()),
321        env: false,
322    }))
323    .map_err(|e| NounVerbError::execution_error(format!("Runtime error: {}", e)))?
324    .map_err(|e| NounVerbError::execution_error(format!("Doctor execution failed: {}", e)))?;
325
326    Ok(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
327}
328
329// ============================================================================
330// Helper Functions
331// ============================================================================
332
333fn perform_search(query: &str, limit: Option<usize>) -> Result<Vec<SearchResult>> {
334    let packages = list_packs(None)
335        .map_err(|e| NounVerbError::execution_error(format!("Failed to list packages: {}", e)))?;
336
337    let query_lower = query.to_lowercase();
338    let max = limit.unwrap_or(20);
339
340    let mut scored: Vec<SearchResult> = packages
341        .into_iter()
342        .filter_map(|p| {
343            let relevance = calculate_relevance(&p.name, &p.description, &p.id, &query_lower)?;
344            Some(SearchResult {
345                pack_id: p.id,
346                name: p.name,
347                description: p.description,
348                score: relevance,
349                registry_type: p.registry_type.unwrap_or_else(|| "local".to_string()),
350            })
351        })
352        .collect();
353
354    scored.sort_by(|a, b| {
355        b.score
356            .partial_cmp(&a.score)
357            .unwrap_or(std::cmp::Ordering::Equal)
358    });
359    scored.truncate(max);
360    Ok(scored)
361}
362
363fn calculate_relevance(name: &str, desc: &str, id: &str, query: &str) -> Option<f64> {
364    if name.to_lowercase().contains(query) {
365        Some(1.0)
366    } else if id.to_lowercase().contains(query) {
367        Some(0.8)
368    } else if desc.to_lowercase().contains(query) {
369        Some(0.5)
370    } else {
371        None
372    }
373}
374
375fn validate_pack_name(pack_name: &str) -> Result<()> {
376    if pack_name.trim().is_empty() {
377        return Err(NounVerbError::argument_error("Pack name must not be empty"));
378    }
379    let valid = pack_name
380        .chars()
381        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.');
382    if !valid {
383        return Err(NounVerbError::argument_error(
384            "Pack name contains invalid characters. Use alphanumeric, hyphens, underscores only.",
385        ));
386    }
387    Ok(())
388}
389
390fn resolve_cache_dir() -> Result<PathBuf> {
391    std::env::var_os("GGEN_PACK_CACHE_DIR")
392        .map(PathBuf::from)
393        .or_else(|| dirs::home_dir().map(|h| h.join(".ggen").join("packs")))
394        .ok_or_else(|| {
395            NounVerbError::execution_error(
396                "Cannot resolve pack cache: set HOME or GGEN_PACK_CACHE_DIR",
397            )
398        })
399}