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(pack_name: String, force: Option<bool>) -> Result<AddOutput> {
96    // Verify the pack exists before attempting installation
97    if let Err(e) = load_pack_metadata(&pack_name) {
98        return Ok(AddOutput {
99            pack_name: pack_name.clone(),
100            status: "not_found".to_string(),
101            message: format!(
102                "Pack '{}' not found in local registry: {}. \
103                 Ensure marketplace/packs/{}.toml exists.",
104                pack_name, e, pack_name
105            ),
106        });
107    }
108
109    // Run the real installation via the domain layer
110    let input = InstallInput {
111        pack_id: pack_name.clone(),
112        target_dir: None,
113        force: force.unwrap_or(false),
114        dry_run: false,
115    };
116
117    let install_result = crate::runtime::block_on(install_pack(&input)).map_err(|e| {
118        NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
119    })?;
120    let output = install_result.map_err(|e| {
121        NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
122    })?;
123    Ok(AddOutput {
124        pack_name: output.pack_id.clone(),
125        status: "installed".to_string(),
126        message: format!(
127            "Pack '{}' ({}) installed successfully. {} package(s) recorded, {} template(s) available. Lockfile: .ggen/packs.lock",
128            output.pack_name,
129            output.pack_id,
130            output.packages_installed.len(),
131            output.templates_available.len()
132        ),
133    })
134}
135
136/// Remove an installed pack
137#[verb]
138pub fn remove(pack_name: String) -> Result<RemoveOutput> {
139    validate_pack_name(&pack_name)?;
140
141    // Step 1: Resolve lock_path
142    let lock_path = std::env::current_dir()
143        .map_err(|e| {
144            NounVerbError::execution_error(format!("Cannot resolve project directory: {}", e))
145        })?
146        .join(".ggen")
147        .join("packs.lock");
148
149    // Step 2: Check if lockfile exists
150    if !lock_path.exists() {
151        return Err(NounVerbError::execution_error(
152            "No packs installed: .ggen/packs.lock not found",
153        ));
154    }
155
156    // Step 3: Load lockfile
157    let mut lockfile = PackLockfile::from_file(&lock_path)
158        .map_err(|e| NounVerbError::execution_error(format!("Failed to load lockfile: {}", e)))?;
159
160    // Step 4: Check if pack exists in lockfile
161    if lockfile.get_pack(&pack_name).is_none() {
162        return Err(NounVerbError::execution_error(format!(
163            "Pack '{}' is not installed",
164            pack_name
165        )));
166    }
167
168    // Step 5: Compute pack_dir and remove if exists
169    let pack_dir = resolve_cache_dir()?.join(&pack_name);
170
171    if pack_dir.exists() {
172        std::fs::remove_dir_all(&pack_dir).map_err(|e| {
173            NounVerbError::execution_error(format!("Failed to remove pack directory: {}", e))
174        })?;
175    }
176
177    // Step 6: Remove from lockfile
178    lockfile.remove_pack(&pack_name);
179
180    // Step 7: Save lockfile
181    lockfile.save(&lock_path).map_err(|e| {
182        NounVerbError::execution_error(format!(
183            "Failed to save lockfile (partial removal may have occurred): {}",
184            e
185        ))
186    })?;
187
188    Ok(RemoveOutput {
189        pack_name: pack_name.clone(),
190        status: "removed".to_string(),
191        message: format!(
192            "Pack '{}' removed successfully. \
193             Run `ggen pack list` to see remaining installed packs.",
194            pack_name
195        ),
196    })
197}
198
199/// List all available packs
200#[verb]
201pub fn list(verbose: Option<bool>, category: Option<String>) -> Result<ListOutput> {
202    let packages = list_packs(None)
203        .map_err(|e| NounVerbError::execution_error(format!("Failed to list packs: {}", e)))?;
204
205    let is_verbose = verbose.unwrap_or(false);
206    let filtered_packages: Vec<_> = if let Some(cat) = category.as_ref() {
207        packages
208            .into_iter()
209            .filter(|pkg| &pkg.category == cat)
210            .collect()
211    } else {
212        packages
213    };
214
215    let total = filtered_packages.len();
216    let default_category = category.unwrap_or_else(|| "marketplace".to_string());
217
218    let packs: Vec<PackSummary> = filtered_packages
219        .into_iter()
220        .map(|pkg| {
221            if is_verbose {
222                println!("  - {} (v{})", pkg.id, pkg.version);
223                println!("    Name: {}", pkg.name);
224                println!("    Description: {}", pkg.description);
225                println!();
226            }
227
228            PackSummary {
229                id: pkg.id,
230                name: pkg.name,
231                description: pkg.description,
232                version: pkg.version,
233                category: default_category.clone(),
234                package_count: 0,
235                template_count: 0,
236                production_ready: pkg.production_ready,
237                registry_type: pkg.registry_type.unwrap_or_else(|| "local".to_string()),
238            }
239        })
240        .collect();
241
242    Ok(ListOutput { packs, total })
243}
244
245/// Show detailed pack information
246#[verb]
247pub fn show(pack_id: String) -> Result<ShowOutput> {
248    let detail = show_pack(&pack_id).map_err(|e| {
249        NounVerbError::execution_error(format!("Failed to get pack '{}': {}", pack_id, e))
250    })?;
251
252    let dependencies: Vec<String> = detail
253        .dependencies
254        .iter()
255        .map(|d| format!("{} {}", d.pack_id, d.version))
256        .collect();
257
258    let package_count = detail.packages.len();
259    let packages: Vec<String> = detail.packages.iter().map(|p| p.to_string()).collect();
260
261    Ok(ShowOutput {
262        id: detail.id,
263        name: detail.name,
264        description: detail.description,
265        version: detail.version,
266        category: "marketplace".to_string(),
267        package_count,
268        packages,
269        dependencies,
270        registry_type: detail.registry_type.unwrap_or_else(|| "local".to_string()),
271    })
272}
273
274/// Search for packs
275#[verb]
276pub fn search(query: String, limit: Option<usize>) -> Result<SearchOutput> {
277    let results = perform_search(&query, limit)?;
278    let total = results.len();
279    println!("Found {} result(s) for '{}'", total, query);
280
281    Ok(SearchOutput {
282        query,
283        results,
284        total,
285    })
286}
287
288/// Run health check on installed packs and lockfile
289#[verb]
290pub fn doctor() -> Result<serde_json::Value> {
291    use ggen_core::domain::utils::{execute_doctor, DoctorInput};
292
293    let result = crate::runtime::block_on(execute_doctor(DoctorInput {
294        verbose: true,
295        check: Some("cache".to_string()),
296        env: false,
297    }))
298    .map_err(|e| NounVerbError::execution_error(format!("Runtime error: {}", e)))?
299    .map_err(|e| NounVerbError::execution_error(format!("Doctor execution failed: {}", e)))?;
300
301    Ok(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
302}
303
304// ============================================================================
305// Helper Functions
306// ============================================================================
307
308fn perform_search(query: &str, limit: Option<usize>) -> Result<Vec<SearchResult>> {
309    let packages = list_packs(None)
310        .map_err(|e| NounVerbError::execution_error(format!("Failed to list packages: {}", e)))?;
311
312    let query_lower = query.to_lowercase();
313    let max = limit.unwrap_or(20);
314
315    let mut scored: Vec<SearchResult> = packages
316        .into_iter()
317        .filter_map(|p| {
318            let relevance = calculate_relevance(&p.name, &p.description, &p.id, &query_lower)?;
319            Some(SearchResult {
320                pack_id: p.id,
321                name: p.name,
322                description: p.description,
323                score: relevance,
324                registry_type: p.registry_type.unwrap_or_else(|| "local".to_string()),
325            })
326        })
327        .collect();
328
329    scored.sort_by(|a, b| {
330        b.score
331            .partial_cmp(&a.score)
332            .unwrap_or(std::cmp::Ordering::Equal)
333    });
334    scored.truncate(max);
335    Ok(scored)
336}
337
338fn calculate_relevance(name: &str, desc: &str, id: &str, query: &str) -> Option<f64> {
339    if name.to_lowercase().contains(query) {
340        Some(1.0)
341    } else if id.to_lowercase().contains(query) {
342        Some(0.8)
343    } else if desc.to_lowercase().contains(query) {
344        Some(0.5)
345    } else {
346        None
347    }
348}
349
350fn validate_pack_name(pack_name: &str) -> Result<()> {
351    if pack_name.trim().is_empty() {
352        return Err(NounVerbError::argument_error("Pack name must not be empty"));
353    }
354    let valid = pack_name
355        .chars()
356        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.');
357    if !valid {
358        return Err(NounVerbError::argument_error(
359            "Pack name contains invalid characters. Use alphanumeric, hyphens, underscores only.",
360        ));
361    }
362    Ok(())
363}
364
365fn resolve_cache_dir() -> Result<PathBuf> {
366    std::env::var_os("GGEN_PACK_CACHE_DIR")
367        .map(PathBuf::from)
368        .or_else(|| dirs::home_dir().map(|h| h.join(".ggen").join("packs")))
369        .ok_or_else(|| {
370            NounVerbError::execution_error(
371                "Cannot resolve pack cache: set HOME or GGEN_PACK_CACHE_DIR",
372            )
373        })
374}