Skip to main content

mur_common/skill/
lockfile.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fs;
4use std::io;
5use std::path::Path;
6
7pub const SCHEMA_VERSION: u32 = 1;
8pub const FILE_NAME: &str = "skill.lock";
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct SkillLock {
12    #[serde(default = "default_schema")]
13    pub schema_version: u32,
14    #[serde(default)]
15    pub locked: BTreeMap<String, String>,
16    #[serde(default)]
17    pub installed_at: String,
18}
19
20fn default_schema() -> u32 {
21    SCHEMA_VERSION
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum LockfileError {
26    #[error("io: {0}")]
27    Io(#[from] io::Error),
28    #[error("parse: {0}")]
29    Parse(#[from] serde_yaml_ng::Error),
30}
31
32impl SkillLock {
33    pub fn path(skill_dir: &Path) -> std::path::PathBuf {
34        skill_dir.join(FILE_NAME)
35    }
36
37    pub fn read(skill_dir: &Path) -> Result<Self, LockfileError> {
38        let p = Self::path(skill_dir);
39        if !p.exists() {
40            return Ok(Self {
41                schema_version: SCHEMA_VERSION,
42                ..Default::default()
43            });
44        }
45        let s = fs::read_to_string(&p)?;
46        if s.trim().is_empty() {
47            return Ok(Self {
48                schema_version: SCHEMA_VERSION,
49                ..Default::default()
50            });
51        }
52        Ok(serde_yaml_ng::from_str(&s)?)
53    }
54
55    pub fn write(&self, skill_dir: &Path) -> Result<(), LockfileError> {
56        fs::create_dir_all(skill_dir)?;
57        let yaml = serde_yaml_ng::to_string(self)?;
58        let final_path = Self::path(skill_dir);
59        let tmp = skill_dir.join(format!(".{FILE_NAME}.tmp"));
60        fs::write(&tmp, yaml)?;
61        fs::rename(tmp, final_path)?;
62        Ok(())
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use tempfile::tempdir;
70
71    #[test]
72    fn empty_when_missing() {
73        let d = tempdir().unwrap();
74        let l = SkillLock::read(d.path()).unwrap();
75        assert_eq!(l.schema_version, SCHEMA_VERSION);
76        assert!(l.locked.is_empty());
77    }
78
79    #[test]
80    fn round_trip() {
81        let d = tempdir().unwrap();
82        let mut l = SkillLock {
83            schema_version: SCHEMA_VERSION,
84            locked: BTreeMap::new(),
85            installed_at: "2026-05-25T00:00:00Z".into(),
86        };
87        l.locked.insert("web-browsing".into(), "1.2.0".into());
88        l.locked.insert("data-table-export".into(), "0.6.1".into());
89        l.write(d.path()).unwrap();
90        let back = SkillLock::read(d.path()).unwrap();
91        assert_eq!(back.locked["web-browsing"], "1.2.0");
92        assert_eq!(back.installed_at, l.installed_at);
93    }
94
95    #[test]
96    fn corrupt_yaml_returns_parse_err() {
97        let d = tempdir().unwrap();
98        fs::write(SkillLock::path(d.path()), "this is :: not yaml :: at all").unwrap();
99        assert!(matches!(
100            SkillLock::read(d.path()),
101            Err(LockfileError::Parse(_))
102        ));
103    }
104}