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#[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}