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}