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
23pub struct XdgDirs {
29 pub data_dir: PathBuf,
30 pub config_dir: PathBuf,
31 pub topics_dir: PathBuf,
32 pub cache_dir: PathBuf,
33}
34
35fn 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
49pub 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 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
154fn 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 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 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}