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