cmx-core 0.3.0

Embeddable core for installing agent skills across platforms — cmx-aware lockfile tracking, plan/apply installs, version guards
Documentation
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};

// ---------------------------------------------------------------------------
// Testable variants (accept injected Filesystem + ConfigPaths)
// ---------------------------------------------------------------------------

/// Load a `LockFile` from an explicit path via the given filesystem.
/// Returns a default (empty) lock file if the path does not exist.
pub fn load_from(path: &Path, fs: &dyn Filesystem) -> Result<LockFile> {
    crate::json_file::load_json(path, fs)
}

/// Save a `LockFile` to an explicit path via the given filesystem,
/// creating parent directories as needed.
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)
}

/// Load, mutate via `f`, and save a `LockFile` in one step.
///
/// The lock file is loaded from the appropriate scope, `f` is called with
/// a mutable reference to the in-memory lock, and the result is written
/// back to disk.  Returns whatever `f` returns.
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)
}

/// Load both the global and local lock files in one call.
///
/// Returns a `BTreeMap` keyed by `InstallScope`.
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)
}

/// Search both scopes (global first, then local) for a lock entry by name.
/// Returns the entry and the scope it was found in, or `None` if not found.
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)
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[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;

    // --- load_from_with ---

    #[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());
    }

    // --- save_to_with ---

    #[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));
    }

    // --- find_entry_with ---

    #[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");
    }

    // --- mutate_with ---

    #[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"));
    }

    // --- round-trip via load_with / save_with ---

    #[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");
    }

    // --- failure-path tests ---

    #[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);

        // save_json writes to a sibling .tmp file first — fail that write
        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}");
    }
}