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
33impl Theme {
34    /// All user-selectable themes, in display order (RFC 093).
35    ///
36    /// `System` is excluded until OS dark-mode detection is available
37    /// (RFC 093 §5.1 — hiding avoids a visibly broken picker option).
38    /// RFC 094 appends `HighContrastLight` and `HighContrastDark` here.
39    pub fn choices() -> &'static [Theme] {
40        &[Theme::Light, Theme::Dark]
41    }
42}
43
44/// Persisted user preferences.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct UserPrefs {
47    /// Selected GUI theme.
48    #[serde(default)]
49    pub theme: Theme,
50
51    /// Locale code (e.g. "en", "ja"). Empty string = follow system / fallback.
52    /// RFC 036 — previously tracked only in the GUI session; now persisted.
53    #[serde(default)]
54    pub language: String,
55
56    /// Directory names silently excluded from every audit.
57    /// Converted to `<name>/**` glob patterns and prepended to the
58    /// `IgnoreRules` before any per-project `.aaaiignore` patterns.
59    /// RFC 036 — configurable via the Settings dialog.
60    #[serde(default = "default_ignored_dirs")]
61    pub global_ignored_dirs: Vec<String>,
62}
63
64fn default_ignored_dirs() -> Vec<String> {
65    vec![
66        ".git".into(),
67        "target".into(),
68        "node_modules".into(),
69        ".DS_Store".into(),
70    ]
71}
72
73impl UserPrefs {
74    fn path() -> anyhow::Result<PathBuf> {
75        let base = dirs::home_dir()
76            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
77            .join(".aaai");
78        std::fs::create_dir_all(&base)?;
79        Ok(base.join("prefs.yaml"))
80    }
81
82    /// Load from `~/.aaai/prefs.yaml`.  Returns defaults if the file is absent.
83    pub fn load() -> Self {
84        match Self::path().and_then(|p| {
85            if !p.exists() { return Ok(Self::default()); }
86            let text = std::fs::read_to_string(&p)?;
87            serde_yaml::from_str(&text).map_err(|e| anyhow::anyhow!(e))
88        }) {
89            Ok(prefs) => prefs,
90            Err(e) => {
91                log::warn!("Could not load prefs: {e}");
92                Self::default()
93            }
94        }
95    }
96
97    /// Save to `~/.aaai/prefs.yaml`.
98    pub fn save(&self) {
99        if let Err(e) = Self::path().and_then(|p| {
100            let yaml = serde_yaml::to_string(self).map_err(|e| anyhow::anyhow!(e))?;
101            std::fs::write(&p, yaml)?;
102            Ok(())
103        }) {
104            log::warn!("Could not save prefs: {e}");
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn round_trip_yaml() {
115        let prefs = UserPrefs { theme: Theme::Dark, ..Default::default() };
116        let yaml = serde_yaml::to_string(&prefs).unwrap();
117        let restored: UserPrefs = serde_yaml::from_str(&yaml).unwrap();
118        assert_eq!(restored.theme, Theme::Dark);
119    }
120
121    #[test]
122    fn default_is_light() {
123        assert_eq!(UserPrefs::default().theme, Theme::Light);
124    }
125
126    #[test]
127    fn display_names() {
128        assert_eq!(Theme::Light.to_string(), "Light");
129        assert_eq!(Theme::Dark.to_string(), "Dark");
130    }
131
132    // RFC 036 ────────────────────────────────────────────────────────
133
134    #[test]
135    fn new_fields_round_trip() {
136        let p = UserPrefs {
137            language: "ja".into(),
138            global_ignored_dirs: vec![".git".into(), "target".into()],
139            ..Default::default()
140        };
141        let yaml = serde_yaml::to_string(&p).unwrap();
142        let r: UserPrefs = serde_yaml::from_str(&yaml).unwrap();
143        assert_eq!(r.language, "ja");
144        assert_eq!(r.global_ignored_dirs, vec![".git", "target"]);
145    }
146
147    #[test]
148    fn missing_fields_get_defaults() {
149        // Simulate an old prefs.yaml that predates RFC 036 fields.
150        let yaml = "theme: light\n";
151        let p: UserPrefs = serde_yaml::from_str(yaml).unwrap();
152        assert!(!p.global_ignored_dirs.is_empty(), "default dirs should be applied");
153        assert_eq!(p.language, "", "absent language should be empty string");
154    }
155}