use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::Context;
const MC_HOME_ENV: &str = "MC_HOME";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McPaths {
pub root: PathBuf,
pub cache: PathBuf,
pub state: PathBuf,
pub sessions: PathBuf,
pub checkpoints: PathBuf,
pub skills: PathBuf,
pub prompts: PathBuf,
pub subagents: PathBuf,
pub primary_agents: PathBuf,
pub user_agents: PathBuf,
pub settings_file: PathBuf,
pub project_settings_file: PathBuf,
pub local_settings_file: Option<PathBuf>,
pub auth_file: PathBuf,
}
impl McPaths {
pub fn resolve() -> anyhow::Result<Self> {
let root = match env::var_os(MC_HOME_ENV) {
Some(value) => {
if value.is_empty() {
anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
}
let root = PathBuf::from(value);
if !root.is_absolute() {
anyhow::bail!(
"{MC_HOME_ENV} must be an absolute directory path: {}",
root.display()
);
}
if root.exists() && !root.is_dir() {
anyhow::bail!(
"{MC_HOME_ENV} must point to a directory, not a file: {}",
root.display()
);
}
root
}
None => {
let home = dirs::home_dir().ok_or_else(|| {
anyhow::anyhow!("could not resolve home directory for ~/.magi-code")
})?;
default_root_with_migration(&home)?
}
};
let mut paths = Self::from_root(root);
let cwd = env::current_dir().ok();
if let Some(cwd) = cwd {
paths.project_settings_file = cwd.join(".magi-code").join("settings.json");
paths.local_settings_file = paths
.project_settings_file
.exists()
.then_some(paths.project_settings_file.clone());
}
Ok(paths)
}
pub fn from_root(root: PathBuf) -> Self {
Self {
cache: root.join("cache"),
state: root.join("state"),
sessions: root.join("sessions"),
checkpoints: root.join("checkpoints"),
skills: root.join("skills"),
prompts: root.join("prompts"),
subagents: root.join("subagents"),
primary_agents: root.join("primary-agents"),
user_agents: root.join("AGENTS.md"),
settings_file: root.join("settings.json"),
project_settings_file: env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".magi-code")
.join("settings.json"),
local_settings_file: None,
auth_file: root.join("auth.json"),
root,
}
}
pub fn ensure_runtime_dirs(&self) -> anyhow::Result<()> {
fs::create_dir_all(&self.cache)?;
fs::create_dir_all(&self.state)?;
crate::sessions::prepare_session_root(&self.sessions)?;
fs::create_dir_all(&self.checkpoints)?;
Ok(())
}
}
fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
let new_root = home.join(".magi-code");
let legacy_root = home.join(".mc");
if new_root.exists() || !legacy_root.is_dir() {
return Ok(new_root);
}
migrate_legacy_default_root(&legacy_root, &new_root)?;
Ok(new_root)
}
fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
match fs::rename(legacy_root, new_root) {
Ok(()) => Ok(()),
Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
format!(
"failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
legacy_root.display(),
new_root.display()
)
}),
}
}
fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
new_root.is_dir() && !legacy_root.exists()
}
fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
let parent = new_root.parent().ok_or_else(|| {
anyhow::anyhow!(
"new config root has no parent directory: {}",
new_root.display()
)
})?;
let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
if temp_root.exists() {
fs::remove_dir_all(&temp_root).with_context(|| {
format!(
"failed to remove stale temp config dir: {}",
temp_root.display()
)
})?;
}
if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
let _ = fs::remove_dir_all(&temp_root);
return Err(error).with_context(|| {
format!(
"failed to copy legacy config root {} to temp dir {}",
legacy_root.display(),
temp_root.display()
)
});
}
if new_root.exists() {
let _ = fs::remove_dir_all(&temp_root);
if migration_already_completed(legacy_root, new_root) {
return Ok(());
}
anyhow::bail!(
"new config root appeared during migration; refusing to overwrite: {}",
new_root.display()
);
}
if let Err(error) = fs::rename(&temp_root, new_root) {
let _ = fs::remove_dir_all(&temp_root);
return Err(error).with_context(|| {
format!(
"failed to finalize migrated config root from {} to {}",
temp_root.display(),
new_root.display()
)
});
}
fs::remove_dir_all(legacy_root).with_context(|| {
format!(
"migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
new_root.display(),
legacy_root.display()
)
})?;
Ok(())
}
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_all(&from, &to)?;
} else {
fs::copy(&from, &to)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
struct EnvVarSnapshot {
key: &'static str,
value: Option<std::ffi::OsString>,
}
impl EnvVarSnapshot {
fn capture(key: &'static str) -> Self {
let _guard = crate::test_support::env::env_lock();
Self {
key,
value: env::var_os(key),
}
}
}
impl Drop for EnvVarSnapshot {
fn drop(&mut self) {
let env = crate::test_support::env::env_lock();
match &self.value {
Some(value) => env.set_var(self.key, value),
None => env.remove_var(self.key),
}
}
}
#[test]
fn from_root_sets_checkpoint_path_under_mc_home() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(paths.checkpoints, temp.path().join("mc/checkpoints"));
}
#[test]
fn ensure_runtime_dirs_creates_checkpoints_dir() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
paths.ensure_runtime_dirs().unwrap();
assert!(paths.checkpoints.is_dir());
}
#[test]
fn from_root_defaults_local_settings_file_to_none() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(paths.local_settings_file, None);
}
#[test]
fn resolve_uses_cwd_local_settings_file_when_present() {
let _mc_home = EnvVarSnapshot::capture("MC_HOME");
let env_guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
let mc_home = temp.path().join("global");
let cwd = temp.path().join("project");
let local_dir = cwd.join(".magi-code");
let local_settings = local_dir.join("settings.json");
fs::create_dir_all(&local_dir).unwrap();
fs::write(&local_settings, "{}").unwrap();
env_guard.set_var("MC_HOME", &mc_home);
cwd_guard.set_current_dir(&cwd).unwrap();
let expected_local_settings = env::current_dir()
.unwrap()
.join(".magi-code")
.join("settings.json");
let paths = McPaths::resolve().unwrap();
assert_eq!(
paths.local_settings_file,
Some(expected_local_settings.clone())
);
assert_eq!(paths.project_settings_file, expected_local_settings);
cwd_guard.restore().unwrap();
}
#[test]
fn resolve_stores_project_settings_target_when_file_missing() {
let _mc_home = EnvVarSnapshot::capture("MC_HOME");
let env_guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let cwd = temp.path().join("project");
fs::create_dir_all(&cwd).unwrap();
let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
env_guard.set_var("MC_HOME", temp.path().join("global"));
cwd_guard.set_current_dir(&cwd).unwrap();
let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
let paths = McPaths::resolve().unwrap();
assert_eq!(paths.local_settings_file, None);
assert_eq!(paths.project_settings_file, expected);
cwd_guard.restore().unwrap();
}
#[test]
fn resolved_project_settings_target_is_stable_after_cwd_changes() {
let _mc_home = EnvVarSnapshot::capture("MC_HOME");
let env_guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let first = temp.path().join("first");
let second = temp.path().join("second");
fs::create_dir_all(&first).unwrap();
fs::create_dir_all(&second).unwrap();
let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
env_guard.set_var("MC_HOME", temp.path().join("global"));
cwd_guard.set_current_dir(&first).unwrap();
let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
let paths = McPaths::resolve().unwrap();
cwd_guard.set_current_dir(&second).unwrap();
assert_eq!(paths.project_settings_file, expected);
cwd_guard.restore().unwrap();
}
#[test]
fn resolve_does_not_search_parent_for_local_settings_file() {
let _mc_home = EnvVarSnapshot::capture("MC_HOME");
let env_guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let parent = temp.path().join("parent");
let child = parent.join("child");
fs::create_dir_all(parent.join(".magi-code")).unwrap();
fs::create_dir_all(&child).unwrap();
fs::write(parent.join(".magi-code").join("settings.json"), "{}").unwrap();
let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
env_guard.set_var("MC_HOME", temp.path().join("global"));
cwd_guard.set_current_dir(&child).unwrap();
let paths = McPaths::resolve().unwrap();
assert_eq!(paths.local_settings_file, None);
cwd_guard.restore().unwrap();
}
#[test]
fn default_root_with_migration_returns_new_default_without_legacy() {
let temp = TempDir::new().unwrap();
let root = default_root_with_migration(temp.path()).unwrap();
assert_eq!(root, temp.path().join(".magi-code"));
assert!(!root.exists());
assert!(!temp.path().join(".mc").exists());
}
#[test]
fn default_root_with_migration_migrates_legacy_when_new_root_missing() {
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
fs::create_dir_all(&legacy).unwrap();
fs::write(legacy.join("settings.json"), "settings").unwrap();
fs::write(legacy.join("auth.json"), "auth").unwrap();
let root = default_root_with_migration(temp.path()).unwrap();
assert_eq!(root, temp.path().join(".magi-code"));
assert_eq!(
fs::read_to_string(root.join("settings.json")).unwrap(),
"settings"
);
assert_eq!(fs::read_to_string(root.join("auth.json")).unwrap(), "auth");
assert!(!legacy.exists());
}
#[test]
fn default_root_with_migration_skips_migration_when_new_root_exists() {
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
let new = temp.path().join(".magi-code");
fs::create_dir_all(&legacy).unwrap();
fs::create_dir_all(&new).unwrap();
fs::write(legacy.join("settings.json"), "legacy").unwrap();
fs::write(new.join("settings.json"), "new").unwrap();
let root = default_root_with_migration(temp.path()).unwrap();
assert_eq!(root, new);
assert_eq!(
fs::read_to_string(root.join("settings.json")).unwrap(),
"new"
);
assert_eq!(
fs::read_to_string(legacy.join("settings.json")).unwrap(),
"legacy"
);
}
#[test]
fn migrate_treats_existing_new_root_without_legacy_as_already_done() {
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
let new = temp.path().join(".magi-code");
fs::create_dir_all(&new).unwrap();
fs::write(new.join("settings.json"), "new").unwrap();
migrate_legacy_default_root(&legacy, &new).unwrap();
assert_eq!(
fs::read_to_string(new.join("settings.json")).unwrap(),
"new"
);
assert!(!legacy.exists());
}
#[test]
fn resolve_respects_mc_home_without_migration() {
let _mc_home = EnvVarSnapshot::capture("MC_HOME");
let env_guard = crate::test_support::env::env_lock();
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
let override_root = temp.path().join("override");
fs::create_dir_all(&legacy).unwrap();
fs::write(legacy.join("settings.json"), "legacy").unwrap();
env_guard.set_var("MC_HOME", &override_root);
let paths = McPaths::resolve().unwrap();
assert_eq!(paths.root, override_root);
assert!(legacy.exists());
assert!(!temp.path().join(".magi-code").exists());
}
#[test]
fn copy_fallback_uses_temp_and_cleans_up_on_success() {
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
let new = temp.path().join(".magi-code");
let nested_dir = legacy.join("subdir").join("nested");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(legacy.join("settings.json"), "settings").unwrap();
fs::write(nested_dir.join("sentinel.txt"), "sentinel").unwrap();
copy_legacy_root_via_temp(&legacy, &new).unwrap();
assert_eq!(
fs::read_to_string(new.join("settings.json")).unwrap(),
"settings"
);
assert_eq!(
fs::read_to_string(new.join("subdir/nested/sentinel.txt")).unwrap(),
"sentinel"
);
assert!(
!temp
.path()
.join(format!(".magi-code.tmp-{}", std::process::id()))
.exists()
);
assert!(!legacy.exists());
}
#[test]
fn copy_fallback_failure_leaves_legacy_and_no_partial_new_root() {
let temp = TempDir::new().unwrap();
let legacy = temp.path().join(".mc");
let blocked_parent = temp.path().join("blocked-parent");
let new = blocked_parent.join(".magi-code");
fs::create_dir_all(&legacy).unwrap();
fs::write(legacy.join("settings.json"), "legacy").unwrap();
fs::write(&blocked_parent, "not a directory").unwrap();
let error = copy_legacy_root_via_temp(&legacy, &new).unwrap_err();
assert!(
error
.to_string()
.contains("failed to copy legacy config root")
);
assert!(legacy.exists());
assert!(!new.exists());
assert!(
!blocked_parent
.join(format!(".magi-code.tmp-{}", std::process::id()))
.exists()
);
}
}