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