Skip to main content

mars_agents/sync/
filter.rs

1//! Intent-based filtering for discovered items.
2//!
3//! Applies filter modes (All, Exclude, Include, OnlySkills, OnlyAgents) to
4//! discovered items, including transitive skill dependency resolution via
5//! agent frontmatter parsing.
6
7use std::collections::HashSet;
8use std::path::Path;
9
10use crate::config::FilterMode;
11use crate::discover;
12use crate::error::MarsError;
13use crate::lock::ItemKind;
14use crate::types::ItemName;
15use crate::validate;
16
17/// Apply filter mode to discovered items.
18///
19/// For Include mode with agents: also resolves transitive skill dependencies
20/// by parsing agent frontmatter.
21pub(crate) fn apply_filter(
22    discovered: &[discover::DiscoveredItem],
23    filter: &FilterMode,
24    tree_path: &Path,
25) -> Result<Vec<discover::DiscoveredItem>, MarsError> {
26    match filter {
27        FilterMode::All => Ok(discovered.to_vec()),
28
29        FilterMode::Exclude(excluded) => Ok(discovered
30            .iter()
31            .filter(|item| {
32                let path_str = item.source_path.to_string_lossy();
33                !excluded.iter().any(|e| {
34                    // Match against full source path or just the name
35                    path_str == e.as_ref() || item.id.name == *e
36                })
37            })
38            .cloned()
39            .collect()),
40
41        FilterMode::Include { agents, skills } => {
42            // Start with explicitly requested items
43            let mut include_set: HashSet<ItemName> = HashSet::new();
44
45            // Add explicitly requested agents and skills
46            for a in agents {
47                include_set.insert(a.clone());
48            }
49            for s in skills {
50                include_set.insert(s.clone());
51            }
52
53            // Resolve transitive skill deps from agent frontmatter
54            resolve_agent_skill_deps(discovered, agents, tree_path, &mut include_set);
55
56            Ok(discovered
57                .iter()
58                .filter(|item| include_set.contains(&item.id.name))
59                .cloned()
60                .collect())
61        }
62
63        FilterMode::OnlySkills => Ok(discovered
64            .iter()
65            .filter(|item| item.id.kind == ItemKind::Skill)
66            .cloned()
67            .collect()),
68
69        FilterMode::OnlyAgents => {
70            // Collect all agents
71            let agents: Vec<_> = discovered
72                .iter()
73                .filter(|item| item.id.kind == ItemKind::Agent)
74                .cloned()
75                .collect();
76
77            // Resolve transitive skill deps from all agent frontmatter
78            let agent_names: Vec<ItemName> = agents.iter().map(|a| a.id.name.clone()).collect();
79            let mut skill_deps: HashSet<ItemName> = HashSet::new();
80            resolve_agent_skill_deps(discovered, &agent_names, tree_path, &mut skill_deps);
81
82            // Include agents + their transitive skill deps only
83            let skills: Vec<_> = discovered
84                .iter()
85                .filter(|item| {
86                    item.id.kind == ItemKind::Skill && skill_deps.contains(&item.id.name)
87                })
88                .cloned()
89                .collect();
90
91            let mut result = agents;
92            result.extend(skills);
93            Ok(result)
94        }
95    }
96}
97
98/// Resolve transitive skill dependencies from agent frontmatter.
99///
100/// For each agent name, finds the matching discovered item and parses its
101/// frontmatter to extract skill dependencies, inserting them into the provided set.
102fn resolve_agent_skill_deps(
103    discovered: &[discover::DiscoveredItem],
104    agent_names: &[ItemName],
105    tree_path: &Path,
106    skill_deps: &mut HashSet<ItemName>,
107) {
108    for agent_name in agent_names {
109        if let Some(agent_item) = discovered
110            .iter()
111            .find(|i| i.id.kind == ItemKind::Agent && i.id.name == *agent_name)
112        {
113            let agent_path = tree_path.join(&agent_item.source_path);
114            let deps = validate::parse_agent_skills(&agent_path).unwrap_or_default();
115            for skill in deps {
116                skill_deps.insert(ItemName::from(skill));
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::discover;
126    use std::fs;
127    use tempfile::TempDir;
128
129    /// Helper: create a source tree with agents and skills
130    fn make_source_tree(agents: &[(&str, &str)], skills: &[(&str, &str)]) -> TempDir {
131        let dir = TempDir::new().unwrap();
132        if !agents.is_empty() {
133            let agents_dir = dir.path().join("agents");
134            fs::create_dir_all(&agents_dir).unwrap();
135            for (name, content) in agents {
136                fs::write(agents_dir.join(name), content).unwrap();
137            }
138        }
139        if !skills.is_empty() {
140            let skills_dir = dir.path().join("skills");
141            fs::create_dir_all(&skills_dir).unwrap();
142            for (name, content) in skills {
143                let skill_dir = skills_dir.join(name);
144                fs::create_dir_all(&skill_dir).unwrap();
145                fs::write(skill_dir.join("SKILL.md"), content).unwrap();
146            }
147        }
148        dir
149    }
150
151    #[test]
152    fn filter_all_returns_everything() {
153        let tree = make_source_tree(
154            &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
155            &[("planning", "# planning")],
156        );
157        let discovered = discover::discover_source(tree.path(), None).unwrap();
158        let filtered = apply_filter(&discovered, &FilterMode::All, tree.path()).unwrap();
159        assert_eq!(filtered.len(), 3);
160    }
161
162    #[test]
163    fn filter_exclude_removes_items() {
164        let tree = make_source_tree(
165            &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
166            &[],
167        );
168        let discovered = discover::discover_source(tree.path(), None).unwrap();
169        let filtered = apply_filter(
170            &discovered,
171            &FilterMode::Exclude(vec!["reviewer".into()]),
172            tree.path(),
173        )
174        .unwrap();
175        assert_eq!(filtered.len(), 1);
176        assert_eq!(filtered[0].id.name, "coder");
177    }
178
179    #[test]
180    fn filter_include_agents_only() {
181        let tree = make_source_tree(
182            &[("coder.md", "# coder"), ("reviewer.md", "# reviewer")],
183            &[("planning", "# planning")],
184        );
185        let discovered = discover::discover_source(tree.path(), None).unwrap();
186        let filtered = apply_filter(
187            &discovered,
188            &FilterMode::Include {
189                agents: vec!["coder".into()],
190                skills: vec![],
191            },
192            tree.path(),
193        )
194        .unwrap();
195        assert_eq!(filtered.len(), 1);
196        assert_eq!(filtered[0].id.name, "coder");
197    }
198
199    #[test]
200    fn filter_include_with_transitive_skill_deps() {
201        let tree = make_source_tree(
202            &[(
203                "coder.md",
204                "---\nskills:\n  - planning\n---\n# Coder agent\n",
205            )],
206            &[
207                ("planning", "# Planning skill"),
208                ("review", "# Review skill"),
209            ],
210        );
211        let discovered = discover::discover_source(tree.path(), None).unwrap();
212        let filtered = apply_filter(
213            &discovered,
214            &FilterMode::Include {
215                agents: vec!["coder".into()],
216                skills: vec![],
217            },
218            tree.path(),
219        )
220        .unwrap();
221        // Should include coder agent + planning skill (transitive dep)
222        assert_eq!(filtered.len(), 2);
223        let names: Vec<&str> = filtered.iter().map(|i| i.id.name.as_str()).collect();
224        assert!(names.contains(&"coder"));
225        assert!(names.contains(&"planning"));
226    }
227}