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