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    resolve_data_dir(EnvOs::current())
14}
15
16pub fn config_dir() -> Result<PathBuf> {
17    resolve_config_dir(EnvOs::current())
18}
19
20// Env + target OS bundled so path resolution is testable without touching
21// the process environment.
22struct 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// Only one variant is ever constructed on a given host, so at least two
34// look "dead" to clippy. Tests exercise all three cross-platform paths, so
35// the variants have to exist unconditionally.
36#[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/// Controls where session data is stored.
158#[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    /// Storage scope: `global` (shared across projects) or `local` (per-project).
171    #[serde(default)]
172    pub scope: Option<StorageScope>,
173}
174
175/// Whether data is shared globally or scoped to the local project.
176#[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    /// Shared across all projects.
191    Global,
192    /// Scoped to the current project directory.
193    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}