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(pack_name: String, force: Option<bool>) -> Result<AddOutput> {
96 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 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#[verb]
138pub fn remove(pack_name: String) -> Result<RemoveOutput> {
139 validate_pack_name(&pack_name)?;
140
141 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 if !lock_path.exists() {
151 return Err(NounVerbError::execution_error(
152 "No packs installed: .ggen/packs.lock not found",
153 ));
154 }
155
156 let mut lockfile = PackLockfile::from_file(&lock_path)
158 .map_err(|e| NounVerbError::execution_error(format!("Failed to load lockfile: {}", e)))?;
159
160 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 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 lockfile.remove_pack(&pack_name);
179
180 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#[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#[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#[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#[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
304fn 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}