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 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 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 pub fn from_root(root: PathBuf) -> Self {
66 let project_settings_file = project_settings_file_for(¤t_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}