Skip to main content

oxicode_sdk/ports/fs/
skill.rs

1//! File-based `SkillLoader` — discovers `SKILL.md` files under a root directory.
2
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6
7use crate::SdkError;
8use crate::ports::{Skill, SkillLoader, SkillMeta};
9
10/// Discovers `SKILL.md` files under one or more root directories.
11///
12/// Layout:
13/// ```text
14/// <root>/
15///   <skill-name>/
16///     SKILL.md
17/// ```
18///
19/// `SKILL.md` is parsed: lines 1..N are YAML frontmatter (delimited by `---`),
20/// the remainder is the body.
21pub 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    /// Create a loader that scans the given root(s).
35    pub fn new(roots: impl IntoIterator<Item = PathBuf>) -> Self {
36        Self {
37            roots: roots.into_iter().collect(),
38        }
39    }
40
41    /// Convenience: scan a single root directory.
42    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}