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, 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    /// Resolves the storage root without migration or runtime directory creation.
42    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    /// Constructs paths from an explicit storage root and project directory.
49    ///
50    /// The project settings target is always `<project_dir>/.magi-code/settings.json`.
51    /// `local_settings_file` is populated only when that target exists when this method runs.
52    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    /// Test fixture root: uses the current process directory as the project directory and never
61    /// detects a local settings file, so tests stay hermetic.
62    #[cfg(test)]
63    pub(crate) fn from_root(root: PathBuf) -> Self {
64        let project_settings_file = project_settings_file_for(&current_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
174// Temporary compatibility for the primary-agents -> agents transition (introduced in 0.79).
175// Keep for the next few releases, then remove this helper and its runtime call.
176fn 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(()), // Never follow a legacy directory symlink.
182        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(()), // Preserve both paths, including dangling symlinks.
187        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}