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