Skip to main content

miku/
paths.rs

1use serde::Deserialize;
2use std::env;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Deserialize, Default)]
6pub struct MikuConfig {
7    pub data_dir: Option<PathBuf>,
8    pub config_dir: Option<PathBuf>,
9    pub topics_dir: Option<PathBuf>,
10    pub observation_limit: Option<usize>,
11}
12
13pub struct MikuPaths {
14    pub data_dir: PathBuf,
15    pub config_dir: PathBuf,
16    pub topics_dir: PathBuf,
17    pub cache_dir: PathBuf,
18    pub observation_limit: Option<usize>,
19}
20
21pub const ENV_MIKU_HOME: &str = "MIKU_HOME";
22
23/// XDG base directories for the user-level Miku workspace. A single root
24/// (`$XDG_DATA_HOME/miku`, honoring the env var on every platform — macOS
25/// included, where the `directories` crate would prefer `~/Library/...`) holds
26/// the same `{data,config,topics,caches}` subtree as a project-local
27/// `.miku/`, so the two layouts mirror each other.
28pub struct XdgDirs {
29    pub data_dir: PathBuf,
30    pub config_dir: PathBuf,
31    pub topics_dir: PathBuf,
32    pub cache_dir: PathBuf,
33}
34
35/// Resolve `$XDG_DATA_HOME` (or its conventional `~/.local/share` fallback) on
36/// any platform. Returns `None` only when both the env var and `$HOME` are
37/// unset (e.g. a stripped CI environment).
38fn xdg_data_home() -> Option<PathBuf> {
39    if let Ok(val) = env::var("XDG_DATA_HOME")
40        && !val.trim().is_empty()
41    {
42        return Some(PathBuf::from(val.trim()));
43    }
44    env::var_os("HOME")
45        .filter(|h| !h.is_empty())
46        .map(|h| PathBuf::from(h).join(".local/share"))
47}
48
49/// XDG paths for the user-level Miku workspace, rooted at a single
50/// `$XDG_DATA_HOME/miku/` directory. `None` when `$XDG_DATA_HOME` and
51/// `$HOME` are both unset.
52pub fn xdg_dirs() -> Option<XdgDirs> {
53    let root = xdg_data_home()?.join("miku");
54    Some(XdgDirs {
55        data_dir: root.join("data"),
56        config_dir: root.join("config"),
57        topics_dir: root.join("topics"),
58        cache_dir: root.join("caches"),
59    })
60}
61
62impl MikuPaths {
63    pub fn resolve() -> Self {
64        let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
65        Self::resolve_from(&cwd)
66    }
67
68    /// Resolution order:
69    /// 1. `MIKU_HOME` env var (forces a unified root, bypasses discovery).
70    /// 2. Nearest `miku.toml` walking up from `start` — relative paths in
71    ///    the config are anchored to the config file's directory, not cwd.
72    /// 3. Nearest `.miku/` directory walking up from `start`.
73    /// 4. XDG fallback.
74    pub fn resolve_from(start: &Path) -> Self {
75        if let Ok(home) = env::var(ENV_MIKU_HOME) {
76            let root = PathBuf::from(home);
77            return Self {
78                cache_dir: root.join("caches"),
79                data_dir: root.clone(),
80                config_dir: root.clone(),
81                topics_dir: root,
82                observation_limit: None,
83            };
84        }
85
86        if let Some(config_path) = find_upwards(start, "miku.toml", false)
87            && let Ok(content) = std::fs::read_to_string(&config_path)
88            && let Ok(conf) = toml::from_str::<MikuConfig>(&content)
89        {
90            let anchor = config_path.parent().unwrap_or(Path::new("."));
91            return Self::from_config(conf, anchor);
92        }
93
94        if let Some(local_root) = find_upwards(start, ".miku", true) {
95            return Self {
96                data_dir: local_root.join("data"),
97                config_dir: local_root.join("config"),
98                topics_dir: local_root.join("topics"),
99                cache_dir: local_root.join("caches"),
100                observation_limit: None,
101            };
102        }
103
104        match xdg_dirs() {
105            Some(x) => Self {
106                data_dir: x.data_dir,
107                config_dir: x.config_dir,
108                topics_dir: x.topics_dir,
109                cache_dir: x.cache_dir,
110                observation_limit: None,
111            },
112            None => Self {
113                data_dir: PathBuf::from(".miku/data"),
114                config_dir: PathBuf::from(".miku/config"),
115                topics_dir: PathBuf::from(".miku/topics"),
116                cache_dir: PathBuf::from(".miku/caches"),
117                observation_limit: None,
118            },
119        }
120    }
121
122    fn from_config(conf: MikuConfig, anchor: &Path) -> Self {
123        let resolve = |p: Option<PathBuf>, default: &str| -> PathBuf {
124            let raw = p.unwrap_or_else(|| PathBuf::from(default));
125            if raw.is_absolute() {
126                raw
127            } else {
128                anchor.join(raw)
129            }
130        };
131        let data_dir = resolve(conf.data_dir, ".miku/data");
132        let cache_dir = data_dir
133            .parent()
134            .map(|p| p.join("caches"))
135            .unwrap_or_else(|| PathBuf::from(".miku/caches"));
136        Self {
137            config_dir: resolve(conf.config_dir, ".miku/config"),
138            topics_dir: resolve(conf.topics_dir, ".miku/topics"),
139            data_dir,
140            cache_dir,
141            observation_limit: conf.observation_limit,
142        }
143    }
144
145    pub fn db_path(&self) -> PathBuf {
146        self.data_dir.join("miku.db")
147    }
148
149    pub fn caches_dir(&self) -> PathBuf {
150        self.cache_dir.clone()
151    }
152}
153
154/// Walk up from `start` looking for a file (or directory if `is_dir`) named `name`.
155/// Returns the full path to the match, or `None` if not found before the root.
156fn find_upwards(start: &Path, name: &str, is_dir: bool) -> Option<PathBuf> {
157    let mut current = start.to_path_buf();
158    loop {
159        let target = current.join(name);
160        if (is_dir && target.is_dir()) || (!is_dir && target.is_file()) {
161            return Some(target);
162        }
163        if !current.pop() {
164            break;
165        }
166    }
167    None
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use tempfile::tempdir;
174
175    #[test]
176    fn toml_relative_paths_anchor_to_config_dir_not_cwd() {
177        let dir = tempdir().unwrap();
178        let cfg = dir.path().join("miku.toml");
179        std::fs::write(
180            &cfg,
181            r#"
182            data_dir = "custom-data"
183            "#,
184        )
185        .unwrap();
186
187        let paths = MikuPaths::resolve_from(dir.path());
188        assert_eq!(paths.data_dir, dir.path().join("custom-data"));
189    }
190
191    #[test]
192    fn absolute_paths_in_toml_preserved() {
193        let dir = tempdir().unwrap();
194        let cfg = dir.path().join("miku.toml");
195        let abs_path = if cfg!(windows) {
196            "C:\\data"
197        } else {
198            "/tmp/data"
199        };
200        std::fs::write(
201            &cfg,
202            format!(
203                r#"
204            data_dir = "{}"
205            "#,
206                abs_path.replace('\\', "\\\\")
207            ),
208        )
209        .unwrap();
210
211        let paths = MikuPaths::resolve_from(dir.path());
212        assert_eq!(paths.data_dir, PathBuf::from(abs_path));
213    }
214
215    #[test]
216    fn dot_miku_dir_discovered_walking_up() {
217        let dir = tempdir().unwrap();
218        let local_root = dir.path().join(".miku");
219        std::fs::create_dir_all(local_root.join("data")).unwrap();
220
221        let sub = dir.path().join("a/b/c");
222        std::fs::create_dir_all(&sub).unwrap();
223
224        let paths = MikuPaths::resolve_from(&sub);
225        assert_eq!(paths.data_dir, local_root.join("data"));
226    }
227
228    #[test]
229    fn miku_home_overrides_discovery() {
230        let dir = tempdir().unwrap();
231        let home = dir.path().join("fake-home");
232        std::fs::create_dir_all(&home).unwrap();
233
234        let prev = env::var(ENV_MIKU_HOME).ok();
235        unsafe { env::set_var(ENV_MIKU_HOME, &home) };
236
237        let paths = MikuPaths::resolve();
238
239        match prev {
240            Some(v) => unsafe { env::set_var(ENV_MIKU_HOME, v) },
241            None => unsafe { env::remove_var(ENV_MIKU_HOME) },
242        }
243
244        assert_eq!(paths.data_dir, home);
245    }
246
247    #[test]
248    fn xdg_data_home_drives_unified_root_on_all_platforms() {
249        let dir = tempdir().unwrap();
250        let data = dir.path().join("xdg-data");
251
252        let saved: Vec<(&str, Option<String>)> = [ENV_MIKU_HOME, "XDG_DATA_HOME"]
253            .iter()
254            .map(|k| (*k, env::var(*k).ok()))
255            .collect();
256
257        unsafe {
258            env::remove_var(ENV_MIKU_HOME);
259            env::set_var("XDG_DATA_HOME", &data);
260        }
261
262        // Resolve from a dir with no miku.toml / .miku upward.
263        let probe = dir.path().join("probe");
264        std::fs::create_dir_all(&probe).unwrap();
265        let paths = MikuPaths::resolve_from(&probe);
266
267        for (k, v) in saved {
268            match v {
269                Some(val) => unsafe { env::set_var(k, val) },
270                None => unsafe { env::remove_var(k) },
271            }
272        }
273
274        // Single root mirrors the project-local `.miku/{...}` layout.
275        let root = data.join("miku");
276        assert_eq!(paths.data_dir, root.join("data"));
277        assert_eq!(paths.config_dir, root.join("config"));
278        assert_eq!(paths.topics_dir, root.join("topics"));
279        assert_eq!(paths.caches_dir(), root.join("caches"));
280    }
281}