1pub 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 pub path: String,
23 pub stem: String,
25 pub fields: Option<Map<String, Value>>,
27 pub invalid: Option<String>,
29 pub body_line: usize,
31 pub text: String,
32}
33
34impl Asset {
35 pub fn field(&self, key: &str) -> Option<&str> {
37 self.fields.as_ref()?.get(key)?.as_str()
38 }
39
40 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 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
72fn 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
89fn 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
108pub 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
150fn 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
189pub 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}