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/// Extract skills list from markdown content with YAML frontmatter.
41///
42/// Defensive: returns empty vec on any parse failure.
43pub(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
50/// Check that agent→skill references resolve.
51///
52/// Reads YAML frontmatter from each agent .md file to extract `skills: [...]`.
53/// Checks each referenced skill name exists in `available_skills`.
54///
55/// Returns warnings, not errors — a missing skill doesn't prevent sync.
56pub 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        // Defensive: if we can't read/parse the file, treat as no skills
64        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
84/// Find a suggestion for a missing skill using substring matching.
85///
86/// Checks if any available skill name contains the missing name as a
87/// substring or vice versa. No edit distance library needed for v1.
88pub(crate) fn find_suggestion(missing: &str, available: &HashSet<String>) -> Option<String> {
89    let missing_lower = missing.to_lowercase();
90
91    // Sort for deterministic suggestion when multiple match
92    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    // ── Validation tests ────────────────────────────────────────────
112
113    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            } // only variant is MissingSkill; exhaustive match above
174        }
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        // Malformed YAML → empty skills → no missing skill warnings
215        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); // 1 MissingSkill only
228
229        match &warnings[0] {
230            ValidationWarning::MissingSkill { suggestion, .. } => {
231                assert_eq!(suggestion.as_deref(), Some("planning"));
232            } // only variant is MissingSkill; exhaustive match above
233        }
234    }
235
236    #[test]
237    fn suggestion_reverse_substring() {
238        // "planning" contains "plan" → suggestion
239        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        // "review-pr" contains "review" → suggestion
249        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        // Only MissingSkill warnings — no orphan warnings
294        assert_eq!(warnings.len(), 2); // missing-a, missing-b
295        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        // Path to a file that doesn't exist — check_deps should not crash
314        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}