oxicode_sdk/ports/fs/
skill.rs1use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6
7use crate::SdkError;
8use crate::ports::{Skill, SkillLoader, SkillMeta};
9
10pub struct FileSkillLoader {
22 roots: Vec<PathBuf>,
23}
24
25impl std::fmt::Debug for FileSkillLoader {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("FileSkillLoader")
28 .field("roots", &self.roots)
29 .finish()
30 }
31}
32
33impl FileSkillLoader {
34 pub fn new(roots: impl IntoIterator<Item = PathBuf>) -> Self {
36 Self {
37 roots: roots.into_iter().collect(),
38 }
39 }
40
41 pub fn single(root: impl Into<PathBuf>) -> Self {
43 Self::new(vec![root.into()])
44 }
45}
46
47impl SkillLoader for FileSkillLoader {
48 fn list(&self) -> Pin<Box<dyn Future<Output = Result<Vec<SkillMeta>, SdkError>> + Send + '_>> {
49 let mut out = Vec::new();
50 for root in &self.roots {
51 if !root.exists() {
52 continue;
53 }
54 let entries = match std::fs::read_dir(root) {
55 Ok(e) => e,
56 Err(e) => return Box::pin(async { Err(scan_err(e)) }),
57 };
58 for entry in entries.flatten() {
59 let path = entry.path();
60 if !path.is_dir() {
61 continue;
62 }
63 let skill_md = path.join("SKILL.md");
64 if !skill_md.exists() {
65 continue;
66 }
67 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
68 continue;
69 };
70 if let Ok(meta) = parse_meta(name, &skill_md) {
71 out.push(meta);
72 }
73 }
74 }
75 Box::pin(async { Ok(out) })
76 }
77
78 fn load(
79 &self,
80 name: &str,
81 ) -> Pin<Box<dyn Future<Output = Result<Option<Skill>, SdkError>> + Send + '_>> {
82 for root in &self.roots {
83 let path = root.join(name).join("SKILL.md");
84 if path.exists() {
85 let text = match std::fs::read_to_string(&path) {
86 Ok(t) => t,
87 Err(e) => return Box::pin(async { Err(read_err(e)) }),
88 };
89 let meta = match parse_meta(name, &path) {
90 Ok(m) => m,
91 Err(e) => return Box::pin(async { Err(e) }),
92 };
93 let body = strip_frontmatter(&text);
94 return Box::pin(async { Ok(Some(Skill { meta, body })) });
95 }
96 }
97 Box::pin(async { Ok(None) })
98 }
99}
100
101fn parse_meta(name: &str, path: &Path) -> Result<SkillMeta, SdkError> {
102 let text = std::fs::read_to_string(path).map_err(read_err)?;
103 let mut description = String::new();
104 let mut version = None;
105 if let Some(body) = text.strip_prefix("---\n")
106 && let Some(end) = body.find("\n---")
107 {
108 let fm = &body[..end];
109 for line in fm.lines() {
110 let line = line.trim();
111 if let Some(rest) = line.strip_prefix("description:") {
112 description = rest.trim().trim_matches('"').to_string();
113 } else if let Some(rest) = line.strip_prefix("version:") {
114 version = Some(rest.trim().trim_matches('"').to_string());
115 }
116 }
117 }
118 Ok(SkillMeta {
119 name: name.to_string(),
120 description,
121 path: path.to_path_buf(),
122 version,
123 })
124}
125
126fn strip_frontmatter(text: &str) -> String {
127 if let Some(body) = text.strip_prefix("---\n")
128 && let Some(idx) = body.find("\n---")
129 {
130 let after = &body[idx + 4..];
131 return after.trim_start_matches('\n').to_string();
132 }
133 text.to_string()
134}
135
136fn read_err(e: std::io::Error) -> SdkError {
137 SdkError::Io(e)
138}
139fn scan_err(e: std::io::Error) -> SdkError {
140 SdkError::Io(e)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::fs;
147 use tempfile::TempDir;
148
149 #[tokio::test]
150 async fn discovers_skill_md() {
151 let tmp = TempDir::new().unwrap();
152 let skill_dir = tmp.path().join("git-commit");
153 fs::create_dir_all(&skill_dir).unwrap();
154 fs::write(
155 skill_dir.join("SKILL.md"),
156 "---\ndescription: write a commit\nversion: \"1.0\"\n---\n# body\nhello",
157 )
158 .unwrap();
159 let loader = FileSkillLoader::single(tmp.path());
160 let list = loader.list().await.unwrap();
161 assert_eq!(list.len(), 1);
162 assert_eq!(list[0].name, "git-commit");
163 assert_eq!(list[0].description, "write a commit");
164 }
165
166 #[tokio::test]
167 async fn load_returns_body() {
168 let tmp = TempDir::new().unwrap();
169 let skill_dir = tmp.path().join("review");
170 fs::create_dir_all(&skill_dir).unwrap();
171 fs::write(
172 skill_dir.join("SKILL.md"),
173 "---\ndescription: code review\n---\nreview the diff",
174 )
175 .unwrap();
176 let loader = FileSkillLoader::single(tmp.path());
177 let s = loader.load("review").await.unwrap().unwrap();
178 assert!(s.body.contains("review the diff"));
179 }
180
181 #[tokio::test]
182 async fn load_missing_returns_none() {
183 let tmp = TempDir::new().unwrap();
184 let loader = FileSkillLoader::single(tmp.path());
185 assert!(loader.load("absent").await.unwrap().is_none());
186 }
187}