1use std::path::{Path, PathBuf};
13
14use serde::Serialize;
15
16use crate::evidence::hasher::sha256_bytes;
17
18pub struct BundledSkill {
20 pub name: &'static str,
21 pub body: &'static str,
22}
23
24macro_rules! bundled {
25 ($name:literal) => {
26 BundledSkill {
27 name: $name,
28 body: include_str!(concat!("../skills/", $name, ".md")),
32 }
33 };
34}
35
36pub const BUNDLED: &[BundledSkill] = &[
40 bundled!("capture-sweep"),
41 bundled!("attestation-review"),
42 bundled!("policy-annual-review"),
43 bundled!("report"),
44 bundled!("bootstrap"),
45 bundled!("inventory-seed"),
46];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillSource {
52 Local,
54 Bundled,
56}
57
58impl SkillSource {
59 pub fn as_str(self) -> &'static str {
60 match self {
61 SkillSource::Local => "local",
62 SkillSource::Bundled => "bundled",
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
70pub struct ResolvedSkill {
71 pub name: String,
72 pub body: String,
73 pub source: SkillSource,
74 pub path: Option<PathBuf>,
76 pub sha256: String,
77}
78
79pub fn resolve(root: &Path, name: &str) -> Option<ResolvedSkill> {
82 let local = root.join("skills").join(format!("{name}.md"));
83 if local.is_file() {
84 match std::fs::read(&local) {
90 Ok(bytes) => {
91 return Some(ResolvedSkill {
92 name: name.to_string(),
93 sha256: sha256_bytes(&bytes),
94 body: String::from_utf8_lossy(&bytes).into_owned(),
95 source: SkillSource::Local,
96 path: Some(local),
97 });
98 }
99 Err(e) => {
100 tracing::warn!(
101 path = %local.display(),
102 error = %e,
103 "skill `{name}` exists locally but could not be read; falling back to bundled"
104 );
105 }
106 }
107 }
108 BUNDLED
109 .iter()
110 .find(|s| s.name == name)
111 .map(|s| ResolvedSkill {
112 name: name.to_string(),
113 body: s.body.to_string(),
114 source: SkillSource::Bundled,
115 path: None,
116 sha256: sha256_bytes(s.body.as_bytes()),
117 })
118}
119
120pub fn exists(root: &Path, name: &str) -> bool {
122 root.join("skills").join(format!("{name}.md")).is_file()
123 || BUNDLED.iter().any(|s| s.name == name)
124}
125
126pub fn frontmatter(body: &str) -> Option<&str> {
129 let rest = body.strip_prefix("---\n")?;
130 let end = rest.find("\n---")?;
131 Some(&rest[..end])
132}
133
134pub fn description(body: &str) -> Option<String> {
136 let fm = frontmatter(body)?;
137 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).ok()?;
138 parsed
139 .get("description")
140 .and_then(|v| v.as_str())
141 .map(str::to_string)
142}
143
144pub fn requires_features(body: &str) -> Vec<String> {
146 let Some(fm) = frontmatter(body) else {
147 return Vec::new();
148 };
149 let Ok(parsed) = serde_yaml::from_str::<serde_yaml::Value>(fm) else {
150 return Vec::new();
151 };
152 parsed
153 .get("requires_features")
154 .and_then(|v| v.as_sequence())
155 .map(|seq| {
156 seq.iter()
157 .filter_map(|i| i.as_str().map(str::to_string))
158 .collect()
159 })
160 .unwrap_or_default()
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn every_bundled_skill_has_matching_frontmatter_name() {
169 for s in BUNDLED {
170 let fm = frontmatter(s.body)
171 .unwrap_or_else(|| panic!("bundled skill {} lacks frontmatter", s.name));
172 let parsed: serde_yaml::Value = serde_yaml::from_str(fm).unwrap();
173 assert_eq!(
174 parsed.get("name").and_then(|v| v.as_str()),
175 Some(s.name),
176 "bundled skill `{}` frontmatter name must match its registry key",
177 s.name
178 );
179 }
180 }
181
182 #[test]
183 fn resolve_falls_back_to_bundled() {
184 let tmp = tempfile::tempdir().unwrap();
186 let r = resolve(tmp.path(), "capture-sweep").expect("capture-sweep is bundled");
187 assert_eq!(r.source, SkillSource::Bundled);
188 assert!(!r.sha256.is_empty());
189 assert!(r.body.contains("# Capture sweep"));
190 }
191
192 #[test]
193 fn resolve_local_overrides_bundled() {
194 let tmp = tempfile::tempdir().unwrap();
197 std::fs::create_dir(tmp.path().join("skills")).unwrap();
198 let body = "---\nname: capture-sweep\n---\n# local override\n";
199 std::fs::write(tmp.path().join("skills/capture-sweep.md"), body).unwrap();
200 let r = resolve(tmp.path(), "capture-sweep").expect("resolves locally");
201 assert_eq!(r.source, SkillSource::Local);
202 assert_eq!(r.body, body);
203 assert_eq!(r.sha256, sha256_bytes(body.as_bytes()));
204 }
205
206 #[test]
207 fn resolve_unknown_is_none() {
208 let tmp = tempfile::tempdir().unwrap();
209 assert!(resolve(tmp.path(), "no-such-skill").is_none());
210 }
211
212 #[test]
213 fn frontmatter_extract_basic() {
214 let body = "---\nname: x\nrequires_features: [a, b]\n---\n# body";
215 assert_eq!(requires_features(body), vec!["a", "b"]);
216 }
217}