1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::session_meta::fingerprint_from_root;
7
8pub fn data_dir() -> Result<PathBuf> {
13 #[cfg(debug_assertions)]
14 if let Ok(dir) = std::env::var("ATMAN_TEST_DATA_DIR") {
15 let p = PathBuf::from(dir);
16 std::fs::create_dir_all(&p).ok();
17 return Ok(p);
18 }
19 resolve_data_dir(EnvOs::current())
20}
21
22pub fn config_dir() -> Result<PathBuf> {
23 #[cfg(debug_assertions)]
24 if let Ok(dir) = std::env::var("ATMAN_TEST_CONFIG_DIR") {
25 let p = PathBuf::from(dir);
26 std::fs::create_dir_all(&p).ok();
27 return Ok(p);
28 }
29 resolve_config_dir(EnvOs::current())
30}
31
32struct EnvOs {
35 atman_config_dir: Option<String>,
36 atman_data_dir: Option<String>,
37 xdg_config_home: Option<String>,
38 xdg_data_home: Option<String>,
39 home: Option<String>,
40 appdata: Option<String>,
41 local_appdata: Option<String>,
42 target_os: TargetOs,
43}
44
45#[allow(dead_code)]
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum TargetOs {
51 Macos,
52 Linux,
53 Windows,
54}
55
56impl EnvOs {
57 fn current() -> Self {
58 Self {
59 atman_config_dir: std::env::var("ATMAN_CONFIG_DIR").ok(),
60 atman_data_dir: std::env::var("ATMAN_DATA_DIR").ok(),
61 xdg_config_home: std::env::var("XDG_CONFIG_HOME").ok(),
62 xdg_data_home: std::env::var("XDG_DATA_HOME").ok(),
63 home: std::env::var("HOME").ok(),
64 appdata: std::env::var("APPDATA").ok(),
65 local_appdata: std::env::var("LOCALAPPDATA").ok(),
66 target_os: TargetOs::current(),
67 }
68 }
69}
70
71impl TargetOs {
72 fn current() -> Self {
73 #[cfg(target_os = "macos")]
74 {
75 Self::Macos
76 }
77 #[cfg(target_os = "windows")]
78 {
79 Self::Windows
80 }
81 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
82 {
83 Self::Linux
84 }
85 }
86}
87
88fn resolve_config_dir(env: EnvOs) -> Result<PathBuf> {
89 if let Some(p) = env.atman_config_dir {
90 return Ok(PathBuf::from(p));
91 }
92 if let Some(x) = env.xdg_config_home {
93 return Ok(PathBuf::from(x).join("atman"));
94 }
95 match env.target_os {
96 TargetOs::Windows => {
97 let base = env
98 .appdata
99 .context("APPDATA not set; set ATMAN_CONFIG_DIR to override")?;
100 Ok(PathBuf::from(base).join("atman").join("config"))
101 }
102 TargetOs::Macos | TargetOs::Linux => {
103 let home = env
104 .home
105 .context("HOME not set; set ATMAN_CONFIG_DIR to override")?;
106 Ok(PathBuf::from(home).join(".config").join("atman"))
107 }
108 }
109}
110
111fn resolve_data_dir(env: EnvOs) -> Result<PathBuf> {
112 if let Some(p) = env.atman_data_dir {
113 return Ok(PathBuf::from(p));
114 }
115 if let Some(x) = env.xdg_data_home {
116 return Ok(PathBuf::from(x).join("atman"));
117 }
118 match env.target_os {
119 TargetOs::Windows => {
120 let base = env
121 .local_appdata
122 .context("LOCALAPPDATA not set; set ATMAN_DATA_DIR to override")?;
123 Ok(PathBuf::from(base).join("atman"))
124 }
125 TargetOs::Macos => {
126 let home = env
127 .home
128 .context("HOME not set; set ATMAN_DATA_DIR to override")?;
129 Ok(PathBuf::from(home)
130 .join("Library")
131 .join("Application Support")
132 .join("atman"))
133 }
134 TargetOs::Linux => {
135 let home = env
136 .home
137 .context("HOME not set; set ATMAN_DATA_DIR to override")?;
138 Ok(PathBuf::from(home)
139 .join(".local")
140 .join("share")
141 .join("atman"))
142 }
143 }
144}
145
146pub fn load_storage_config(project_root: Option<&Path>) -> StorageConfig {
147 crate::config_hub::ConfigHub::global()
148 .map(|hub| hub.storage_config(project_root))
149 .unwrap_or_default()
150}
151
152pub fn resolve_project_scope_with(
153 hub: &crate::config_hub::ConfigHub,
154 project_root: &Path,
155 data_dir: &Path,
156) -> Result<PathBuf> {
157 let cfg = hub.storage_config(Some(project_root));
158 resolve_project_storage_root(project_root, &cfg, data_dir)
159}
160
161pub fn resolve_project_scope_for(project_root: &Path) -> Result<PathBuf> {
162 let hub = crate::config_hub::ConfigHub::global()
163 .map_err(|error| anyhow::anyhow!("resolve config hub: {error}"))?;
164 let data = data_dir()?;
165 resolve_project_scope_with(&hub, project_root, &data)
166}
167
168pub fn resolve_current_project_scope() -> Result<PathBuf> {
169 let cwd = std::env::current_dir()?;
170 let project_root = crate::session_meta::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());
171 resolve_project_scope_for(&project_root)
172}
173
174#[derive(
176 Debug,
177 Clone,
178 Default,
179 Serialize,
180 Deserialize,
181 PartialEq,
182 Eq,
183 documented::Documented,
184 documented::DocumentedFields,
185)]
186pub struct StorageConfig {
187 #[serde(default)]
189 pub scope: Option<StorageScope>,
190}
191
192#[derive(
194 Debug,
195 Clone,
196 Copy,
197 Default,
198 Serialize,
199 Deserialize,
200 PartialEq,
201 Eq,
202 documented::DocumentedVariants,
203)]
204#[serde(rename_all = "lowercase")]
205pub enum StorageScope {
206 #[default]
207 Global,
209 Local,
211}
212
213impl StorageConfig {
214 pub fn load_from(path: &Path) -> Result<Self> {
215 let Ok(text) = std::fs::read_to_string(path) else {
216 return Ok(Self::default());
217 };
218 #[derive(Deserialize, Default)]
219 struct Wrapper {
220 #[serde(default)]
221 storage: StorageConfig,
222 }
223 let wrapper: Wrapper =
224 toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
225 Ok(wrapper.storage)
226 }
227
228 pub fn merge(base: Self, overlay: Self) -> Self {
229 Self {
230 scope: overlay.scope.or(base.scope),
231 }
232 }
233}
234
235pub fn resolve_project_storage_root(
236 project_root: &Path,
237 cfg: &StorageConfig,
238 data_dir: &Path,
239) -> Result<PathBuf> {
240 let scope = cfg.scope.unwrap_or_default();
241 let out = match scope {
242 StorageScope::Local => project_root.join(".atman"),
243 StorageScope::Global => {
244 let fp = fingerprint_from_root(project_root);
245 data_dir.join("projects").join(fp)
246 }
247 };
248 std::fs::create_dir_all(&out).with_context(|| format!("mkdir {}", out.display()))?;
249 Ok(out)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use tempfile::TempDir;
256
257 fn env_with(target: TargetOs) -> EnvOs {
258 EnvOs {
259 atman_config_dir: None,
260 atman_data_dir: None,
261 xdg_config_home: None,
262 xdg_data_home: None,
263 home: None,
264 appdata: None,
265 local_appdata: None,
266 target_os: target,
267 }
268 }
269
270 #[test]
271 fn scope_defaults_to_global() {
272 let cfg = StorageConfig::default();
273 assert_eq!(cfg.scope.unwrap_or_default(), StorageScope::Global);
274 }
275
276 #[test]
277 fn local_scope_resolves_to_project_dot_atman() {
278 let project = TempDir::new().unwrap();
279 let data = TempDir::new().unwrap();
280 let cfg = StorageConfig {
281 scope: Some(StorageScope::Local),
282 };
283 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
284 assert_eq!(out, project.path().join(".atman"));
285 assert!(out.is_dir());
286 }
287
288 #[test]
289 fn global_scope_resolves_to_data_dir_projects_fingerprint() {
290 let project = TempDir::new().unwrap();
291 let data = TempDir::new().unwrap();
292 let cfg = StorageConfig {
293 scope: Some(StorageScope::Global),
294 };
295 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
296 let fp = fingerprint_from_root(project.path());
297 assert_eq!(out, data.path().join("projects").join(fp));
298 assert!(out.is_dir());
299 }
300
301 #[test]
302 fn missing_scope_falls_back_to_global() {
303 let project = TempDir::new().unwrap();
304 let data = TempDir::new().unwrap();
305 let cfg = StorageConfig::default();
306 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
307 assert!(out.starts_with(data.path().join("projects")));
308 }
309
310 #[test]
311 fn explicit_hub_and_data_dir_control_scope_resolution() {
312 let config = TempDir::new().unwrap();
313 let project = TempDir::new().unwrap();
314 let data = TempDir::new().unwrap();
315 std::fs::write(
316 config.path().join("config.toml"),
317 "[storage]\nscope = \"global\"\n",
318 )
319 .unwrap();
320 std::fs::create_dir(project.path().join(".atman")).unwrap();
321 std::fs::write(
322 project.path().join(".atman/config.toml"),
323 "[storage]\nscope = \"local\"\n",
324 )
325 .unwrap();
326 let hub = crate::config_hub::ConfigHub::from_config_dir(config.path());
327
328 let local = resolve_project_scope_with(&hub, project.path(), data.path()).unwrap();
329 assert_eq!(local, project.path().join(".atman"));
330
331 std::fs::remove_file(project.path().join(".atman/config.toml")).unwrap();
332 let global = resolve_project_scope_with(&hub, project.path(), data.path()).unwrap();
333 assert_eq!(
334 global,
335 data.path()
336 .join("projects")
337 .join(fingerprint_from_root(project.path()))
338 );
339 }
340
341 #[test]
342 fn load_from_missing_file_returns_default() {
343 let tmp = TempDir::new().unwrap();
344 let cfg = StorageConfig::load_from(&tmp.path().join("nonexistent.toml")).unwrap();
345 assert_eq!(cfg, StorageConfig::default());
346 }
347
348 #[test]
349 fn load_from_parses_scope() {
350 let tmp = TempDir::new().unwrap();
351 let path = tmp.path().join("config.toml");
352 std::fs::write(&path, "[storage]\nscope = \"local\"\n").unwrap();
353 let cfg = StorageConfig::load_from(&path).unwrap();
354 assert_eq!(cfg.scope, Some(StorageScope::Local));
355 }
356
357 #[test]
358 fn load_from_tolerates_unrelated_sections() {
359 let tmp = TempDir::new().unwrap();
360 let path = tmp.path().join("config.toml");
361 std::fs::write(
362 &path,
363 "[compaction]\nreview = \"manual-only\"\n\n[storage]\nscope = \"global\"\n",
364 )
365 .unwrap();
366 let cfg = StorageConfig::load_from(&path).unwrap();
367 assert_eq!(cfg.scope, Some(StorageScope::Global));
368 }
369
370 #[test]
371 fn merge_overlay_overrides_base() {
372 let base = StorageConfig {
373 scope: Some(StorageScope::Global),
374 };
375 let overlay = StorageConfig {
376 scope: Some(StorageScope::Local),
377 };
378 let merged = StorageConfig::merge(base, overlay);
379 assert_eq!(merged.scope, Some(StorageScope::Local));
380 }
381
382 #[test]
383 fn merge_empty_overlay_keeps_base() {
384 let base = StorageConfig {
385 scope: Some(StorageScope::Local),
386 };
387 let overlay = StorageConfig::default();
388 let merged = StorageConfig::merge(base, overlay);
389 assert_eq!(merged.scope, Some(StorageScope::Local));
390 }
391
392 #[test]
393 fn atman_config_dir_env_wins() {
394 let mut env = env_with(TargetOs::Linux);
395 env.atman_config_dir = Some("/somewhere/else".into());
396 env.xdg_config_home = Some("/xdg".into());
397 env.home = Some("/home/u".into());
398 assert_eq!(
399 resolve_config_dir(env).unwrap(),
400 PathBuf::from("/somewhere/else")
401 );
402 }
403
404 #[test]
405 fn xdg_config_home_beats_home_fallback() {
406 let mut env = env_with(TargetOs::Linux);
407 env.xdg_config_home = Some("/xdg".into());
408 env.home = Some("/home/u".into());
409 assert_eq!(
410 resolve_config_dir(env).unwrap(),
411 PathBuf::from("/xdg/atman")
412 );
413 }
414
415 #[test]
416 fn macos_config_dir_is_dot_config_not_library() {
417 let mut env = env_with(TargetOs::Macos);
418 env.home = Some("/Users/u".into());
419 assert_eq!(
420 resolve_config_dir(env).unwrap(),
421 PathBuf::from("/Users/u/.config/atman"),
422 );
423 }
424
425 #[test]
426 fn linux_config_dir_defaults_to_dot_config() {
427 let mut env = env_with(TargetOs::Linux);
428 env.home = Some("/home/u".into());
429 assert_eq!(
430 resolve_config_dir(env).unwrap(),
431 PathBuf::from("/home/u/.config/atman"),
432 );
433 }
434
435 #[test]
436 fn windows_config_dir_uses_appdata_config_subdir() {
437 let mut env = env_with(TargetOs::Windows);
438 env.appdata = Some(r"C:\Users\u\AppData\Roaming".into());
439 assert_eq!(
440 resolve_config_dir(env).unwrap(),
441 PathBuf::from(r"C:\Users\u\AppData\Roaming")
442 .join("atman")
443 .join("config"),
444 );
445 }
446
447 #[test]
448 fn atman_data_dir_env_wins() {
449 let mut env = env_with(TargetOs::Macos);
450 env.atman_data_dir = Some("/data/here".into());
451 env.xdg_data_home = Some("/xdg".into());
452 env.home = Some("/Users/u".into());
453 assert_eq!(resolve_data_dir(env).unwrap(), PathBuf::from("/data/here"));
454 }
455
456 #[test]
457 fn xdg_data_home_beats_platform_fallback() {
458 let mut env = env_with(TargetOs::Macos);
459 env.xdg_data_home = Some("/xdg".into());
460 env.home = Some("/Users/u".into());
461 assert_eq!(resolve_data_dir(env).unwrap(), PathBuf::from("/xdg/atman"));
462 }
463
464 #[test]
465 fn macos_data_dir_is_application_support() {
466 let mut env = env_with(TargetOs::Macos);
467 env.home = Some("/Users/u".into());
468 assert_eq!(
469 resolve_data_dir(env).unwrap(),
470 PathBuf::from("/Users/u/Library/Application Support/atman"),
471 );
472 }
473
474 #[test]
475 fn linux_data_dir_defaults_to_local_share() {
476 let mut env = env_with(TargetOs::Linux);
477 env.home = Some("/home/u".into());
478 assert_eq!(
479 resolve_data_dir(env).unwrap(),
480 PathBuf::from("/home/u/.local/share/atman"),
481 );
482 }
483
484 #[test]
485 fn windows_data_dir_uses_local_appdata() {
486 let mut env = env_with(TargetOs::Windows);
487 env.local_appdata = Some(r"C:\Users\u\AppData\Local".into());
488 assert_eq!(
489 resolve_data_dir(env).unwrap(),
490 PathBuf::from(r"C:\Users\u\AppData\Local").join("atman"),
491 );
492 }
493
494 #[test]
495 fn missing_home_on_unix_errors_out() {
496 let env = env_with(TargetOs::Linux);
497 let err = resolve_config_dir(env).unwrap_err();
498 assert!(err.to_string().contains("HOME"));
499 }
500
501 #[test]
502 fn missing_appdata_on_windows_errors_out() {
503 let env = env_with(TargetOs::Windows);
504 let err = resolve_config_dir(env).unwrap_err();
505 assert!(err.to_string().contains("APPDATA"));
506 }
507}