Skip to main content

magi_code/config/
paths.rs

1use std::{
2    env, fs,
3    path::{Path, PathBuf},
4};
5
6use anyhow::Context;
7
8const MC_HOME_ENV: &str = "MC_HOME";
9
10#[derive(Debug, Clone, Copy)]
11enum RootResolutionMode {
12    Runtime,
13    ReadOnly,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct McPaths {
18    pub root: PathBuf,
19    pub cache: PathBuf,
20    pub state: PathBuf,
21    pub sessions: PathBuf,
22    pub checkpoints: PathBuf,
23    pub skills: PathBuf,
24    pub prompts: PathBuf,
25    pub subagents: PathBuf,
26    pub primary_agents: PathBuf,
27    pub user_agents: PathBuf,
28    pub settings_file: PathBuf,
29    pub project_settings_file: PathBuf,
30    pub local_settings_file: Option<PathBuf>,
31    pub auth_file: PathBuf,
32}
33
34impl McPaths {
35    pub fn resolve() -> anyhow::Result<Self> {
36        let root = resolve_storage_root(RootResolutionMode::Runtime)?;
37        let project_dir = current_project_dir();
38        Ok(Self::from_root_and_project_dir(root, project_dir))
39    }
40
41    /// Resolves the storage root without migration or runtime directory creation.
42    pub(crate) fn resolve_read_only() -> anyhow::Result<Self> {
43        let root = resolve_storage_root(RootResolutionMode::ReadOnly)?;
44        let project_dir = current_project_dir();
45        Ok(Self::from_root_and_project_dir(root, project_dir))
46    }
47
48    /// Constructs paths from an explicit storage root and project directory.
49    ///
50    /// The project settings target is always `<project_dir>/.magi-code/settings.json`.
51    /// `local_settings_file` is populated only when that target exists when this method runs.
52    pub fn from_root_and_project_dir(root: PathBuf, project_dir: PathBuf) -> Self {
53        let project_settings_file = project_settings_file_for(&project_dir);
54        let local_settings_file = project_settings_file
55            .exists()
56            .then_some(project_settings_file.clone());
57        Self::from_parts(root, project_settings_file, local_settings_file)
58    }
59
60    /// Compatibility wrapper that uses the current process directory as the project directory.
61    ///
62    /// New internal callers should use [`Self::from_root_and_project_dir`] so path resolution is
63    /// deterministic. This wrapper retains the historical `from_root` behavior and does not
64    /// detect a local settings file.
65    pub fn from_root(root: PathBuf) -> Self {
66        let project_settings_file = project_settings_file_for(&current_project_dir());
67        Self::from_parts(root, project_settings_file, None)
68    }
69
70    fn from_parts(
71        root: PathBuf,
72        project_settings_file: PathBuf,
73        local_settings_file: Option<PathBuf>,
74    ) -> Self {
75        Self {
76            cache: root.join("cache"),
77            state: root.join("state"),
78            sessions: root.join("sessions"),
79            checkpoints: root.join("checkpoints"),
80            skills: root.join("skills"),
81            prompts: root.join("prompts"),
82            subagents: root.join("subagents"),
83            primary_agents: root.join("primary-agents"),
84            user_agents: root.join("AGENTS.md"),
85            settings_file: root.join("settings.json"),
86            project_settings_file,
87            local_settings_file,
88            auth_file: root.join("auth.json"),
89            root,
90        }
91    }
92
93    pub fn ensure_runtime_dirs(&self) -> anyhow::Result<()> {
94        fs::create_dir_all(&self.cache)?;
95        fs::create_dir_all(&self.state)?;
96        crate::sessions::prepare_session_root(&self.sessions)?;
97        fs::create_dir_all(&self.checkpoints)?;
98        Ok(())
99    }
100}
101
102fn current_project_dir() -> PathBuf {
103    env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
104}
105
106fn project_settings_file_for(project_dir: &Path) -> PathBuf {
107    project_dir.join(".magi-code").join("settings.json")
108}
109
110fn resolve_storage_root(mode: RootResolutionMode) -> anyhow::Result<PathBuf> {
111    let root = match env::var_os(MC_HOME_ENV) {
112        Some(value) => resolve_explicit_mc_home(value, mode)?,
113        None => {
114            let home = dirs::home_dir().ok_or_else(|| {
115                anyhow::anyhow!("could not resolve home directory for ~/.magi-code")
116            })?;
117            match mode {
118                RootResolutionMode::Runtime => default_root_with_migration(&home)?,
119                RootResolutionMode::ReadOnly => home.join(".magi-code"),
120            }
121        }
122    };
123    validate_storage_root(&root, mode)?;
124    Ok(root)
125}
126
127fn resolve_explicit_mc_home(
128    value: std::ffi::OsString,
129    mode: RootResolutionMode,
130) -> anyhow::Result<PathBuf> {
131    if value.is_empty() {
132        anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
133    }
134    let root = PathBuf::from(value);
135    if !root.is_absolute() {
136        match mode {
137            RootResolutionMode::Runtime => anyhow::bail!(
138                "{MC_HOME_ENV} must be an absolute directory path: {}",
139                root.display()
140            ),
141            RootResolutionMode::ReadOnly => {
142                anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path");
143            }
144        }
145    }
146    Ok(root)
147}
148
149fn validate_storage_root(root: &Path, mode: RootResolutionMode) -> anyhow::Result<()> {
150    match mode {
151        RootResolutionMode::Runtime => {
152            if root.exists() && !root.is_dir() {
153                anyhow::bail!(
154                    "{MC_HOME_ENV} must point to a directory, not a file: {}",
155                    root.display()
156                );
157            }
158            Ok(())
159        }
160        RootResolutionMode::ReadOnly => validate_read_only_mc_home(root),
161    }
162}
163
164fn validate_read_only_mc_home(root: &Path) -> anyhow::Result<()> {
165    match fs::symlink_metadata(root) {
166        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
167        Ok(_) => anyhow::bail!("{MC_HOME_ENV} must point to a directory"),
168        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
169        Err(_) => anyhow::bail!("{MC_HOME_ENV} could not be inspected"),
170    }
171}
172
173fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
174    let new_root = home.join(".magi-code");
175    let legacy_root = home.join(".mc");
176    if new_root.exists() || !legacy_root.is_dir() {
177        return Ok(new_root);
178    }
179    migrate_legacy_default_root(&legacy_root, &new_root)?;
180    Ok(new_root)
181}
182
183fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
184    match fs::rename(legacy_root, new_root) {
185        Ok(()) => Ok(()),
186        Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
187        Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
188            format!(
189                "failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
190                legacy_root.display(),
191                new_root.display()
192            )
193        }),
194    }
195}
196
197fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
198    new_root.is_dir() && !legacy_root.exists()
199}
200
201fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
202    let parent = new_root.parent().ok_or_else(|| {
203        anyhow::anyhow!(
204            "new config root has no parent directory: {}",
205            new_root.display()
206        )
207    })?;
208    let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
209
210    if temp_root.exists() {
211        fs::remove_dir_all(&temp_root).with_context(|| {
212            format!(
213                "failed to remove stale temp config dir: {}",
214                temp_root.display()
215            )
216        })?;
217    }
218
219    if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
220        let _ = fs::remove_dir_all(&temp_root);
221        return Err(error).with_context(|| {
222            format!(
223                "failed to copy legacy config root {} to temp dir {}",
224                legacy_root.display(),
225                temp_root.display()
226            )
227        });
228    }
229
230    if new_root.exists() {
231        let _ = fs::remove_dir_all(&temp_root);
232        if migration_already_completed(legacy_root, new_root) {
233            return Ok(());
234        }
235        anyhow::bail!(
236            "new config root appeared during migration; refusing to overwrite: {}",
237            new_root.display()
238        );
239    }
240
241    if let Err(error) = fs::rename(&temp_root, new_root) {
242        let _ = fs::remove_dir_all(&temp_root);
243        return Err(error).with_context(|| {
244            format!(
245                "failed to finalize migrated config root from {} to {}",
246                temp_root.display(),
247                new_root.display()
248            )
249        });
250    }
251
252    fs::remove_dir_all(legacy_root).with_context(|| {
253        format!(
254            "migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
255            new_root.display(),
256            legacy_root.display()
257        )
258    })?;
259    Ok(())
260}
261
262fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
263    fs::create_dir_all(dst)?;
264    for entry in fs::read_dir(src)? {
265        let entry = entry?;
266        let file_type = entry.file_type()?;
267        let from = entry.path();
268        let to = dst.join(entry.file_name());
269        if file_type.is_dir() {
270            copy_dir_all(&from, &to)?;
271        } else {
272            fs::copy(&from, &to)?;
273        }
274    }
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use tempfile::TempDir;
282
283    struct EnvVarSnapshot {
284        key: &'static str,
285        value: Option<std::ffi::OsString>,
286    }
287
288    impl EnvVarSnapshot {
289        fn capture(key: &'static str) -> Self {
290            let _guard = crate::test_support::env::env_lock();
291            Self {
292                key,
293                value: env::var_os(key),
294            }
295        }
296    }
297
298    impl Drop for EnvVarSnapshot {
299        fn drop(&mut self) {
300            let env = crate::test_support::env::env_lock();
301            match &self.value {
302                Some(value) => env.set_var(self.key, value),
303                None => env.remove_var(self.key),
304            }
305        }
306    }
307
308    #[test]
309    fn from_root_sets_checkpoint_path_under_mc_home() {
310        let temp = TempDir::new().unwrap();
311
312        let paths = McPaths::from_root(temp.path().join("mc"));
313
314        assert_eq!(paths.checkpoints, temp.path().join("mc/checkpoints"));
315    }
316
317    #[test]
318    fn ensure_runtime_dirs_creates_checkpoints_dir() {
319        let temp = TempDir::new().unwrap();
320        let paths = McPaths::from_root(temp.path().join("mc"));
321
322        paths.ensure_runtime_dirs().unwrap();
323
324        assert!(paths.checkpoints.is_dir());
325    }
326
327    #[test]
328    fn from_root_defaults_local_settings_file_to_none() {
329        let temp = TempDir::new().unwrap();
330
331        let paths = McPaths::from_root(temp.path().join("mc"));
332
333        assert_eq!(paths.local_settings_file, None);
334    }
335
336    #[test]
337    fn explicit_project_dir_sets_target_and_detects_existing_local_settings() {
338        let temp = TempDir::new().unwrap();
339        let project_dir = temp.path().join("project");
340        fs::create_dir_all(project_dir.join(".magi-code")).unwrap();
341        let project_dir = project_dir.canonicalize().unwrap();
342        let local_settings = project_dir.join(".magi-code/settings.json");
343        fs::write(&local_settings, "{}").unwrap();
344
345        let paths =
346            McPaths::from_root_and_project_dir(temp.path().join("global"), project_dir.clone());
347
348        assert_eq!(paths.project_settings_file, local_settings);
349        assert_eq!(
350            paths.local_settings_file,
351            Some(paths.project_settings_file.clone())
352        );
353    }
354
355    #[test]
356    fn explicit_project_dir_is_stable_after_cwd_changes() {
357        let temp = TempDir::new().unwrap();
358        let first = temp.path().join("first");
359        let second = temp.path().join("second");
360        fs::create_dir_all(&first).unwrap();
361        fs::create_dir_all(&second).unwrap();
362        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
363        cwd_guard.set_current_dir(&second).unwrap();
364
365        let paths = McPaths::from_root_and_project_dir(temp.path().join("global"), first.clone());
366
367        assert_eq!(
368            paths.project_settings_file,
369            first.join(".magi-code/settings.json")
370        );
371        cwd_guard.restore().unwrap();
372    }
373
374    #[test]
375    fn resolve_uses_cwd_local_settings_file_when_present() {
376        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
377        let env_guard = crate::test_support::env::env_lock();
378        let temp = TempDir::new().unwrap();
379        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
380        let mc_home = temp.path().join("global");
381        let cwd = temp.path().join("project");
382        let local_dir = cwd.join(".magi-code");
383        let local_settings = local_dir.join("settings.json");
384        fs::create_dir_all(&local_dir).unwrap();
385        fs::write(&local_settings, "{}").unwrap();
386        env_guard.set_var("MC_HOME", &mc_home);
387        cwd_guard.set_current_dir(&cwd).unwrap();
388
389        let expected_local_settings = env::current_dir()
390            .unwrap()
391            .join(".magi-code")
392            .join("settings.json");
393
394        let paths = McPaths::resolve().unwrap();
395
396        assert_eq!(
397            paths.local_settings_file,
398            Some(expected_local_settings.clone())
399        );
400        assert_eq!(paths.project_settings_file, expected_local_settings);
401        cwd_guard.restore().unwrap();
402    }
403
404    #[test]
405    fn resolve_stores_project_settings_target_when_file_missing() {
406        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
407        let env_guard = crate::test_support::env::env_lock();
408        let temp = TempDir::new().unwrap();
409        let cwd = temp.path().join("project");
410        fs::create_dir_all(&cwd).unwrap();
411        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
412        env_guard.set_var("MC_HOME", temp.path().join("global"));
413        cwd_guard.set_current_dir(&cwd).unwrap();
414
415        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
416        let paths = McPaths::resolve().unwrap();
417
418        assert_eq!(paths.local_settings_file, None);
419        assert_eq!(paths.project_settings_file, expected);
420        cwd_guard.restore().unwrap();
421    }
422
423    #[test]
424    fn resolved_project_settings_target_is_stable_after_cwd_changes() {
425        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
426        let env_guard = crate::test_support::env::env_lock();
427        let temp = TempDir::new().unwrap();
428        let first = temp.path().join("first");
429        let second = temp.path().join("second");
430        fs::create_dir_all(&first).unwrap();
431        fs::create_dir_all(&second).unwrap();
432        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
433        env_guard.set_var("MC_HOME", temp.path().join("global"));
434        cwd_guard.set_current_dir(&first).unwrap();
435        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
436        let paths = McPaths::resolve().unwrap();
437        cwd_guard.set_current_dir(&second).unwrap();
438
439        assert_eq!(paths.project_settings_file, expected);
440        cwd_guard.restore().unwrap();
441    }
442
443    #[test]
444    fn resolve_does_not_search_parent_for_local_settings_file() {
445        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
446        let env_guard = crate::test_support::env::env_lock();
447        let temp = TempDir::new().unwrap();
448        let parent = temp.path().join("parent");
449        let child = parent.join("child");
450        fs::create_dir_all(parent.join(".magi-code")).unwrap();
451        fs::create_dir_all(&child).unwrap();
452        fs::write(parent.join(".magi-code").join("settings.json"), "{}").unwrap();
453        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
454        env_guard.set_var("MC_HOME", temp.path().join("global"));
455        cwd_guard.set_current_dir(&child).unwrap();
456
457        let paths = McPaths::resolve().unwrap();
458
459        assert_eq!(paths.local_settings_file, None);
460        cwd_guard.restore().unwrap();
461    }
462
463    #[test]
464    fn default_root_with_migration_returns_new_default_without_legacy() {
465        let temp = TempDir::new().unwrap();
466
467        let root = default_root_with_migration(temp.path()).unwrap();
468
469        assert_eq!(root, temp.path().join(".magi-code"));
470        assert!(!root.exists());
471        assert!(!temp.path().join(".mc").exists());
472    }
473
474    #[test]
475    fn default_root_with_migration_migrates_legacy_when_new_root_missing() {
476        let temp = TempDir::new().unwrap();
477        let legacy = temp.path().join(".mc");
478        fs::create_dir_all(&legacy).unwrap();
479        fs::write(legacy.join("settings.json"), "settings").unwrap();
480        fs::write(legacy.join("auth.json"), "auth").unwrap();
481
482        let root = default_root_with_migration(temp.path()).unwrap();
483
484        assert_eq!(root, temp.path().join(".magi-code"));
485        assert_eq!(
486            fs::read_to_string(root.join("settings.json")).unwrap(),
487            "settings"
488        );
489        assert_eq!(fs::read_to_string(root.join("auth.json")).unwrap(), "auth");
490        assert!(!legacy.exists());
491    }
492
493    #[test]
494    fn default_root_with_migration_skips_migration_when_new_root_exists() {
495        let temp = TempDir::new().unwrap();
496        let legacy = temp.path().join(".mc");
497        let new = temp.path().join(".magi-code");
498        fs::create_dir_all(&legacy).unwrap();
499        fs::create_dir_all(&new).unwrap();
500        fs::write(legacy.join("settings.json"), "legacy").unwrap();
501        fs::write(new.join("settings.json"), "new").unwrap();
502
503        let root = default_root_with_migration(temp.path()).unwrap();
504
505        assert_eq!(root, new);
506        assert_eq!(
507            fs::read_to_string(root.join("settings.json")).unwrap(),
508            "new"
509        );
510        assert_eq!(
511            fs::read_to_string(legacy.join("settings.json")).unwrap(),
512            "legacy"
513        );
514    }
515
516    #[test]
517    fn migrate_treats_existing_new_root_without_legacy_as_already_done() {
518        let temp = TempDir::new().unwrap();
519        let legacy = temp.path().join(".mc");
520        let new = temp.path().join(".magi-code");
521        fs::create_dir_all(&new).unwrap();
522        fs::write(new.join("settings.json"), "new").unwrap();
523
524        migrate_legacy_default_root(&legacy, &new).unwrap();
525
526        assert_eq!(
527            fs::read_to_string(new.join("settings.json")).unwrap(),
528            "new"
529        );
530        assert!(!legacy.exists());
531    }
532
533    #[test]
534    fn resolve_respects_mc_home_without_migration() {
535        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
536        let env_guard = crate::test_support::env::env_lock();
537        let temp = TempDir::new().unwrap();
538        let legacy = temp.path().join(".mc");
539        let override_root = temp.path().join("override");
540        fs::create_dir_all(&legacy).unwrap();
541        fs::write(legacy.join("settings.json"), "legacy").unwrap();
542        env_guard.set_var("MC_HOME", &override_root);
543
544        let paths = McPaths::resolve().unwrap();
545
546        assert_eq!(paths.root, override_root);
547        assert!(legacy.exists());
548        assert!(!temp.path().join(".magi-code").exists());
549    }
550
551    #[test]
552    fn copy_fallback_uses_temp_and_cleans_up_on_success() {
553        let temp = TempDir::new().unwrap();
554        let legacy = temp.path().join(".mc");
555        let new = temp.path().join(".magi-code");
556        let nested_dir = legacy.join("subdir").join("nested");
557        fs::create_dir_all(&nested_dir).unwrap();
558        fs::write(legacy.join("settings.json"), "settings").unwrap();
559        fs::write(nested_dir.join("sentinel.txt"), "sentinel").unwrap();
560
561        copy_legacy_root_via_temp(&legacy, &new).unwrap();
562
563        assert_eq!(
564            fs::read_to_string(new.join("settings.json")).unwrap(),
565            "settings"
566        );
567        assert_eq!(
568            fs::read_to_string(new.join("subdir/nested/sentinel.txt")).unwrap(),
569            "sentinel"
570        );
571        assert!(
572            !temp
573                .path()
574                .join(format!(".magi-code.tmp-{}", std::process::id()))
575                .exists()
576        );
577        assert!(!legacy.exists());
578    }
579
580    #[test]
581    fn copy_fallback_failure_leaves_legacy_and_no_partial_new_root() {
582        let temp = TempDir::new().unwrap();
583        let legacy = temp.path().join(".mc");
584        let blocked_parent = temp.path().join("blocked-parent");
585        let new = blocked_parent.join(".magi-code");
586        fs::create_dir_all(&legacy).unwrap();
587        fs::write(legacy.join("settings.json"), "legacy").unwrap();
588        fs::write(&blocked_parent, "not a directory").unwrap();
589
590        let error = copy_legacy_root_via_temp(&legacy, &new).unwrap_err();
591
592        assert!(
593            error
594                .to_string()
595                .contains("failed to copy legacy config root")
596        );
597        assert!(legacy.exists());
598        assert!(!new.exists());
599        assert!(
600            !blocked_parent
601                .join(format!(".magi-code.tmp-{}", std::process::id()))
602                .exists()
603        );
604    }
605    #[test]
606    fn resolve_read_only_rejects_existing_mc_home_file_without_path_leak() {
607        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
608        let env_guard = crate::test_support::env::env_lock();
609        let temp = TempDir::new().unwrap();
610        let canary = temp.path().join("mc-home-private-canary");
611        fs::write(&canary, "not a directory").unwrap();
612        env_guard.set_var("MC_HOME", &canary);
613
614        let error = McPaths::resolve_read_only().unwrap_err().to_string();
615
616        assert_eq!(error, "MC_HOME must point to a directory");
617        assert!(!error.contains("mc-home-private-canary"));
618    }
619
620    #[test]
621    fn resolve_read_only_does_not_create_or_migrate_storage() {
622        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
623        let env_guard = crate::test_support::env::env_lock();
624        let temp = TempDir::new().unwrap();
625        let root = temp.path().join("new-root");
626        let legacy = temp.path().join(".mc");
627        fs::create_dir_all(&legacy).unwrap();
628        fs::write(legacy.join("settings.json"), "legacy").unwrap();
629        env_guard.set_var("MC_HOME", &root);
630
631        let paths = McPaths::resolve_read_only().unwrap();
632
633        assert_eq!(paths.root, root);
634        assert!(!paths.root.exists());
635        assert!(legacy.exists());
636    }
637
638    #[test]
639    fn resolve_read_only_captures_project_target_and_existing_local_settings() {
640        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
641        let env_guard = crate::test_support::env::env_lock();
642        let temp = TempDir::new().unwrap();
643        let root = temp.path().join("global");
644        let project = temp.path().join("project");
645        let local_dir = project.join(".magi-code");
646        fs::create_dir_all(&local_dir).unwrap();
647        let project = project.canonicalize().unwrap();
648        let local = project.join(".magi-code/settings.json");
649        fs::write(&local, "{}").unwrap();
650        env_guard.set_var("MC_HOME", &root);
651        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
652        cwd_guard.set_current_dir(&project).unwrap();
653
654        let paths = McPaths::resolve_read_only().unwrap();
655
656        assert_eq!(paths.root, root);
657        assert_eq!(paths.project_settings_file, local);
658        assert_eq!(
659            paths.local_settings_file,
660            Some(paths.project_settings_file.clone())
661        );
662        cwd_guard.restore().unwrap();
663    }
664}