use clap_noun_verb::{NounVerbError, Result};
use clap_noun_verb_macros::verb;
use serde::Serialize;
use std::path::PathBuf;
use ggen_core::domain::packs::install::{install_pack, InstallInput};
use ggen_core::domain::packs::metadata::{list_packs, load_pack_metadata, show_pack};
use ggen_core::packs::lockfile::PackLockfile;
#[derive(Serialize)]
pub struct AddOutput {
pub pack_name: String,
pub status: String,
pub message: String,
}
#[derive(Serialize)]
pub struct RemoveOutput {
pub pack_name: String,
pub status: String,
pub message: String,
}
#[derive(Serialize)]
pub struct ListOutput {
pub packs: Vec<PackSummary>,
pub total: usize,
}
#[derive(Serialize)]
pub struct PackSummary {
pub id: String,
pub name: String,
pub description: String,
pub version: String,
pub category: String,
pub package_count: usize,
pub template_count: usize,
pub production_ready: bool,
pub registry_type: String,
}
#[derive(Serialize)]
pub struct ShowOutput {
pub id: String,
pub name: String,
pub description: String,
pub version: String,
pub category: String,
pub package_count: usize,
pub packages: Vec<String>,
pub dependencies: Vec<String>,
pub registry_type: String,
}
#[derive(Serialize)]
pub struct SearchOutput {
pub query: String,
pub results: Vec<SearchResult>,
pub total: usize,
}
#[derive(Serialize)]
pub struct SearchResult {
pub pack_id: String,
pub name: String,
pub description: String,
pub score: f64,
pub registry_type: String,
}
#[derive(Serialize)]
pub struct InstallOutput {
pub pack_id: String,
pub pack_name: String,
pub status: String,
pub message: String,
}
#[verb]
pub fn add(pack_name: String, force: Option<bool>) -> Result<AddOutput> {
validate_pack_name(&pack_name)?;
if let Err(e) = load_pack_metadata(&pack_name) {
return Ok(AddOutput {
pack_name: pack_name.clone(),
status: "not_found".to_string(),
message: format!(
"Pack '{}' not found in local registry: {}. \
Ensure marketplace/packs/{}.toml exists.",
pack_name, e, pack_name
),
});
}
let input = InstallInput {
pack_id: pack_name.clone(),
target_dir: None,
force: force.unwrap_or(false),
dry_run: false,
};
let install_result = crate::runtime::block_on(install_pack(&input)).map_err(|e| {
NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
})?;
let output = install_result.map_err(|e| {
NounVerbError::execution_error(format!("Failed to install pack '{}': {}", pack_name, e))
})?;
let mut artifact_paths = vec![output.install_path.clone()];
if let Some(lock) = &output.lockfile_path {
artifact_paths.push(lock.clone());
}
let closure = crate::cmds::packs_receipt::PackInstallClosure {
pack_id: &output.pack_id,
pack_version: &output.pack_version,
pack_digest: &output.digest,
packages_installed: &output.packages_installed,
artifact_paths: &artifact_paths,
};
let receipt_path = crate::cmds::packs_receipt::generate_pack_install_receipt(&closure)
.map_err(|e| {
NounVerbError::execution_error(format!(
"Pack '{}' installed but receipt emission failed: {}",
pack_name, e
))
})?;
Ok(AddOutput {
pack_name: output.pack_id.clone(),
status: "installed".to_string(),
message: format!(
"Pack '{}' ({}) installed successfully. {} package(s) recorded, {} template(s) available. Lockfile: .ggen/packs.lock. Receipt: {}",
output.pack_name,
output.pack_id,
output.packages_installed.len(),
output.templates_available.len(),
receipt_path.display()
),
})
}
#[verb]
pub fn remove(pack_name: String) -> Result<RemoveOutput> {
validate_pack_name(&pack_name)?;
let lock_path = std::env::current_dir()
.map_err(|e| {
NounVerbError::execution_error(format!("Cannot resolve project directory: {}", e))
})?
.join(".ggen")
.join("packs.lock");
if !lock_path.exists() {
return Err(NounVerbError::execution_error(
"No packs installed: .ggen/packs.lock not found",
));
}
let mut lockfile = PackLockfile::from_file(&lock_path)
.map_err(|e| NounVerbError::execution_error(format!("Failed to load lockfile: {}", e)))?;
if lockfile.get_pack(&pack_name).is_none() {
return Err(NounVerbError::execution_error(format!(
"Pack '{}' is not installed",
pack_name
)));
}
let pack_dir = resolve_cache_dir()?.join(&pack_name);
if pack_dir.exists() {
std::fs::remove_dir_all(&pack_dir).map_err(|e| {
NounVerbError::execution_error(format!("Failed to remove pack directory: {}", e))
})?;
}
lockfile.remove_pack(&pack_name);
lockfile.save(&lock_path).map_err(|e| {
NounVerbError::execution_error(format!(
"Failed to save lockfile (partial removal may have occurred): {}",
e
))
})?;
Ok(RemoveOutput {
pack_name: pack_name.clone(),
status: "removed".to_string(),
message: format!(
"Pack '{}' removed successfully. \
Run `ggen pack list` to see remaining installed packs.",
pack_name
),
})
}
#[verb]
pub fn list(verbose: Option<bool>, category: Option<String>) -> Result<ListOutput> {
let packages = list_packs(None)
.map_err(|e| NounVerbError::execution_error(format!("Failed to list packs: {}", e)))?;
let is_verbose = verbose.unwrap_or(false);
let filtered_packages: Vec<_> = if let Some(cat) = category.as_ref() {
packages
.into_iter()
.filter(|pkg| &pkg.category == cat)
.collect()
} else {
packages
};
let total = filtered_packages.len();
let default_category = category.unwrap_or_else(|| "marketplace".to_string());
let packs: Vec<PackSummary> = filtered_packages
.into_iter()
.map(|pkg| {
if is_verbose {
log::debug!(" - {} (v{})", pkg.id, pkg.version);
}
PackSummary {
id: pkg.id,
name: pkg.name,
description: pkg.description,
version: pkg.version,
category: default_category.clone(),
package_count: 0,
template_count: 0,
production_ready: pkg.production_ready,
registry_type: pkg.registry_type.unwrap_or_else(|| "local".to_string()),
}
})
.collect();
Ok(ListOutput { packs, total })
}
#[verb]
pub fn show(pack_id: String) -> Result<ShowOutput> {
let detail = show_pack(&pack_id).map_err(|e| {
NounVerbError::execution_error(format!("Failed to get pack '{}': {}", pack_id, e))
})?;
let dependencies: Vec<String> = detail
.dependencies
.iter()
.map(|d| format!("{} {}", d.pack_id, d.version))
.collect();
let package_count = detail.packages.len();
let packages: Vec<String> = detail.packages.iter().map(|p| p.to_string()).collect();
Ok(ShowOutput {
id: detail.id,
name: detail.name,
description: detail.description,
version: detail.version,
category: "marketplace".to_string(),
package_count,
packages,
dependencies,
registry_type: detail.registry_type.unwrap_or_else(|| "local".to_string()),
})
}
#[verb]
pub fn search(query: String, limit: Option<usize>) -> Result<SearchOutput> {
let results = perform_search(&query, limit)?;
let total = results.len();
log::info!("Found {} result(s) for '{}'", total, query);
Ok(SearchOutput {
query,
results,
total,
})
}
#[verb]
pub fn doctor() -> Result<serde_json::Value> {
use ggen_core::domain::utils::{execute_doctor, DoctorInput};
let result = crate::runtime::block_on(execute_doctor(DoctorInput {
verbose: true,
check: Some("cache".to_string()),
env: false,
}))
.map_err(|e| NounVerbError::execution_error(format!("Runtime error: {}", e)))?
.map_err(|e| NounVerbError::execution_error(format!("Doctor execution failed: {}", e)))?;
Ok(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
}
fn perform_search(query: &str, limit: Option<usize>) -> Result<Vec<SearchResult>> {
let packages = list_packs(None)
.map_err(|e| NounVerbError::execution_error(format!("Failed to list packages: {}", e)))?;
let query_lower = query.to_lowercase();
let max = limit.unwrap_or(20);
let mut scored: Vec<SearchResult> = packages
.into_iter()
.filter_map(|p| {
let relevance = calculate_relevance(&p.name, &p.description, &p.id, &query_lower)?;
Some(SearchResult {
pack_id: p.id,
name: p.name,
description: p.description,
score: relevance,
registry_type: p.registry_type.unwrap_or_else(|| "local".to_string()),
})
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(max);
Ok(scored)
}
fn calculate_relevance(name: &str, desc: &str, id: &str, query: &str) -> Option<f64> {
if name.to_lowercase().contains(query) {
Some(1.0)
} else if id.to_lowercase().contains(query) {
Some(0.8)
} else if desc.to_lowercase().contains(query) {
Some(0.5)
} else {
None
}
}
fn validate_pack_name(pack_name: &str) -> Result<()> {
if pack_name.trim().is_empty() {
return Err(NounVerbError::argument_error("Pack name must not be empty"));
}
let valid = pack_name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.');
if !valid {
return Err(NounVerbError::argument_error(
"Pack name contains invalid characters. Use alphanumeric, hyphens, underscores only.",
));
}
Ok(())
}
fn resolve_cache_dir() -> Result<PathBuf> {
std::env::var_os("GGEN_PACK_CACHE_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".ggen").join("packs")))
.ok_or_else(|| {
NounVerbError::execution_error(
"Cannot resolve pack cache: set HOME or GGEN_PACK_CACHE_DIR",
)
})
}