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, PartialEq, Eq)]
11pub struct McPaths {
12    pub root: PathBuf,
13    pub cache: PathBuf,
14    pub state: PathBuf,
15    pub sessions: PathBuf,
16    pub checkpoints: PathBuf,
17    pub skills: PathBuf,
18    pub prompts: PathBuf,
19    pub subagents: PathBuf,
20    pub primary_agents: PathBuf,
21    pub user_agents: PathBuf,
22    pub settings_file: PathBuf,
23    pub project_settings_file: PathBuf,
24    pub local_settings_file: Option<PathBuf>,
25    pub auth_file: PathBuf,
26}
27
28impl McPaths {
29    pub fn resolve() -> anyhow::Result<Self> {
30        let root = match env::var_os(MC_HOME_ENV) {
31            Some(value) => {
32                if value.is_empty() {
33                    anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
34                }
35                let root = PathBuf::from(value);
36                if !root.is_absolute() {
37                    anyhow::bail!(
38                        "{MC_HOME_ENV} must be an absolute directory path: {}",
39                        root.display()
40                    );
41                }
42                if root.exists() && !root.is_dir() {
43                    anyhow::bail!(
44                        "{MC_HOME_ENV} must point to a directory, not a file: {}",
45                        root.display()
46                    );
47                }
48                root
49            }
50            None => {
51                let home = dirs::home_dir().ok_or_else(|| {
52                    anyhow::anyhow!("could not resolve home directory for ~/.magi-code")
53                })?;
54                default_root_with_migration(&home)?
55            }
56        };
57        let mut paths = Self::from_root(root);
58        let cwd = env::current_dir().ok();
59        if let Some(cwd) = cwd {
60            paths.project_settings_file = cwd.join(".magi-code").join("settings.json");
61            paths.local_settings_file = paths
62                .project_settings_file
63                .exists()
64                .then_some(paths.project_settings_file.clone());
65        }
66        Ok(paths)
67    }
68
69    pub fn from_root(root: PathBuf) -> Self {
70        Self {
71            cache: root.join("cache"),
72            state: root.join("state"),
73            sessions: root.join("sessions"),
74            checkpoints: root.join("checkpoints"),
75            skills: root.join("skills"),
76            prompts: root.join("prompts"),
77            subagents: root.join("subagents"),
78            primary_agents: root.join("primary-agents"),
79            user_agents: root.join("AGENTS.md"),
80            settings_file: root.join("settings.json"),
81            project_settings_file: env::current_dir()
82                .unwrap_or_else(|_| PathBuf::from("."))
83                .join(".magi-code")
84                .join("settings.json"),
85            local_settings_file: None,
86            auth_file: root.join("auth.json"),
87            root,
88        }
89    }
90
91    pub fn ensure_runtime_dirs(&self) -> anyhow::Result<()> {
92        fs::create_dir_all(&self.cache)?;
93        fs::create_dir_all(&self.state)?;
94        crate::sessions::prepare_session_root(&self.sessions)?;
95        fs::create_dir_all(&self.checkpoints)?;
96        Ok(())
97    }
98}
99
100fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
101    let new_root = home.join(".magi-code");
102    let legacy_root = home.join(".mc");
103    if new_root.exists() || !legacy_root.is_dir() {
104        return Ok(new_root);
105    }
106    migrate_legacy_default_root(&legacy_root, &new_root)?;
107    Ok(new_root)
108}
109
110fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
111    match fs::rename(legacy_root, new_root) {
112        Ok(()) => Ok(()),
113        Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
114        Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
115            format!(
116                "failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
117                legacy_root.display(),
118                new_root.display()
119            )
120        }),
121    }
122}
123
124fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
125    new_root.is_dir() && !legacy_root.exists()
126}
127
128fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
129    let parent = new_root.parent().ok_or_else(|| {
130        anyhow::anyhow!(
131            "new config root has no parent directory: {}",
132            new_root.display()
133        )
134    })?;
135    let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
136
137    if temp_root.exists() {
138        fs::remove_dir_all(&temp_root).with_context(|| {
139            format!(
140                "failed to remove stale temp config dir: {}",
141                temp_root.display()
142            )
143        })?;
144    }
145
146    if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
147        let _ = fs::remove_dir_all(&temp_root);
148        return Err(error).with_context(|| {
149            format!(
150                "failed to copy legacy config root {} to temp dir {}",
151                legacy_root.display(),
152                temp_root.display()
153            )
154        });
155    }
156
157    if new_root.exists() {
158        let _ = fs::remove_dir_all(&temp_root);
159        if migration_already_completed(legacy_root, new_root) {
160            return Ok(());
161        }
162        anyhow::bail!(
163            "new config root appeared during migration; refusing to overwrite: {}",
164            new_root.display()
165        );
166    }
167
168    if let Err(error) = fs::rename(&temp_root, new_root) {
169        let _ = fs::remove_dir_all(&temp_root);
170        return Err(error).with_context(|| {
171            format!(
172                "failed to finalize migrated config root from {} to {}",
173                temp_root.display(),
174                new_root.display()
175            )
176        });
177    }
178
179    fs::remove_dir_all(legacy_root).with_context(|| {
180        format!(
181            "migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
182            new_root.display(),
183            legacy_root.display()
184        )
185    })?;
186    Ok(())
187}
188
189fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
190    fs::create_dir_all(dst)?;
191    for entry in fs::read_dir(src)? {
192        let entry = entry?;
193        let file_type = entry.file_type()?;
194        let from = entry.path();
195        let to = dst.join(entry.file_name());
196        if file_type.is_dir() {
197            copy_dir_all(&from, &to)?;
198        } else {
199            fs::copy(&from, &to)?;
200        }
201    }
202    Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use tempfile::TempDir;
209
210    struct EnvVarSnapshot {
211        key: &'static str,
212        value: Option<std::ffi::OsString>,
213    }
214
215    impl EnvVarSnapshot {
216        fn capture(key: &'static str) -> Self {
217            let _guard = crate::test_support::env::env_lock();
218            Self {
219                key,
220                value: env::var_os(key),
221            }
222        }
223    }
224
225    impl Drop for EnvVarSnapshot {
226        fn drop(&mut self) {
227            let env = crate::test_support::env::env_lock();
228            match &self.value {
229                Some(value) => env.set_var(self.key, value),
230                None => env.remove_var(self.key),
231            }
232        }
233    }
234
235    #[test]
236    fn from_root_sets_checkpoint_path_under_mc_home() {
237        let temp = TempDir::new().unwrap();
238
239        let paths = McPaths::from_root(temp.path().join("mc"));
240
241        assert_eq!(paths.checkpoints, temp.path().join("mc/checkpoints"));
242    }
243
244    #[test]
245    fn ensure_runtime_dirs_creates_checkpoints_dir() {
246        let temp = TempDir::new().unwrap();
247        let paths = McPaths::from_root(temp.path().join("mc"));
248
249        paths.ensure_runtime_dirs().unwrap();
250
251        assert!(paths.checkpoints.is_dir());
252    }
253
254    #[test]
255    fn from_root_defaults_local_settings_file_to_none() {
256        let temp = TempDir::new().unwrap();
257
258        let paths = McPaths::from_root(temp.path().join("mc"));
259
260        assert_eq!(paths.local_settings_file, None);
261    }
262
263    #[test]
264    fn resolve_uses_cwd_local_settings_file_when_present() {
265        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
266        let env_guard = crate::test_support::env::env_lock();
267        let temp = TempDir::new().unwrap();
268        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
269        let mc_home = temp.path().join("global");
270        let cwd = temp.path().join("project");
271        let local_dir = cwd.join(".magi-code");
272        let local_settings = local_dir.join("settings.json");
273        fs::create_dir_all(&local_dir).unwrap();
274        fs::write(&local_settings, "{}").unwrap();
275        env_guard.set_var("MC_HOME", &mc_home);
276        cwd_guard.set_current_dir(&cwd).unwrap();
277
278        let expected_local_settings = env::current_dir()
279            .unwrap()
280            .join(".magi-code")
281            .join("settings.json");
282
283        let paths = McPaths::resolve().unwrap();
284
285        assert_eq!(
286            paths.local_settings_file,
287            Some(expected_local_settings.clone())
288        );
289        assert_eq!(paths.project_settings_file, expected_local_settings);
290        cwd_guard.restore().unwrap();
291    }
292
293    #[test]
294    fn resolve_stores_project_settings_target_when_file_missing() {
295        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
296        let env_guard = crate::test_support::env::env_lock();
297        let temp = TempDir::new().unwrap();
298        let cwd = temp.path().join("project");
299        fs::create_dir_all(&cwd).unwrap();
300        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
301        env_guard.set_var("MC_HOME", temp.path().join("global"));
302        cwd_guard.set_current_dir(&cwd).unwrap();
303
304        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
305        let paths = McPaths::resolve().unwrap();
306
307        assert_eq!(paths.local_settings_file, None);
308        assert_eq!(paths.project_settings_file, expected);
309        cwd_guard.restore().unwrap();
310    }
311
312    #[test]
313    fn resolved_project_settings_target_is_stable_after_cwd_changes() {
314        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
315        let env_guard = crate::test_support::env::env_lock();
316        let temp = TempDir::new().unwrap();
317        let first = temp.path().join("first");
318        let second = temp.path().join("second");
319        fs::create_dir_all(&first).unwrap();
320        fs::create_dir_all(&second).unwrap();
321        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
322        env_guard.set_var("MC_HOME", temp.path().join("global"));
323        cwd_guard.set_current_dir(&first).unwrap();
324        let expected = env::current_dir().unwrap().join(".magi-code/settings.json");
325        let paths = McPaths::resolve().unwrap();
326        cwd_guard.set_current_dir(&second).unwrap();
327
328        assert_eq!(paths.project_settings_file, expected);
329        cwd_guard.restore().unwrap();
330    }
331
332    #[test]
333    fn resolve_does_not_search_parent_for_local_settings_file() {
334        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
335        let env_guard = crate::test_support::env::env_lock();
336        let temp = TempDir::new().unwrap();
337        let parent = temp.path().join("parent");
338        let child = parent.join("child");
339        fs::create_dir_all(parent.join(".magi-code")).unwrap();
340        fs::create_dir_all(&child).unwrap();
341        fs::write(parent.join(".magi-code").join("settings.json"), "{}").unwrap();
342        let mut cwd_guard = crate::test_support::env::CurrentDirGuard::capture();
343        env_guard.set_var("MC_HOME", temp.path().join("global"));
344        cwd_guard.set_current_dir(&child).unwrap();
345
346        let paths = McPaths::resolve().unwrap();
347
348        assert_eq!(paths.local_settings_file, None);
349        cwd_guard.restore().unwrap();
350    }
351
352    #[test]
353    fn default_root_with_migration_returns_new_default_without_legacy() {
354        let temp = TempDir::new().unwrap();
355
356        let root = default_root_with_migration(temp.path()).unwrap();
357
358        assert_eq!(root, temp.path().join(".magi-code"));
359        assert!(!root.exists());
360        assert!(!temp.path().join(".mc").exists());
361    }
362
363    #[test]
364    fn default_root_with_migration_migrates_legacy_when_new_root_missing() {
365        let temp = TempDir::new().unwrap();
366        let legacy = temp.path().join(".mc");
367        fs::create_dir_all(&legacy).unwrap();
368        fs::write(legacy.join("settings.json"), "settings").unwrap();
369        fs::write(legacy.join("auth.json"), "auth").unwrap();
370
371        let root = default_root_with_migration(temp.path()).unwrap();
372
373        assert_eq!(root, temp.path().join(".magi-code"));
374        assert_eq!(
375            fs::read_to_string(root.join("settings.json")).unwrap(),
376            "settings"
377        );
378        assert_eq!(fs::read_to_string(root.join("auth.json")).unwrap(), "auth");
379        assert!(!legacy.exists());
380    }
381
382    #[test]
383    fn default_root_with_migration_skips_migration_when_new_root_exists() {
384        let temp = TempDir::new().unwrap();
385        let legacy = temp.path().join(".mc");
386        let new = temp.path().join(".magi-code");
387        fs::create_dir_all(&legacy).unwrap();
388        fs::create_dir_all(&new).unwrap();
389        fs::write(legacy.join("settings.json"), "legacy").unwrap();
390        fs::write(new.join("settings.json"), "new").unwrap();
391
392        let root = default_root_with_migration(temp.path()).unwrap();
393
394        assert_eq!(root, new);
395        assert_eq!(
396            fs::read_to_string(root.join("settings.json")).unwrap(),
397            "new"
398        );
399        assert_eq!(
400            fs::read_to_string(legacy.join("settings.json")).unwrap(),
401            "legacy"
402        );
403    }
404
405    #[test]
406    fn migrate_treats_existing_new_root_without_legacy_as_already_done() {
407        let temp = TempDir::new().unwrap();
408        let legacy = temp.path().join(".mc");
409        let new = temp.path().join(".magi-code");
410        fs::create_dir_all(&new).unwrap();
411        fs::write(new.join("settings.json"), "new").unwrap();
412
413        migrate_legacy_default_root(&legacy, &new).unwrap();
414
415        assert_eq!(
416            fs::read_to_string(new.join("settings.json")).unwrap(),
417            "new"
418        );
419        assert!(!legacy.exists());
420    }
421
422    #[test]
423    fn resolve_respects_mc_home_without_migration() {
424        let _mc_home = EnvVarSnapshot::capture("MC_HOME");
425        let env_guard = crate::test_support::env::env_lock();
426        let temp = TempDir::new().unwrap();
427        let legacy = temp.path().join(".mc");
428        let override_root = temp.path().join("override");
429        fs::create_dir_all(&legacy).unwrap();
430        fs::write(legacy.join("settings.json"), "legacy").unwrap();
431        env_guard.set_var("MC_HOME", &override_root);
432
433        let paths = McPaths::resolve().unwrap();
434
435        assert_eq!(paths.root, override_root);
436        assert!(legacy.exists());
437        assert!(!temp.path().join(".magi-code").exists());
438    }
439
440    #[test]
441    fn copy_fallback_uses_temp_and_cleans_up_on_success() {
442        let temp = TempDir::new().unwrap();
443        let legacy = temp.path().join(".mc");
444        let new = temp.path().join(".magi-code");
445        let nested_dir = legacy.join("subdir").join("nested");
446        fs::create_dir_all(&nested_dir).unwrap();
447        fs::write(legacy.join("settings.json"), "settings").unwrap();
448        fs::write(nested_dir.join("sentinel.txt"), "sentinel").unwrap();
449
450        copy_legacy_root_via_temp(&legacy, &new).unwrap();
451
452        assert_eq!(
453            fs::read_to_string(new.join("settings.json")).unwrap(),
454            "settings"
455        );
456        assert_eq!(
457            fs::read_to_string(new.join("subdir/nested/sentinel.txt")).unwrap(),
458            "sentinel"
459        );
460        assert!(
461            !temp
462                .path()
463                .join(format!(".magi-code.tmp-{}", std::process::id()))
464                .exists()
465        );
466        assert!(!legacy.exists());
467    }
468
469    #[test]
470    fn copy_fallback_failure_leaves_legacy_and_no_partial_new_root() {
471        let temp = TempDir::new().unwrap();
472        let legacy = temp.path().join(".mc");
473        let blocked_parent = temp.path().join("blocked-parent");
474        let new = blocked_parent.join(".magi-code");
475        fs::create_dir_all(&legacy).unwrap();
476        fs::write(legacy.join("settings.json"), "legacy").unwrap();
477        fs::write(&blocked_parent, "not a directory").unwrap();
478
479        let error = copy_legacy_root_via_temp(&legacy, &new).unwrap_err();
480
481        assert!(
482            error
483                .to_string()
484                .contains("failed to copy legacy config root")
485        );
486        assert!(legacy.exists());
487        assert!(!new.exists());
488        assert!(
489            !blocked_parent
490                .join(format!(".magi-code.tmp-{}", std::process::id()))
491                .exists()
492        );
493    }
494}