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/// Parse YAML frontmatter from an agent .md file.
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_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
34/// Extract skills list from markdown content with YAML frontmatter.
35///
36/// Defensive: returns empty vec on any parse failure.
37fn 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
44/// Check that agent→skill references resolve.
45///
46/// Reads YAML frontmatter from each agent .md file to extract `skills: [...]`.
47/// Checks each referenced skill name exists in `available_skills`.
48///
49/// Returns warnings, not errors — a missing skill doesn't prevent sync.
50pub 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        // Defensive: if we can't read/parse the file, treat as no skills
58        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
78/// Find a suggestion for a missing skill using substring matching.
79///
80/// Checks if any available skill name contains the missing name as a
81/// substring or vice versa. No edit distance library needed for v1.
82fn find_suggestion(missing: &str, available: &HashSet<String>) -> Option<String> {
83    let missing_lower = missing.to_lowercase();
84
85    // Sort for deterministic suggestion when multiple match
86    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    // ── Validation tests ────────────────────────────────────────────
106
107    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            } // only variant is MissingSkill; exhaustive match above
157        }
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        // Malformed YAML → empty skills → no missing skill warnings
198        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); // 1 MissingSkill only
211
212        match &warnings[0] {
213            ValidationWarning::MissingSkill { suggestion, .. } => {
214                assert_eq!(suggestion.as_deref(), Some("planning"));
215            } // only variant is MissingSkill; exhaustive match above
216        }
217    }
218
219    #[test]
220    fn suggestion_reverse_substring() {
221        // "planning" contains "plan" → suggestion
222        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        // "review-pr" contains "review" → suggestion
232        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        // Only MissingSkill warnings — no orphan warnings
277        assert_eq!(warnings.len(), 2); // missing-a, missing-b
278        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        // Path to a file that doesn't exist — check_deps should not crash
297        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}