use crate::OutputFormat;
use crate::config::{find_project_root, global_skillc_dir, global_source_store};
use crate::error::{Result, SkillcError};
use crate::util::{project_skill_runtime_dir, project_skills_dir};
use comfy_table::{Cell, Color, ContentArrangement, Table};
use glob::Pattern;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkillStatus {
Normal,
NotBuilt,
Obsolete,
}
impl std::fmt::Display for SkillStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillStatus::Normal => write!(f, "normal"),
SkillStatus::NotBuilt => write!(f, "not-built"),
SkillStatus::Obsolete => write!(f, "obsolete"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillScope {
Project,
Global,
}
impl std::fmt::Display for SkillScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillScope::Project => write!(f, "project"),
SkillScope::Global => write!(f, "global"),
}
}
}
#[derive(Debug, Serialize)]
pub struct SkillInfo {
pub name: String,
pub scope: SkillScope,
pub status: SkillStatus,
pub source_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_path: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct ListOptions {
pub scope: Option<SkillScope>,
pub status: Option<SkillStatus>,
pub limit: Option<usize>,
pub pattern: Option<String>,
pub check_obsolete: bool,
}
#[derive(Debug, Serialize)]
pub struct ListResult {
pub skills: Vec<SkillInfo>,
pub total: usize,
}
pub fn list(options: &ListOptions) -> Result<ListResult> {
let mut skills = Vec::new();
if let Some(project_root) = find_project_root() {
let skills_dir = project_skills_dir(&project_root);
if skills_dir.is_dir() {
discover_skills_in_dir(
&skills_dir,
SkillScope::Project,
&project_root,
options.check_obsolete,
&mut skills,
)?;
}
}
if let Ok(global_skills_dir) = global_source_store()
&& global_skills_dir.is_dir()
{
discover_skills_in_dir(
&global_skills_dir,
SkillScope::Global,
&global_skillc_dir()?,
options.check_obsolete,
&mut skills,
)?;
}
skills.sort_by(|a, b| match (&a.scope, &b.scope) {
(SkillScope::Project, SkillScope::Global) => std::cmp::Ordering::Less,
(SkillScope::Global, SkillScope::Project) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
let total = skills.len();
let pattern = options.pattern.as_ref().and_then(|p| Pattern::new(p).ok());
let filtered_skills: Vec<SkillInfo> = skills
.into_iter()
.filter(|s| {
if let Some(scope) = options.scope
&& s.scope != scope
{
return false;
}
if let Some(status) = options.status
&& s.status != status
{
return false;
}
if let Some(ref pat) = pattern
&& !pat.matches(&s.name)
{
return false;
}
true
})
.collect();
let skills = match options.limit {
Some(limit) => filtered_skills.into_iter().take(limit).collect(),
None => filtered_skills,
};
Ok(ListResult { skills, total })
}
pub fn format_list(result: &ListResult, format: OutputFormat, verbose: bool) -> Result<String> {
match format {
OutputFormat::Text => format_text(result, verbose),
OutputFormat::Json => serde_json::to_string_pretty(result)
.map_err(|e| SkillcError::Internal(format!("JSON serialization failed: {}", e))),
}
}
fn format_text(result: &ListResult, verbose: bool) -> Result<String> {
if result.skills.is_empty() {
return Ok("No skills found.".to_string());
}
let mut table = Table::new();
table
.load_preset(comfy_table::presets::NOTHING)
.set_content_arrangement(ContentArrangement::Dynamic);
if table.width().unwrap_or(0) < 80 {
table.set_width(120);
}
if verbose {
table.set_header(vec!["SKILL", "SCOPE", "STATUS", "SOURCE"]);
} else {
table.set_header(vec!["SKILL", "SCOPE", "STATUS"]);
}
for skill in &result.skills {
let scope_cell = match skill.scope {
SkillScope::Project => Cell::new("project").fg(Color::Cyan),
SkillScope::Global => Cell::new("global").fg(Color::DarkGrey),
};
let status_cell = match skill.status {
SkillStatus::Normal => Cell::new("normal").fg(Color::Green),
SkillStatus::NotBuilt => Cell::new("not-built").fg(Color::Yellow),
SkillStatus::Obsolete => Cell::new("obsolete").fg(Color::Red),
};
if verbose {
table.add_row(vec![
Cell::new(&skill.name),
scope_cell,
status_cell,
Cell::new(skill.source_path.display().to_string()),
]);
} else {
table.add_row(vec![Cell::new(&skill.name), scope_cell, status_cell]);
}
}
Ok(table.to_string())
}
fn discover_skills_in_dir(
skills_dir: &Path,
scope: SkillScope,
context_root: &Path,
check_obsolete: bool,
skills: &mut Vec<SkillInfo>,
) -> Result<()> {
for entry in fs::read_dir(skills_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let runtime_path = match scope {
SkillScope::Project => project_skill_runtime_dir(context_root, &name),
SkillScope::Global => global_skillc_dir()?.join("runtime").join(&name),
};
let (status, has_valid_runtime) = determine_status(&path, &runtime_path, check_obsolete)?;
skills.push(SkillInfo {
name,
scope,
status,
source_path: path,
runtime_path: if has_valid_runtime {
Some(runtime_path)
} else {
None
},
});
}
Ok(())
}
fn determine_status(
source_dir: &Path,
runtime_dir: &Path,
check_obsolete: bool,
) -> Result<(SkillStatus, bool)> {
let manifest_path = runtime_dir.join(".skillc-meta").join("manifest.json");
if !runtime_dir.exists() || !manifest_path.exists() {
return Ok((SkillStatus::NotBuilt, false));
}
if !check_obsolete {
return Ok((SkillStatus::Normal, true));
}
let manifest_content = fs::read_to_string(&manifest_path)?;
let manifest: serde_json::Value = serde_json::from_str(&manifest_content)
.map_err(|e| SkillcError::Internal(format!("Failed to parse manifest: {}", e)))?;
let stored_hash = match manifest.get("source_hash").and_then(|v| v.as_str()) {
Some(h) => h,
None => return Ok((SkillStatus::Obsolete, true)), };
let current_hash = compute_source_hash(source_dir)?;
if current_hash == stored_hash {
Ok((SkillStatus::Normal, true))
} else {
Ok((SkillStatus::Obsolete, true))
}
}
fn compute_source_hash(source_dir: &Path) -> Result<String> {
let mut file_hashes: Vec<(String, String)> = Vec::new();
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_entry(|e| {
!e.file_type().is_dir() || !e.file_name().to_string_lossy().starts_with('.')
})
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let relative_path = entry
.path()
.strip_prefix(source_dir)
.map_err(|_| SkillcError::Internal("path does not start with source_dir".into()))?
.to_string_lossy()
.to_string();
let content = fs::read(entry.path())?;
let mut hasher = Sha256::new();
hasher.update(&content);
let file_hash = format!("{:x}", hasher.finalize());
file_hashes.push((relative_path, file_hash));
}
file_hashes.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (path, hash) in &file_hashes {
hasher.update(path.as_bytes());
hasher.update(hash.as_bytes());
}
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_skill_status_display() {
assert_eq!(format!("{}", SkillStatus::Normal), "normal");
assert_eq!(format!("{}", SkillStatus::NotBuilt), "not-built");
assert_eq!(format!("{}", SkillStatus::Obsolete), "obsolete");
}
#[test]
fn test_skill_scope_display() {
assert_eq!(format!("{}", SkillScope::Project), "project");
assert_eq!(format!("{}", SkillScope::Global), "global");
}
#[test]
fn test_determine_status_not_built() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
let runtime_dir = temp.path().join("runtime");
fs::create_dir_all(&source_dir).expect("create test dir");
fs::write(source_dir.join("SKILL.md"), "# Test").expect("test operation");
let (status, has_runtime) =
determine_status(&source_dir, &runtime_dir, true).expect("determine status");
assert_eq!(status, SkillStatus::NotBuilt);
assert!(!has_runtime);
}
#[test]
fn test_determine_status_normal_without_check() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
let runtime_dir = temp.path().join("runtime");
let meta_dir = runtime_dir.join(".skillc-meta");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::create_dir_all(&meta_dir).expect("create meta dir");
fs::write(source_dir.join("SKILL.md"), "# Test").expect("write source");
fs::write(
meta_dir.join("manifest.json"),
r#"{"source_hash": "abc123"}"#,
)
.expect("write manifest");
let (status, has_runtime) =
determine_status(&source_dir, &runtime_dir, false).expect("determine status");
assert_eq!(status, SkillStatus::Normal);
assert!(has_runtime);
}
#[test]
fn test_determine_status_obsolete_hash_mismatch() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
let runtime_dir = temp.path().join("runtime");
let meta_dir = runtime_dir.join(".skillc-meta");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::create_dir_all(&meta_dir).expect("create meta dir");
fs::write(source_dir.join("SKILL.md"), "# Test").expect("write source");
fs::write(
meta_dir.join("manifest.json"),
r#"{"source_hash": "wrong_hash"}"#,
)
.expect("write manifest");
let (status, has_runtime) =
determine_status(&source_dir, &runtime_dir, true).expect("determine status");
assert_eq!(status, SkillStatus::Obsolete);
assert!(has_runtime);
}
#[test]
fn test_determine_status_obsolete_no_hash_in_manifest() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
let runtime_dir = temp.path().join("runtime");
let meta_dir = runtime_dir.join(".skillc-meta");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::create_dir_all(&meta_dir).expect("create meta dir");
fs::write(source_dir.join("SKILL.md"), "# Test").expect("write source");
fs::write(meta_dir.join("manifest.json"), r#"{"version": "1.0"}"#).expect("write manifest");
let (status, has_runtime) =
determine_status(&source_dir, &runtime_dir, true).expect("determine status");
assert_eq!(status, SkillStatus::Obsolete);
assert!(has_runtime);
}
#[test]
fn test_compute_source_hash_deterministic() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::write(source_dir.join("SKILL.md"), "# Test\nContent here").expect("write file");
fs::write(source_dir.join("extra.md"), "More content").expect("write file");
let hash1 = compute_source_hash(&source_dir).expect("compute hash");
let hash2 = compute_source_hash(&source_dir).expect("compute hash again");
assert_eq!(hash1, hash2, "hash should be deterministic");
assert_eq!(hash1.len(), 64, "should be SHA-256 hex (64 chars)");
}
#[test]
fn test_compute_source_hash_changes_with_content() {
let temp = TempDir::new().expect("create temp dir");
let source_dir = temp.path().join("source");
fs::create_dir_all(&source_dir).expect("create source dir");
fs::write(source_dir.join("SKILL.md"), "# Test").expect("write file");
let hash1 = compute_source_hash(&source_dir).expect("compute hash");
fs::write(source_dir.join("SKILL.md"), "# Test Modified").expect("modify file");
let hash2 = compute_source_hash(&source_dir).expect("compute hash again");
assert_ne!(hash1, hash2, "hash should change when content changes");
}
#[test]
fn test_format_list_empty() {
let result = ListResult {
skills: vec![],
total: 0,
};
let output = format_list(&result, OutputFormat::Text, false).expect("format");
assert_eq!(output, "No skills found.");
}
#[test]
fn test_format_list_text() {
let result = ListResult {
skills: vec![
SkillInfo {
name: "test-skill".to_string(),
scope: SkillScope::Project,
status: SkillStatus::Normal,
source_path: PathBuf::from("/path/to/skill"),
runtime_path: Some(PathBuf::from("/runtime/path")),
},
SkillInfo {
name: "global-skill".to_string(),
scope: SkillScope::Global,
status: SkillStatus::NotBuilt,
source_path: PathBuf::from("/global/skill"),
runtime_path: None,
},
],
total: 2,
};
let output = format_list(&result, OutputFormat::Text, false).expect("format");
assert!(
output.contains("test-skill"),
"should contain skill name, got: {}",
output
);
assert!(
output.contains("project"),
"should contain scope, got: {}",
output
);
assert!(
output.contains("normal"),
"should contain status, got: {}",
output
);
}
#[test]
fn test_format_list_text_verbose() {
let result = ListResult {
skills: vec![SkillInfo {
name: "verbose-skill".to_string(),
scope: SkillScope::Project,
status: SkillStatus::Obsolete,
source_path: PathBuf::from("/path/to/source"),
runtime_path: Some(PathBuf::from("/path/to/runtime")),
}],
total: 1,
};
let output = format_list(&result, OutputFormat::Text, true).expect("format");
assert!(
output.contains("verbose-skill"),
"should contain skill name, got: {}",
output
);
assert!(
output.contains("obsolete"),
"should contain status, got: {}",
output
);
assert!(
output.contains("/path/to/source"),
"verbose should show source path, got: {}",
output
);
}
#[test]
fn test_format_list_json() {
let result = ListResult {
skills: vec![SkillInfo {
name: "json-skill".to_string(),
scope: SkillScope::Global,
status: SkillStatus::Normal,
source_path: PathBuf::from("/path"),
runtime_path: None,
}],
total: 1,
};
let output = format_list(&result, OutputFormat::Json, false).expect("format");
let parsed: serde_json::Value = serde_json::from_str(&output).expect("parse JSON");
assert!(parsed.get("skills").is_some(), "should have skills array");
assert_eq!(
parsed["skills"][0]["name"].as_str(),
Some("json-skill"),
"should have skill name"
);
}
}