Skip to main content

atman_runtime/
storage.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::session_meta::fingerprint_from_root;
7
8// Config lives with the user's other tool configs (`~/.config/atman` on
9// unix, `%APPDATA%\atman\config` on Windows). Data — sessions, indexes,
10// project stores — lives in the platform's data location so it doesn't
11// pollute dotfile repos.
12pub 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
32// Env + target OS bundled so path resolution is testable without touching
33// the process environment.
34struct 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// Only one variant is ever constructed on a given host, so at least two
46// look "dead" to clippy. Tests exercise all three cross-platform paths, so
47// the variants have to exist unconditionally.
48#[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/// Controls where session data is stored.
170#[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    /// Storage scope: `global` (shared across projects) or `local` (per-project).
183    #[serde(default)]
184    pub scope: Option<StorageScope>,
185}
186
187/// Whether data is shared globally or scoped to the local project.
188#[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    /// Shared across all projects.
203    Global,
204    /// Scoped to the current project directory.
205    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}