mur-common 2.20.4

Shared types and traits for the MUR ecosystem
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;

pub const SCHEMA_VERSION: u32 = 1;
pub const FILE_NAME: &str = "skill.lock";

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillLock {
    #[serde(default = "default_schema")]
    pub schema_version: u32,
    #[serde(default)]
    pub locked: BTreeMap<String, String>,
    #[serde(default)]
    pub installed_at: String,
}

fn default_schema() -> u32 {
    SCHEMA_VERSION
}

#[derive(Debug, thiserror::Error)]
pub enum LockfileError {
    #[error("io: {0}")]
    Io(#[from] io::Error),
    #[error("parse: {0}")]
    Parse(#[from] serde_yaml_ng::Error),
}

impl SkillLock {
    pub fn path(skill_dir: &Path) -> std::path::PathBuf {
        skill_dir.join(FILE_NAME)
    }

    pub fn read(skill_dir: &Path) -> Result<Self, LockfileError> {
        let p = Self::path(skill_dir);
        if !p.exists() {
            return Ok(Self {
                schema_version: SCHEMA_VERSION,
                ..Default::default()
            });
        }
        let s = fs::read_to_string(&p)?;
        if s.trim().is_empty() {
            return Ok(Self {
                schema_version: SCHEMA_VERSION,
                ..Default::default()
            });
        }
        Ok(serde_yaml_ng::from_str(&s)?)
    }

    pub fn write(&self, skill_dir: &Path) -> Result<(), LockfileError> {
        fs::create_dir_all(skill_dir)?;
        let yaml = serde_yaml_ng::to_string(self)?;
        let final_path = Self::path(skill_dir);
        let tmp = skill_dir.join(format!(".{FILE_NAME}.tmp"));
        fs::write(&tmp, yaml)?;
        fs::rename(tmp, final_path)?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn empty_when_missing() {
        let d = tempdir().unwrap();
        let l = SkillLock::read(d.path()).unwrap();
        assert_eq!(l.schema_version, SCHEMA_VERSION);
        assert!(l.locked.is_empty());
    }

    #[test]
    fn round_trip() {
        let d = tempdir().unwrap();
        let mut l = SkillLock {
            schema_version: SCHEMA_VERSION,
            locked: BTreeMap::new(),
            installed_at: "2026-05-25T00:00:00Z".into(),
        };
        l.locked.insert("web-browsing".into(), "1.2.0".into());
        l.locked.insert("data-table-export".into(), "0.6.1".into());
        l.write(d.path()).unwrap();
        let back = SkillLock::read(d.path()).unwrap();
        assert_eq!(back.locked["web-browsing"], "1.2.0");
        assert_eq!(back.installed_at, l.installed_at);
    }

    #[test]
    fn corrupt_yaml_returns_parse_err() {
        let d = tempdir().unwrap();
        fs::write(SkillLock::path(d.path()), "this is :: not yaml :: at all").unwrap();
        assert!(matches!(
            SkillLock::read(d.path()),
            Err(LockfileError::Parse(_))
        ));
    }
}