1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::error::MarsError;
5use crate::frontmatter;
6use crate::lock::{ItemId, ItemKind};
7use crate::types::ItemName;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ValidationWarning {
15 MissingSkill {
17 agent: ItemId,
18 skill_name: String,
19 suggestion: Option<String>,
21 },
22}
23
24pub fn parse_item_skill_deps(item_path: &Path) -> Result<Vec<String>, MarsError> {
30 let content = std::fs::read_to_string(item_path)?;
31 Ok(extract_skills_from_content(&content))
32}
33
34pub fn parse_agent_skills(agent_path: &Path) -> Result<Vec<String>, MarsError> {
38 parse_item_skill_deps(agent_path)
39}
40
41pub fn parse_skill_skills(skill_path: &Path) -> Result<Vec<String>, MarsError> {
45 parse_item_skill_deps(skill_path)
46}
47
48pub(crate) fn extract_skills_from_content(content: &str) -> Vec<String> {
52 match frontmatter::parse(content) {
53 Ok(fm) => fm.skills(),
54 Err(_) => Vec::new(),
55 }
56}
57
58pub fn check_deps(
65 agents: &[(String, PathBuf)],
66 available_skills: &HashSet<String>,
67) -> Result<Vec<ValidationWarning>, MarsError> {
68 let mut warnings = Vec::new();
69
70 for (agent_name, agent_path) in agents {
71 let skills = parse_agent_skills(agent_path).unwrap_or_default();
73
74 for skill_name in skills {
75 if !available_skills.contains(&skill_name) {
76 let suggestion = find_suggestion(&skill_name, available_skills);
77 warnings.push(ValidationWarning::MissingSkill {
78 agent: ItemId {
79 kind: ItemKind::Agent,
80 name: ItemName::from(agent_name.clone()),
81 },
82 skill_name,
83 suggestion,
84 });
85 }
86 }
87 }
88
89 Ok(warnings)
90}
91
92pub(crate) fn find_suggestion(missing: &str, available: &HashSet<String>) -> Option<String> {
97 let missing_lower = missing.to_lowercase();
98
99 let mut candidates: Vec<&String> = available.iter().collect();
101 candidates.sort();
102
103 for name in candidates {
104 let name_lower = name.to_lowercase();
105 if name_lower.contains(&missing_lower) || missing_lower.contains(&name_lower) {
106 return Some(name.clone());
107 }
108 }
109
110 None
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use std::fs;
117 use tempfile::TempDir;
118
119 fn write_agent(dir: &Path, name: &str, content: &str) -> PathBuf {
122 let path = dir.join(format!("{name}.md"));
123 fs::write(&path, content).unwrap();
124 path
125 }
126
127 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
128 let path = dir.join(format!("{name}.md"));
129 fs::write(&path, content).unwrap();
130 path
131 }
132
133 #[test]
134 fn parse_agent_skills_reads_frontmatter() {
135 let dir = TempDir::new().unwrap();
136 let path = write_agent(
137 dir.path(),
138 "coder",
139 "---\nskills:\n - planning\n - review\n---\n# Coder\n",
140 );
141
142 let skills = parse_agent_skills(&path).unwrap();
143 assert_eq!(skills, vec!["planning", "review"]);
144 }
145
146 #[test]
147 fn parse_skill_skills_reads_frontmatter() {
148 let dir = TempDir::new().unwrap();
149 let path = write_skill(
150 dir.path(),
151 "frontend",
152 "---\nskills:\n - design-tokens\n - motion\n---\n# Frontend Skill\n",
153 );
154
155 let skills = parse_skill_skills(&path).unwrap();
156 assert_eq!(skills, vec!["design-tokens", "motion"]);
157 }
158
159 #[test]
160 fn all_skills_present_no_warnings() {
161 let dir = TempDir::new().unwrap();
162 let p = write_agent(
163 dir.path(),
164 "coder",
165 "---\nskills: [planning, review]\n---\n# Coder\n",
166 );
167
168 let agents = vec![("coder".to_string(), p)];
169 let skills: HashSet<String> = ["planning", "review"]
170 .iter()
171 .map(|s| s.to_string())
172 .collect();
173
174 let warnings = check_deps(&agents, &skills).unwrap();
175 assert!(warnings.is_empty());
176 }
177
178 #[test]
179 fn missing_skill_produces_warning() {
180 let dir = TempDir::new().unwrap();
181 let p = write_agent(
182 dir.path(),
183 "coder",
184 "---\nskills: [missing-skill]\n---\n# Coder\n",
185 );
186
187 let agents = vec![("coder".to_string(), p)];
188 let skills: HashSet<String> = HashSet::new();
189
190 let warnings = check_deps(&agents, &skills).unwrap();
191 assert_eq!(warnings.len(), 1);
192 match &warnings[0] {
193 ValidationWarning::MissingSkill {
194 agent,
195 skill_name,
196 suggestion,
197 } => {
198 assert_eq!(agent.name, "coder");
199 assert_eq!(agent.kind, ItemKind::Agent);
200 assert_eq!(skill_name, "missing-skill");
201 assert!(suggestion.is_none());
202 } }
204 }
205
206 #[test]
207 fn unreferenced_skill_produces_no_warning() {
208 let dir = TempDir::new().unwrap();
209 let p = write_agent(dir.path(), "coder", "---\nskills: []\n---\n# Coder\n");
210
211 let agents = vec![("coder".to_string(), p)];
212 let skills: HashSet<String> = ["unused-skill"].iter().map(|s| s.to_string()).collect();
213
214 let warnings = check_deps(&agents, &skills).unwrap();
215 assert!(warnings.is_empty());
216 }
217
218 #[test]
219 fn agent_with_no_frontmatter_no_warnings() {
220 let dir = TempDir::new().unwrap();
221 let p = write_agent(dir.path(), "simple", "# Simple agent\n\nNo frontmatter.\n");
222
223 let agents = vec![("simple".to_string(), p)];
224 let skills: HashSet<String> = HashSet::new();
225
226 let warnings = check_deps(&agents, &skills).unwrap();
227 assert!(warnings.is_empty());
228 }
229
230 #[test]
231 fn agent_with_malformed_yaml_no_crash() {
232 let dir = TempDir::new().unwrap();
233 let p = write_agent(
234 dir.path(),
235 "broken",
236 "---\n{{invalid: yaml[[\n---\n# Broken\n",
237 );
238
239 let agents = vec![("broken".to_string(), p)];
240 let skills: HashSet<String> = HashSet::new();
241
242 let warnings = check_deps(&agents, &skills).unwrap();
243 assert!(warnings.is_empty());
245 }
246
247 #[test]
248 fn missing_skill_with_suggestion() {
249 let dir = TempDir::new().unwrap();
250 let p = write_agent(dir.path(), "coder", "---\nskills: [plan]\n---\n# Coder\n");
251
252 let agents = vec![("coder".to_string(), p)];
253 let skills: HashSet<String> = ["planning"].iter().map(|s| s.to_string()).collect();
254
255 let warnings = check_deps(&agents, &skills).unwrap();
256 assert_eq!(warnings.len(), 1); match &warnings[0] {
259 ValidationWarning::MissingSkill { suggestion, .. } => {
260 assert_eq!(suggestion.as_deref(), Some("planning"));
261 } }
263 }
264
265 #[test]
266 fn suggestion_reverse_substring() {
267 let available: HashSet<String> = ["planning"].iter().map(|s| s.to_string()).collect();
269 assert_eq!(
270 find_suggestion("plan", &available),
271 Some("planning".to_string())
272 );
273 }
274
275 #[test]
276 fn suggestion_forward_substring() {
277 let available: HashSet<String> = ["review"].iter().map(|s| s.to_string()).collect();
279 assert_eq!(
280 find_suggestion("review-pr", &available),
281 Some("review".to_string())
282 );
283 }
284
285 #[test]
286 fn suggestion_case_insensitive() {
287 let available: HashSet<String> = ["Planning"].iter().map(|s| s.to_string()).collect();
288 assert_eq!(
289 find_suggestion("plan", &available),
290 Some("Planning".to_string())
291 );
292 }
293
294 #[test]
295 fn no_suggestion_when_no_match() {
296 let available: HashSet<String> = ["review"].iter().map(|s| s.to_string()).collect();
297 assert_eq!(find_suggestion("completely-different", &available), None);
298 }
299
300 #[test]
301 fn multiple_agents_multiple_warnings() {
302 let dir = TempDir::new().unwrap();
303 let p1 = write_agent(
304 dir.path(),
305 "coder",
306 "---\nskills: [missing-a, existing]\n---\n# Coder\n",
307 );
308 let p2 = write_agent(
309 dir.path(),
310 "reviewer",
311 "---\nskills: [missing-b]\n---\n# Reviewer\n",
312 );
313
314 let agents = vec![("coder".to_string(), p1), ("reviewer".to_string(), p2)];
315 let skills: HashSet<String> = ["existing", "orphan"]
316 .iter()
317 .map(|s| s.to_string())
318 .collect();
319
320 let warnings = check_deps(&agents, &skills).unwrap();
321
322 assert_eq!(warnings.len(), 2); assert!(
325 warnings
326 .iter()
327 .all(|w| matches!(w, ValidationWarning::MissingSkill { .. }))
328 );
329 }
330
331 #[test]
332 fn empty_agents_and_skills() {
333 let agents: Vec<(String, PathBuf)> = vec![];
334 let skills: HashSet<String> = HashSet::new();
335
336 let warnings = check_deps(&agents, &skills).unwrap();
337 assert!(warnings.is_empty());
338 }
339
340 #[test]
341 fn unreadable_agent_file_treated_as_no_skills() {
342 let agents = vec![("ghost".to_string(), PathBuf::from("/nonexistent/ghost.md"))];
344 let skills: HashSet<String> = HashSet::new();
345
346 let warnings = check_deps(&agents, &skills).unwrap();
347 assert!(warnings.is_empty());
348 }
349
350 #[test]
351 fn skills_with_dunder_prefix() {
352 let dir = TempDir::new().unwrap();
353 let p = write_agent(
354 dir.path(),
355 "coder",
356 "---\nskills:\n - __meridian-spawn\n - planning\n---\n# Coder\n",
357 );
358
359 let agents = vec![("coder".to_string(), p)];
360 let skills: HashSet<String> = ["__meridian-spawn", "planning"]
361 .iter()
362 .map(|s| s.to_string())
363 .collect();
364
365 let warnings = check_deps(&agents, &skills).unwrap();
366 assert!(warnings.is_empty());
367 }
368}