1use anyhow::{Context, Result, bail};
2use std::path::PathBuf;
3
4use crate::gateway::Filesystem;
5use crate::platform::Platform;
6use crate::types::{ArtifactKind, InstallScope};
7
8pub struct ConfigPaths {
14 pub config_dir: PathBuf,
15 pub home_dir: PathBuf,
16 pub platform: Platform,
17}
18
19impl ConfigPaths {
20 pub fn from_env(platform: Platform) -> Result<Self> {
22 let home = dirs::home_dir().context("Could not determine home directory")?;
23 let config_dir = home.join(".config").join("context-mixer");
24 Ok(Self {
25 config_dir,
26 home_dir: home,
27 platform,
28 })
29 }
30
31 pub fn for_test(home: PathBuf, config: PathBuf) -> Self {
34 Self {
35 config_dir: config,
36 home_dir: home,
37 platform: Platform::Claude,
38 }
39 }
40
41 pub fn for_test_with_platform(home: PathBuf, config: PathBuf, platform: Platform) -> Self {
43 Self {
44 config_dir: config,
45 home_dir: home,
46 platform,
47 }
48 }
49
50 #[must_use]
57 pub fn with_platform(&self, platform: Platform) -> ConfigPaths {
58 ConfigPaths {
59 config_dir: self.config_dir.clone(),
60 home_dir: self.home_dir.clone(),
61 platform,
62 }
63 }
64
65 pub fn sources_path(&self) -> PathBuf {
67 self.config_dir.join("sources.json")
68 }
69
70 pub fn git_clones_dir(&self) -> PathBuf {
72 self.config_dir.join("sources")
73 }
74
75 pub fn sets_path(&self, scope: InstallScope) -> PathBuf {
80 if scope.is_local() {
81 PathBuf::from(".context-mixer").join("sets.json")
82 } else {
83 self.config_dir.join("sets.json")
84 }
85 }
86
87 pub fn config_path(&self) -> PathBuf {
89 self.config_dir.join("config.json")
90 }
91
92 pub fn default_artifact_home(&self) -> PathBuf {
100 self.config_dir.join("home")
101 }
102
103 pub fn lock_path(&self, scope: InstallScope) -> PathBuf {
108 let file_name = if self.platform.slug().is_empty() {
109 "cmx-lock.json".to_string()
110 } else {
111 format!("cmx-lock-{}.json", self.platform.slug())
112 };
113
114 if scope.is_local() {
115 PathBuf::from(".context-mixer").join(&file_name)
116 } else {
117 self.config_dir.join(&file_name)
118 }
119 }
120
121 pub fn install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
132 let subpath = self.platform.install_subpath(kind, scope)?;
133 Some(if scope.is_local() {
134 subpath
135 } else {
136 self.home_dir.join(subpath)
137 })
138 }
139
140 pub fn installed_artifact_path(
149 &self,
150 kind: ArtifactKind,
151 name: &str,
152 scope: InstallScope,
153 ) -> Option<PathBuf> {
154 let dir = self.install_dir(kind, scope)?;
155 Some(kind.installed_path(name, &dir, self.platform.agent_extension()))
156 }
157
158 pub fn is_installed(
162 &self,
163 kind: ArtifactKind,
164 name: &str,
165 scope: InstallScope,
166 fs: &dyn Filesystem,
167 ) -> bool {
168 self.installed_artifact_path(kind, name, scope)
169 .is_some_and(|path| fs.exists(&path))
170 }
171
172 pub fn ensure_supports(&self, kind: ArtifactKind) -> Result<()> {
175 if self.platform.supports(kind) {
176 Ok(())
177 } else {
178 bail!(
179 "The {platform} platform does not support {kind}s. \
180 {platform} has no native {kind} concept.",
181 platform = self.platform,
182 kind = kind,
183 );
184 }
185 }
186
187 pub fn require_install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Result<PathBuf> {
190 self.install_dir(kind, scope)
191 .ok_or_else(|| unsupported_artifact_error(self.platform, kind))
192 }
193
194 pub fn require_installed_artifact_path(
197 &self,
198 kind: ArtifactKind,
199 name: &str,
200 scope: InstallScope,
201 ) -> Result<PathBuf> {
202 self.installed_artifact_path(kind, name, scope)
203 .ok_or_else(|| unsupported_artifact_error(self.platform, kind))
204 }
205}
206
207fn unsupported_artifact_error(platform: Platform, kind: ArtifactKind) -> anyhow::Error {
208 anyhow::anyhow!(
209 "The {platform} platform does not support {kind}s. \
210 {platform} has no native {kind} concept."
211 )
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::gateway::fakes::FakeFilesystem;
218 use crate::types::InstallScope;
219
220 fn test_paths() -> ConfigPaths {
221 ConfigPaths::for_test(
222 PathBuf::from("/home/testuser"),
223 PathBuf::from("/home/testuser/.config/context-mixer"),
224 )
225 }
226
227 fn test_paths_for(platform: Platform) -> ConfigPaths {
228 ConfigPaths::for_test_with_platform(
229 PathBuf::from("/home/testuser"),
230 PathBuf::from("/home/testuser/.config/context-mixer"),
231 platform,
232 )
233 }
234
235 #[test]
238 fn is_installed_returns_false_for_absent_artifact() {
239 let paths = test_paths();
240 let fs = FakeFilesystem::new();
241 assert!(!paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
242 }
243
244 #[test]
245 fn is_installed_returns_true_when_file_present() {
246 let paths = test_paths();
247 let fs = FakeFilesystem::new();
248 let path = paths
249 .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
250 .unwrap();
251 fs.add_file(path, "# agent");
252 assert!(paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
253 }
254
255 #[test]
258 fn with_platform_rebinds_platform_keeping_dirs() {
259 let base = test_paths(); let codex = base.with_platform(Platform::Codex);
261 assert_eq!(codex.platform, Platform::Codex);
262 assert_eq!(codex.config_dir, base.config_dir);
264 assert_eq!(codex.home_dir, base.home_dir);
265 assert_eq!(
267 codex.lock_path(InstallScope::Global),
268 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-codex.json")
269 );
270 assert_eq!(
271 codex.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
272 PathBuf::from("/home/testuser/.codex/agents")
273 );
274 }
275
276 #[test]
279 fn sources_path_returns_config_dir_sources_json() {
280 let paths = test_paths();
281 assert_eq!(
282 paths.sources_path(),
283 PathBuf::from("/home/testuser/.config/context-mixer/sources.json")
284 );
285 }
286
287 #[test]
288 fn git_clones_dir_returns_config_dir_sources() {
289 let paths = test_paths();
290 assert_eq!(
291 paths.git_clones_dir(),
292 PathBuf::from("/home/testuser/.config/context-mixer/sources")
293 );
294 }
295
296 #[test]
297 fn sets_path_global_returns_config_dir_sets_json() {
298 let paths = test_paths();
299 assert_eq!(
300 paths.sets_path(InstallScope::Global),
301 PathBuf::from("/home/testuser/.config/context-mixer/sets.json")
302 );
303 }
304
305 #[test]
306 fn sets_path_local_returns_relative_path() {
307 let paths = test_paths();
308 assert_eq!(paths.sets_path(InstallScope::Local), PathBuf::from(".context-mixer/sets.json"));
309 }
310
311 #[test]
312 fn config_path_returns_config_dir_config_json() {
313 let paths = test_paths();
314 assert_eq!(
315 paths.config_path(),
316 PathBuf::from("/home/testuser/.config/context-mixer/config.json")
317 );
318 }
319
320 #[test]
321 fn lock_path_global_returns_config_dir_lock_file() {
322 let paths = test_paths();
323 assert_eq!(
324 paths.lock_path(InstallScope::Global),
325 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock.json")
326 );
327 }
328
329 #[test]
330 fn lock_path_local_returns_relative_path() {
331 let paths = test_paths();
332 assert_eq!(
333 paths.lock_path(InstallScope::Local),
334 PathBuf::from(".context-mixer/cmx-lock.json")
335 );
336 }
337
338 #[test]
339 fn install_dir_agent_global_returns_home_claude_agents() {
340 let paths = test_paths();
341 assert_eq!(
342 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
343 PathBuf::from("/home/testuser/.claude/agents")
344 );
345 }
346
347 #[test]
348 fn install_dir_skill_global_returns_home_claude_skills() {
349 let paths = test_paths();
350 assert_eq!(
351 paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
352 PathBuf::from("/home/testuser/.claude/skills")
353 );
354 }
355
356 #[test]
357 fn install_dir_agent_local_returns_relative_claude_agents() {
358 let paths = test_paths();
359 assert_eq!(
360 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
361 PathBuf::from(".claude/agents")
362 );
363 }
364
365 #[test]
366 fn install_dir_skill_local_returns_relative_claude_skills() {
367 let paths = test_paths();
368 assert_eq!(
369 paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
370 PathBuf::from(".claude/skills")
371 );
372 }
373
374 #[test]
377 fn install_dir_cursor_agent_local_returns_cursor_agents() {
378 let paths = test_paths_for(Platform::Cursor);
379 assert_eq!(
380 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
381 PathBuf::from(".cursor/agents")
382 );
383 }
384
385 #[test]
386 fn install_dir_cursor_skill_local_returns_cursor_skills() {
387 let paths = test_paths_for(Platform::Cursor);
388 assert_eq!(
389 paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
390 PathBuf::from(".cursor/skills")
391 );
392 }
393
394 #[test]
395 fn install_dir_cursor_agent_global_returns_home_cursor_agents() {
396 let paths = test_paths_for(Platform::Cursor);
397 assert_eq!(
398 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
399 PathBuf::from("/home/testuser/.cursor/agents")
400 );
401 }
402
403 #[test]
404 fn install_dir_cursor_skill_global_returns_home_cursor_skills() {
405 let paths = test_paths_for(Platform::Cursor);
406 assert_eq!(
407 paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
408 PathBuf::from("/home/testuser/.cursor/skills")
409 );
410 }
411
412 #[test]
413 fn lock_path_cursor_global_uses_cursor_slug() {
414 let paths = test_paths_for(Platform::Cursor);
415 assert_eq!(
416 paths.lock_path(InstallScope::Global),
417 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-cursor.json")
418 );
419 }
420
421 #[test]
422 fn lock_path_cursor_local_uses_cursor_slug() {
423 let paths = test_paths_for(Platform::Cursor);
424 assert_eq!(
425 paths.lock_path(InstallScope::Local),
426 PathBuf::from(".context-mixer/cmx-lock-cursor.json")
427 );
428 }
429
430 #[test]
433 fn install_dir_copilot_agent_local_returns_github_agents() {
434 let paths = test_paths_for(Platform::Copilot);
435 assert_eq!(
436 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
437 PathBuf::from(".github/agents")
438 );
439 }
440
441 #[test]
442 fn install_dir_copilot_agent_global_returns_home_copilot_agents() {
443 let paths = test_paths_for(Platform::Copilot);
444 assert_eq!(
445 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
446 PathBuf::from("/home/testuser/.copilot/agents")
447 );
448 }
449
450 #[test]
451 fn lock_path_copilot_global_uses_copilot_slug() {
452 let paths = test_paths_for(Platform::Copilot);
453 assert_eq!(
454 paths.lock_path(InstallScope::Global),
455 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-copilot.json")
456 );
457 }
458
459 #[test]
462 fn install_dir_windsurf_skill_global_returns_codeium_windsurf_skills() {
463 let paths = test_paths_for(Platform::Windsurf);
464 assert_eq!(
465 paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
466 PathBuf::from("/home/testuser/.codeium/windsurf/skills")
467 );
468 }
469
470 #[test]
471 fn install_dir_windsurf_agent_local_returns_windsurf_agents() {
472 let paths = test_paths_for(Platform::Windsurf);
473 assert_eq!(
474 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
475 PathBuf::from(".windsurf/agents")
476 );
477 }
478
479 #[test]
480 fn lock_path_windsurf_global_uses_windsurf_slug() {
481 let paths = test_paths_for(Platform::Windsurf);
482 assert_eq!(
483 paths.lock_path(InstallScope::Global),
484 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-windsurf.json")
485 );
486 }
487
488 #[test]
491 fn install_dir_gemini_agent_global_returns_home_gemini_agents() {
492 let paths = test_paths_for(Platform::Gemini);
493 assert_eq!(
494 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
495 PathBuf::from("/home/testuser/.gemini/agents")
496 );
497 }
498
499 #[test]
500 fn lock_path_gemini_global_uses_gemini_slug() {
501 let paths = test_paths_for(Platform::Gemini);
502 assert_eq!(
503 paths.lock_path(InstallScope::Global),
504 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-gemini.json")
505 );
506 }
507
508 #[test]
511 fn install_dir_opencode_agent_local_uses_singular_leaf() {
512 let paths = test_paths_for(Platform::Opencode);
513 assert_eq!(
514 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
515 PathBuf::from(".opencode/agent")
516 );
517 }
518
519 #[test]
520 fn install_dir_opencode_agent_global_uses_xdg_config() {
521 let paths = test_paths_for(Platform::Opencode);
522 assert_eq!(
523 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
524 PathBuf::from("/home/testuser/.config/opencode/agent")
525 );
526 }
527
528 #[test]
529 fn install_dir_opencode_skill_uses_shared_dot_agents() {
530 let paths = test_paths_for(Platform::Opencode);
531 assert_eq!(
532 paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
533 PathBuf::from(".agents/skills")
534 );
535 assert_eq!(
536 paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
537 PathBuf::from("/home/testuser/.agents/skills")
538 );
539 }
540
541 #[test]
542 fn lock_path_opencode_uses_opencode_slug() {
543 let paths = test_paths_for(Platform::Opencode);
544 assert_eq!(
545 paths.lock_path(InstallScope::Global),
546 PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-opencode.json")
547 );
548 }
549
550 #[test]
553 fn install_dir_codex_agent_uses_dot_codex_agents() {
554 let paths = test_paths_for(Platform::Codex);
555 assert_eq!(
556 paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
557 PathBuf::from(".codex/agents")
558 );
559 assert_eq!(
560 paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
561 PathBuf::from("/home/testuser/.codex/agents")
562 );
563 }
564
565 #[test]
566 fn install_dir_codex_skill_uses_shared_dot_agents() {
567 let paths = test_paths_for(Platform::Codex);
568 assert_eq!(
569 paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
570 PathBuf::from("/home/testuser/.agents/skills")
571 );
572 }
573
574 #[test]
575 fn installed_artifact_path_codex_agent_is_toml() {
576 let paths = test_paths_for(Platform::Codex);
577 assert_eq!(
578 paths
579 .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
580 .unwrap(),
581 PathBuf::from("/home/testuser/.codex/agents/my-agent.toml")
582 );
583 }
584
585 #[test]
586 fn installed_artifact_path_default_agent_is_md() {
587 let paths = test_paths();
588 assert_eq!(
589 paths
590 .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Local)
591 .unwrap(),
592 PathBuf::from(".claude/agents/my-agent.md")
593 );
594 }
595
596 #[test]
597 fn installed_artifact_path_skill_is_directory() {
598 let paths = test_paths_for(Platform::Codex);
599 assert_eq!(
600 paths
601 .installed_artifact_path(ArtifactKind::Skill, "my-skill", InstallScope::Local)
602 .unwrap(),
603 PathBuf::from(".agents/skills/my-skill")
604 );
605 }
606
607 #[test]
610 fn install_dir_pi_skill_uses_shared_dot_agents() {
611 let paths = test_paths_for(Platform::Pi);
612 assert_eq!(
613 paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
614 PathBuf::from(".agents/skills")
615 );
616 }
617
618 #[test]
621 fn require_install_dir_returns_ok_for_supported_combo() {
622 let paths = test_paths(); let result = paths.require_install_dir(ArtifactKind::Skill, InstallScope::Global);
624 assert!(result.is_ok());
625 assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/skills"));
626 }
627
628 #[test]
629 fn require_install_dir_returns_err_for_unsupported_combo() {
630 let paths = test_paths_for(Platform::Pi);
631 let err = paths
632 .require_install_dir(ArtifactKind::Agent, InstallScope::Global)
633 .unwrap_err();
634 let msg = err.to_string();
635 assert!(msg.contains("pi"), "error should name the platform: {msg}");
636 assert!(msg.contains("agent"), "error should name the kind: {msg}");
637 }
638
639 #[test]
642 fn require_installed_artifact_path_returns_ok_for_supported_combo() {
643 let paths = test_paths();
644 let result = paths.require_installed_artifact_path(
645 ArtifactKind::Agent,
646 "my-agent",
647 InstallScope::Global,
648 );
649 assert!(result.is_ok());
650 assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/agents/my-agent.md"));
651 }
652
653 #[test]
654 fn require_installed_artifact_path_returns_err_for_unsupported_combo() {
655 let paths = test_paths_for(Platform::Pi);
656 let err = paths
657 .require_installed_artifact_path(ArtifactKind::Agent, "x", InstallScope::Global)
658 .unwrap_err();
659 let msg = err.to_string();
660 assert!(msg.contains("pi"), "error should name the platform: {msg}");
661 assert!(msg.contains("agent"), "error should name the kind: {msg}");
662 }
663
664 #[test]
665 fn ensure_supports_pi_rejects_agents() {
666 let paths = test_paths_for(Platform::Pi);
667 let err = paths.ensure_supports(ArtifactKind::Agent).unwrap_err().to_string();
668 assert!(err.contains("pi"), "error should name the platform: {err}");
669 assert!(err.contains("agent"), "error should name the kind: {err}");
670 }
671
672 #[test]
673 fn ensure_supports_pi_allows_skills() {
674 let paths = test_paths_for(Platform::Pi);
675 assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
676 }
677
678 #[test]
679 fn ensure_supports_codex_allows_both() {
680 let paths = test_paths_for(Platform::Codex);
681 assert!(paths.ensure_supports(ArtifactKind::Agent).is_ok());
682 assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
683 }
684}