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
11pub 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
23pub 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
55pub 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
83pub 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
97pub 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
112pub fn resolve_artifact_home(config: &CmxConfig, paths: &ConfigPaths) -> PathBuf {
115 config.home.clone().unwrap_or_else(|| paths.default_artifact_home())
116}
117
118fn 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#[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 #[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 #[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 #[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 assert!(load_sets(InstallScope::Global, &fs, &paths).unwrap().sets.is_empty());
309 }
310
311 #[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 #[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 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 #[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}