1#[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 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 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 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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}