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    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/// Controls where session data is stored.
175#[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    /// Storage scope: `global` (shared across projects) or `local` (per-project).
188    #[serde(default)]
189    pub scope: Option<StorageScope>,
190}
191
192/// Whether data is shared globally or scoped to the local project.
193#[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    /// Shared across all projects.
208    Global,
209    /// Scoped to the current project directory.
210    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}