mur_common/skill/
store.rs1use 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}