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("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
176fn 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(()), 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(()), 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}