1use 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#[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#[verb]
95pub fn add(#[arg(index = 1)] pack_name: String, force: Option<bool>) -> Result<AddOutput> {
96 validate_pack_name(&pack_name)?;
97 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 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 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#[verb]
166pub fn remove(#[arg(index = 1)] pack_name: String) -> Result<RemoveOutput> {
167 validate_pack_name(&pack_name)?;
168
169 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 if !lock_path.exists() {
179 return Err(NounVerbError::execution_error(
180 "No packs installed: .ggen/packs.lock not found",
181 ));
182 }
183
184 let mut lockfile = PackLockfile::from_file(&lock_path)
186 .map_err(|e| NounVerbError::execution_error(format!("Failed to load lockfile: {}", e)))?;
187
188 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 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 lockfile.remove_pack(&pack_name);
207
208 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#[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#[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#[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#[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
329fn 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}