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("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    if matches!(mode, RootResolutionMode::Runtime) {
125        migrate_primary_agents_directory(&root)?;
126    }
127    Ok(root)
128}
129
130fn resolve_explicit_mc_home(
131    value: std::ffi::OsString,
132    mode: RootResolutionMode,
133) -> anyhow::Result<PathBuf> {
134    if value.is_empty() {
135        anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
136    }
137    let root = PathBuf::from(value);
138    if !root.is_absolute() {
139        match mode {
140            RootResolutionMode::Runtime => anyhow::bail!(
141                "{MC_HOME_ENV} must be an absolute directory path: {}",
142                root.display()
143            ),
144            RootResolutionMode::ReadOnly => {
145                anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path");
146            }
147        }
148    }
149    Ok(root)
150}
151
152fn validate_storage_root(root: &Path, mode: RootResolutionMode) -> anyhow::Result<()> {
153    match mode {
154        RootResolutionMode::Runtime => {
155            if root.exists() && !root.is_dir() {
156                anyhow::bail!(
157                    "{MC_HOME_ENV} must point to a directory, not a file: {}",
158                    root.display()
159                );
160            }
161            Ok(())
162        }
163        RootResolutionMode::ReadOnly => validate_read_only_mc_home(root),
164    }
165}
166
167fn validate_read_only_mc_home(root: &Path) -> anyhow::Result<()> {
168    match fs::symlink_metadata(root) {
169        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
170        Ok(_) => anyhow::bail!("{MC_HOME_ENV} must point to a directory"),
171        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
172        Err(_) => anyhow::bail!("{MC_HOME_ENV} could not be inspected"),
173    }
174}
175
176// Temporary compatibility for the primary-agents -> agents transition (introduced in 0.79).
177// Keep for the next few releases, then remove this helper and its runtime call.
178fn migrate_primary_agents_directory(root: &Path) -> anyhow::Result<()> {
179    let legacy = root.join("primary-agents");
180    let destination = root.join("agents");
181    match fs::symlink_metadata(&legacy) {
182        Ok(metadata) if metadata.is_dir() => {}
183        Ok(_) => return Ok(()), // Never follow a legacy directory symlink.
184        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
185        Err(error) => return Err(error).context("could not inspect primary-agents directory"),
186    }
187    match fs::symlink_metadata(&destination) {
188        Ok(_) => return Ok(()), // Preserve both paths, including dangling symlinks.
189        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
190        Err(error) => return Err(error).context("could not inspect agents directory"),
191    }
192    match fs::rename(&legacy, &destination) {
193        Ok(()) => Ok(()),
194        Err(_) if migration_already_completed(&legacy, &destination) => Ok(()),
195        Err(error) => Err(error).context("could not migrate primary-agents directory to agents"),
196    }
197}
198
199fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
200    let new_root = home.join(".magi-code");
201    let legacy_root = home.join(".mc");
202    if new_root.exists() || !legacy_root.is_dir() {
203        return Ok(new_root);
204    }
205    migrate_legacy_default_root(&legacy_root, &new_root)?;
206    Ok(new_root)
207}
208
209fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
210    match fs::rename(legacy_root, new_root) {
211        Ok(()) => Ok(()),
212        Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
213        Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
214            format!(
215                "failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
216                legacy_root.display(),
217                new_root.display()
218            )
219        }),
220    }
221}
222
223fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
224    new_root.is_dir() && !legacy_root.exists()
225}
226
227fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
228    let parent = new_root.parent().ok_or_else(|| {
229        anyhow::anyhow!(
230            "new config root has no parent directory: {}",
231            new_root.display()
232        )
233    })?;
234    let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
235
236    if temp_root.exists() {
237        fs::remove_dir_all(&temp_root).with_context(|| {
238            format!(
239                "failed to remove stale temp config dir: {}",
240                temp_root.display()
241            )
242        })?;
243    }
244
245    if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
246        let _ = fs::remove_dir_all(&temp_root);
247        return Err(error).with_context(|| {
248            format!(
249                "failed to copy legacy config root {} to temp dir {}",
250                legacy_root.display(),
251                temp_root.display()
252            )
253        });
254    }
255
256    if new_root.exists() {
257        let _ = fs::remove_dir_all(&temp_root);
258        if migration_already_completed(legacy_root, new_root) {
259            return Ok(());
260        }
261        anyhow::bail!(
262            "new config root appeared during migration; refusing to overwrite: {}",
263            new_root.display()
264        );
265    }
266
267    if let Err(error) = fs::rename(&temp_root, new_root) {
268        let _ = fs::remove_dir_all(&temp_root);
269        return Err(error).with_context(|| {
270            format!(
271                "failed to finalize migrated config root from {} to {}",
272                temp_root.display(),
273                new_root.display()
274            )
275        });
276    }
277
278    fs::remove_dir_all(legacy_root).with_context(|| {
279        format!(
280            "migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
281            new_root.display(),
282            legacy_root.display()
283        )
284    })?;
285    Ok(())
286}
287
288fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
289    fs::create_dir_all(dst)?;
290    for entry in fs::read_dir(src)? {
291        let entry = entry?;
292        let file_type = entry.file_type()?;
293        let from = entry.path();
294        let to = dst.join(entry.file_name());
295        if file_type.is_dir() {
296            copy_dir_all(&from, &to)?;
297        } else {
298            fs::copy(&from, &to)?;
299        }
300    }
301    Ok(())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use tempfile::TempDir;
308
309    struct EnvVarSnapshot {
310        key: &'static str,
311        value: Option<std::ffi::OsString>,
312    }
313
314    impl EnvVarSnapshot {
315        fn capture(key: &'static str) -> Self {
316            let _guard = crate::test_support::env::env_lock();
317            Self {
318                key,
319                value: env::var_os(key),
320            }
321        }
322    }
323
324    impl Drop for EnvVarSnapshot {
325        fn drop(&mut self) {
326            let env = crate::test_support::env::env_lock();
327            match &self.value {
328                Some(value) => env.set_var(self.key, value),
329                None => env.remove_var(self.key),
330            }
331        }
332    }
333
334    #[test]
335    fn runtime_resolution_migrates_primary_agents_but_read_only_does_not() {
336        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
337        let env_guard = crate::test_support::env::env_lock();
338        let temp = TempDir::new().unwrap();
339        env_guard.set_var("MC_HOME", temp.path());
340        let legacy = temp.path().join("primary-agents");
341        fs::create_dir(&legacy).unwrap();
342        let profile = "---\nname: Reviewer\ndescription: Reviews code\n---\nReview carefully.\n";
343        fs::write(legacy.join("reviewer.md"), profile).unwrap();
344
345        McPaths::resolve_read_only().unwrap();
346        assert!(legacy.is_dir());
347        assert!(!temp.path().join("agents").exists());
348
349        let paths = McPaths::resolve().unwrap();
350        assert!(!legacy.exists());
351        assert_eq!(
352            fs::read_to_string(paths.primary_agents.join("reviewer.md")).unwrap(),
353            profile
354        );
355        McPaths::resolve().unwrap();
356        assert_eq!(
357            fs::read_to_string(paths.primary_agents.join("reviewer.md")).unwrap(),
358            profile
359        );
360    }
361
362    #[test]
363    fn primary_agents_migration_preserves_both_existing_directories() {
364        let temp = TempDir::new().unwrap();
365        for (directory, body) in [("primary-agents", "old"), ("agents", "new")] {
366            fs::create_dir(temp.path().join(directory)).unwrap();
367            fs::write(temp.path().join(directory).join("reviewer.md"), body).unwrap();
368        }
369        migrate_primary_agents_directory(temp.path()).unwrap();
370        assert_eq!(
371            fs::read_to_string(temp.path().join("primary-agents/reviewer.md")).unwrap(),
372            "old"
373        );
374        assert_eq!(
375            fs::read_to_string(temp.path().join("agents/reviewer.md")).unwrap(),
376            "new"
377        );
378    }
379
380    #[cfg(unix)]
381    #[test]
382    fn primary_agents_migration_preserves_symlinks() {
383        use std::os::unix::fs::symlink;
384        let temp = TempDir::new().unwrap();
385        let outside = TempDir::new().unwrap();
386        let legacy = temp.path().join("primary-agents");
387        let destination = temp.path().join("agents");
388        symlink(outside.path(), &legacy).unwrap();
389        migrate_primary_agents_directory(temp.path()).unwrap();
390        assert!(fs::symlink_metadata(&legacy).unwrap().is_symlink());
391        assert!(!destination.exists());
392
393        fs::remove_file(&legacy).unwrap();
394        fs::create_dir(&legacy).unwrap();
395        symlink(outside.path().join("missing"), &destination).unwrap();
396        migrate_primary_agents_directory(temp.path()).unwrap();
397        assert!(legacy.is_dir());
398        assert!(fs::symlink_metadata(&destination).unwrap().is_symlink());
399    }
400
401    #[test]
402    fn from_root_sets_checkpoint_path_under_mc_home() {
403        let temp = TempDir::new().unwrap();
404
405        let paths = McPaths::from_root(temp.path().join("mc"));
406
407        assert_eq!(paths.checkpoints, temp.path().join("mc/checkpoints"));
408    }
409
410    #[test]
411    fn ensure_runtime_dirs_creates_checkpoints_dir() {
412        let temp = TempDir::new().unwrap();
413        let paths = McPaths::from_root(temp.path().join("mc"));
414
415        paths.ensure_runtime_dirs().unwrap();
416
417        assert!(paths.checkpoints.is_dir());
418    }
419
420    #[test]
421    fn from_root_defaults_local_settings_file_to_none() {
422        let temp = TempDir::new().unwrap();
423
424        let paths = McPaths::from_root(temp.path().join("mc"));
425
426        assert_eq!(paths.local_settings_file, None);
427    }
428
429    #[test]
430    fn explicit_project_dir_sets_target_and_detects_existing_local_settings() {
431        let temp = TempDir::new().unwrap();
432        let project_dir = temp.path().join("project");
433        fs::create_dir_all(project_dir.join(".magi-code")).unwrap();
434        let project_dir = project_dir.canonicalize().unwrap();
435        let local_settings = project_dir.join(".magi-code/settings.json");
436        fs::write(&local_settings, "{}").unwrap();
437
438        let paths =
439            McPaths::from_root_and_project_dir(temp.path().join("global"), project_dir.clone());
440
441        assert_eq!(paths.project_settings_file, local_settings);
442        assert_eq!(
443            paths.local_settings_file,
444            Some(paths.project_settings_file.clone())
445        );
446    }
447
448    #[test]
449    fn explicit_project_dir_is_stable_after_cwd_changes() {
450        let temp = TempDir::new().unwrap();
451        let first = temp.path().join("first");
452        let second = temp.path().join("second");
453        fs::create_dir_all(&first).unwrap();
454        fs::create_dir_all(&second).unwrap();
455        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
456        cwd_guard.set_current_dir(&second).unwrap();
457
458        let paths = McPaths::from_root_and_project_dir(temp.path().join("global"), first.clone());
459
460        assert_eq!(
461            paths.project_settings_file,
462            first.join(".magi-code/settings.json")
463        );
464        cwd_guard.restore().unwrap();
465    }
466
467    #[test]
468    fn resolve_uses_cwd_local_settings_file_when_present() {
469        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
470        let env_guard = crate::test_support::env::env_lock();
471        let temp = TempDir::new().unwrap();
472        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
473        let mc_home = temp.path().join("global");
474        let cwd = temp.path().join("project");
475        let local_dir = cwd.join(".magi-code");
476        let local_settings = local_dir.join("settings.json");
477        fs::create_dir_all(&local_dir).unwrap();
478        fs::write(&local_settings, "{}").unwrap();
479        env_guard.set_var("MC_HOME", &mc_home);
480        cwd_guard.set_current_dir(&cwd).unwrap();
481
482        let expected_local_settings = env::current_dir()
483            .unwrap()
484            .join(".magi-code")
485            .join("settings.json");
486
487        let paths = McPaths::resolve().unwrap();
488
489        assert_eq!(
490            paths.local_settings_file,
491            Some(expected_local_settings.clone())
492        );
493        assert_eq!(paths.project_settings_file, expected_local_settings);
494        cwd_guard.restore().unwrap();
495    }
496
497    #[test]
498    fn resolve_stores_project_settings_target_when_file_missing() {
499        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
500        let env_guard = crate::test_support::env::env_lock();
501        let temp = TempDir::new().unwrap();
502        let cwd = temp.path().join("project");
503        fs::create_dir_all(&cwd).unwrap();
504        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
505        env_guard.set_var("MC_HOME", temp.path().join("global"));
506        cwd_guard.set_current_dir(&cwd).unwrap();
507
508        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
509        let paths = McPaths::resolve().unwrap();
510
511        assert_eq!(paths.local_settings_file, None);
512        assert_eq!(paths.project_settings_file, expected);
513        cwd_guard.restore().unwrap();
514    }
515
516    #[test]
517    fn resolved_project_settings_target_is_stable_after_cwd_changes() {
518        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
519        let env_guard = crate::test_support::env::env_lock();
520        let temp = TempDir::new().unwrap();
521        let first = temp.path().join("first");
522        let second = temp.path().join("second");
523        fs::create_dir_all(&first).unwrap();
524        fs::create_dir_all(&second).unwrap();
525        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
526        env_guard.set_var("MC_HOME", temp.path().join("global"));
527        cwd_guard.set_current_dir(&first).unwrap();
528        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
529        let paths = McPaths::resolve().unwrap();
530        cwd_guard.set_current_dir(&second).unwrap();
531
532        assert_eq!(paths.project_settings_file, expected);
533        cwd_guard.restore().unwrap();
534    }
535
536    #[test]
537    fn resolve_does_not_search_parent_for_local_settings_file() {
538        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
539        let env_guard = crate::test_support::env::env_lock();
540        let temp = TempDir::new().unwrap();
541        let parent = temp.path().join("parent");
542        let child = parent.join("child");
543        fs::create_dir_all(parent.join(".magi-code")).unwrap();
544        fs::create_dir_all(&child).unwrap();
545        fs::write(parent.join(".magi-code").join("settings.json"), "{}").unwrap();
546        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
547        env_guard.set_var("MC_HOME", temp.path().join("global"));
548        cwd_guard.set_current_dir(&child).unwrap();
549
550        let paths = McPaths::resolve().unwrap();
551
552        assert_eq!(paths.local_settings_file, None);
553        cwd_guard.restore().unwrap();
554    }
555
556    #[test]
557    fn default_root_with_migration_returns_new_default_without_legacy() {
558        let temp = TempDir::new().unwrap();
559
560        let root = default_root_with_migration(temp.path()).unwrap();
561
562        assert_eq!(root, temp.path().join(".magi-code"));
563        assert!(!root.exists());
564        assert!(!temp.path().join(".mc").exists());
565    }
566
567    #[test]
568    fn default_root_with_migration_migrates_legacy_when_new_root_missing() {
569        let temp = TempDir::new().unwrap();
570        let legacy = temp.path().join(".mc");
571        fs::create_dir_all(&legacy).unwrap();
572        fs::write(legacy.join("settings.json"), "settings").unwrap();
573        fs::write(legacy.join("auth.json"), "auth").unwrap();
574
575        let root = default_root_with_migration(temp.path()).unwrap();
576
577        assert_eq!(root, temp.path().join(".magi-code"));
578        assert_eq!(
579            fs::read_to_string(root.join("settings.json")).unwrap(),
580            "settings"
581        );
582        assert_eq!(fs::read_to_string(root.join("auth.json")).unwrap(), "auth");
583        assert!(!legacy.exists());
584    }
585
586    #[test]
587    fn default_root_with_migration_skips_migration_when_new_root_exists() {
588        let temp = TempDir::new().unwrap();
589        let legacy = temp.path().join(".mc");
590        let new = temp.path().join(".magi-code");
591        fs::create_dir_all(&legacy).unwrap();
592        fs::create_dir_all(&new).unwrap();
593        fs::write(legacy.join("settings.json"), "legacy").unwrap();
594        fs::write(new.join("settings.json"), "new").unwrap();
595
596        let root = default_root_with_migration(temp.path()).unwrap();
597
598        assert_eq!(root, new);
599        assert_eq!(
600            fs::read_to_string(root.join("settings.json")).unwrap(),
601            "new"
602        );
603        assert_eq!(
604            fs::read_to_string(legacy.join("settings.json")).unwrap(),
605            "legacy"
606        );
607    }
608
609    #[test]
610    fn migrate_treats_existing_new_root_without_legacy_as_already_done() {
611        let temp = TempDir::new().unwrap();
612        let legacy = temp.path().join(".mc");
613        let new = temp.path().join(".magi-code");
614        fs::create_dir_all(&new).unwrap();
615        fs::write(new.join("settings.json"), "new").unwrap();
616
617        migrate_legacy_default_root(&legacy, &new).unwrap();
618
619        assert_eq!(
620            fs::read_to_string(new.join("settings.json")).unwrap(),
621            "new"
622        );
623        assert!(!legacy.exists());
624    }
625
626    #[test]
627    fn resolve_respects_mc_home_without_migration() {
628        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
629        let env_guard = crate::test_support::env::env_lock();
630        let temp = TempDir::new().unwrap();
631        let legacy = temp.path().join(".mc");
632        let override_root = temp.path().join("override");
633        fs::create_dir_all(&legacy).unwrap();
634        fs::write(legacy.join("settings.json"), "legacy").unwrap();
635        env_guard.set_var("MC_HOME", &override_root);
636
637        let paths = McPaths::resolve().unwrap();
638
639        assert_eq!(paths.root, override_root);
640        assert!(legacy.exists());
641        assert!(!temp.path().join(".magi-code").exists());
642    }
643
644    #[test]
645    fn copy_fallback_uses_temp_and_cleans_up_on_success() {
646        let temp = TempDir::new().unwrap();
647        let legacy = temp.path().join(".mc");
648        let new = temp.path().join(".magi-code");
649        let nested_dir = legacy.join("subdir").join("nested");
650        fs::create_dir_all(&nested_dir).unwrap();
651        fs::write(legacy.join("settings.json"), "settings").unwrap();
652        fs::write(nested_dir.join("sentinel.txt"), "sentinel").unwrap();
653
654        copy_legacy_root_via_temp(&legacy, &new).unwrap();
655
656        assert_eq!(
657            fs::read_to_string(new.join("settings.json")).unwrap(),
658            "settings"
659        );
660        assert_eq!(
661            fs::read_to_string(new.join("subdir/nested/sentinel.txt")).unwrap(),
662            "sentinel"
663        );
664        assert!(
665            !temp
666                .path()
667                .join(format!(".magi-code.tmp-{}", std::process::id()))
668                .exists()
669        );
670        assert!(!legacy.exists());
671    }
672
673    #[test]
674    fn copy_fallback_failure_leaves_legacy_and_no_partial_new_root() {
675        let temp = TempDir::new().unwrap();
676        let legacy = temp.path().join(".mc");
677        let blocked_parent = temp.path().join("blocked-parent");
678        let new = blocked_parent.join(".magi-code");
679        fs::create_dir_all(&legacy).unwrap();
680        fs::write(legacy.join("settings.json"), "legacy").unwrap();
681        fs::write(&blocked_parent, "not a directory").unwrap();
682
683        let error = copy_legacy_root_via_temp(&legacy, &new).unwrap_err();
684
685        assert!(
686            error
687                .to_string()
688                .contains("failed to copy legacy config root")
689        );
690        assert!(legacy.exists());
691        assert!(!new.exists());
692        assert!(
693            !blocked_parent
694                .join(format!(".magi-code.tmp-{}", std::process::id()))
695                .exists()
696        );
697    }
698    #[test]
699    fn resolve_read_only_rejects_existing_mc_home_file_without_path_leak() {
700        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
701        let env_guard = crate::test_support::env::env_lock();
702        let temp = TempDir::new().unwrap();
703        let canary = temp.path().join("mc-home-private-canary");
704        fs::write(&canary, "not a directory").unwrap();
705        env_guard.set_var("MC_HOME", &canary);
706
707        let error = McPaths::resolve_read_only().unwrap_err().to_string();
708
709        assert_eq!(error, "MC_HOME must point to a directory");
710        assert!(!error.contains("mc-home-private-canary"));
711    }
712
713    #[test]
714    fn resolve_read_only_does_not_create_or_migrate_storage() {
715        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
716        let env_guard = crate::test_support::env::env_lock();
717        let temp = TempDir::new().unwrap();
718        let root = temp.path().join("new-root");
719        let legacy = temp.path().join(".mc");
720        fs::create_dir_all(&legacy).unwrap();
721        fs::write(legacy.join("settings.json"), "legacy").unwrap();
722        env_guard.set_var("MC_HOME", &root);
723
724        let paths = McPaths::resolve_read_only().unwrap();
725
726        assert_eq!(paths.root, root);
727        assert!(!paths.root.exists());
728        assert!(legacy.exists());
729    }
730
731    #[test]
732    fn resolve_read_only_captures_project_target_and_existing_local_settings() {
733        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
734        let env_guard = crate::test_support::env::env_lock();
735        let temp = TempDir::new().unwrap();
736        let root = temp.path().join("global");
737        let project = temp.path().join("project");
738        let local_dir = project.join(".magi-code");
739        fs::create_dir_all(&local_dir).unwrap();
740        let project = project.canonicalize().unwrap();
741        let local = project.join(".magi-code/settings.json");
742        fs::write(&local, "{}").unwrap();
743        env_guard.set_var("MC_HOME", &root);
744        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
745        cwd_guard.set_current_dir(&project).unwrap();
746
747        let paths = McPaths::resolve_read_only().unwrap();
748
749        assert_eq!(paths.root, root);
750        assert_eq!(paths.project_settings_file, local);
751        assert_eq!(
752            paths.local_settings_file,
753            Some(paths.project_settings_file.clone())
754        );
755        cwd_guard.restore().unwrap();
756    }
757}