use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
use crate::gateway::filesystem::Filesystem;
use crate::paths::ConfigPaths;
use crate::types::{InstallScope, LockEntry, LockFile};
pub fn load_from(path: &Path, fs: &dyn Filesystem) -> Result<LockFile> {
crate::json_file::load_json(path, fs)
}
pub fn save_to(lock: &LockFile, path: &Path, fs: &dyn Filesystem) -> Result<()> {
crate::json_file::save_json(lock, path, fs)
}
pub fn load(scope: InstallScope, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<LockFile> {
let path = paths.lock_path(scope);
load_from(&path, fs)
}
pub fn save(
lock: &LockFile,
scope: InstallScope,
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<()> {
let path = paths.lock_path(scope);
save_to(lock, &path, fs)
}
pub fn mutate<F, T>(
scope: InstallScope,
fs: &dyn Filesystem,
paths: &ConfigPaths,
f: F,
) -> Result<T>
where
F: FnOnce(&mut LockFile) -> T,
{
let mut lock = load(scope, fs, paths)?;
let result = f(&mut lock);
save(&lock, scope, fs, paths)?;
Ok(result)
}
pub fn load_both(
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<BTreeMap<InstallScope, LockFile>> {
let mut locks = BTreeMap::new();
for scope in InstallScope::ALL {
locks.insert(scope, load(scope, fs, paths)?);
}
Ok(locks)
}
pub fn find_entry(
name: &str,
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<Option<(LockEntry, InstallScope)>> {
for scope in InstallScope::ALL {
let lock = load(scope, fs, paths)?;
if let Some(entry) = lock.packages.get(name) {
return Ok(Some((entry.clone(), scope)));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gateway::fakes::FakeFilesystem;
use crate::test_support::{sample_lock_entry, sample_lock_file, test_paths};
use std::path::PathBuf;
#[test]
fn load_from_returns_empty_when_path_absent() {
let fs = FakeFilesystem::new();
let lock = load_from(Path::new("/nonexistent/cmx-lock.json"), &fs).unwrap();
assert!(lock.packages.is_empty());
assert_eq!(lock.version, 1);
}
#[test]
fn load_from_parses_valid_json() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/cmx-lock.json");
let json = serde_json::to_string(&sample_lock_file()).unwrap();
fs.add_file(path.clone(), json);
let lock = load_from(&path, &fs).unwrap();
assert!(lock.packages.contains_key("my-agent"));
}
#[test]
fn load_from_returns_error_on_malformed_json() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/cmx-lock.json");
fs.add_file(path.clone(), "not json");
assert!(load_from(&path, &fs).is_err());
}
#[test]
fn save_to_creates_parent_dirs_and_writes() {
let fs = FakeFilesystem::new();
let path = PathBuf::from("/config/context-mixer/cmx-lock.json");
save_to(&sample_lock_file(), &path, &fs).unwrap();
assert!(fs.file_exists(&path));
}
#[test]
fn find_entry_returns_none_when_absent_in_both_scopes() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let result = find_entry("missing", &fs, &paths).unwrap();
assert!(result.is_none());
}
#[test]
fn find_entry_finds_entry_in_global_scope() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock = sample_lock_file();
save(&lock, InstallScope::Global, &fs, &paths).unwrap();
let result = find_entry("my-agent", &fs, &paths).unwrap();
assert!(result.is_some());
let (_, scope) = result.unwrap();
assert_eq!(scope, InstallScope::Global, "expected global scope");
}
#[test]
fn find_entry_finds_entry_in_local_scope() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock = sample_lock_file();
save(&lock, InstallScope::Local, &fs, &paths).unwrap();
let result = find_entry("my-agent", &fs, &paths).unwrap();
assert!(result.is_some());
let (_, scope) = result.unwrap();
assert_eq!(scope, InstallScope::Local, "expected local scope");
}
#[test]
fn find_entry_prefers_global_when_present_in_both_scopes() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock = sample_lock_file();
save(&lock, InstallScope::Global, &fs, &paths).unwrap();
save(&lock, InstallScope::Local, &fs, &paths).unwrap();
let result = find_entry("my-agent", &fs, &paths).unwrap();
let (_, scope) = result.unwrap();
assert_eq!(scope, InstallScope::Global, "expected global to be preferred over local");
}
#[test]
fn mutate_with_loads_applies_and_saves() {
let fs = FakeFilesystem::new();
let paths = test_paths();
mutate(InstallScope::Global, &fs, &paths, |lock| {
lock.packages.insert("test-agent".to_string(), sample_lock_entry());
})
.unwrap();
let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
assert!(loaded.packages.contains_key("test-agent"));
}
#[test]
fn mutate_with_returns_closure_value() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock = sample_lock_file();
save(&lock, InstallScope::Global, &fs, &paths).unwrap();
let was_removed = mutate(InstallScope::Global, &fs, &paths, |lock| {
lock.packages.remove("my-agent").is_some()
})
.unwrap();
assert!(was_removed, "expected closure return value to be propagated");
let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
assert!(!loaded.packages.contains_key("my-agent"));
}
#[test]
fn save_and_load_with_round_trip() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock = sample_lock_file();
save(&lock, InstallScope::Global, &fs, &paths).unwrap();
let loaded = load(InstallScope::Global, &fs, &paths).unwrap();
assert_eq!(loaded.packages.len(), 1);
let entry = loaded.packages.get("my-agent").unwrap();
assert_eq!(entry.version.as_deref(), Some("1.0.0"));
assert_eq!(entry.source_checksum, "sha256:abc123");
}
#[test]
fn save_lock_returns_error_with_context_when_write_fails() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let lock_path = paths.lock_path(InstallScope::Global);
fs.set_fail_on_write(crate::json_file::tmp_path(&lock_path));
let lock = sample_lock_file();
let result = save(&lock, InstallScope::Global, &fs, &paths);
assert!(result.is_err(), "expected Err when lock file write fails");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("Failed to write"), "expected 'Failed to write' in error: {msg}");
}
}