1use std::path::Path;
4
5use crate::error::MarsError;
6use crate::frontmatter;
7use crate::hash;
8use crate::lock::ItemKind;
9
10use super::output::{self, CatalogEntry, ListEntry};
11
12#[derive(Debug, clap::Args)]
14pub struct ListArgs {
15 #[arg(long)]
17 pub source: Option<String>,
18
19 #[arg(long)]
21 pub kind: Option<String>,
22
23 #[arg(long)]
25 pub status: bool,
26}
27
28pub fn run(args: &ListArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
30 let lock = crate::lock::load(&ctx.project_root)?;
31
32 if args.status {
33 return run_status(args, &ctx.managed_root, &lock, json);
34 }
35
36 let mut agents = Vec::new();
38 let mut skills = Vec::new();
39
40 for (dest_path, item) in &lock.items {
41 if let Some(ref filter_source) = args.source
43 && &item.source != filter_source
44 {
45 continue;
46 }
47
48 if let Some(ref filter_kind) = args.kind {
50 let kind_str = match item.kind {
51 ItemKind::Agent => "agents",
52 ItemKind::Skill => "skills",
53 };
54 if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
55 continue;
56 }
57 }
58
59 let disk_path = ctx.managed_root.join(dest_path);
61 let content_path = match item.kind {
62 ItemKind::Agent => disk_path.clone(),
63 ItemKind::Skill => disk_path.join("SKILL.md"),
64 };
65
66 let fallback_name = path_to_name(&disk_path);
67 let (name, description) = read_name_description(&content_path, &fallback_name);
68
69 let entry = CatalogEntry {
70 name,
71 description,
72 kind: item.kind.to_string(),
73 };
74
75 match item.kind {
76 ItemKind::Agent => agents.push(entry),
77 ItemKind::Skill => skills.push(entry),
78 }
79 }
80
81 agents.sort_by(|a, b| a.name.cmp(&b.name));
82 skills.sort_by(|a, b| a.name.cmp(&b.name));
83
84 if json {
85 output::print_json(&serde_json::json!({
86 "agents": agents,
87 "skills": skills,
88 }));
89 } else {
90 output::print_catalog(&agents, &skills, args.kind.as_deref());
91 }
92
93 Ok(0)
94}
95
96fn read_name_description(path: &Path, fallback_name: &str) -> (String, String) {
98 let content = match std::fs::read_to_string(path) {
99 Ok(c) => c,
100 Err(_) => return (fallback_name.to_string(), String::new()),
101 };
102 match frontmatter::parse(&content) {
103 Ok(fm) => {
104 let name = fm
105 .name()
106 .map(str::to_string)
107 .unwrap_or_else(|| fallback_name.to_string());
108 let description = fm
109 .get("description")
110 .and_then(|v| v.as_str())
111 .unwrap_or_default()
112 .to_string();
113 (name, description)
114 }
115 Err(_) => (fallback_name.to_string(), String::new()),
116 }
117}
118
119fn path_to_name(path: &Path) -> String {
121 path.file_stem()
122 .or_else(|| path.parent().and_then(|p| p.file_name()))
123 .and_then(|n| n.to_str())
124 .unwrap_or("unknown")
125 .to_string()
126}
127
128fn run_status(
130 args: &ListArgs,
131 root: &Path,
132 lock: &crate::lock::LockFile,
133 json: bool,
134) -> Result<i32, MarsError> {
135 let mut entries = Vec::new();
136
137 for (dest_path, item) in &lock.items {
138 if let Some(ref filter_source) = args.source
139 && &item.source != filter_source
140 {
141 continue;
142 }
143
144 if let Some(ref filter_kind) = args.kind {
145 let kind_str = match item.kind {
146 ItemKind::Agent => "agents",
147 ItemKind::Skill => "skills",
148 };
149 if kind_str != filter_kind && &item.kind.to_string() != filter_kind {
150 continue;
151 }
152 }
153
154 let disk_path = root.join(dest_path);
155 let status = if !disk_path.exists() {
156 "missing".to_string()
157 } else if has_conflict_markers(&disk_path) {
158 "conflicted".to_string()
159 } else {
160 let disk_hash = hash::compute_hash(&disk_path, item.kind)?;
161 if disk_hash == item.installed_checksum {
162 "ok".to_string()
163 } else {
164 "modified".to_string()
165 }
166 };
167
168 entries.push(ListEntry {
169 source: item.source.to_string(),
170 item: dest_path.to_string(),
171 kind: item.kind.to_string(),
172 version: item.version.clone().unwrap_or_else(|| "-".to_string()),
173 status,
174 });
175 }
176
177 entries.sort_by(|a, b| (&a.source, &a.item).cmp(&(&b.source, &b.item)));
178 output::print_list(&entries, json);
179 Ok(0)
180}
181
182fn has_conflict_markers(path: &Path) -> bool {
184 std::fs::read_to_string(path)
185 .map(|content| content.contains("<<<<<<<") && content.contains(">>>>>>>"))
186 .unwrap_or(false)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use tempfile::TempDir;
193
194 #[test]
195 fn read_name_description_uses_provided_fallback_name() {
196 let dir = TempDir::new().unwrap();
197 let missing_skill_md = dir.path().join("skills/test-skill/SKILL.md");
198 let (name, description) = read_name_description(&missing_skill_md, "test-skill");
199 assert_eq!(name, "test-skill");
200 assert_eq!(description, "");
201 }
202}