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 #[cfg(test)]
63 pub(crate) fn from_root(root: PathBuf) -> Self {
64 let project_settings_file = project_settings_file_for(¤t_project_dir());
65 Self::from_parts(root, project_settings_file, None)
66 }
67
68 fn from_parts(
69 root: PathBuf,
70 project_settings_file: PathBuf,
71 local_settings_file: Option<PathBuf>,
72 ) -> Self {
73 Self {
74 cache: root.join("cache"),
75 state: root.join("state"),
76 sessions: root.join("sessions"),
77 checkpoints: root.join("checkpoints"),
78 skills: root.join("skills"),
79 prompts: root.join("prompts"),
80 subagents: root.join("subagents"),
81 primary_agents: root.join("agents"),
82 user_agents: root.join("AGENTS.md"),
83 settings_file: root.join("settings.json"),
84 project_settings_file,
85 local_settings_file,
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 current_project_dir() -> PathBuf {
101 env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
102}
103
104fn project_settings_file_for(project_dir: &Path) -> PathBuf {
105 project_dir.join(".magi-code").join("settings.json")
106}
107
108fn resolve_storage_root(mode: RootResolutionMode) -> anyhow::Result<PathBuf> {
109 let root = match env::var_os(MC_HOME_ENV) {
110 Some(value) => resolve_explicit_mc_home(value, mode)?,
111 None => {
112 let home = dirs::home_dir().ok_or_else(|| {
113 anyhow::anyhow!("could not resolve home directory for ~/.magi-code")
114 })?;
115 match mode {
116 RootResolutionMode::Runtime => default_root_with_migration(&home)?,
117 RootResolutionMode::ReadOnly => home.join(".magi-code"),
118 }
119 }
120 };
121 validate_storage_root(&root, mode)?;
122 if matches!(mode, RootResolutionMode::Runtime) {
123 migrate_primary_agents_directory(&root)?;
124 }
125 Ok(root)
126}
127
128fn resolve_explicit_mc_home(
129 value: std::ffi::OsString,
130 mode: RootResolutionMode,
131) -> anyhow::Result<PathBuf> {
132 if value.is_empty() {
133 anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path, not empty");
134 }
135 let root = PathBuf::from(value);
136 if !root.is_absolute() {
137 match mode {
138 RootResolutionMode::Runtime => anyhow::bail!(
139 "{MC_HOME_ENV} must be an absolute directory path: {}",
140 root.display()
141 ),
142 RootResolutionMode::ReadOnly => {
143 anyhow::bail!("{MC_HOME_ENV} must be an absolute directory path");
144 }
145 }
146 }
147 Ok(root)
148}
149
150fn validate_storage_root(root: &Path, mode: RootResolutionMode) -> anyhow::Result<()> {
151 match mode {
152 RootResolutionMode::Runtime => {
153 if root.exists() && !root.is_dir() {
154 anyhow::bail!(
155 "{MC_HOME_ENV} must point to a directory, not a file: {}",
156 root.display()
157 );
158 }
159 Ok(())
160 }
161 RootResolutionMode::ReadOnly => validate_read_only_mc_home(root),
162 }
163}
164
165fn validate_read_only_mc_home(root: &Path) -> anyhow::Result<()> {
166 match fs::symlink_metadata(root) {
167 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
168 Ok(_) => anyhow::bail!("{MC_HOME_ENV} must point to a directory"),
169 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
170 Err(_) => anyhow::bail!("{MC_HOME_ENV} could not be inspected"),
171 }
172}
173
174fn migrate_primary_agents_directory(root: &Path) -> anyhow::Result<()> {
177 let legacy = root.join("primary-agents");
178 let destination = root.join("agents");
179 match fs::symlink_metadata(&legacy) {
180 Ok(metadata) if metadata.is_dir() => {}
181 Ok(_) => return Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
183 Err(error) => return Err(error).context("could not inspect primary-agents directory"),
184 }
185 match fs::symlink_metadata(&destination) {
186 Ok(_) => return Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
188 Err(error) => return Err(error).context("could not inspect agents directory"),
189 }
190 match fs::rename(&legacy, &destination) {
191 Ok(()) => Ok(()),
192 Err(_) if migration_already_completed(&legacy, &destination) => Ok(()),
193 Err(error) => Err(error).context("could not migrate primary-agents directory to agents"),
194 }
195}
196
197fn default_root_with_migration(home: &Path) -> anyhow::Result<PathBuf> {
198 let new_root = home.join(".magi-code");
199 let legacy_root = home.join(".mc");
200 if new_root.exists() || !legacy_root.is_dir() {
201 return Ok(new_root);
202 }
203 migrate_legacy_default_root(&legacy_root, &new_root)?;
204 Ok(new_root)
205}
206
207fn migrate_legacy_default_root(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
208 match fs::rename(legacy_root, new_root) {
209 Ok(()) => Ok(()),
210 Err(_) if migration_already_completed(legacy_root, new_root) => Ok(()),
211 Err(rename_error) => copy_legacy_root_via_temp(legacy_root, new_root).with_context(|| {
212 format!(
213 "failed to rename legacy config root {} to {}; fallback copy also failed after rename error: {rename_error}",
214 legacy_root.display(),
215 new_root.display()
216 )
217 }),
218 }
219}
220
221fn migration_already_completed(legacy_root: &Path, new_root: &Path) -> bool {
222 new_root.is_dir() && !legacy_root.exists()
223}
224
225fn copy_legacy_root_via_temp(legacy_root: &Path, new_root: &Path) -> anyhow::Result<()> {
226 let parent = new_root.parent().ok_or_else(|| {
227 anyhow::anyhow!(
228 "new config root has no parent directory: {}",
229 new_root.display()
230 )
231 })?;
232 let temp_root = parent.join(format!(".magi-code.tmp-{}", std::process::id()));
233
234 if temp_root.exists() {
235 fs::remove_dir_all(&temp_root).with_context(|| {
236 format!(
237 "failed to remove stale temp config dir: {}",
238 temp_root.display()
239 )
240 })?;
241 }
242
243 if let Err(error) = copy_dir_all(legacy_root, &temp_root) {
244 let _ = fs::remove_dir_all(&temp_root);
245 return Err(error).with_context(|| {
246 format!(
247 "failed to copy legacy config root {} to temp dir {}",
248 legacy_root.display(),
249 temp_root.display()
250 )
251 });
252 }
253
254 if new_root.exists() {
255 let _ = fs::remove_dir_all(&temp_root);
256 if migration_already_completed(legacy_root, new_root) {
257 return Ok(());
258 }
259 anyhow::bail!(
260 "new config root appeared during migration; refusing to overwrite: {}",
261 new_root.display()
262 );
263 }
264
265 if let Err(error) = fs::rename(&temp_root, new_root) {
266 let _ = fs::remove_dir_all(&temp_root);
267 return Err(error).with_context(|| {
268 format!(
269 "failed to finalize migrated config root from {} to {}",
270 temp_root.display(),
271 new_root.display()
272 )
273 });
274 }
275
276 fs::remove_dir_all(legacy_root).with_context(|| {
277 format!(
278 "migrated config root to {} but failed to remove legacy config root {}; credentials may be duplicated",
279 new_root.display(),
280 legacy_root.display()
281 )
282 })?;
283 Ok(())
284}
285
286fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
287 fs::create_dir_all(dst)?;
288 for entry in fs::read_dir(src)? {
289 let entry = entry?;
290 let file_type = entry.file_type()?;
291 let from = entry.path();
292 let to = dst.join(entry.file_name());
293 if file_type.is_dir() {
294 copy_dir_all(&from, &to)?;
295 } else {
296 fs::copy(&from, &to)?;
297 }
298 }
299 Ok(())
300}