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 config_path(&self) -> PathBuf {
77 self.config_dir.join("config.json")
78 }
79
80 pub fn default_artifact_home(&self) -> PathBuf {
88 self.config_dir.join("home")
89 }
90
91 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 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 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 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 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 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 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 #[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 #[test]
246 fn with_platform_rebinds_platform_keeping_dirs() {
247 let base = test_paths(); let codex = base.with_platform(Platform::Codex);
249 assert_eq!(codex.platform, Platform::Codex);
250 assert_eq!(codex.config_dir, base.config_dir);
252 assert_eq!(codex.home_dir, base.home_dir);
253 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
594 fn require_install_dir_returns_ok_for_supported_combo() {
595 let paths = test_paths(); 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 #[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}