Skip to main content

aaai_core/profile/
prefs.rs

1//! User preferences — persisted to `~/.aaai/prefs.yaml`.
2//!
3//! Currently stores the GUI theme selection.  Future preferences
4//! (font size, language override, etc.) can be added here without
5//! breaking the format because unknown YAML keys are ignored by serde.
6
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10/// GUI colour theme.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Theme {
14    /// iced built-in light palette (default).
15    #[default]
16    Light,
17    /// iced built-in dark palette.
18    Dark,
19    /// System preference (not yet implemented — falls back to Light).
20    System,
21}
22
23impl std::fmt::Display for Theme {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Theme::Light  => write!(f, "Light"),
27            Theme::Dark   => write!(f, "Dark"),
28            Theme::System => write!(f, "System"),
29        }
30    }
31}
32
33/// Persisted user preferences.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct UserPrefs {
36    /// Selected GUI theme.
37    #[serde(default)]
38    pub theme: Theme,
39
40    /// Locale code (e.g. "en", "ja"). Empty string = follow system / fallback.
41    /// RFC 036 — previously tracked only in the GUI session; now persisted.
42    #[serde(default)]
43    pub language: String,
44
45    /// Directory names silently excluded from every audit.
46    /// Converted to `<name>/**` glob patterns and prepended to the
47    /// `IgnoreRules` before any per-project `.aaaiignore` patterns.
48    /// RFC 036 — configurable via the Settings dialog.
49    #[serde(default = "default_ignored_dirs")]
50    pub global_ignored_dirs: Vec<String>,
51}
52
53fn default_ignored_dirs() -> Vec<String> {
54    vec![
55        ".git".into(),
56        "target".into(),
57        "node_modules".into(),
58        ".DS_Store".into(),
59    ]
60}
61
62impl UserPrefs {
63    fn path() -> anyhow::Result<PathBuf> {
64        let base = dirs::home_dir()
65            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
66            .join(".aaai");
67        std::fs::create_dir_all(&base)?;
68        Ok(base.join("prefs.yaml"))
69    }
70
71    /// Load from `~/.aaai/prefs.yaml`.  Returns defaults if the file is absent.
72    pub fn load() -> Self {
73        match Self::path().and_then(|p| {
74            if !p.exists() { return Ok(Self::default()); }
75            let text = std::fs::read_to_string(&p)?;
76            serde_yaml::from_str(&text).map_err(|e| anyhow::anyhow!(e))
77        }) {
78            Ok(prefs) => prefs,
79            Err(e) => {
80                log::warn!("Could not load prefs: {e}");
81                Self::default()
82            }
83        }
84    }
85
86    /// Save to `~/.aaai/prefs.yaml`.
87    pub fn save(&self) {
88        if let Err(e) = Self::path().and_then(|p| {
89            let yaml = serde_yaml::to_string(self).map_err(|e| anyhow::anyhow!(e))?;
90            std::fs::write(&p, yaml)?;
91            Ok(())
92        }) {
93            log::warn!("Could not save prefs: {e}");
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn round_trip_yaml() {
104        let prefs = UserPrefs { theme: Theme::Dark, ..Default::default() };
105        let yaml = serde_yaml::to_string(&prefs).unwrap();
106        let restored: UserPrefs = serde_yaml::from_str(&yaml).unwrap();
107        assert_eq!(restored.theme, Theme::Dark);
108    }
109
110    #[test]
111    fn default_is_light() {
112        assert_eq!(UserPrefs::default().theme, Theme::Light);
113    }
114
115    #[test]
116    fn display_names() {
117        assert_eq!(Theme::Light.to_string(), "Light");
118        assert_eq!(Theme::Dark.to_string(), "Dark");
119    }
120
121    // RFC 036 ────────────────────────────────────────────────────────
122
123    #[test]
124    fn new_fields_round_trip() {
125        let p = UserPrefs {
126            language: "ja".into(),
127            global_ignored_dirs: vec![".git".into(), "target".into()],
128            ..Default::default()
129        };
130        let yaml = serde_yaml::to_string(&p).unwrap();
131        let r: UserPrefs = serde_yaml::from_str(&yaml).unwrap();
132        assert_eq!(r.language, "ja");
133        assert_eq!(r.global_ignored_dirs, vec![".git", "target"]);
134    }
135
136    #[test]
137    fn missing_fields_get_defaults() {
138        // Simulate an old prefs.yaml that predates RFC 036 fields.
139        let yaml = "theme: light\n";
140        let p: UserPrefs = serde_yaml::from_str(yaml).unwrap();
141        assert!(!p.global_ignored_dirs.is_empty(), "default dirs should be applied");
142        assert_eq!(p.language, "", "absent language should be empty string");
143    }
144}