Skip to main content

miwa/
lock.rs

1//! `.miwa/lock.json`: agreed-upon hashes for each edge.
2
3#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
4pub struct LockEntry {
5    pub from: String,
6    pub to: String,
7    pub by: String,
8    #[serde(default, skip_serializing_if = "Option::is_none")]
9    pub reason: Option<String>,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub score: Option<f64>,
12    pub at: String,
13}
14
15#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
16pub struct Lock {
17    pub edges: std::collections::BTreeMap<String, LockEntry>,
18}
19
20impl Lock {
21    pub fn parse(json: &str) -> anyhow::Result<Lock> {
22        let lock: Lock = serde_json::from_str(json)?;
23        Ok(lock)
24    }
25
26    /// Pretty JSON, stable key order, trailing newline.
27    pub fn to_json(&self) -> String {
28        let mut json = serde_json::to_string_pretty(self).expect("Lock serializes to json");
29        json.push('\n');
30        json
31    }
32
33    /// Returns `Lock::default()` if the file does not exist.
34    pub fn load(root: &std::path::Path) -> anyhow::Result<Lock> {
35        let path = root.join(crate::config::LOCK_PATH);
36        if !path.exists() {
37            return Ok(Lock::default());
38        }
39        let content = std::fs::read_to_string(&path)?;
40        Lock::parse(&content)
41    }
42
43    /// Loads via a `Source` (for `GitTreeSource`). `.miwa/` is excluded from
44    /// `list_files`, so this reads the lock file directly. A `NotFound` read
45    /// error means "no lock yet"; any other error (e.g. a `git` failure)
46    /// propagates instead of being silently treated as "absent".
47    pub fn load_from_source(source: &dyn crate::source::Source) -> anyhow::Result<Lock> {
48        let bytes = match source.read(crate::config::LOCK_PATH) {
49            Ok(bytes) => bytes,
50            Err(e) if crate::source::is_not_found(&e) => return Ok(Lock::default()),
51            Err(e) => return Err(e),
52        };
53        let content = String::from_utf8(bytes)?;
54        Lock::parse(&content)
55    }
56
57    /// Writes via a temp file + rename.
58    pub fn save(&self, root: &std::path::Path) -> anyhow::Result<()> {
59        let dir = root.join(".miwa");
60        std::fs::create_dir_all(&dir)?;
61        let final_path = root.join(crate::config::LOCK_PATH);
62        let tmp_path = dir.join("lock.json.tmp");
63        std::fs::write(&tmp_path, self.to_json())?;
64        std::fs::rename(&tmp_path, &final_path)?;
65        Ok(())
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    // [FR-021]
74    #[test]
75    fn ロックファイルが存在しない場合は空のロックとして扱う() {
76        let dir = tempfile::tempdir().unwrap();
77        let lock = Lock::load(dir.path()).unwrap();
78        assert!(lock.edges.is_empty());
79    }
80
81    // [FR-021]
82    #[test]
83    fn 壊れたjsonのロックはparseがエラーになる() {
84        assert!(Lock::parse("{ not valid json").is_err());
85        assert!(Lock::parse(r#"{"edges": "not-an-object"}"#).is_err());
86    }
87
88    // [FR-022]
89    #[test]
90    fn to_jsonはエッジidの昇順で出力される() {
91        let mut lock = Lock::default();
92        let make_entry = |from: &str, to: &str| LockEntry {
93            from: from.to_string(),
94            to: to.to_string(),
95            by: "human".to_string(),
96            reason: None,
97            score: None,
98            at: "2026-01-01T00:00:00Z".to_string(),
99        };
100        // 挿入順はキー順とは無関係にしておく
101        lock.edges.insert(
102            "doc-req:DOC-Z:FR-002".to_string(),
103            make_entry("sha256:z", "sha256:2"),
104        );
105        lock.edges.insert(
106            "doc-req:DOC-A:FR-001".to_string(),
107            make_entry("sha256:a", "sha256:1"),
108        );
109        lock.edges.insert(
110            "test-req:src/a:FR-001".to_string(),
111            make_entry("sha256:t", "sha256:1"),
112        );
113
114        let json = lock.to_json();
115        let pos_a = json.find("doc-req:DOC-A:FR-001").unwrap();
116        let pos_z = json.find("doc-req:DOC-Z:FR-002").unwrap();
117        let pos_t = json.find("test-req:src/a:FR-001").unwrap();
118        assert!(
119            pos_a < pos_z,
120            "doc-req:DOC-A should come before doc-req:DOC-Z: {json}"
121        );
122        assert!(
123            pos_z < pos_t,
124            "doc-req:DOC-Z should come before test-req:src/a: {json}"
125        );
126
127        // 往復させてもキー順は保たれる
128        let reparsed = Lock::parse(&json).unwrap();
129        let keys: Vec<&String> = reparsed.edges.keys().collect();
130        let mut sorted = keys.clone();
131        sorted.sort();
132        assert_eq!(keys, sorted);
133    }
134
135    /// A `Source` test double whose `read` always fails with a fixed error.
136    struct FailingSource {
137        error_kind: std::io::ErrorKind,
138    }
139
140    impl crate::source::Source for FailingSource {
141        fn list_files(&self) -> anyhow::Result<Vec<String>> {
142            Ok(Vec::new())
143        }
144
145        fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
146            Err(anyhow::Error::new(std::io::Error::new(
147                self.error_kind,
148                format!("{path} is unavailable"),
149            )))
150        }
151    }
152
153    // [FR-021]
154    #[test]
155    fn load_from_sourceはnotfoundなら空ロックとして扱う() {
156        let source = FailingSource {
157            error_kind: std::io::ErrorKind::NotFound,
158        };
159        let lock = Lock::load_from_source(&source).unwrap();
160        assert!(lock.edges.is_empty());
161    }
162
163    // [FR-021]
164    #[test]
165    fn load_from_sourceはnotfound以外のエラーを伝播する() {
166        let source = FailingSource {
167            error_kind: std::io::ErrorKind::PermissionDenied,
168        };
169        let result = Lock::load_from_source(&source);
170        assert!(
171            result.is_err(),
172            "non-NotFound read errors must propagate, not be treated as an absent lock"
173        );
174    }
175}