Skip to main content

cmx_core/
lockfile.rs

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
9// ---------------------------------------------------------------------------
10// Testable variants (accept injected Filesystem + ConfigPaths)
11// ---------------------------------------------------------------------------
12
13/// Load a `LockFile` from an explicit path via the given filesystem.
14/// Returns a default (empty) lock file if the path does not exist.
15pub fn load_from(path: &Path, fs: &dyn Filesystem) -> Result<LockFile> {
16    crate::json_file::load_json(path, fs)
17}
18
19/// Save a `LockFile` to an explicit path via the given filesystem,
20/// creating parent directories as needed.
21pub 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
40/// Load, mutate via `f`, and save a `LockFile` in one step.
41///
42/// The lock file is loaded from the appropriate scope, `f` is called with
43/// a mutable reference to the in-memory lock, and the result is written
44/// back to disk.  Returns whatever `f` returns.
45pub 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
60/// Load both the global and local lock files in one call.
61///
62/// Returns a `BTreeMap` keyed by `InstallScope`.
63pub 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
74/// Search both scopes (global first, then local) for a lock entry by name.
75/// Returns the entry and the scope it was found in, or `None` if not found.
76pub 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// ---------------------------------------------------------------------------
91// Unit tests
92// ---------------------------------------------------------------------------
93
94#[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    // --- load_from_with ---
102
103    #[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    // --- save_to_with ---
130
131    #[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    // --- find_entry_with ---
140
141    #[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    // --- mutate_with ---
189
190    #[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    // --- round-trip via load_with / save_with ---
222
223    #[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    // --- failure-path tests ---
237
238    #[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        // save_json writes to a sibling .tmp file first — fail that write
245        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}