Skip to main content

mag/
app_paths.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, anyhow};
4const APP_DIR: &str = ".mag";
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct AppPaths {
8    pub home_dir: PathBuf,
9    pub data_root: PathBuf,
10    pub database_path: PathBuf,
11    pub model_root: PathBuf,
12    pub benchmark_root: PathBuf,
13}
14
15/// Returns the XDG config home directory, respecting `XDG_CONFIG_HOME` if set
16/// and absolute, otherwise falling back to `$HOME/.config`.
17#[allow(dead_code)] // used by setup and tool_detection; not reachable from the binary target
18pub fn xdg_config_home(home: &Path) -> PathBuf {
19    std::env::var_os("XDG_CONFIG_HOME")
20        .map(PathBuf::from)
21        .filter(|p| p.is_absolute())
22        .unwrap_or_else(|| home.join(".config"))
23}
24
25pub fn home_dir() -> Result<PathBuf> {
26    std::env::var_os("HOME")
27        .or_else(|| std::env::var_os("USERPROFILE"))
28        .map(PathBuf::from)
29        .ok_or_else(|| anyhow!("neither HOME nor USERPROFILE is set"))
30}
31
32/// Resolves the MAG data root directory.
33///
34/// If `MAG_DATA_ROOT` is set, it must be an absolute path; a relative path is
35/// rejected with an error. When the variable is unset the default `$HOME/.mag`
36/// is used.
37pub fn resolve_data_root(home: &Path) -> Result<PathBuf> {
38    match std::env::var_os("MAG_DATA_ROOT") {
39        Some(val) => {
40            if val.is_empty() {
41                return Err(anyhow!(
42                    "MAG_DATA_ROOT is set but empty; unset it or provide an absolute path"
43                ));
44            }
45            let path = PathBuf::from(val);
46            if path.is_absolute() {
47                Ok(path)
48            } else {
49                Err(anyhow!(
50                    "MAG_DATA_ROOT must be an absolute path, got: {}",
51                    path.display()
52                ))
53            }
54        }
55        None => Ok(home.join(APP_DIR)),
56    }
57}
58
59pub fn resolve_app_paths() -> Result<AppPaths> {
60    let home = home_dir()?;
61    let data_root = resolve_data_root(&home)?;
62    Ok(app_paths_for(home, data_root))
63}
64
65fn app_paths_for(home: PathBuf, data_root: PathBuf) -> AppPaths {
66    AppPaths {
67        home_dir: home,
68        database_path: data_root.join("memory.db"),
69        model_root: data_root.join("models"),
70        benchmark_root: data_root.join("benchmarks"),
71        data_root,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn uses_mag_root() {
81        let home = std::env::temp_dir().join(format!("mag-paths-{}", uuid::Uuid::new_v4()));
82        let data_root = home.join(APP_DIR);
83        let paths = app_paths_for(home.clone(), data_root);
84        assert_eq!(paths.data_root, home.join(APP_DIR));
85        assert_eq!(paths.database_path, home.join(APP_DIR).join("memory.db"));
86        assert_eq!(paths.model_root, home.join(APP_DIR).join("models"));
87        assert_eq!(paths.benchmark_root, home.join(APP_DIR).join("benchmarks"));
88    }
89
90    #[test]
91    fn mag_data_root_override_absolute_path() {
92        let override_dir =
93            std::env::temp_dir().join(format!("mag-override-{}", uuid::Uuid::new_v4()));
94        // Serialize with all other tests that mutate env vars (HOME, XDG_CONFIG_HOME, MAG_DATA_ROOT).
95        let _guard = crate::test_helpers::HOME_MUTEX
96            .lock()
97            .unwrap_or_else(|e| e.into_inner());
98        let prev = std::env::var_os("MAG_DATA_ROOT");
99        // SAFETY: Serialized by HOME_MUTEX; no other test mutates MAG_DATA_ROOT concurrently.
100        unsafe { std::env::set_var("MAG_DATA_ROOT", &override_dir) };
101        let home = PathBuf::from("/home/testuser");
102        let result = resolve_data_root(&home);
103        // Restore
104        unsafe {
105            match prev {
106                Some(v) => std::env::set_var("MAG_DATA_ROOT", v),
107                None => std::env::remove_var("MAG_DATA_ROOT"),
108            }
109        }
110        let data_root = result.expect("absolute MAG_DATA_ROOT should be accepted");
111        assert_eq!(data_root, override_dir);
112    }
113
114    #[test]
115    fn mag_data_root_fallback_to_home_dot_mag() {
116        let _guard = crate::test_helpers::HOME_MUTEX
117            .lock()
118            .unwrap_or_else(|e| e.into_inner());
119        let prev = std::env::var_os("MAG_DATA_ROOT");
120        // SAFETY: Serialized by HOME_MUTEX; no other test mutates MAG_DATA_ROOT concurrently.
121        unsafe { std::env::remove_var("MAG_DATA_ROOT") };
122        let home = PathBuf::from("/home/testuser");
123        let result = resolve_data_root(&home);
124        // Restore
125        unsafe {
126            if let Some(v) = prev {
127                std::env::set_var("MAG_DATA_ROOT", v);
128            }
129        }
130        assert_eq!(result.unwrap(), PathBuf::from("/home/testuser/.mag"));
131    }
132
133    #[test]
134    fn mag_data_root_rejects_empty_string() {
135        let _guard = crate::test_helpers::HOME_MUTEX
136            .lock()
137            .unwrap_or_else(|e| e.into_inner());
138        let prev = std::env::var_os("MAG_DATA_ROOT");
139        // SAFETY: Serialized by HOME_MUTEX; no other test mutates MAG_DATA_ROOT concurrently.
140        unsafe { std::env::set_var("MAG_DATA_ROOT", "") };
141        let home = PathBuf::from("/home/testuser");
142        let result = resolve_data_root(&home);
143        // Restore
144        unsafe {
145            match prev {
146                Some(v) => std::env::set_var("MAG_DATA_ROOT", v),
147                None => std::env::remove_var("MAG_DATA_ROOT"),
148            }
149        }
150        assert!(result.is_err(), "empty MAG_DATA_ROOT should be rejected");
151        let err = result.unwrap_err().to_string();
152        assert!(err.contains("empty"), "error should mention 'empty'");
153    }
154
155    #[test]
156    fn mag_data_root_rejects_relative_path() {
157        let _guard = crate::test_helpers::HOME_MUTEX
158            .lock()
159            .unwrap_or_else(|e| e.into_inner());
160        let prev = std::env::var_os("MAG_DATA_ROOT");
161        // SAFETY: Serialized by HOME_MUTEX; no other test mutates MAG_DATA_ROOT concurrently.
162        unsafe { std::env::set_var("MAG_DATA_ROOT", "relative/path") };
163        let home = PathBuf::from("/home/testuser");
164        let result = resolve_data_root(&home);
165        // Restore
166        unsafe {
167            match prev {
168                Some(v) => std::env::set_var("MAG_DATA_ROOT", v),
169                None => std::env::remove_var("MAG_DATA_ROOT"),
170            }
171        }
172        assert!(result.is_err(), "relative MAG_DATA_ROOT should be rejected");
173        let err = result.unwrap_err().to_string();
174        assert!(err.contains("absolute"), "error should mention 'absolute'");
175    }
176}