Skip to main content

rtango/engine/
expand.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::agent::{
5    self,
6    frontmatter::{FrontMatter, FrontMatterMapper, split_frontmatter, tokenize_tools},
7};
8use crate::spec::{Rule, RuleKind, Source};
9
10use super::{
11    ExpandedItem, ExpandedKind, SystemFile, fetch_github, hash_content, read_collection_spec,
12};
13
14/// Expand a single rule into its constituent items by reading source files.
15///
16/// - `Skill` / `Agent`: produces one item (single file).
17/// - `SkillSet` / `AgentSet`: produces N items (one per source file in the directory).
18pub fn expand_rule(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
19    match &rule.kind {
20        RuleKind::Collection {
21            include,
22            exclude,
23            schema_override,
24        } => expand_collection(root, rule, include, exclude, schema_override),
25        _ => expand_local_or_github(root, rule),
26    }
27}
28
29/// Handle the non-collection kinds (skill, agent, skill-set, agent-set, system).
30fn expand_local_or_github(root: &Path, rule: &Rule) -> anyhow::Result<Vec<ExpandedItem>> {
31    let (project_root, abs_path) = materialize(root, &rule.source)?;
32
33    match &rule.kind {
34        RuleKind::SkillSet { include, exclude } => {
35            expand_skill_set(&project_root, rule, &abs_path, include, exclude)
36        }
37        RuleKind::AgentSet { include, exclude } => {
38            expand_agent_set(&project_root, rule, &abs_path, include, exclude)
39        }
40        RuleKind::Skill {
41            name,
42            description,
43            allowed_tools,
44        } => expand_single_skill(
45            rule,
46            &abs_path,
47            name.as_deref(),
48            description.as_deref(),
49            allowed_tools.as_deref(),
50        ),
51        RuleKind::Agent {
52            name,
53            description,
54            allowed_tools,
55        } => expand_single_agent(
56            rule,
57            &abs_path,
58            name.as_deref(),
59            description.as_deref(),
60            allowed_tools.as_deref(),
61        ),
62        RuleKind::System => expand_single_system(rule, &abs_path),
63        RuleKind::Collection { .. } => unreachable!("handled in expand_rule"),
64    }
65}
66
67/// Expand a Collection rule: fetch the remote repo, parse its spec.yaml,
68/// and expand each matching rule. Imported rules get their id prefixed with
69/// `<collection_rule_id>/` to avoid collisions with local rules.
70fn expand_collection(
71    root: &Path,
72    rule: &Rule,
73    include: &[String],
74    exclude: &[String],
75    schema_override: &Option<crate::spec::AgentName>,
76) -> anyhow::Result<Vec<ExpandedItem>> {
77    // Resolve the source to an on-disk root, exactly as non-collection rules do.
78    let collection_root = match &rule.source {
79        Source::Local(rel) => {
80            let abs = if rel.is_absolute() {
81                rel.clone()
82            } else {
83                root.join(rel)
84            };
85            if !abs.is_dir() {
86                anyhow::bail!(
87                    "collection '{}': source directory not found: {}",
88                    rule.id,
89                    abs.display()
90                );
91            }
92            abs
93        }
94        Source::Github(g) => fetch_github(g)?,
95    };
96    let remote_spec = read_collection_spec(&collection_root)?;
97
98    let mut all_items = Vec::new();
99    for remote_rule in &remote_spec.rules {
100        if !collection_passes_filter(&remote_rule.id, include, exclude) {
101            continue;
102        }
103        // Build a synthetic rule that uses the remote rule's definition,
104        // but with an optional schema_agent override from the local collection rule.
105        let effective_schema = schema_override
106            .clone()
107            .unwrap_or_else(|| remote_rule.schema_agent.clone());
108
109        let synthetic = Rule {
110            id: format!("{}/{}", rule.id, remote_rule.id),
111            source: remote_rule.source.clone(),
112            schema_agent: effective_schema,
113            on_target_modified: rule.on_target_modified,
114            kind: remote_rule.kind.clone(),
115        };
116
117        // Expand the synthetic rule using the cache root as the project root
118        let items = expand_local_or_github(&collection_root, &synthetic)?;
119
120        // Re-tag the items so they carry the collection's source for lock tracking
121        for mut item in items {
122            item.rule_id = synthetic.id.clone();
123            all_items.push(item);
124        }
125    }
126    Ok(all_items)
127}
128
129/// Check if a remote rule id passes the collection's include/exclude filter.
130fn collection_passes_filter(rule_id: &str, include: &[String], exclude: &[String]) -> bool {
131    if !include.is_empty() {
132        return include.iter().any(|p| p == rule_id);
133    }
134    !exclude.iter().any(|p| p == rule_id)
135}
136
137/// Decide whether an entry named `name` passes the include/exclude filter.
138/// Include wins if set (whitelist); otherwise exclude drops matches.
139/// Include/exclude are mutually exclusive — validated at the CLI layer.
140fn passes_filter(name: &str, include: &[String], exclude: &[String]) -> bool {
141    if !include.is_empty() {
142        return include.iter().any(|p| p == name);
143    }
144    !exclude.iter().any(|p| p == name)
145}
146
147/// Apply CLI-level overrides from the rule to a parsed `FrontMatter`.
148fn apply_overrides(
149    fm: &mut FrontMatter,
150    mapper: &dyn FrontMatterMapper,
151    name: Option<&str>,
152    description: Option<&str>,
153    allowed_tools: Option<&str>,
154) {
155    if let Some(n) = name {
156        fm.name = Some(n.to_string());
157    }
158    if let Some(d) = description {
159        fm.description = Some(d.to_string());
160    }
161    if let Some(t) = allowed_tools {
162        fm.allowed_tools = tokenize_tools(t)
163            .into_iter()
164            .map(|tok| mapper.parse_permission(&tok))
165            .collect();
166    }
167}
168
169/// Resolve a source to (project_root, filter_path) on disk.
170///
171/// `project_root` is what gets handed to agent parsers (which internally append
172/// `.claude/skills`, `.agent/skills`, etc.). `filter_path` is what the
173/// `expand_*_set` helpers use to narrow the parser's results to a subtree.
174fn materialize(root: &Path, source: &Source) -> anyhow::Result<(PathBuf, PathBuf)> {
175    match source {
176        Source::Local(rel) => Ok((root.to_path_buf(), root.join(rel))),
177        Source::Github(g) => {
178            let cache_root = fetch_github(g)?;
179            let filter = if g.path.is_empty() {
180                cache_root.clone()
181            } else {
182                cache_root.join(&g.path)
183            };
184            Ok((cache_root, filter))
185        } // Collection rules are dispatched before reaching materialize.
186          // The Source variants (Local/Github) are handled above for all other kinds.
187    }
188}
189
190fn expand_skill_set(
191    root: &Path,
192    rule: &Rule,
193    abs_path: &Path,
194    include: &[String],
195    exclude: &[String],
196) -> anyhow::Result<Vec<ExpandedItem>> {
197    let parser = agent::skills_parser(&rule.schema_agent)
198        .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
199    let skills = parser.parse_skills(root)?;
200    let mut items = Vec::new();
201    for skill in &skills {
202        if !skill.dir.starts_with(abs_path) {
203            continue;
204        }
205        if !passes_filter(&skill.name, include, exclude) {
206            continue;
207        }
208        let content = fs::read_to_string(&skill.file)?;
209        let hash = hash_content(&content);
210        items.push(ExpandedItem {
211            rule_id: rule.id.clone(),
212            source: rule.source.clone(),
213            source_content: content,
214            source_hash: hash,
215            kind: ExpandedKind::Skill(skill.clone()),
216        });
217    }
218    Ok(items)
219}
220
221fn expand_agent_set(
222    root: &Path,
223    rule: &Rule,
224    abs_path: &Path,
225    include: &[String],
226    exclude: &[String],
227) -> anyhow::Result<Vec<ExpandedItem>> {
228    let parser = agent::agents_parser(&rule.schema_agent)
229        .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
230    let agents = parser.parse_agents(root)?;
231    let mut items = Vec::new();
232    for ag in &agents {
233        if !ag.file.starts_with(abs_path) {
234            continue;
235        }
236        if !passes_filter(&ag.name, include, exclude) {
237            continue;
238        }
239        let content = fs::read_to_string(&ag.file)?;
240        let hash = hash_content(&content);
241        items.push(ExpandedItem {
242            rule_id: rule.id.clone(),
243            source: rule.source.clone(),
244            source_content: content,
245            source_hash: hash,
246            kind: ExpandedKind::Agent(ag.clone()),
247        });
248    }
249    Ok(items)
250}
251
252fn expand_single_skill(
253    rule: &Rule,
254    abs_path: &Path,
255    override_name: Option<&str>,
256    override_description: Option<&str>,
257    override_allowed_tools: Option<&str>,
258) -> anyhow::Result<Vec<ExpandedItem>> {
259    let mapper = agent::frontmatter_mapper(&rule.schema_agent)
260        .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
261
262    let skill_file = abs_path.join("SKILL.md");
263    if !skill_file.is_file() {
264        anyhow::bail!("skill file not found: {}", skill_file.display());
265    }
266    let name = abs_path
267        .file_name()
268        .ok_or_else(|| anyhow::anyhow!("skill path has no name"))?
269        .to_string_lossy()
270        .into_owned();
271    let content = fs::read_to_string(&skill_file)?;
272    let (yaml, body) = split_frontmatter(&content);
273    let mut front_matter = match yaml {
274        Some(y) => mapper.parse_frontmatter(y)?,
275        None => FrontMatter::default(),
276    };
277    apply_overrides(
278        &mut front_matter,
279        mapper.as_ref(),
280        override_name,
281        override_description,
282        override_allowed_tools,
283    );
284    let hash = hash_content(&content);
285    let skill = crate::agent::Skill {
286        name,
287        dir: abs_path.to_path_buf(),
288        file: skill_file,
289        front_matter,
290        body: body.to_string(),
291    };
292    Ok(vec![ExpandedItem {
293        rule_id: rule.id.clone(),
294        source: rule.source.clone(),
295        source_content: content,
296        source_hash: hash,
297        kind: ExpandedKind::Skill(skill),
298    }])
299}
300
301fn expand_single_system(rule: &Rule, abs_path: &Path) -> anyhow::Result<Vec<ExpandedItem>> {
302    if !abs_path.is_file() {
303        anyhow::bail!("system file not found: {}", abs_path.display());
304    }
305    let content = fs::read_to_string(abs_path)?;
306    let hash = hash_content(&content);
307    let system = SystemFile {
308        file: abs_path.to_path_buf(),
309        body: content.clone(),
310    };
311    Ok(vec![ExpandedItem {
312        rule_id: rule.id.clone(),
313        source: rule.source.clone(),
314        source_content: content,
315        source_hash: hash,
316        kind: ExpandedKind::System(system),
317    }])
318}
319
320fn expand_single_agent(
321    rule: &Rule,
322    abs_path: &Path,
323    override_name: Option<&str>,
324    override_description: Option<&str>,
325    override_allowed_tools: Option<&str>,
326) -> anyhow::Result<Vec<ExpandedItem>> {
327    let mapper = agent::frontmatter_mapper(&rule.schema_agent)
328        .ok_or_else(|| anyhow::anyhow!("unknown agent: {}", rule.schema_agent))?;
329
330    if !abs_path.is_file() {
331        anyhow::bail!("agent file not found: {}", abs_path.display());
332    }
333    let file_name = abs_path
334        .file_name()
335        .ok_or_else(|| anyhow::anyhow!("agent path has no file name"))?
336        .to_string_lossy();
337    let agent_name = file_name
338        .strip_suffix(".agent.md")
339        .ok_or_else(|| anyhow::anyhow!("agent file must end with .agent.md: {}", file_name))?
340        .to_owned();
341    let content = fs::read_to_string(abs_path)?;
342    let (yaml, body) = split_frontmatter(&content);
343    let mut front_matter = match yaml {
344        Some(y) => mapper.parse_frontmatter(y)?,
345        None => FrontMatter::default(),
346    };
347    apply_overrides(
348        &mut front_matter,
349        mapper.as_ref(),
350        override_name,
351        override_description,
352        override_allowed_tools,
353    );
354    let hash = hash_content(&content);
355    let agent = crate::agent::Agent {
356        name: agent_name,
357        file: abs_path.to_path_buf(),
358        front_matter,
359        body: body.to_string(),
360    };
361    Ok(vec![ExpandedItem {
362        rule_id: rule.id.clone(),
363        source: rule.source.clone(),
364        source_content: content,
365        source_hash: hash,
366        kind: ExpandedKind::Agent(agent),
367    }])
368}