Skip to main content

mur_common/skill/
store.rs

1use crate::skill::manifest::SkillManifest;
2use crate::skill::parser::{
3    ParseError, parse_canonical, parse_legacy_markdown, parse_markdown, serialize_canonical,
4};
5use std::fs;
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug)]
10pub enum StoreError {
11    Io(io::Error),
12    Parse(ParseError),
13    NotFound(PathBuf),
14}
15
16impl std::fmt::Display for StoreError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            StoreError::Io(e) => write!(f, "io: {e}"),
20            StoreError::Parse(e) => write!(f, "parse: {e}"),
21            StoreError::NotFound(p) => write!(f, "skill not found: {}", p.display()),
22        }
23    }
24}
25
26impl std::error::Error for StoreError {}
27
28impl From<io::Error> for StoreError {
29    fn from(e: io::Error) -> Self {
30        StoreError::Io(e)
31    }
32}
33
34impl From<ParseError> for StoreError {
35    fn from(e: ParseError) -> Self {
36        StoreError::Parse(e)
37    }
38}
39
40pub fn global_skill_dir(mur_home: &Path, name: &str) -> PathBuf {
41    mur_home.join("skills").join(name)
42}
43
44pub fn agent_skill_dir(mur_home: &Path, agent: &str) -> PathBuf {
45    mur_home.join("agents").join(agent).join("skills")
46}
47
48pub fn read_from_dir(dir: &Path) -> Result<SkillManifest, StoreError> {
49    let yaml = dir.join("skill.yaml");
50    if yaml.exists() {
51        let text = fs::read_to_string(&yaml)?;
52        return Ok(parse_canonical(&text)?);
53    }
54    let md = dir.join("skill.md");
55    if md.exists() {
56        let text = fs::read_to_string(&md)?;
57        return Ok(parse_markdown(&text)?);
58    }
59    let legacy = dir.with_extension("md");
60    if legacy.exists() {
61        let text = fs::read_to_string(&legacy)?;
62        return Ok(parse_legacy_markdown(&text)?);
63    }
64    Err(StoreError::NotFound(dir.to_path_buf()))
65}
66
67pub fn write_to_dir(dir: &Path, m: &SkillManifest) -> Result<PathBuf, StoreError> {
68    fs::create_dir_all(dir)?;
69    let final_path = dir.join("skill.yaml");
70    let tmp_path = dir.join(".skill.yaml.tmp");
71    let yaml = serialize_canonical(m)?;
72
73    {
74        let mut f = fs::File::create(&tmp_path)?;
75        f.write_all(yaml.as_bytes())?;
76        f.sync_all()?;
77    }
78
79    #[cfg(unix)]
80    {
81        use std::os::unix::fs::PermissionsExt;
82        fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?;
83    }
84
85    fs::rename(&tmp_path, &final_path)?;
86    Ok(final_path)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use tempfile::tempdir;
93
94    fn sample() -> SkillManifest {
95        let yaml = r#"
96name: stored
97version: 1.0.0
98publisher: human:t
99description: d
100category: context
101content:
102  abstract: a
103  context: b
104"#;
105        parse_canonical(yaml).unwrap()
106    }
107
108    #[test]
109    fn write_then_read_roundtrips() {
110        let dir = tempdir().unwrap();
111        let path = dir.path().join("stored");
112        write_to_dir(&path, &sample()).unwrap();
113        let read = read_from_dir(&path).unwrap();
114        assert_eq!(read.name, "stored");
115    }
116
117    #[test]
118    fn reads_markdown_when_yaml_missing() {
119        let dir = tempdir().unwrap();
120        let path = dir.path().join("md-skill");
121        fs::create_dir_all(&path).unwrap();
122        let md = "---\nname: md-skill\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: context\n---\n\nBody content\n";
123        fs::write(path.join("skill.md"), md).unwrap();
124        let read = read_from_dir(&path).unwrap();
125        assert_eq!(read.name, "md-skill");
126    }
127
128    #[test]
129    fn legacy_dot_md_path_resolves() {
130        let dir = tempdir().unwrap();
131        let path = dir.path().join("mur-context");
132        let legacy = "---\nname: mur-context\ndescription: d\n---\n\nbody\n";
133        fs::write(dir.path().join("mur-context.md"), legacy).unwrap();
134        let read = read_from_dir(&path).unwrap();
135        assert_eq!(read.name, "mur-context");
136    }
137
138    #[cfg(unix)]
139    #[test]
140    fn written_file_is_0600() {
141        use std::os::unix::fs::PermissionsExt;
142        let dir = tempdir().unwrap();
143        let path = dir.path().join("perm-test");
144        let written = write_to_dir(&path, &sample()).unwrap();
145        let mode = fs::metadata(&written).unwrap().permissions().mode() & 0o777;
146        assert_eq!(mode, 0o600);
147    }
148
149    #[test]
150    fn missing_skill_returns_not_found() {
151        let dir = tempdir().unwrap();
152        let r = read_from_dir(&dir.path().join("missing"));
153        assert!(matches!(r, Err(StoreError::NotFound(_))));
154    }
155}