1use anyhow::Result;
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::gateway::filesystem::Filesystem;
6use crate::paths::ConfigPaths;
7use crate::types::{InstallScope, LockEntry, LockFile};
8
9pub fn load_from(path: &Path, fs: &dyn Filesystem) -> Result<LockFile> {
16 crate::json_file::load_json(path, fs)
17}
18
19pub fn save_to(lock: &LockFile, path: &Path, fs: &dyn Filesystem) -> Result<()> {
22 crate::json_file::save_json(lock, path, fs)
23}
24
25pub fn load(scope: InstallScope, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<LockFile> {
26 let path = paths.lock_path(scope);
27 load_from(&path, fs)
28}
29
30pub fn save(
31 lock: &LockFile,
32 scope: InstallScope,
33 fs: &dyn Filesystem,
34 paths: &ConfigPaths,
35) -> Result<()> {
36 let path = paths.lock_path(scope);
37 save_to(lock, &path, fs)
38}
39
40pub fn mutate<F, T>(
46 scope: InstallScope,
47 fs: &dyn Filesystem,
48 paths: &ConfigPaths,
49 f: F,
50) -> Result<T>
51where
52 F: FnOnce(&mut LockFile) -> T,
53{
54 let mut lock = load(scope, fs, paths)?;
55 let result = f(&mut lock);
56 save(&lock, scope, fs, paths)?;
57 Ok(result)
58}
59
60pub fn load_both(
64 fs: &dyn Filesystem,
65 paths: &ConfigPaths,
66) -> Result<BTreeMap<InstallScope, LockFile>> {
67 let mut locks = BTreeMap::new();
68 for scope in InstallScope::ALL {
69 locks.insert(scope, load(scope, fs, paths)?);
70 }
71 Ok(locks)
72}
73
74pub fn find_entry(
77 name: &str,
78 fs: &dyn Filesystem,
79 paths: &ConfigPaths,
80) -> Result<Option<(LockEntry, InstallScope)>> {
81 for scope in InstallScope::ALL {
82 let lock = load(scope, fs, paths)?;
83 if let Some(entry) = lock.packages.get(name) {
84 return Ok(Some((entry.clone(), scope)));
85 }
86 }
87 Ok(None)
88}
89
90#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::gateway::fakes::FakeFilesystem;
98 use crate::test_support::{sample_lock_entry, sample_lock_file, test_paths};
99 use std::path::PathBuf;
100
101 #[test]
104 fn load_from_returns_empty_when_path_absent() {
105 let fs = FakeFilesystem::new();
106 let lock = load_from(Path::new("/nonexistent/cmx-lock.json"), &fs).unwrap();
107 assert!(lock.packages.is_empty());
108 assert_eq!(lock.version, 1);
109 }
110
111 #[test]
112 fn load_from_parses_valid_json() {
113 let fs = FakeFilesystem::new();
114 let path = PathBuf::from("/config/cmx-lock.json");
115 let json = serde_json::to_string(&sample_lock_file()).unwrap();
116 fs.add_file(path.clone(), json);
117 let lock = load_from(&path, &fs).unwrap();
118 assert!(lock.packages.contains_key("my-agent"));
119 }
120
121 #[test]
122 fn load_from_returns_error_on_malformed_json() {
123 let fs = FakeFilesystem::new();
124 let path = PathBuf::from("/config/cmx-lock.json");
125 fs.add_file(path.clone(), "not json");
126 assert!(load_from(&path, &fs).is_err());
127 }
128
129 #[test]
132 fn save_to_creates_parent_dirs_and_writes() {
133 let fs = FakeFilesystem::new();
134 let path = PathBuf::from("/config/context-mixer/cmx-lock.json");
135 save_to(&sample_lock_file(), &path, &fs).unwrap();
136 assert!(fs.file_exists(&path));
137 }
138
139 #[test]
142 fn find_entry_returns_none_when_absent_in_both_scopes() {
143 let fs = FakeFilesystem::new();
144 let paths = test_paths();
145 let result = find_entry("missing", &fs, &paths).unwrap();
146 assert!(result.is_none());
147 }
148
149 #[test]
150 fn find_entry_finds_entry_in_global_scope() {
151 let fs = FakeFilesystem::new();
152 let paths = test_paths();
153 let lock = sample_lock_file();
154 save(&lock, InstallScope::Global, &fs, &paths).unwrap();
155
156 let result = find_entry("my-agent", &fs, &paths).unwrap();
157 assert!(result.is_some());
158 let (_, scope) = result.unwrap();
159 assert_eq!(scope, InstallScope::Global, "expected global scope");
160 }
161
162 #[test]
163 fn find_entry_finds_entry_in_local_scope() {
164 let fs = FakeFilesystem::new();
165 let paths = test_paths();
166 let lock = sample_lock_file();
167 save(&lock, InstallScope::Local, &fs, &paths).unwrap();
168
169 let result = find_entry("my-agent", &fs, &paths).unwrap();
170 assert!(result.is_some());
171 let (_, scope) = result.unwrap();
172 assert_eq!(scope, InstallScope::Local, "expected local scope");
173 }
174
175 #[test]
176 fn find_entry_prefers_global_when_present_in_both_scopes() {
177 let fs = FakeFilesystem::new();
178 let paths = test_paths();
179 let lock = sample_lock_file();
180 save(&lock, InstallScope::Global, &fs, &paths).unwrap();
181 save(&lock, InstallScope::Local, &fs, &paths).unwrap();
182
183 let result = find_entry("my-agent", &fs, &paths).unwrap();
184 let (_, scope) = result.unwrap();
185 assert_eq!(scope, InstallScope::Global, "expected global to be preferred over local");
186 }
187
188 #[test]
191 fn mutate_with_loads_applies_and_saves() {
192 let fs = FakeFilesystem::new();
193 let paths = test_paths();
194
195 mutate(InstallScope::Global, &fs, &paths, |lock| {
196 lock.packages.insert("test-agent".to_string(), sample_lock_entry());
197 })
198 .unwrap();
199
200 let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
201 assert!(loaded.packages.contains_key("test-agent"));
202 }
203
204 #[test]
205 fn mutate_with_returns_closure_value() {
206 let fs = FakeFilesystem::new();
207 let paths = test_paths();
208 let lock = sample_lock_file();
209 save(&lock, InstallScope::Global, &fs, &paths).unwrap();
210
211 let was_removed = mutate(InstallScope::Global, &fs, &paths, |lock| {
212 lock.packages.remove("my-agent").is_some()
213 })
214 .unwrap();
215
216 assert!(was_removed, "expected closure return value to be propagated");
217 let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
218 assert!(!loaded.packages.contains_key("my-agent"));
219 }
220
221 #[test]
224 fn save_and_load_with_round_trip() {
225 let fs = FakeFilesystem::new();
226 let paths = test_paths();
227 let lock = sample_lock_file();
228 save(&lock, InstallScope::Global, &fs, &paths).unwrap();
229 let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
230 assert_eq!(loaded.packages.len(), 1);
231 let entry = loaded.packages.get("my-agent").unwrap();
232 assert_eq!(entry.version.as_deref(), Some("1.0.0"));
233 assert_eq!(entry.source_checksum, "sha256:abc123");
234 }
235
236 #[test]
239 fn save_lock_returns_error_with_context_when_write_fails() {
240 let fs = FakeFilesystem::new();
241 let paths = test_paths();
242 let lock_path = paths.lock_path(InstallScope::Global);
243
244 fs.set_fail_on_write(crate::json_file::tmp_path(&lock_path));
246
247 let lock = sample_lock_file();
248 let result = save(&lock, InstallScope::Global, &fs, &paths);
249 assert!(result.is_err(), "expected Err when lock file write fails");
250
251 let msg = result.unwrap_err().to_string();
252 assert!(msg.contains("Failed to write"), "expected 'Failed to write' in error: {msg}");
253 }
254}