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 let global = config_dir()
148 .ok()
149 .map(|d| StorageConfig::load_from(&d.join("config.toml")).unwrap_or_default())
150 .unwrap_or_default();
151 let project = project_root
152 .map(|r| StorageConfig::load_from(&r.join(".atman/config.toml")).unwrap_or_default())
153 .unwrap_or_default();
154 StorageConfig::merge(global, project)
155}
156
157pub fn resolve_project_scope_for(project_root: &Path) -> Result<PathBuf> {
158 let cfg = load_storage_config(Some(project_root));
159 let data = data_dir()?;
160 resolve_project_storage_root(project_root, &cfg, &data)
161}
162
163pub fn resolve_current_project_scope() -> Result<PathBuf> {
164 let cwd = std::env::current_dir()?;
165 let project_root = crate::session_meta::find_project_root(&cwd).unwrap_or_else(|| cwd.clone());
166 resolve_project_scope_for(&project_root)
167}
168
169#[derive(
171 Debug,
172 Clone,
173 Default,
174 Serialize,
175 Deserialize,
176 PartialEq,
177 Eq,
178 documented::Documented,
179 documented::DocumentedFields,
180)]
181pub struct StorageConfig {
182 #[serde(default)]
184 pub scope: Option<StorageScope>,
185}
186
187#[derive(
189 Debug,
190 Clone,
191 Copy,
192 Default,
193 Serialize,
194 Deserialize,
195 PartialEq,
196 Eq,
197 documented::DocumentedVariants,
198)]
199#[serde(rename_all = "lowercase")]
200pub enum StorageScope {
201 #[default]
202 Global,
204 Local,
206}
207
208impl StorageConfig {
209 pub fn load_from(path: &Path) -> Result<Self> {
210 let Ok(text) = std::fs::read_to_string(path) else {
211 return Ok(Self::default());
212 };
213 #[derive(Deserialize, Default)]
214 struct Wrapper {
215 #[serde(default)]
216 storage: StorageConfig,
217 }
218 let wrapper: Wrapper =
219 toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
220 Ok(wrapper.storage)
221 }
222
223 pub fn merge(base: Self, overlay: Self) -> Self {
224 Self {
225 scope: overlay.scope.or(base.scope),
226 }
227 }
228}
229
230pub fn resolve_project_storage_root(
231 project_root: &Path,
232 cfg: &StorageConfig,
233 data_dir: &Path,
234) -> Result<PathBuf> {
235 let scope = cfg.scope.unwrap_or_default();
236 let out = match scope {
237 StorageScope::Local => project_root.join(".atman"),
238 StorageScope::Global => {
239 let fp = fingerprint_from_root(project_root);
240 data_dir.join("projects").join(fp)
241 }
242 };
243 std::fs::create_dir_all(&out).with_context(|| format!("mkdir {}", out.display()))?;
244 Ok(out)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use tempfile::TempDir;
251
252 fn env_with(target: TargetOs) -> EnvOs {
253 EnvOs {
254 atman_config_dir: None,
255 atman_data_dir: None,
256 xdg_config_home: None,
257 xdg_data_home: None,
258 home: None,
259 appdata: None,
260 local_appdata: None,
261 target_os: target,
262 }
263 }
264
265 #[test]
266 fn scope_defaults_to_global() {
267 let cfg = StorageConfig::default();
268 assert_eq!(cfg.scope.unwrap_or_default(), StorageScope::Global);
269 }
270
271 #[test]
272 fn local_scope_resolves_to_project_dot_atman() {
273 let project = TempDir::new().unwrap();
274 let data = TempDir::new().unwrap();
275 let cfg = StorageConfig {
276 scope: Some(StorageScope::Local),
277 };
278 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
279 assert_eq!(out, project.path().join(".atman"));
280 assert!(out.is_dir());
281 }
282
283 #[test]
284 fn global_scope_resolves_to_data_dir_projects_fingerprint() {
285 let project = TempDir::new().unwrap();
286 let data = TempDir::new().unwrap();
287 let cfg = StorageConfig {
288 scope: Some(StorageScope::Global),
289 };
290 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
291 let fp = fingerprint_from_root(project.path());
292 assert_eq!(out, data.path().join("projects").join(fp));
293 assert!(out.is_dir());
294 }
295
296 #[test]
297 fn missing_scope_falls_back_to_global() {
298 let project = TempDir::new().unwrap();
299 let data = TempDir::new().unwrap();
300 let cfg = StorageConfig::default();
301 let out = resolve_project_storage_root(project.path(), &cfg, data.path()).unwrap();
302 assert!(out.starts_with(data.path().join("projects")));
303 }
304
305 #[test]
306 fn load_from_missing_file_returns_default() {
307 let tmp = TempDir::new().unwrap();
308 let cfg = StorageConfig::load_from(&tmp.path().join("nonexistent.toml")).unwrap();
309 assert_eq!(cfg, StorageConfig::default());
310 }
311
312 #[test]
313 fn load_from_parses_scope() {
314 let tmp = TempDir::new().unwrap();
315 let path = tmp.path().join("config.toml");
316 std::fs::write(&path, "[storage]\nscope = \"local\"\n").unwrap();
317 let cfg = StorageConfig::load_from(&path).unwrap();
318 assert_eq!(cfg.scope, Some(StorageScope::Local));
319 }
320
321 #[test]
322 fn load_from_tolerates_unrelated_sections() {
323 let tmp = TempDir::new().unwrap();
324 let path = tmp.path().join("config.toml");
325 std::fs::write(
326 &path,
327 "[compaction]\nreview = \"manual-only\"\n\n[storage]\nscope = \"global\"\n",
328 )
329 .unwrap();
330 let cfg = StorageConfig::load_from(&path).unwrap();
331 assert_eq!(cfg.scope, Some(StorageScope::Global));
332 }
333
334 #[test]
335 fn merge_overlay_overrides_base() {
336 let base = StorageConfig {
337 scope: Some(StorageScope::Global),
338 };
339 let overlay = StorageConfig {
340 scope: Some(StorageScope::Local),
341 };
342 let merged = StorageConfig::merge(base, overlay);
343 assert_eq!(merged.scope, Some(StorageScope::Local));
344 }
345
346 #[test]
347 fn merge_empty_overlay_keeps_base() {
348 let base = StorageConfig {
349 scope: Some(StorageScope::Local),
350 };
351 let overlay = StorageConfig::default();
352 let merged = StorageConfig::merge(base, overlay);
353 assert_eq!(merged.scope, Some(StorageScope::Local));
354 }
355
356 #[test]
357 fn atman_config_dir_env_wins() {
358 let mut env = env_with(TargetOs::Linux);
359 env.atman_config_dir = Some("/somewhere/else".into());
360 env.xdg_config_home = Some("/xdg".into());
361 env.home = Some("/home/u".into());
362 assert_eq!(
363 resolve_config_dir(env).unwrap(),
364 PathBuf::from("/somewhere/else")
365 );
366 }
367
368 #[test]
369 fn xdg_config_home_beats_home_fallback() {
370 let mut env = env_with(TargetOs::Linux);
371 env.xdg_config_home = Some("/xdg".into());
372 env.home = Some("/home/u".into());
373 assert_eq!(
374 resolve_config_dir(env).unwrap(),
375 PathBuf::from("/xdg/atman")
376 );
377 }
378
379 #[test]
380 fn macos_config_dir_is_dot_config_not_library() {
381 let mut env = env_with(TargetOs::Macos);
382 env.home = Some("/Users/u".into());
383 assert_eq!(
384 resolve_config_dir(env).unwrap(),
385 PathBuf::from("/Users/u/.config/atman"),
386 );
387 }
388
389 #[test]
390 fn linux_config_dir_defaults_to_dot_config() {
391 let mut env = env_with(TargetOs::Linux);
392 env.home = Some("/home/u".into());
393 assert_eq!(
394 resolve_config_dir(env).unwrap(),
395 PathBuf::from("/home/u/.config/atman"),
396 );
397 }
398
399 #[test]
400 fn windows_config_dir_uses_appdata_config_subdir() {
401 let mut env = env_with(TargetOs::Windows);
402 env.appdata = Some(r"C:\Users\u\AppData\Roaming".into());
403 assert_eq!(
404 resolve_config_dir(env).unwrap(),
405 PathBuf::from(r"C:\Users\u\AppData\Roaming")
406 .join("atman")
407 .join("config"),
408 );
409 }
410
411 #[test]
412 fn atman_data_dir_env_wins() {
413 let mut env = env_with(TargetOs::Macos);
414 env.atman_data_dir = Some("/data/here".into());
415 env.xdg_data_home = Some("/xdg".into());
416 env.home = Some("/Users/u".into());
417 assert_eq!(resolve_data_dir(env).unwrap(), PathBuf::from("/data/here"));
418 }
419
420 #[test]
421 fn xdg_data_home_beats_platform_fallback() {
422 let mut env = env_with(TargetOs::Macos);
423 env.xdg_data_home = Some("/xdg".into());
424 env.home = Some("/Users/u".into());
425 assert_eq!(resolve_data_dir(env).unwrap(), PathBuf::from("/xdg/atman"));
426 }
427
428 #[test]
429 fn macos_data_dir_is_application_support() {
430 let mut env = env_with(TargetOs::Macos);
431 env.home = Some("/Users/u".into());
432 assert_eq!(
433 resolve_data_dir(env).unwrap(),
434 PathBuf::from("/Users/u/Library/Application Support/atman"),
435 );
436 }
437
438 #[test]
439 fn linux_data_dir_defaults_to_local_share() {
440 let mut env = env_with(TargetOs::Linux);
441 env.home = Some("/home/u".into());
442 assert_eq!(
443 resolve_data_dir(env).unwrap(),
444 PathBuf::from("/home/u/.local/share/atman"),
445 );
446 }
447
448 #[test]
449 fn windows_data_dir_uses_local_appdata() {
450 let mut env = env_with(TargetOs::Windows);
451 env.local_appdata = Some(r"C:\Users\u\AppData\Local".into());
452 assert_eq!(
453 resolve_data_dir(env).unwrap(),
454 PathBuf::from(r"C:\Users\u\AppData\Local").join("atman"),
455 );
456 }
457
458 #[test]
459 fn missing_home_on_unix_errors_out() {
460 let env = env_with(TargetOs::Linux);
461 let err = resolve_config_dir(env).unwrap_err();
462 assert!(err.to_string().contains("HOME"));
463 }
464
465 #[test]
466 fn missing_appdata_on_windows_errors_out() {
467 let env = env_with(TargetOs::Windows);
468 let err = resolve_config_dir(env).unwrap_err();
469 assert!(err.to_string().contains("APPDATA"));
470 }
471}