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}
40pub(crate) fn extract_skills_from_content(content: &str) -> Vec<String> {
44 match frontmatter::parse(content) {
45 Ok(fm) => fm.skills(),
46 Err(_) => Vec::new(),
47 }
48}
49
50pub fn check_deps(
57 agents: &[(String, PathBuf)],
58 available_skills: &HashSet<String>,
59) -> Result<Vec<ValidationWarning>, MarsError> {
60 let mut warnings = Vec::new();
61
62 for (agent_name, agent_path) in agents {
63 let skills = parse_agent_skills(agent_path).unwrap_or_default();
65
66 for skill_name in skills {
67 if !available_skills.contains(&skill_name) {
68 let suggestion = find_suggestion(&skill_name, available_skills);
69 warnings.push(ValidationWarning::MissingSkill {
70 agent: ItemId {
71 kind: ItemKind::Agent,
72 name: ItemName::from(agent_name.clone()),
73 },
74 skill_name,
75 suggestion,
76 });
77 }
78 }
79 }
80
81 Ok(warnings)
82}
83
84pub(crate) fn find_suggestion(missing: &str, available: &HashSet<String>) -> Option<String> {
89 let missing_lower = missing.to_lowercase();
90
91 let mut candidates: Vec<&String> = available.iter().collect();
93 candidates.sort();
94
95 for name in candidates {
96 let name_lower = name.to_lowercase();
97 if name_lower.contains(&missing_lower) || missing_lower.contains(&name_lower) {
98 return Some(name.clone());
99 }
100 }
101
102 None
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use std::fs;
109 use tempfile::TempDir;
110
111 fn write_agent(dir: &Path, name: &str, content: &str) -> PathBuf {
114 let path = dir.join(format!("{name}.md"));
115 fs::write(&path, content).unwrap();
116 path
117 }
118 #[test]
119 fn parse_agent_skills_reads_frontmatter() {
120 let dir = TempDir::new().unwrap();
121 let path = write_agent(
122 dir.path(),
123 "coder",
124 "---\nskills:\n - planning\n - review\n---\n# Coder\n",
125 );
126
127 let skills = parse_agent_skills(&path).unwrap();
128 assert_eq!(skills, vec!["planning", "review"]);
129 }
130 #[test]
131 fn all_skills_present_no_warnings() {
132 let dir = TempDir::new().unwrap();
133 let p = write_agent(
134 dir.path(),
135 "coder",
136 "---\nskills: [planning, review]\n---\n# Coder\n",
137 );
138
139 let agents = vec![("coder".to_string(), p)];
140 let skills: HashSet<String> = ["planning", "review"]
141 .iter()
142 .map(|s| s.to_string())
143 .collect();
144
145 let warnings = check_deps(&agents, &skills).unwrap();
146 assert!(warnings.is_empty());
147 }
148
149 #[test]
150 fn missing_skill_produces_warning() {
151 let dir = TempDir::new().unwrap();
152 let p = write_agent(
153 dir.path(),
154 "coder",
155 "---\nskills: [missing-skill]\n---\n# Coder\n",
156 );
157
158 let agents = vec![("coder".to_string(), p)];
159 let skills: HashSet<String> = HashSet::new();
160
161 let warnings = check_deps(&agents, &skills).unwrap();
162 assert_eq!(warnings.len(), 1);
163 match &warnings[0] {
164 ValidationWarning::MissingSkill {
165 agent,
166 skill_name,
167 suggestion,
168 } => {
169 assert_eq!(agent.name, "coder");
170 assert_eq!(agent.kind, ItemKind::Agent);
171 assert_eq!(skill_name, "missing-skill");
172 assert!(suggestion.is_none());
173 } }
175 }
176
177 #[test]
178 fn unreferenced_skill_produces_no_warning() {
179 let dir = TempDir::new().unwrap();
180 let p = write_agent(dir.path(), "coder", "---\nskills: []\n---\n# Coder\n");
181
182 let agents = vec![("coder".to_string(), p)];
183 let skills: HashSet<String> = ["unused-skill"].iter().map(|s| s.to_string()).collect();
184
185 let warnings = check_deps(&agents, &skills).unwrap();
186 assert!(warnings.is_empty());
187 }
188
189 #[test]
190 fn agent_with_no_frontmatter_no_warnings() {
191 let dir = TempDir::new().unwrap();
192 let p = write_agent(dir.path(), "simple", "# Simple agent\n\nNo frontmatter.\n");
193
194 let agents = vec![("simple".to_string(), p)];
195 let skills: HashSet<String> = HashSet::new();
196
197 let warnings = check_deps(&agents, &skills).unwrap();
198 assert!(warnings.is_empty());
199 }
200
201 #[test]
202 fn agent_with_malformed_yaml_no_crash() {
203 let dir = TempDir::new().unwrap();
204 let p = write_agent(
205 dir.path(),
206 "broken",
207 "---\n{{invalid: yaml[[\n---\n# Broken\n",
208 );
209
210 let agents = vec![("broken".to_string(), p)];
211 let skills: HashSet<String> = HashSet::new();
212
213 let warnings = check_deps(&agents, &skills).unwrap();
214 assert!(warnings.is_empty());
216 }
217
218 #[test]
219 fn missing_skill_with_suggestion() {
220 let dir = TempDir::new().unwrap();
221 let p = write_agent(dir.path(), "coder", "---\nskills: [plan]\n---\n# Coder\n");
222
223 let agents = vec![("coder".to_string(), p)];
224 let skills: HashSet<String> = ["planning"].iter().map(|s| s.to_string()).collect();
225
226 let warnings = check_deps(&agents, &skills).unwrap();
227 assert_eq!(warnings.len(), 1); match &warnings[0] {
230 ValidationWarning::MissingSkill { suggestion, .. } => {
231 assert_eq!(suggestion.as_deref(), Some("planning"));
232 } }
234 }
235
236 #[test]
237 fn suggestion_reverse_substring() {
238 let available: HashSet<String> = ["planning"].iter().map(|s| s.to_string()).collect();
240 assert_eq!(
241 find_suggestion("plan", &available),
242 Some("planning".to_string())
243 );
244 }
245
246 #[test]
247 fn suggestion_forward_substring() {
248 let available: HashSet<String> = ["review"].iter().map(|s| s.to_string()).collect();
250 assert_eq!(
251 find_suggestion("review-pr", &available),
252 Some("review".to_string())
253 );
254 }
255
256 #[test]
257 fn suggestion_case_insensitive() {
258 let available: HashSet<String> = ["Planning"].iter().map(|s| s.to_string()).collect();
259 assert_eq!(
260 find_suggestion("plan", &available),
261 Some("Planning".to_string())
262 );
263 }
264
265 #[test]
266 fn no_suggestion_when_no_match() {
267 let available: HashSet<String> = ["review"].iter().map(|s| s.to_string()).collect();
268 assert_eq!(find_suggestion("completely-different", &available), None);
269 }
270
271 #[test]
272 fn multiple_agents_multiple_warnings() {
273 let dir = TempDir::new().unwrap();
274 let p1 = write_agent(
275 dir.path(),
276 "coder",
277 "---\nskills: [missing-a, existing]\n---\n# Coder\n",
278 );
279 let p2 = write_agent(
280 dir.path(),
281 "reviewer",
282 "---\nskills: [missing-b]\n---\n# Reviewer\n",
283 );
284
285 let agents = vec![("coder".to_string(), p1), ("reviewer".to_string(), p2)];
286 let skills: HashSet<String> = ["existing", "orphan"]
287 .iter()
288 .map(|s| s.to_string())
289 .collect();
290
291 let warnings = check_deps(&agents, &skills).unwrap();
292
293 assert_eq!(warnings.len(), 2); assert!(
296 warnings
297 .iter()
298 .all(|w| matches!(w, ValidationWarning::MissingSkill { .. }))
299 );
300 }
301
302 #[test]
303 fn empty_agents_and_skills() {
304 let agents: Vec<(String, PathBuf)> = vec![];
305 let skills: HashSet<String> = HashSet::new();
306
307 let warnings = check_deps(&agents, &skills).unwrap();
308 assert!(warnings.is_empty());
309 }
310
311 #[test]
312 fn unreadable_agent_file_treated_as_no_skills() {
313 let agents = vec![("ghost".to_string(), PathBuf::from("/nonexistent/ghost.md"))];
315 let skills: HashSet<String> = HashSet::new();
316
317 let warnings = check_deps(&agents, &skills).unwrap();
318 assert!(warnings.is_empty());
319 }
320
321 #[test]
322 fn skills_with_dunder_prefix() {
323 let dir = TempDir::new().unwrap();
324 let p = write_agent(
325 dir.path(),
326 "coder",
327 "---\nskills:\n - __meridian-spawn\n - planning\n---\n# Coder\n",
328 );
329
330 let agents = vec![("coder".to_string(), p)];
331 let skills: HashSet<String> = ["__meridian-spawn", "planning"]
332 .iter()
333 .map(|s| s.to_string())
334 .collect();
335
336 let warnings = check_deps(&agents, &skills).unwrap();
337 assert!(warnings.is_empty());
338 }
339}