use anyhow::{Context, Result, bail};
use std::path::PathBuf;
use crate::gateway::Filesystem;
use crate::platform::Platform;
use crate::types::{ArtifactKind, InstallScope};
pub struct ConfigPaths {
pub config_dir: PathBuf,
pub home_dir: PathBuf,
pub platform: Platform,
}
impl ConfigPaths {
pub fn from_env(platform: Platform) -> Result<Self> {
let home = dirs::home_dir().context("Could not determine home directory")?;
let config_dir = home.join(".config").join("context-mixer");
Ok(Self {
config_dir,
home_dir: home,
platform,
})
}
pub fn for_test(home: PathBuf, config: PathBuf) -> Self {
Self {
config_dir: config,
home_dir: home,
platform: Platform::Claude,
}
}
pub fn for_test_with_platform(home: PathBuf, config: PathBuf, platform: Platform) -> Self {
Self {
config_dir: config,
home_dir: home,
platform,
}
}
#[must_use]
pub fn with_platform(&self, platform: Platform) -> ConfigPaths {
ConfigPaths {
config_dir: self.config_dir.clone(),
home_dir: self.home_dir.clone(),
platform,
}
}
pub fn sources_path(&self) -> PathBuf {
self.config_dir.join("sources.json")
}
pub fn git_clones_dir(&self) -> PathBuf {
self.config_dir.join("sources")
}
pub fn sets_path(&self, scope: InstallScope) -> PathBuf {
if scope.is_local() {
PathBuf::from(".context-mixer").join("sets.json")
} else {
self.config_dir.join("sets.json")
}
}
pub fn config_path(&self) -> PathBuf {
self.config_dir.join("config.json")
}
pub fn default_artifact_home(&self) -> PathBuf {
self.config_dir.join("home")
}
pub fn lock_path(&self, scope: InstallScope) -> PathBuf {
let file_name = if self.platform.slug().is_empty() {
"cmx-lock.json".to_string()
} else {
format!("cmx-lock-{}.json", self.platform.slug())
};
if scope.is_local() {
PathBuf::from(".context-mixer").join(&file_name)
} else {
self.config_dir.join(&file_name)
}
}
pub fn install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
let subpath = self.platform.install_subpath(kind, scope)?;
Some(if scope.is_local() {
subpath
} else {
self.home_dir.join(subpath)
})
}
pub fn installed_artifact_path(
&self,
kind: ArtifactKind,
name: &str,
scope: InstallScope,
) -> Option<PathBuf> {
let dir = self.install_dir(kind, scope)?;
Some(kind.installed_path(name, &dir, self.platform.agent_extension()))
}
pub fn is_installed(
&self,
kind: ArtifactKind,
name: &str,
scope: InstallScope,
fs: &dyn Filesystem,
) -> bool {
self.installed_artifact_path(kind, name, scope)
.is_some_and(|path| fs.exists(&path))
}
pub fn ensure_supports(&self, kind: ArtifactKind) -> Result<()> {
if self.platform.supports(kind) {
Ok(())
} else {
bail!(
"The {platform} platform does not support {kind}s. \
{platform} has no native {kind} concept.",
platform = self.platform,
kind = kind,
);
}
}
pub fn require_install_dir(&self, kind: ArtifactKind, scope: InstallScope) -> Result<PathBuf> {
self.install_dir(kind, scope)
.ok_or_else(|| unsupported_artifact_error(self.platform, kind))
}
pub fn require_installed_artifact_path(
&self,
kind: ArtifactKind,
name: &str,
scope: InstallScope,
) -> Result<PathBuf> {
self.installed_artifact_path(kind, name, scope)
.ok_or_else(|| unsupported_artifact_error(self.platform, kind))
}
}
fn unsupported_artifact_error(platform: Platform, kind: ArtifactKind) -> anyhow::Error {
anyhow::anyhow!(
"The {platform} platform does not support {kind}s. \
{platform} has no native {kind} concept."
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gateway::fakes::FakeFilesystem;
use crate::types::InstallScope;
fn test_paths() -> ConfigPaths {
ConfigPaths::for_test(
PathBuf::from("/home/testuser"),
PathBuf::from("/home/testuser/.config/context-mixer"),
)
}
fn test_paths_for(platform: Platform) -> ConfigPaths {
ConfigPaths::for_test_with_platform(
PathBuf::from("/home/testuser"),
PathBuf::from("/home/testuser/.config/context-mixer"),
platform,
)
}
#[test]
fn is_installed_returns_false_for_absent_artifact() {
let paths = test_paths();
let fs = FakeFilesystem::new();
assert!(!paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
}
#[test]
fn is_installed_returns_true_when_file_present() {
let paths = test_paths();
let fs = FakeFilesystem::new();
let path = paths
.installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
.unwrap();
fs.add_file(path, "# agent");
assert!(paths.is_installed(ArtifactKind::Agent, "my-agent", InstallScope::Global, &fs));
}
#[test]
fn with_platform_rebinds_platform_keeping_dirs() {
let base = test_paths(); let codex = base.with_platform(Platform::Codex);
assert_eq!(codex.platform, Platform::Codex);
assert_eq!(codex.config_dir, base.config_dir);
assert_eq!(codex.home_dir, base.home_dir);
assert_eq!(
codex.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-codex.json")
);
assert_eq!(
codex.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.codex/agents")
);
}
#[test]
fn sources_path_returns_config_dir_sources_json() {
let paths = test_paths();
assert_eq!(
paths.sources_path(),
PathBuf::from("/home/testuser/.config/context-mixer/sources.json")
);
}
#[test]
fn git_clones_dir_returns_config_dir_sources() {
let paths = test_paths();
assert_eq!(
paths.git_clones_dir(),
PathBuf::from("/home/testuser/.config/context-mixer/sources")
);
}
#[test]
fn sets_path_global_returns_config_dir_sets_json() {
let paths = test_paths();
assert_eq!(
paths.sets_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/sets.json")
);
}
#[test]
fn sets_path_local_returns_relative_path() {
let paths = test_paths();
assert_eq!(paths.sets_path(InstallScope::Local), PathBuf::from(".context-mixer/sets.json"));
}
#[test]
fn config_path_returns_config_dir_config_json() {
let paths = test_paths();
assert_eq!(
paths.config_path(),
PathBuf::from("/home/testuser/.config/context-mixer/config.json")
);
}
#[test]
fn lock_path_global_returns_config_dir_lock_file() {
let paths = test_paths();
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock.json")
);
}
#[test]
fn lock_path_local_returns_relative_path() {
let paths = test_paths();
assert_eq!(
paths.lock_path(InstallScope::Local),
PathBuf::from(".context-mixer/cmx-lock.json")
);
}
#[test]
fn install_dir_agent_global_returns_home_claude_agents() {
let paths = test_paths();
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.claude/agents")
);
}
#[test]
fn install_dir_skill_global_returns_home_claude_skills() {
let paths = test_paths();
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.claude/skills")
);
}
#[test]
fn install_dir_agent_local_returns_relative_claude_agents() {
let paths = test_paths();
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".claude/agents")
);
}
#[test]
fn install_dir_skill_local_returns_relative_claude_skills() {
let paths = test_paths();
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
PathBuf::from(".claude/skills")
);
}
#[test]
fn install_dir_cursor_agent_local_returns_cursor_agents() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".cursor/agents")
);
}
#[test]
fn install_dir_cursor_skill_local_returns_cursor_skills() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
PathBuf::from(".cursor/skills")
);
}
#[test]
fn install_dir_cursor_agent_global_returns_home_cursor_agents() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.cursor/agents")
);
}
#[test]
fn install_dir_cursor_skill_global_returns_home_cursor_skills() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.cursor/skills")
);
}
#[test]
fn lock_path_cursor_global_uses_cursor_slug() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-cursor.json")
);
}
#[test]
fn lock_path_cursor_local_uses_cursor_slug() {
let paths = test_paths_for(Platform::Cursor);
assert_eq!(
paths.lock_path(InstallScope::Local),
PathBuf::from(".context-mixer/cmx-lock-cursor.json")
);
}
#[test]
fn install_dir_copilot_agent_local_returns_github_agents() {
let paths = test_paths_for(Platform::Copilot);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".github/agents")
);
}
#[test]
fn install_dir_copilot_agent_global_returns_home_copilot_agents() {
let paths = test_paths_for(Platform::Copilot);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.copilot/agents")
);
}
#[test]
fn lock_path_copilot_global_uses_copilot_slug() {
let paths = test_paths_for(Platform::Copilot);
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-copilot.json")
);
}
#[test]
fn install_dir_windsurf_skill_global_returns_codeium_windsurf_skills() {
let paths = test_paths_for(Platform::Windsurf);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.codeium/windsurf/skills")
);
}
#[test]
fn install_dir_windsurf_agent_local_returns_windsurf_agents() {
let paths = test_paths_for(Platform::Windsurf);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".windsurf/agents")
);
}
#[test]
fn lock_path_windsurf_global_uses_windsurf_slug() {
let paths = test_paths_for(Platform::Windsurf);
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-windsurf.json")
);
}
#[test]
fn install_dir_gemini_agent_global_returns_home_gemini_agents() {
let paths = test_paths_for(Platform::Gemini);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.gemini/agents")
);
}
#[test]
fn lock_path_gemini_global_uses_gemini_slug() {
let paths = test_paths_for(Platform::Gemini);
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-gemini.json")
);
}
#[test]
fn install_dir_opencode_agent_local_uses_singular_leaf() {
let paths = test_paths_for(Platform::Opencode);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".opencode/agent")
);
}
#[test]
fn install_dir_opencode_agent_global_uses_xdg_config() {
let paths = test_paths_for(Platform::Opencode);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.config/opencode/agent")
);
}
#[test]
fn install_dir_opencode_skill_uses_shared_dot_agents() {
let paths = test_paths_for(Platform::Opencode);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
PathBuf::from(".agents/skills")
);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.agents/skills")
);
}
#[test]
fn lock_path_opencode_uses_opencode_slug() {
let paths = test_paths_for(Platform::Opencode);
assert_eq!(
paths.lock_path(InstallScope::Global),
PathBuf::from("/home/testuser/.config/context-mixer/cmx-lock-opencode.json")
);
}
#[test]
fn install_dir_codex_agent_uses_dot_codex_agents() {
let paths = test_paths_for(Platform::Codex);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Local).unwrap(),
PathBuf::from(".codex/agents")
);
assert_eq!(
paths.install_dir(ArtifactKind::Agent, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.codex/agents")
);
}
#[test]
fn install_dir_codex_skill_uses_shared_dot_agents() {
let paths = test_paths_for(Platform::Codex);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Global).unwrap(),
PathBuf::from("/home/testuser/.agents/skills")
);
}
#[test]
fn installed_artifact_path_codex_agent_is_toml() {
let paths = test_paths_for(Platform::Codex);
assert_eq!(
paths
.installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Global)
.unwrap(),
PathBuf::from("/home/testuser/.codex/agents/my-agent.toml")
);
}
#[test]
fn installed_artifact_path_default_agent_is_md() {
let paths = test_paths();
assert_eq!(
paths
.installed_artifact_path(ArtifactKind::Agent, "my-agent", InstallScope::Local)
.unwrap(),
PathBuf::from(".claude/agents/my-agent.md")
);
}
#[test]
fn installed_artifact_path_skill_is_directory() {
let paths = test_paths_for(Platform::Codex);
assert_eq!(
paths
.installed_artifact_path(ArtifactKind::Skill, "my-skill", InstallScope::Local)
.unwrap(),
PathBuf::from(".agents/skills/my-skill")
);
}
#[test]
fn install_dir_pi_skill_uses_shared_dot_agents() {
let paths = test_paths_for(Platform::Pi);
assert_eq!(
paths.install_dir(ArtifactKind::Skill, InstallScope::Local).unwrap(),
PathBuf::from(".agents/skills")
);
}
#[test]
fn require_install_dir_returns_ok_for_supported_combo() {
let paths = test_paths(); let result = paths.require_install_dir(ArtifactKind::Skill, InstallScope::Global);
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/skills"));
}
#[test]
fn require_install_dir_returns_err_for_unsupported_combo() {
let paths = test_paths_for(Platform::Pi);
let err = paths
.require_install_dir(ArtifactKind::Agent, InstallScope::Global)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("pi"), "error should name the platform: {msg}");
assert!(msg.contains("agent"), "error should name the kind: {msg}");
}
#[test]
fn require_installed_artifact_path_returns_ok_for_supported_combo() {
let paths = test_paths();
let result = paths.require_installed_artifact_path(
ArtifactKind::Agent,
"my-agent",
InstallScope::Global,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.claude/agents/my-agent.md"));
}
#[test]
fn require_installed_artifact_path_returns_err_for_unsupported_combo() {
let paths = test_paths_for(Platform::Pi);
let err = paths
.require_installed_artifact_path(ArtifactKind::Agent, "x", InstallScope::Global)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("pi"), "error should name the platform: {msg}");
assert!(msg.contains("agent"), "error should name the kind: {msg}");
}
#[test]
fn ensure_supports_pi_rejects_agents() {
let paths = test_paths_for(Platform::Pi);
let err = paths.ensure_supports(ArtifactKind::Agent).unwrap_err().to_string();
assert!(err.contains("pi"), "error should name the platform: {err}");
assert!(err.contains("agent"), "error should name the kind: {err}");
}
#[test]
fn ensure_supports_pi_allows_skills() {
let paths = test_paths_for(Platform::Pi);
assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
}
#[test]
fn ensure_supports_codex_allows_both() {
let paths = test_paths_for(Platform::Codex);
assert!(paths.ensure_supports(ArtifactKind::Agent).is_ok());
assert!(paths.ensure_supports(ArtifactKind::Skill).is_ok());
}
}