Skip to main content

mars_agents/validate/
mod.rs

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/// Warning from dependency validation.
10///
11/// Agents declare `skills: [X, Y]` in YAML frontmatter. After resolution,
12/// every referenced skill must exist somewhere in the target state.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ValidationWarning {
15    /// An agent references a skill that doesn't exist in target state.
16    MissingSkill {
17        agent: ItemId,
18        skill_name: String,
19        /// Fuzzy match suggestion: "did you mean X?"
20        suggestion: Option<String>,
21    },
22}
23
24/// Generic: parse skill dependencies from any item's frontmatter.
25///
26/// Returns the `skills` list, or empty vec if no frontmatter, no skills
27/// field, or malformed YAML. Only reads the frontmatter block between
28/// `---` delimiters, not the full markdown body.
29pub 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
34/// Parse skill dependencies from an agent's frontmatter.
35///
36/// Returns a list of skill names from the `skills:` YAML field.
37pub fn parse_agent_skills(agent_path: &Path) -> Result<Vec<String>, MarsError> {
38    parse_item_skill_deps(agent_path)
39}
40
41/// Parse skill dependencies from a skill's frontmatter.
42///
43/// Skills can also reference other skills via the `skills:` field.
44pub fn parse_skill_skills(skill_path: &Path) -> Result<Vec<String>, MarsError> {
45    parse_item_skill_deps(skill_path)
46}
47
48/// Extract skills list from markdown content with YAML frontmatter.
49///
50/// Defensive: returns empty vec on any parse failure.
51pub(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
58/// Check that agent→skill references resolve.
59///
60/// Reads YAML frontmatter from each agent .md file to extract `skills: [...]`.
61/// Checks each referenced skill name exists in `available_skills`.
62///
63/// Returns warnings, not errors — a missing skill doesn't prevent sync.
64pub 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        // Defensive: if we can't read/parse the file, treat as no skills
72        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
92/// Find a suggestion for a missing skill using substring matching.
93///
94/// Checks if any available skill name contains the missing name as a
95/// substring or vice versa. No edit distance library needed for v1.
96pub(crate) fn find_suggestion(missing: &str, available: &HashSet<String>) -> Option<String> {
97    let missing_lower = missing.to_lowercase();
98
99    // Sort for deterministic suggestion when multiple match
100    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    // ── Validation tests ────────────────────────────────────────────
120
121    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            } // only variant is MissingSkill; exhaustive match above
203        }
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        // Malformed YAML → empty skills → no missing skill warnings
244        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); // 1 MissingSkill only
257
258        match &warnings[0] {
259            ValidationWarning::MissingSkill { suggestion, .. } => {
260                assert_eq!(suggestion.as_deref(), Some("planning"));
261            } // only variant is MissingSkill; exhaustive match above
262        }
263    }
264
265    #[test]
266    fn suggestion_reverse_substring() {
267        // "planning" contains "plan" → suggestion
268        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        // "review-pr" contains "review" → suggestion
278        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        // Only MissingSkill warnings — no orphan warnings
323        assert_eq!(warnings.len(), 2); // missing-a, missing-b
324        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        // Path to a file that doesn't exist — check_deps should not crash
343        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}