use anyhow::Result;
use std::path::PathBuf;
use crate::gateway::filesystem::Filesystem;
use crate::paths::ConfigPaths;
use crate::types::{CmxConfig, InstallScope, SetsFile, SourceEntry, SourceType, SourcesFile};
mod installed;
pub use installed::*;
pub fn load_sources(fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<SourcesFile> {
crate::json_file::load_json(&paths.sources_path(), fs)
}
pub fn save_sources(sources: &SourcesFile, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<()> {
crate::json_file::save_json(sources, &paths.sources_path(), fs)
}
pub fn mutate_sources<F, T>(fs: &dyn Filesystem, paths: &ConfigPaths, f: F) -> Result<T>
where
F: FnOnce(&mut SourcesFile) -> Result<T>,
{
let mut sources = load_sources(fs, paths)?;
let result = f(&mut sources)?;
save_sources(&sources, fs, paths)?;
Ok(result)
}
pub fn load_sets(
scope: InstallScope,
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<SetsFile> {
crate::json_file::load_json(&paths.sets_path(scope), fs)
}
pub fn save_sets(
sets: &SetsFile,
scope: InstallScope,
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<()> {
crate::json_file::save_json(sets, &paths.sets_path(scope), fs)
}
pub fn mutate_sets<F, T>(
scope: InstallScope,
fs: &dyn Filesystem,
paths: &ConfigPaths,
f: F,
) -> Result<T>
where
F: FnOnce(&mut SetsFile) -> Result<T>,
{
let mut sets = load_sets(scope, fs, paths)?;
let result = f(&mut sets)?;
save_sets(&sets, scope, fs, paths)?;
Ok(result)
}
pub fn load_config(fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<CmxConfig> {
crate::json_file::load_json(&paths.config_path(), fs)
}
pub fn save_config(config: &CmxConfig, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<()> {
crate::json_file::save_json(config, &paths.config_path(), fs)
}
pub fn managed_platforms(
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<Option<Vec<crate::platform::Platform>>> {
let cfg = load_config(fs, paths)?;
Ok((!cfg.platforms.is_empty()).then_some(cfg.platforms))
}
pub fn managed_or_all_platforms(
fs: &dyn Filesystem,
paths: &ConfigPaths,
) -> Result<Vec<crate::platform::Platform>> {
Ok(managed_platforms(fs, paths)?.unwrap_or_else(|| crate::platform::Platform::ALL.to_vec()))
}
pub fn resolve_artifact_home(config: &CmxConfig, paths: &ConfigPaths) -> PathBuf {
config.home.clone().unwrap_or_else(|| paths.default_artifact_home())
}
fn expand_tilde(entry: &str, home_dir: &std::path::Path) -> PathBuf {
if let Some(rest) = entry.strip_prefix("~/") {
home_dir.join(rest)
} else if entry == "~" {
home_dir.to_path_buf()
} else {
PathBuf::from(entry)
}
}
pub fn resolve_local_path(entry: &SourceEntry) -> Result<PathBuf> {
match entry.source_type {
SourceType::Local => entry
.path
.clone()
.ok_or_else(|| anyhow::anyhow!("Local source has no path configured")),
SourceType::Git => entry
.local_clone
.clone()
.ok_or_else(|| anyhow::anyhow!("Git source has no local clone path configured")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gateway::fakes::FakeFilesystem;
use crate::test_support::{make_local_entry, test_paths};
#[test]
fn load_sources_returns_default_when_file_absent() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let sources = load_sources(&fs, &paths).unwrap();
assert!(sources.sources.is_empty());
assert_eq!(sources.version, 1);
}
#[test]
fn load_sources_parses_valid_json() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let json = r#"{"version":1,"sources":{"my-source":{"type":"local","path":"/some/path","last_updated":"2024-01-01T00:00:00Z"}}}"#;
fs.add_file(paths.sources_path(), json);
let sources = load_sources(&fs, &paths).unwrap();
assert!(sources.sources.contains_key("my-source"));
}
#[test]
fn load_sources_returns_error_on_malformed_json() {
let fs = FakeFilesystem::new();
let paths = test_paths();
fs.add_file(paths.sources_path(), "not valid json{{{{");
let result = load_sources(&fs, &paths);
assert!(result.is_err());
}
#[test]
fn save_sources_creates_parent_dirs_and_writes_json() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let sources = SourcesFile::default();
save_sources(&sources, &fs, &paths).unwrap();
assert!(fs.file_exists(&paths.sources_path()));
}
#[test]
fn mutate_sources_with_loads_applies_and_saves() {
let fs = FakeFilesystem::new();
let paths = test_paths();
mutate_sources(&fs, &paths, |sources| {
sources
.sources
.insert("test-source".to_string(), make_local_entry("/path", None));
Ok(())
})
.unwrap();
let loaded = load_sources(&fs, &paths).unwrap();
assert!(loaded.sources.contains_key("test-source"));
}
#[test]
fn mutate_sources_with_does_not_save_on_closure_error() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let result: Result<()> =
mutate_sources(&fs, &paths, |_sources| Err(anyhow::anyhow!("closure error")));
assert!(result.is_err());
let loaded = load_sources(&fs, &paths).unwrap();
assert!(loaded.sources.is_empty(), "sources should not be saved after closure error");
}
#[test]
fn load_and_save_sources_round_trip() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let mut sources = SourcesFile::default();
sources
.sources
.insert("test-source".to_string(), make_local_entry("/some/path", None));
save_sources(&sources, &fs, &paths).unwrap();
let loaded = load_sources(&fs, &paths).unwrap();
assert_eq!(loaded.sources.len(), 1);
assert!(loaded.sources.contains_key("test-source"));
}
#[test]
fn load_sets_returns_default_when_file_absent() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let sets = load_sets(InstallScope::Global, &fs, &paths).unwrap();
assert!(sets.sets.is_empty());
assert_eq!(sets.version, 1);
}
#[test]
fn mutate_sets_create_modify_save() {
use crate::types::{SetDef, SetState};
let fs = FakeFilesystem::new();
let paths = test_paths();
mutate_sets(InstallScope::Global, &fs, &paths, |sets| {
sets.sets.insert(
"rust-work".to_string(),
SetDef {
description: Some("desc".to_string()),
state: SetState::Inactive,
members: vec![],
},
);
Ok(())
})
.unwrap();
let loaded = load_sets(InstallScope::Global, &fs, &paths).unwrap();
assert!(loaded.sets.contains_key("rust-work"));
}
#[test]
fn mutate_sets_does_not_save_on_closure_error() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let result: Result<()> = mutate_sets(InstallScope::Global, &fs, &paths, |_sets| {
Err(anyhow::anyhow!("closure error"))
});
assert!(result.is_err());
let loaded = load_sets(InstallScope::Global, &fs, &paths).unwrap();
assert!(loaded.sets.is_empty(), "sets should not be saved after closure error");
}
#[test]
fn load_and_save_sets_round_trip_local_scope() {
use crate::types::{SetDef, SetState};
let fs = FakeFilesystem::new();
let paths = test_paths();
let mut sets = SetsFile::default();
sets.sets.insert(
"blog".to_string(),
SetDef {
description: None,
state: SetState::Active,
members: vec![],
},
);
save_sets(&sets, InstallScope::Local, &fs, &paths).unwrap();
let loaded = load_sets(InstallScope::Local, &fs, &paths).unwrap();
assert_eq!(loaded.sets.len(), 1);
assert!(loaded.sets.contains_key("blog"));
assert!(load_sets(InstallScope::Global, &fs, &paths).unwrap().sets.is_empty());
}
#[test]
fn load_config_returns_default_when_absent() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let cfg = load_config(&fs, &paths).unwrap();
assert_eq!(cfg.version, 1);
}
#[test]
fn load_and_save_config_round_trip() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let mut cfg = CmxConfig::default();
cfg.llm.model = "test-model".to_string();
save_config(&cfg, &fs, &paths).unwrap();
let loaded = load_config(&fs, &paths).unwrap();
assert_eq!(loaded.llm.model, "test-model");
}
#[test]
fn save_sources_returns_error_when_filesystem_write_fails() {
let fs = FakeFilesystem::new();
let paths = test_paths();
let sources_path = paths.sources_path();
fs.set_fail_on_write(crate::json_file::tmp_path(&sources_path));
let sources = SourcesFile::default();
let result = save_sources(&sources, &fs, &paths);
assert!(result.is_err(), "expected Err when sources file write fails");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("Failed to write"), "expected 'Failed to write' in error: {msg}");
}
#[test]
fn resolve_local_path_errors_for_local_entry_with_no_path() {
use crate::types::{SourceEntry, SourceType};
let entry = SourceEntry {
source_type: SourceType::Local,
path: None,
url: None,
local_clone: None,
branch: None,
last_updated: None,
};
let result = resolve_local_path(&entry);
assert!(result.is_err(), "expected Err when Local source has no path");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no path configured"),
"expected 'no path configured' in error: {msg}"
);
}
#[test]
fn resolve_local_path_errors_for_git_entry_with_no_local_clone() {
use crate::types::{SourceEntry, SourceType};
let entry = SourceEntry {
source_type: SourceType::Git,
path: None,
url: Some("https://github.com/example/repo.git".to_string()),
local_clone: None,
branch: None,
last_updated: None,
};
let result = resolve_local_path(&entry);
assert!(result.is_err(), "expected Err when Git source has no local clone");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("no local clone"), "expected 'no local clone' in error: {msg}");
}
#[test]
fn resolve_local_path_returns_path_for_local_entry() {
let entry = make_local_entry("/some/path", None);
let result = resolve_local_path(&entry);
assert!(result.is_ok(), "expected Ok for local entry with path");
assert_eq!(result.unwrap(), std::path::PathBuf::from("/some/path"));
}
}