Skip to main content

cmx_core/config/
mod.rs

1use anyhow::Result;
2use std::path::PathBuf;
3
4use crate::gateway::filesystem::Filesystem;
5use crate::paths::ConfigPaths;
6use crate::types::{CmxConfig, InstallScope, SetsFile, SourceEntry, SourceType, SourcesFile};
7
8mod installed;
9pub use installed::*;
10
11// ---------------------------------------------------------------------------
12// Testable variants (accept injected Filesystem + ConfigPaths)
13// ---------------------------------------------------------------------------
14
15pub fn load_sources(fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<SourcesFile> {
16    crate::json_file::load_json(&paths.sources_path(), fs)
17}
18
19pub fn save_sources(sources: &SourcesFile, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<()> {
20    crate::json_file::save_json(sources, &paths.sources_path(), fs)
21}
22
23/// Load, mutate via `f`, and save the `SourcesFile` in one step.
24///
25/// `f` is called with a mutable reference to the in-memory sources and may
26/// return `Err` to abort without writing; on success the file is saved and
27/// the value returned by `f` is propagated.
28pub fn mutate_sources<F, T>(fs: &dyn Filesystem, paths: &ConfigPaths, f: F) -> Result<T>
29where
30    F: FnOnce(&mut SourcesFile) -> Result<T>,
31{
32    let mut sources = load_sources(fs, paths)?;
33    let result = f(&mut sources)?;
34    save_sources(&sources, fs, paths)?;
35    Ok(result)
36}
37
38pub fn load_sets(
39    scope: InstallScope,
40    fs: &dyn Filesystem,
41    paths: &ConfigPaths,
42) -> Result<SetsFile> {
43    crate::json_file::load_json(&paths.sets_path(scope), fs)
44}
45
46pub fn save_sets(
47    sets: &SetsFile,
48    scope: InstallScope,
49    fs: &dyn Filesystem,
50    paths: &ConfigPaths,
51) -> Result<()> {
52    crate::json_file::save_json(sets, &paths.sets_path(scope), fs)
53}
54
55/// Load, mutate via `f`, and save the `SetsFile` for the given scope in one step.
56///
57/// `f` is called with a mutable reference to the in-memory sets and may
58/// return `Err` to abort without writing; on success the file is saved and
59/// the value returned by `f` is propagated.
60pub fn mutate_sets<F, T>(
61    scope: InstallScope,
62    fs: &dyn Filesystem,
63    paths: &ConfigPaths,
64    f: F,
65) -> Result<T>
66where
67    F: FnOnce(&mut SetsFile) -> Result<T>,
68{
69    let mut sets = load_sets(scope, fs, paths)?;
70    let result = f(&mut sets)?;
71    save_sets(&sets, scope, fs, paths)?;
72    Ok(result)
73}
74
75pub fn load_config(fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<CmxConfig> {
76    crate::json_file::load_json(&paths.config_path(), fs)
77}
78
79pub fn save_config(config: &CmxConfig, fs: &dyn Filesystem, paths: &ConfigPaths) -> Result<()> {
80    crate::json_file::save_json(config, &paths.config_path(), fs)
81}
82
83/// The explicit set of platforms the user has told cmx to manage, if any.
84///
85/// Returns `Some(list)` when `config.platforms` is non-empty (the authoritative
86/// managed set), or `None` when unset — signalling callers to fall back to their
87/// own default (every supported platform for `doctor`/`uninstall`; the in-use
88/// inference for `install`).
89pub fn managed_platforms(
90    fs: &dyn Filesystem,
91    paths: &ConfigPaths,
92) -> Result<Option<Vec<crate::platform::Platform>>> {
93    let cfg = load_config(fs, paths)?;
94    Ok((!cfg.platforms.is_empty()).then_some(cfg.platforms))
95}
96
97/// The platforms a default (no `--platform`) cross-platform command considers:
98/// the explicit managed set when one is configured, otherwise every supported
99/// platform.
100///
101/// This is the shared "managed-or-all" fallback used by `uninstall`, `sync`, and
102/// `diff`. Callers still filter by [`Platform::supports`](crate::platform::Platform::supports)
103/// for the relevant kind. (`install` deliberately differs — with no managed set
104/// it infers the platforms already in use rather than falling back to all.)
105pub fn managed_or_all_platforms(
106    fs: &dyn Filesystem,
107    paths: &ConfigPaths,
108) -> Result<Vec<crate::platform::Platform>> {
109    Ok(managed_platforms(fs, paths)?.unwrap_or_else(|| crate::platform::Platform::ALL.to_vec()))
110}
111
112/// Resolve the effective canonical artifact home: the `home` override in the
113/// config if set, otherwise the default under the config root.
114pub fn resolve_artifact_home(config: &CmxConfig, paths: &ConfigPaths) -> PathBuf {
115    config.home.clone().unwrap_or_else(|| paths.default_artifact_home())
116}
117
118/// Expand a leading `~` in a config path entry against the OS home directory.
119fn expand_tilde(entry: &str, home_dir: &std::path::Path) -> PathBuf {
120    if let Some(rest) = entry.strip_prefix("~/") {
121        home_dir.join(rest)
122    } else if entry == "~" {
123        home_dir.to_path_buf()
124    } else {
125        PathBuf::from(entry)
126    }
127}
128
129pub fn resolve_local_path(entry: &SourceEntry) -> Result<PathBuf> {
130    match entry.source_type {
131        SourceType::Local => entry
132            .path
133            .clone()
134            .ok_or_else(|| anyhow::anyhow!("Local source has no path configured")),
135        SourceType::Git => entry
136            .local_clone
137            .clone()
138            .ok_or_else(|| anyhow::anyhow!("Git source has no local clone path configured")),
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Unit tests (use FakeFilesystem + ConfigPaths::for_test)
144// ---------------------------------------------------------------------------
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::gateway::fakes::FakeFilesystem;
150    use crate::test_support::{make_local_entry, test_paths};
151
152    // --- load_sources_with ---
153
154    #[test]
155    fn load_sources_returns_default_when_file_absent() {
156        let fs = FakeFilesystem::new();
157        let paths = test_paths();
158        let sources = load_sources(&fs, &paths).unwrap();
159        assert!(sources.sources.is_empty());
160        assert_eq!(sources.version, 1);
161    }
162
163    #[test]
164    fn load_sources_parses_valid_json() {
165        let fs = FakeFilesystem::new();
166        let paths = test_paths();
167        let json = r#"{"version":1,"sources":{"my-source":{"type":"local","path":"/some/path","last_updated":"2024-01-01T00:00:00Z"}}}"#;
168        fs.add_file(paths.sources_path(), json);
169        let sources = load_sources(&fs, &paths).unwrap();
170        assert!(sources.sources.contains_key("my-source"));
171    }
172
173    #[test]
174    fn load_sources_returns_error_on_malformed_json() {
175        let fs = FakeFilesystem::new();
176        let paths = test_paths();
177        fs.add_file(paths.sources_path(), "not valid json{{{{");
178        let result = load_sources(&fs, &paths);
179        assert!(result.is_err());
180    }
181
182    #[test]
183    fn save_sources_creates_parent_dirs_and_writes_json() {
184        let fs = FakeFilesystem::new();
185        let paths = test_paths();
186        let sources = SourcesFile::default();
187        save_sources(&sources, &fs, &paths).unwrap();
188        assert!(fs.file_exists(&paths.sources_path()));
189    }
190
191    // --- mutate_sources_with ---
192
193    #[test]
194    fn mutate_sources_with_loads_applies_and_saves() {
195        let fs = FakeFilesystem::new();
196        let paths = test_paths();
197
198        mutate_sources(&fs, &paths, |sources| {
199            sources
200                .sources
201                .insert("test-source".to_string(), make_local_entry("/path", None));
202            Ok(())
203        })
204        .unwrap();
205
206        let loaded = load_sources(&fs, &paths).unwrap();
207        assert!(loaded.sources.contains_key("test-source"));
208    }
209
210    #[test]
211    fn mutate_sources_with_does_not_save_on_closure_error() {
212        let fs = FakeFilesystem::new();
213        let paths = test_paths();
214
215        let result: Result<()> =
216            mutate_sources(&fs, &paths, |_sources| Err(anyhow::anyhow!("closure error")));
217        assert!(result.is_err());
218
219        let loaded = load_sources(&fs, &paths).unwrap();
220        assert!(loaded.sources.is_empty(), "sources should not be saved after closure error");
221    }
222
223    #[test]
224    fn load_and_save_sources_round_trip() {
225        let fs = FakeFilesystem::new();
226        let paths = test_paths();
227
228        let mut sources = SourcesFile::default();
229        sources
230            .sources
231            .insert("test-source".to_string(), make_local_entry("/some/path", None));
232
233        save_sources(&sources, &fs, &paths).unwrap();
234        let loaded = load_sources(&fs, &paths).unwrap();
235        assert_eq!(loaded.sources.len(), 1);
236        assert!(loaded.sources.contains_key("test-source"));
237    }
238
239    // --- load_sets / save_sets / mutate_sets ---
240
241    #[test]
242    fn load_sets_returns_default_when_file_absent() {
243        let fs = FakeFilesystem::new();
244        let paths = test_paths();
245        let sets = load_sets(InstallScope::Global, &fs, &paths).unwrap();
246        assert!(sets.sets.is_empty());
247        assert_eq!(sets.version, 1);
248    }
249
250    #[test]
251    fn mutate_sets_create_modify_save() {
252        use crate::types::{SetDef, SetState};
253
254        let fs = FakeFilesystem::new();
255        let paths = test_paths();
256
257        mutate_sets(InstallScope::Global, &fs, &paths, |sets| {
258            sets.sets.insert(
259                "rust-work".to_string(),
260                SetDef {
261                    description: Some("desc".to_string()),
262                    state: SetState::Inactive,
263                    members: vec![],
264                },
265            );
266            Ok(())
267        })
268        .unwrap();
269
270        let loaded = load_sets(InstallScope::Global, &fs, &paths).unwrap();
271        assert!(loaded.sets.contains_key("rust-work"));
272    }
273
274    #[test]
275    fn mutate_sets_does_not_save_on_closure_error() {
276        let fs = FakeFilesystem::new();
277        let paths = test_paths();
278
279        let result: Result<()> = mutate_sets(InstallScope::Global, &fs, &paths, |_sets| {
280            Err(anyhow::anyhow!("closure error"))
281        });
282        assert!(result.is_err());
283
284        let loaded = load_sets(InstallScope::Global, &fs, &paths).unwrap();
285        assert!(loaded.sets.is_empty(), "sets should not be saved after closure error");
286    }
287
288    #[test]
289    fn load_and_save_sets_round_trip_local_scope() {
290        use crate::types::{SetDef, SetState};
291
292        let fs = FakeFilesystem::new();
293        let paths = test_paths();
294        let mut sets = SetsFile::default();
295        sets.sets.insert(
296            "blog".to_string(),
297            SetDef {
298                description: None,
299                state: SetState::Active,
300                members: vec![],
301            },
302        );
303        save_sets(&sets, InstallScope::Local, &fs, &paths).unwrap();
304        let loaded = load_sets(InstallScope::Local, &fs, &paths).unwrap();
305        assert_eq!(loaded.sets.len(), 1);
306        assert!(loaded.sets.contains_key("blog"));
307        // Global scope remains unaffected
308        assert!(load_sets(InstallScope::Global, &fs, &paths).unwrap().sets.is_empty());
309    }
310
311    // --- load_config_with / save_config_with ---
312
313    #[test]
314    fn load_config_returns_default_when_absent() {
315        let fs = FakeFilesystem::new();
316        let paths = test_paths();
317        let cfg = load_config(&fs, &paths).unwrap();
318        assert_eq!(cfg.version, 1);
319    }
320
321    #[test]
322    fn load_and_save_config_round_trip() {
323        let fs = FakeFilesystem::new();
324        let paths = test_paths();
325        let mut cfg = CmxConfig::default();
326        cfg.llm.model = "test-model".to_string();
327        save_config(&cfg, &fs, &paths).unwrap();
328        let loaded = load_config(&fs, &paths).unwrap();
329        assert_eq!(loaded.llm.model, "test-model");
330    }
331
332    // --- failure-path tests ---
333
334    #[test]
335    fn save_sources_returns_error_when_filesystem_write_fails() {
336        let fs = FakeFilesystem::new();
337        let paths = test_paths();
338        let sources_path = paths.sources_path();
339
340        // save_json writes to a sibling .tmp file first — fail that write
341        fs.set_fail_on_write(crate::json_file::tmp_path(&sources_path));
342
343        let sources = SourcesFile::default();
344        let result = save_sources(&sources, &fs, &paths);
345        assert!(result.is_err(), "expected Err when sources file write fails");
346
347        let msg = result.unwrap_err().to_string();
348        assert!(msg.contains("Failed to write"), "expected 'Failed to write' in error: {msg}");
349    }
350
351    // --- resolve_local_path ---
352
353    #[test]
354    fn resolve_local_path_errors_for_local_entry_with_no_path() {
355        use crate::types::{SourceEntry, SourceType};
356        let entry = SourceEntry {
357            source_type: SourceType::Local,
358            path: None,
359            url: None,
360            local_clone: None,
361            branch: None,
362            last_updated: None,
363        };
364        let result = resolve_local_path(&entry);
365        assert!(result.is_err(), "expected Err when Local source has no path");
366        let msg = result.unwrap_err().to_string();
367        assert!(
368            msg.contains("no path configured"),
369            "expected 'no path configured' in error: {msg}"
370        );
371    }
372
373    #[test]
374    fn resolve_local_path_errors_for_git_entry_with_no_local_clone() {
375        use crate::types::{SourceEntry, SourceType};
376        let entry = SourceEntry {
377            source_type: SourceType::Git,
378            path: None,
379            url: Some("https://github.com/example/repo.git".to_string()),
380            local_clone: None,
381            branch: None,
382            last_updated: None,
383        };
384        let result = resolve_local_path(&entry);
385        assert!(result.is_err(), "expected Err when Git source has no local clone");
386        let msg = result.unwrap_err().to_string();
387        assert!(msg.contains("no local clone"), "expected 'no local clone' in error: {msg}");
388    }
389
390    #[test]
391    fn resolve_local_path_returns_path_for_local_entry() {
392        let entry = make_local_entry("/some/path", None);
393        let result = resolve_local_path(&entry);
394        assert!(result.is_ok(), "expected Ok for local entry with path");
395        assert_eq!(result.unwrap(), std::path::PathBuf::from("/some/path"));
396    }
397}