Skip to main content

specfold_core/
assets.rs

1//! The asset corpus: agent skills, rules, subagents and instruction files declared under
2//! `[[assets]]` in spec.toml. Assets have no ids and are only read; checks report on them, and
3//! only `spec sync assets` writes them, into roots that declare `copy_of`.
4
5pub mod sync;
6
7use crate::config::Config;
8use crate::config::types::{AssetKind, AssetRoot};
9use crate::markdown::frontmatter::{self, Parsed};
10use crate::workspace::owners::{PathMatch, matcher};
11use ignore::WalkBuilder;
12
13use std::collections::BTreeSet;
14use std::path::Path;
15
16/// `dir/rel` with `.` and `..` folded; a path climbing above the root stays as written.
17fn join(dir: &str, rel: &str) -> String {
18    let mut parts: Vec<&str> = if dir.is_empty() { vec![] } else { dir.split('/').collect() };
19    for c in rel.split('/') {
20        match c {
21            "" | "." => {}
22            ".." => {
23                if parts.pop().is_none() {
24                    return rel.to_string();
25                }
26            }
27            c => parts.push(c),
28        }
29    }
30    parts.join("/")
31}
32
33/// Whether a file found under a root has the shape of its kind.
34fn shaped(kind: AssetKind, rel: &str) -> bool {
35    let file = rel.rsplit('/').next().unwrap_or(rel);
36    match kind {
37        AssetKind::Skill => file == "SKILL.md",
38        AssetKind::Rule => file.ends_with(".md") || file.ends_with(".mdc"),
39        AssetKind::Agent | AssetKind::Instructions => file.ends_with(".md"),
40    }
41}
42
43fn stem(kind: AssetKind, rel: &str) -> String {
44    let mut parts = rel.rsplit('/');
45    let file = parts.next().unwrap_or(rel);
46    match kind {
47        AssetKind::Skill => parts.next().unwrap_or("").to_string(),
48        _ => file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file).to_string(),
49    }
50}
51
52/// A new asset from a template: where it goes and what it holds. The first declared directory of
53/// the kind is used, else the usual location; the flag says whether a declared root covers it.
54pub fn template(
55    cfg: &Config,
56    kind: AssetKind,
57    name: &str,
58    description: &str,
59    title: &str,
60) -> Option<(String, String, bool)> {
61    let declared = cfg
62        .raw
63        .assets
64        .iter()
65        .find(|r| r.kind == kind && base(r.path.trim_end_matches('/')) == r.path.trim_end_matches('/'));
66    let dir = declared.map(|r| r.path.trim_end_matches('/').to_string());
67    let one_line = description.replace('\n', " ");
68    let (path, text) = match kind {
69        AssetKind::Skill => (
70            format!("{}/{name}/SKILL.md", dir.as_deref().unwrap_or(".agents/skills")),
71            format!(
72                "---\nname: {name}\ndescription: {one_line}\n---\n\n# {title}\n\nWhen to use this skill and the steps to follow.\n"
73            ),
74        ),
75        AssetKind::Rule => (
76            format!("{}/{name}.mdc", dir.as_deref().unwrap_or(".cursor/rules")),
77            format!(
78                "---\ndescription: {one_line}\nglobs:\nalwaysApply: false\n---\n\n# {title}\n\nThe rule, with a short example.\n"
79            ),
80        ),
81        AssetKind::Agent => (
82            format!("{}/{name}.md", dir.as_deref().unwrap_or(".claude/agents")),
83            format!(
84                "---\nname: {name}\ndescription: {one_line}\n---\n\nYou are the {title} agent. What you do, and what you hand back.\n"
85            ),
86        ),
87        AssetKind::Instructions => return None,
88    };
89    let covered = declared.is_some()
90        || cfg.raw.assets.iter().any(|r| r.kind == kind && matcher(&r.path).is_some_and(|m| m.is_match(&path)));
91    Some((path, text, covered))
92}
93
94/// The directory to walk: the path itself, or the part of a glob before its first wildcard.
95fn base(path: &str) -> String {
96    let lit: Vec<&str> = path.split('/').take_while(|c| !c.contains(['*', '?', '[', '{'])).collect();
97    lit.join("/")
98}
99
100fn files(root: &Path, def: &AssetRoot) -> Vec<String> {
101    let Some(want) = matcher(&def.path) else { return vec![] };
102    let exclude: Vec<PathMatch> = def.exclude.iter().filter_map(|e| matcher(e)).collect();
103    let start = root.join(base(def.path.trim_end_matches('/')));
104    if !start.exists() {
105        return vec![];
106    }
107    let mut out = vec![];
108    for e in WalkBuilder::new(&start).hidden(false).build().flatten() {
109        if !e.file_type().is_some_and(|t| t.is_file()) {
110            continue;
111        }
112        let Ok(rel) = e.path().strip_prefix(root) else { continue };
113        let rel = rel.to_string_lossy().replace('\\', "/");
114        if want.is_match(&rel) && !exclude.iter().any(|m| m.is_match(&rel)) && shaped(def.kind, &rel) {
115            out.push(rel);
116        }
117    }
118    out
119}
120
121fn read(root: &Path, kind: AssetKind, rel: String) -> Option<Asset> {
122    let text = std::fs::read_to_string(root.join(&rel)).ok()?;
123    let split = frontmatter::split(&text);
124    let body_line = split.body_line;
125    let (fields, invalid) = match frontmatter::parse(split.raw) {
126        Parsed::None => (None, None),
127        Parsed::Map(m) => (Some(m), None),
128        Parsed::Invalid(why) => (None, Some(why)),
129    };
130    Some(Asset { kind, stem: stem(kind, &rel), path: rel, fields, invalid, body_line, text })
131}
132
133/// Every declared asset, sorted by path; a file claimed by two roots is read once, by the first.
134pub fn load(cfg: &Config) -> Vec<Asset> {
135    let mut seen = BTreeSet::new();
136    let mut out = vec![];
137    for def in &cfg.raw.assets {
138        for rel in files(&cfg.root, def) {
139            if seen.insert(rel.clone())
140                && let Some(a) = read(&cfg.root, def.kind, rel)
141            {
142                out.push(a);
143            }
144        }
145    }
146    out.sort_by(|a, b| a.path.cmp(&b.path));
147    out
148}
149
150mod asset;
151#[cfg(test)]
152mod tests;
153pub use asset::Asset;