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