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::graph::{Graph, Resolution};
10use crate::markdown::frontmatter::{self, Parsed};
11use crate::workspace::owners::{PathMatch, matcher};
12use ignore::WalkBuilder;
13
14use serde_json::{Map, Value};
15use std::collections::BTreeSet;
16use std::path::Path;
17
18#[derive(Debug, Clone)]
19pub struct Asset {
20    pub kind: AssetKind,
21    /// Repository-relative path of the file read (SKILL.md for a skill).
22    pub path: String,
23    /// The name the location gives: a skill's directory, otherwise the file stem.
24    pub stem: String,
25    /// Frontmatter; None when the file has none.
26    pub fields: Option<Map<String, Value>>,
27    /// Why the frontmatter could not be read.
28    pub invalid: Option<String>,
29    /// 1-based line where the body starts.
30    pub body_line: usize,
31    pub text: String,
32}
33
34impl Asset {
35    /// A frontmatter string field.
36    pub fn field(&self, key: &str) -> Option<&str> {
37        self.fields.as_ref()?.get(key)?.as_str()
38    }
39
40    /// The declared name, or the one the location gives.
41    pub fn name(&self) -> &str {
42        self.field("name").unwrap_or(&self.stem)
43    }
44
45    pub fn body(&self) -> &str {
46        frontmatter::split(&self.text).body
47    }
48
49    /// The documents this asset names, by index: a token that is a document id or path, written
50    /// from the repository root or relative to the asset. Aliases are left out so a common word
51    /// is never a link. Only read: nothing is added to the graph.
52    pub fn docs(&self, g: &Graph) -> Vec<usize> {
53        let dir = self.path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
54        let mut out = BTreeSet::new();
55        let ok = |c: char| c.is_ascii_alphanumeric() || ":/._#-".contains(c);
56        for raw in self.text.split(|c: char| !ok(c)) {
57            let t = raw.trim_end_matches(['.', ':', '#']);
58            if !(t.contains(':') || t.contains('/') || t.ends_with(".md")) || t.contains("://") {
59                continue;
60            }
61            for cand in [t.to_string(), join(dir, t)] {
62                if let Resolution::Doc { idx, via_alias: false, .. } = g.resolve(&cand) {
63                    out.insert(idx);
64                    break;
65                }
66            }
67        }
68        out.into_iter().collect()
69    }
70}
71
72/// `dir/rel` with `.` and `..` folded; a path climbing above the root stays as written.
73fn join(dir: &str, rel: &str) -> String {
74    let mut parts: Vec<&str> = if dir.is_empty() { vec![] } else { dir.split('/').collect() };
75    for c in rel.split('/') {
76        match c {
77            "" | "." => {}
78            ".." => {
79                if parts.pop().is_none() {
80                    return rel.to_string();
81                }
82            }
83            c => parts.push(c),
84        }
85    }
86    parts.join("/")
87}
88
89/// Whether a file found under a root has the shape of its kind.
90fn shaped(kind: AssetKind, rel: &str) -> bool {
91    let file = rel.rsplit('/').next().unwrap_or(rel);
92    match kind {
93        AssetKind::Skill => file == "SKILL.md",
94        AssetKind::Rule => file.ends_with(".md") || file.ends_with(".mdc"),
95        AssetKind::Agent | AssetKind::Instructions => file.ends_with(".md"),
96    }
97}
98
99fn stem(kind: AssetKind, rel: &str) -> String {
100    let mut parts = rel.rsplit('/');
101    let file = parts.next().unwrap_or(rel);
102    match kind {
103        AssetKind::Skill => parts.next().unwrap_or("").to_string(),
104        _ => file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file).to_string(),
105    }
106}
107
108/// A new asset from a template: where it goes and what it holds. The first declared directory of
109/// the kind is used, else the usual location; the flag says whether a declared root covers it.
110pub fn template(
111    cfg: &Config,
112    kind: AssetKind,
113    name: &str,
114    description: &str,
115    title: &str,
116) -> Option<(String, String, bool)> {
117    let declared = cfg
118        .raw
119        .assets
120        .iter()
121        .find(|r| r.kind == kind && base(r.path.trim_end_matches('/')) == r.path.trim_end_matches('/'));
122    let dir = declared.map(|r| r.path.trim_end_matches('/').to_string());
123    let one_line = description.replace('\n', " ");
124    let (path, text) = match kind {
125        AssetKind::Skill => (
126            format!("{}/{name}/SKILL.md", dir.as_deref().unwrap_or(".agents/skills")),
127            format!(
128                "---\nname: {name}\ndescription: {one_line}\n---\n\n# {title}\n\nWhen to use this skill and the steps to follow.\n"
129            ),
130        ),
131        AssetKind::Rule => (
132            format!("{}/{name}.mdc", dir.as_deref().unwrap_or(".cursor/rules")),
133            format!(
134                "---\ndescription: {one_line}\nglobs:\nalwaysApply: false\n---\n\n# {title}\n\nThe rule, with a short example.\n"
135            ),
136        ),
137        AssetKind::Agent => (
138            format!("{}/{name}.md", dir.as_deref().unwrap_or(".claude/agents")),
139            format!(
140                "---\nname: {name}\ndescription: {one_line}\n---\n\nYou are the {title} agent. What you do, and what you hand back.\n"
141            ),
142        ),
143        AssetKind::Instructions => return None,
144    };
145    let covered = declared.is_some()
146        || cfg.raw.assets.iter().any(|r| r.kind == kind && matcher(&r.path).is_some_and(|m| m.is_match(&path)));
147    Some((path, text, covered))
148}
149
150/// The directory to walk: the path itself, or the part of a glob before its first wildcard.
151fn base(path: &str) -> String {
152    let lit: Vec<&str> = path.split('/').take_while(|c| !c.contains(['*', '?', '[', '{'])).collect();
153    lit.join("/")
154}
155
156fn files(root: &Path, def: &AssetRoot) -> Vec<String> {
157    let Some(want) = matcher(&def.path) else { return vec![] };
158    let exclude: Vec<PathMatch> = def.exclude.iter().filter_map(|e| matcher(e)).collect();
159    let start = root.join(base(def.path.trim_end_matches('/')));
160    if !start.exists() {
161        return vec![];
162    }
163    let mut out = vec![];
164    for e in WalkBuilder::new(&start).hidden(false).build().flatten() {
165        if !e.file_type().is_some_and(|t| t.is_file()) {
166            continue;
167        }
168        let Ok(rel) = e.path().strip_prefix(root) else { continue };
169        let rel = rel.to_string_lossy().replace('\\', "/");
170        if want.is_match(&rel) && !exclude.iter().any(|m| m.is_match(&rel)) && shaped(def.kind, &rel) {
171            out.push(rel);
172        }
173    }
174    out
175}
176
177fn read(root: &Path, kind: AssetKind, rel: String) -> Option<Asset> {
178    let text = std::fs::read_to_string(root.join(&rel)).ok()?;
179    let split = frontmatter::split(&text);
180    let body_line = split.body_line;
181    let (fields, invalid) = match frontmatter::parse(split.raw) {
182        Parsed::None => (None, None),
183        Parsed::Map(m) => (Some(m), None),
184        Parsed::Invalid(why) => (None, Some(why)),
185    };
186    Some(Asset { kind, stem: stem(kind, &rel), path: rel, fields, invalid, body_line, text })
187}
188
189/// Every declared asset, sorted by path; a file claimed by two roots is read once, by the first.
190pub fn load(cfg: &Config) -> Vec<Asset> {
191    let mut seen = BTreeSet::new();
192    let mut out = vec![];
193    for def in &cfg.raw.assets {
194        for rel in files(&cfg.root, def) {
195            if seen.insert(rel.clone())
196                && let Some(a) = read(&cfg.root, def.kind, rel)
197            {
198                out.push(a);
199            }
200        }
201    }
202    out.sort_by(|a, b| a.path.cmp(&b.path));
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn stems_and_bases() {
212        assert_eq!(stem(AssetKind::Skill, ".agents/skills/pdf-tools/SKILL.md"), "pdf-tools");
213        assert_eq!(stem(AssetKind::Rule, ".cursor/rules/style.mdc"), "style");
214        assert_eq!(base(".claude/agents/*.md"), ".claude/agents");
215        assert_eq!(base("AGENTS.md"), "AGENTS.md");
216        assert!(!shaped(AssetKind::Skill, ".agents/skills/x/notes.md"));
217        assert_eq!(join(".agents/skills/x", "../../../docs/a.md#h"), "docs/a.md#h");
218        assert_eq!(join("", "../a.md"), "../a.md");
219    }
220}