Skip to main content

cmx_core/
paths.rs

1use anyhow::{Context, Result, bail};
2use std::path::PathBuf;
3
4use crate::gateway::Filesystem;
5use crate::platform::Platform;
6use crate::types::{ArtifactKind, InstallScope};
7
8/// Centralizes all path resolution for cmx configuration and install directories.
9///
10/// Production code constructs this via [`ConfigPaths::from_env`]; tests use
11/// [`ConfigPaths::for_test`] to inject arbitrary root directories and avoid
12/// touching the real home directory.
13pub struct ConfigPaths {
14    pub config_dir: PathBuf,
15    pub home_dir: PathBuf,
16    pub platform: Platform,
17}
18
19impl ConfigPaths {
20    /// Production constructor — derives paths from the real home and config directories.
21    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    /// Test constructor — uses arbitrary root directories so no real home
32    /// directory is touched. Defaults to `Platform::Claude`.
33    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    /// Test constructor with explicit platform.
42    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    /// Return a view of these paths bound to a different platform.
51    ///
52    /// `home_dir` and `config_dir` are platform-independent — only the active
53    /// platform changes. `cmx doctor` uses this to survey every platform's
54    /// install directories and lock files from a single base, reusing all the
55    /// platform-aware path resolution without rebuilding it per platform.
56    #[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    /// Path to `sources.json`.
66    pub fn sources_path(&self) -> PathBuf {
67        self.config_dir.join("sources.json")
68    }
69
70    /// Directory where git-backed sources are cloned.
71    pub fn git_clones_dir(&self) -> PathBuf {
72        self.config_dir.join("sources")
73    }
74
75    /// Path to `config.json` (LLM gateway settings).
76    pub fn config_path(&self) -> PathBuf {
77        self.config_dir.join("config.json")
78    }
79
80    /// Default location of the canonical artifact home — under cmx's existing
81    /// config root, alongside `sources.json` and the lockfiles.
82    ///
83    /// This is the *default*; the `home` field in `config.json` can override it.
84    /// Use [`crate::config::resolve_artifact_home`] to get the effective home,
85    /// which consults the config first. Note this is unrelated to
86    /// [`home_dir`](Self::home_dir), which is the OS home (`$HOME`).
87    pub fn default_artifact_home(&self) -> PathBuf {
88        self.config_dir.join("home")
89    }
90
91    /// Path to the lock file for the given scope.
92    ///
93    /// Claude uses `cmx-lock.json` for backward compatibility. All other
94    /// platforms use `cmx-lock-<slug>.json`.
95    pub fn lock_path(&self, scope: InstallScope) -> PathBuf {
96        let file_name = if self.platform.slug().is_empty() {
97            "cmx-lock.json".to_string()
98        } else {
99            format!("cmx-lock-{}.json", self.platform.slug())
100        };
101
102        if scope.is_local() {
103            PathBuf::from(".context-mixer").join(&file_name)
104        } else {
105            self.config_dir.join(&file_name)
106        }
107    }
108
109    /// Directory where artifacts of the given kind and scope are installed.
110    ///
111    /// Resolution is delegated to [`Platform::install_subpath`], which encodes
112    /// each platform's layout (including per-kind divergence such as codex/pi
113    /// skills living under the shared `.agents/skills`). Local installs are
114    /// relative to the project root; global installs are anchored at `$HOME`.
115    ///
116    /// Returns `None` for unsupported `(platform, kind)` combinations. Callers
117    /// should gate on [`ensure_supports`](Self::ensure_supports) or
118    /// [`Platform::supports`] before calling this.
119    pub fn install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
120        let subpath = self.platform.install_subpath(kind, scope)?;
121        Some(if scope.is_local() {
122            subpath
123        } else {
124            self.home_dir.join(subpath)
125        })
126    }
127
128    /// Full path to where an artifact of `kind` named `name` is (or would be)
129    /// installed under `scope`, accounting for the platform's agent file format.
130    ///
131    /// Agents use the platform's [`agent_extension`](Platform::agent_extension)
132    /// (e.g. `.md`, or `.toml` for codex); skills resolve to a directory named
133    /// after the artifact.
134    ///
135    /// Returns `None` for unsupported `(platform, kind)` combinations.
136    pub fn installed_artifact_path(
137        &self,
138        kind: ArtifactKind,
139        name: &str,
140        scope: InstallScope,
141    ) -> Option<PathBuf> {
142        let dir = self.install_dir(kind, scope)?;
143        Some(kind.installed_path(name, &dir, self.platform.agent_extension()))
144    }
145
146    /// Returns `true` if an artifact of `kind` named `name` exists on disk under `scope`.
147    ///
148    /// Returns `false` for unsupported `(platform, kind)` combinations.
149    pub fn is_installed(
150        &self,
151        kind: ArtifactKind,
152        name: &str,
153        scope: InstallScope,
154        fs: &dyn Filesystem,
155    ) -> bool {
156        self.installed_artifact_path(kind, name, scope)
157            .is_some_and(|path| fs.exists(&path))
158    }
159
160    /// Verify the active platform supports the given artifact kind, returning a
161    /// user-facing error otherwise (e.g. pi has no agent concept).
162    pub fn ensure_supports(&self, kind: ArtifactKind) -> Result<()> {
163        if self.platform.supports(kind) {
164            Ok(())
165        } else {
166            bail!(
167                "The {platform} platform does not support {kind}s. \
168                 {platform} has no native {kind} concept.",
169                platform = self.platform,
170                kind = kind,
171            );
172        }
173    }
174
175    /// Like [`install_dir`](Self::install_dir), but returns `Err` for unsupported
176    /// `(platform, kind)` combinations instead of `None`.
177    pub fn require_install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Result<PathBuf> {
178        self.install_dir(kind, scope)
179            .ok_or_else(|| unsupported_artifact_error(self.platform, kind))
180    }
181
182    /// Like [`installed_artifact_path`](Self::installed_artifact_path), but returns
183    /// `Err` for unsupported `(platform, kind)` combinations instead of `None`.
184    pub fn require_installed_artifact_path(
185        &self,
186        kind: ArtifactKind,
187        name: &str,
188        scope: InstallScope,
189    ) -> Result<PathBuf> {
190        self.installed_artifact_path(kind, name, scope)
191            .ok_or_else(|| unsupported_artifact_error(self.platform, kind))
192    }
193}
194
195fn unsupported_artifact_error(platform: Platform, kind: ArtifactKind) -> anyhow::Error {
196    anyhow::anyhow!(
197        "The {platform} platform does not support {kind}s. \
198         {platform} has no native {kind} concept."
199    )
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::gateway::fakes::FakeFilesystem;
206    use crate::types::InstallScope;
207
208    fn test_paths() -> ConfigPaths {
209        ConfigPaths::for_test(
210            PathBuf::from("/home/testuser"),
211            PathBuf::from("/home/testuser/.config/context-mixer"),
212        )
213    }
214
215    fn test_paths_for(platform: Platform) -> ConfigPaths {
216        ConfigPaths::for_test_with_platform(
217            PathBuf::from("/home/testuser"),
218            PathBuf::from("/home/testuser/.config/context-mixer"),
219            platform,
220        )
221    }
222
223    // --- is_installed ---
224
225    #[test]
226    fn is_installed_returns_false_for_absent_artifact() {
227        let paths = test_paths();
228        let fs = FakeFilesystem::new();
229        assert!(!paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
230    }
231
232    #[test]
233    fn is_installed_returns_true_when_file_present() {
234        let paths = test_paths();
235        let fs = FakeFilesystem::new();
236        let path = paths
237            .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
238            .unwrap();
239        fs.add_file(path, "# agent");
240        assert!(paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
241    }
242
243    // --- with_platform ---
244
245    #[test]
246    fn with_platform_rebinds_platform_keeping_dirs() {
247        let base = test_paths(); // Claude
248        let codex = base.with_platform(Platform::Codex);
249        assert_eq!(codex.platform, Platform::Codex);
250        // home_dir and config_dir are carried over unchanged.
251        assert_eq!(codex.config_dir, base.config_dir);
252        assert_eq!(codex.home_dir, base.home_dir);
253        // Path resolution now reflects the new platform.
254        assert_eq!(
255            codex.lock_path(InstallScope::Global),
256            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-codex.json")
257        );
258        assert_eq!(
259            codex.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
260            PathBuf::from("/home/testuser/.codex/agents")
261        );
262    }
263
264    // --- Claude (default) ---
265
266    #[test]
267    fn sources_path_returns_config_dir_sources_json() {
268        let paths = test_paths();
269        assert_eq!(
270            paths.sources_path(),
271            PathBuf::from("/home/testuser/.config/context-mixer/sources.json")
272        );
273    }
274
275    #[test]
276    fn git_clones_dir_returns_config_dir_sources() {
277        let paths = test_paths();
278        assert_eq!(
279            paths.git_clones_dir(),
280            PathBuf::from("/home/testuser/.config/context-mixer/sources")
281        );
282    }
283
284    #[test]
285    fn config_path_returns_config_dir_config_json() {
286        let paths = test_paths();
287        assert_eq!(
288            paths.config_path(),
289            PathBuf::from("/home/testuser/.config/context-mixer/config.json")
290        );
291    }
292
293    #[test]
294    fn lock_path_global_returns_config_dir_lock_file() {
295        let paths = test_paths();
296        assert_eq!(
297            paths.lock_path(InstallScope::Global),
298            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock.json")
299        );
300    }
301
302    #[test]
303    fn lock_path_local_returns_relative_path() {
304        let paths = test_paths();
305        assert_eq!(
306            paths.lock_path(InstallScope::Local),
307            PathBuf::from(".context-mixer/cmx-lock.json")
308        );
309    }
310
311    #[test]
312    fn install_dir_agent_global_returns_home_claude_agents() {
313        let paths = test_paths();
314        assert_eq!(
315            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
316            PathBuf::from("/home/testuser/.claude/agents")
317        );
318    }
319
320    #[test]
321    fn install_dir_skill_global_returns_home_claude_skills() {
322        let paths = test_paths();
323        assert_eq!(
324            paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
325            PathBuf::from("/home/testuser/.claude/skills")
326        );
327    }
328
329    #[test]
330    fn install_dir_agent_local_returns_relative_claude_agents() {
331        let paths = test_paths();
332        assert_eq!(
333            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
334            PathBuf::from(".claude/agents")
335        );
336    }
337
338    #[test]
339    fn install_dir_skill_local_returns_relative_claude_skills() {
340        let paths = test_paths();
341        assert_eq!(
342            paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
343            PathBuf::from(".claude/skills")
344        );
345    }
346
347    // --- Cursor ---
348
349    #[test]
350    fn install_dir_cursor_agent_local_returns_cursor_agents() {
351        let paths = test_paths_for(Platform::Cursor);
352        assert_eq!(
353            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
354            PathBuf::from(".cursor/agents")
355        );
356    }
357
358    #[test]
359    fn install_dir_cursor_skill_local_returns_cursor_skills() {
360        let paths = test_paths_for(Platform::Cursor);
361        assert_eq!(
362            paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
363            PathBuf::from(".cursor/skills")
364        );
365    }
366
367    #[test]
368    fn install_dir_cursor_agent_global_returns_home_cursor_agents() {
369        let paths = test_paths_for(Platform::Cursor);
370        assert_eq!(
371            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
372            PathBuf::from("/home/testuser/.cursor/agents")
373        );
374    }
375
376    #[test]
377    fn install_dir_cursor_skill_global_returns_home_cursor_skills() {
378        let paths = test_paths_for(Platform::Cursor);
379        assert_eq!(
380            paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
381            PathBuf::from("/home/testuser/.cursor/skills")
382        );
383    }
384
385    #[test]
386    fn lock_path_cursor_global_uses_cursor_slug() {
387        let paths = test_paths_for(Platform::Cursor);
388        assert_eq!(
389            paths.lock_path(InstallScope::Global),
390            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-cursor.json")
391        );
392    }
393
394    #[test]
395    fn lock_path_cursor_local_uses_cursor_slug() {
396        let paths = test_paths_for(Platform::Cursor);
397        assert_eq!(
398            paths.lock_path(InstallScope::Local),
399            PathBuf::from(".context-mixer/cmx-lock-cursor.json")
400        );
401    }
402
403    // --- Copilot ---
404
405    #[test]
406    fn install_dir_copilot_agent_local_returns_github_agents() {
407        let paths = test_paths_for(Platform::Copilot);
408        assert_eq!(
409            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
410            PathBuf::from(".github/agents")
411        );
412    }
413
414    #[test]
415    fn install_dir_copilot_agent_global_returns_home_copilot_agents() {
416        let paths = test_paths_for(Platform::Copilot);
417        assert_eq!(
418            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
419            PathBuf::from("/home/testuser/.copilot/agents")
420        );
421    }
422
423    #[test]
424    fn lock_path_copilot_global_uses_copilot_slug() {
425        let paths = test_paths_for(Platform::Copilot);
426        assert_eq!(
427            paths.lock_path(InstallScope::Global),
428            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-copilot.json")
429        );
430    }
431
432    // --- Windsurf ---
433
434    #[test]
435    fn install_dir_windsurf_skill_global_returns_codeium_windsurf_skills() {
436        let paths = test_paths_for(Platform::Windsurf);
437        assert_eq!(
438            paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
439            PathBuf::from("/home/testuser/.codeium/windsurf/skills")
440        );
441    }
442
443    #[test]
444    fn install_dir_windsurf_agent_local_returns_windsurf_agents() {
445        let paths = test_paths_for(Platform::Windsurf);
446        assert_eq!(
447            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
448            PathBuf::from(".windsurf/agents")
449        );
450    }
451
452    #[test]
453    fn lock_path_windsurf_global_uses_windsurf_slug() {
454        let paths = test_paths_for(Platform::Windsurf);
455        assert_eq!(
456            paths.lock_path(InstallScope::Global),
457            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-windsurf.json")
458        );
459    }
460
461    // --- Gemini ---
462
463    #[test]
464    fn install_dir_gemini_agent_global_returns_home_gemini_agents() {
465        let paths = test_paths_for(Platform::Gemini);
466        assert_eq!(
467            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
468            PathBuf::from("/home/testuser/.gemini/agents")
469        );
470    }
471
472    #[test]
473    fn lock_path_gemini_global_uses_gemini_slug() {
474        let paths = test_paths_for(Platform::Gemini);
475        assert_eq!(
476            paths.lock_path(InstallScope::Global),
477            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-gemini.json")
478        );
479    }
480
481    // --- opencode ---
482
483    #[test]
484    fn install_dir_opencode_agent_local_uses_singular_leaf() {
485        let paths = test_paths_for(Platform::Opencode);
486        assert_eq!(
487            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
488            PathBuf::from(".opencode/agent")
489        );
490    }
491
492    #[test]
493    fn install_dir_opencode_agent_global_uses_xdg_config() {
494        let paths = test_paths_for(Platform::Opencode);
495        assert_eq!(
496            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
497            PathBuf::from("/home/testuser/.config/opencode/agent")
498        );
499    }
500
501    #[test]
502    fn install_dir_opencode_skill_uses_shared_dot_agents() {
503        let paths = test_paths_for(Platform::Opencode);
504        assert_eq!(
505            paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
506            PathBuf::from(".agents/skills")
507        );
508        assert_eq!(
509            paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
510            PathBuf::from("/home/testuser/.agents/skills")
511        );
512    }
513
514    #[test]
515    fn lock_path_opencode_uses_opencode_slug() {
516        let paths = test_paths_for(Platform::Opencode);
517        assert_eq!(
518            paths.lock_path(InstallScope::Global),
519            PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-opencode.json")
520        );
521    }
522
523    // --- codex ---
524
525    #[test]
526    fn install_dir_codex_agent_uses_dot_codex_agents() {
527        let paths = test_paths_for(Platform::Codex);
528        assert_eq!(
529            paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
530            PathBuf::from(".codex/agents")
531        );
532        assert_eq!(
533            paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
534            PathBuf::from("/home/testuser/.codex/agents")
535        );
536    }
537
538    #[test]
539    fn install_dir_codex_skill_uses_shared_dot_agents() {
540        let paths = test_paths_for(Platform::Codex);
541        assert_eq!(
542            paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
543            PathBuf::from("/home/testuser/.agents/skills")
544        );
545    }
546
547    #[test]
548    fn installed_artifact_path_codex_agent_is_toml() {
549        let paths = test_paths_for(Platform::Codex);
550        assert_eq!(
551            paths
552                .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
553                .unwrap(),
554            PathBuf::from("/home/testuser/.codex/agents/my-agent.toml")
555        );
556    }
557
558    #[test]
559    fn installed_artifact_path_default_agent_is_md() {
560        let paths = test_paths();
561        assert_eq!(
562            paths
563                .installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Local)
564                .unwrap(),
565            PathBuf::from(".claude/agents/my-agent.md")
566        );
567    }
568
569    #[test]
570    fn installed_artifact_path_skill_is_directory() {
571        let paths = test_paths_for(Platform::Codex);
572        assert_eq!(
573            paths
574                .installed_artifact_path(ArtifactKind::Skill, "my-skill", InstallScope::Local)
575                .unwrap(),
576            PathBuf::from(".agents/skills/my-skill")
577        );
578    }
579
580    // --- pi ---
581
582    #[test]
583    fn install_dir_pi_skill_uses_shared_dot_agents() {
584        let paths = test_paths_for(Platform::Pi);
585        assert_eq!(
586            paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
587            PathBuf::from(".agents/skills")
588        );
589    }
590
591    // --- require_install_dir ---
592
593    #[test]
594    fn require_install_dir_returns_ok_for_supported_combo() {
595        let paths = test_paths(); // Claude platform
596        let result = paths.require_install_dir(ArtifactKind::Skill, InstallScope::Global);
597        assert!(result.is_ok());
598        assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/skills"));
599    }
600
601    #[test]
602    fn require_install_dir_returns_err_for_unsupported_combo() {
603        let paths = test_paths_for(Platform::Pi);
604        let err = paths
605            .require_install_dir(ArtifactKind::Agent, InstallScope::Global)
606            .unwrap_err();
607        let msg = err.to_string();
608        assert!(msg.contains("pi"), "error should name the platform: {msg}");
609        assert!(msg.contains("agent"), "error should name the kind: {msg}");
610    }
611
612    // --- require_installed_artifact_path ---
613
614    #[test]
615    fn require_installed_artifact_path_returns_ok_for_supported_combo() {
616        let paths = test_paths();
617        let result = paths.require_installed_artifact_path(
618            ArtifactKind::Agent,
619            "my-agent",
620            InstallScope::Global,
621        );
622        assert!(result.is_ok());
623        assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/agents/my-agent.md"));
624    }
625
626    #[test]
627    fn require_installed_artifact_path_returns_err_for_unsupported_combo() {
628        let paths = test_paths_for(Platform::Pi);
629        let err = paths
630            .require_installed_artifact_path(ArtifactKind::Agent, "x", InstallScope::Global)
631            .unwrap_err();
632        let msg = err.to_string();
633        assert!(msg.contains("pi"), "error should name the platform: {msg}");
634        assert!(msg.contains("agent"), "error should name the kind: {msg}");
635    }
636
637    #[test]
638    fn ensure_supports_pi_rejects_agents() {
639        let paths = test_paths_for(Platform::Pi);
640        let err = paths.ensure_supports(ArtifactKind::Agent).unwrap_err().to_string();
641        assert!(err.contains("pi"), "error should name the platform: {err}");
642        assert!(err.contains("agent"), "error should name the kind: {err}");
643    }
644
645    #[test]
646    fn ensure_supports_pi_allows_skills() {
647        let paths = test_paths_for(Platform::Pi);
648        assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
649    }
650
651    #[test]
652    fn ensure_supports_codex_allows_both() {
653        let paths = test_paths_for(Platform::Codex);
654        assert!(paths.ensure_supports(ArtifactKind::Agent).is_ok());
655        assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
656    }
657}