pacsea/theme/settings/
mod.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use std::time::SystemTime;
6
7use crate::theme::paths::{resolve_keybinds_config_path, resolve_settings_config_path};
8use crate::theme::types::Settings;
9use tracing::{debug, warn};
10
11/// Settings normalization module.
12mod normalize;
13/// Keybind parsing module.
14mod parse_keybinds;
15/// Settings parsing module.
16mod parse_settings;
17
18use normalize::normalize;
19use parse_keybinds::parse_keybinds;
20use parse_settings::parse_settings;
21
22/// What: Cache for settings and keybinds with file metadata.
23///
24/// Inputs: Loaded from disk files.
25///
26/// Output: Cached settings with modification times.
27///
28/// Details: Tracks settings and keybinds along with file metadata for cache invalidation.
29struct SettingsCache {
30    /// Cached settings.
31    settings: Settings,
32    /// Settings file modification time.
33    settings_mtime: Option<SystemTime>,
34    /// Keybinds file modification time.
35    keybinds_mtime: Option<SystemTime>,
36    /// Settings file size.
37    settings_size: Option<u64>,
38    /// Keybinds file size.
39    keybinds_size: Option<u64>,
40    /// Whether the cache has been initialized.
41    initialized: bool,
42}
43
44impl SettingsCache {
45    /// What: Create a new settings cache with default values.
46    ///
47    /// Inputs: None.
48    ///
49    /// Output: New cache instance with uninitialized state.
50    ///
51    /// Details: Initializes all fields to default/empty values.
52    fn new() -> Self {
53        Self {
54            settings: Settings::default(),
55            settings_mtime: None,
56            keybinds_mtime: None,
57            settings_size: None,
58            keybinds_size: None,
59            initialized: false,
60        }
61    }
62}
63
64/// Global settings cache singleton.
65///
66/// Details: Initialized on first access, providing thread-safe access to cached settings.
67static SETTINGS_CACHE: OnceLock<Mutex<SettingsCache>> = OnceLock::new();
68
69/// What: Load user settings and keybinds from config files under HOME/XDG.
70///
71/// Inputs:
72/// - None (reads `settings.conf` and `keybinds.conf` if present)
73///
74/// Output:
75/// - A `Settings` value; falls back to `Settings::default()` when missing or invalid.
76///
77/// # Panics
78/// - If the internal settings cache mutex is poisoned (unexpected).
79#[must_use]
80pub fn settings() -> Settings {
81    let mut cache = SETTINGS_CACHE
82        .get_or_init(|| Mutex::new(SettingsCache::new()))
83        .lock()
84        .expect("Settings cache mutex poisoned");
85
86    let mut out = Settings::default();
87    // Load settings from settings.conf (or legacy pacsea.conf)
88    let settings_path = resolve_settings_config_path().or_else(|| {
89        env::var("XDG_CONFIG_HOME")
90            .ok()
91            .map(PathBuf::from)
92            .or_else(|| env::var("HOME").ok().map(|h| Path::new(&h).join(".config")))
93            .map(|base| base.join("pacsea").join("settings.conf"))
94    });
95
96    let settings_metadata = settings_path.as_ref().and_then(|p| fs::metadata(p).ok());
97    let settings_mtime = settings_metadata.as_ref().and_then(|m| m.modified().ok());
98    let settings_size = settings_metadata.as_ref().map(std::fs::Metadata::len);
99
100    let keybinds_path = resolve_keybinds_config_path();
101    let keybinds_metadata = keybinds_path.as_ref().and_then(|p| fs::metadata(p).ok());
102    let keybinds_mtime = keybinds_metadata.as_ref().and_then(|m| m.modified().ok());
103    let keybinds_size = keybinds_metadata.as_ref().map(std::fs::Metadata::len);
104
105    let cache_initialized = cache.initialized;
106    let mtimes_match = cache_initialized
107        && cache.settings_mtime == settings_mtime
108        && cache.settings_size == settings_size
109        && cache.keybinds_mtime == keybinds_mtime
110        && cache.keybinds_size == keybinds_size;
111    if mtimes_match {
112        if tracing::enabled!(tracing::Level::TRACE) {
113            debug!("[Config] Using cached settings (unchanged files)");
114        }
115        return cache.settings.clone();
116    }
117
118    if let Some(p) = settings_path.as_ref()
119        && let Ok(content) = fs::read_to_string(p)
120    {
121        debug!(path = %p.display(), bytes = content.len(), "[Config] Loaded settings.conf");
122        parse_settings(&content, p, &mut out);
123    } else if let Some(p) = settings_path.as_ref() {
124        warn!(
125            path = %p.display(),
126            "[Config] settings.conf missing or unreadable, using defaults"
127        );
128    }
129
130    // Normalize settings
131    normalize(&mut out);
132
133    // Load keybinds from keybinds.conf if available; otherwise fall back to legacy keys in settings file
134    if let Some(kp) = keybinds_path.as_ref() {
135        if let Ok(content) = fs::read_to_string(kp) {
136            debug!(path = %kp.display(), bytes = content.len(), "[Config] Loaded keybinds.conf");
137            parse_keybinds(&content, &mut out);
138            // Done; keybinds loaded from dedicated file, so we can return now after validation
139        }
140    } else if let Some(p) = settings_path.as_ref() {
141        // Fallback: parse legacy keybind_* from settings file if keybinds.conf not present
142        if let Ok(content) = fs::read_to_string(p) {
143            debug!(
144                path = %p.display(),
145                bytes = content.len(),
146                "[Config] Loaded legacy keybinds from settings.conf"
147            );
148            parse_keybinds(&content, &mut out);
149        }
150    }
151
152    // Validate sum; if invalid, revert layout to defaults but preserve keybinds
153    let sum = out
154        .layout_left_pct
155        .saturating_add(out.layout_center_pct)
156        .saturating_add(out.layout_right_pct);
157    if sum != 100
158        || out.layout_left_pct == 0
159        || out.layout_center_pct == 0
160        || out.layout_right_pct == 0
161    {
162        // Preserve keybinds when resetting layout defaults
163        let keymap = out.keymap.clone();
164        out = Settings::default();
165        out.keymap = keymap;
166        debug!(
167            layout_left = out.layout_left_pct,
168            layout_center = out.layout_center_pct,
169            layout_right = out.layout_right_pct,
170            "[Config] Layout percentages invalid, reset to defaults while preserving keybinds"
171        );
172    }
173    cache.settings_mtime = settings_mtime;
174    cache.settings_size = settings_size;
175    cache.keybinds_mtime = keybinds_mtime;
176    cache.keybinds_size = keybinds_size;
177    cache.settings = out.clone();
178    cache.initialized = true;
179    out
180}
181
182#[cfg(test)]
183mod tests {
184    #[test]
185    /// What: Ensure settings parsing applies defaults when layout percentages sum incorrectly while still loading keybinds.
186    ///
187    /// Inputs:
188    /// - Temporary configuration directory containing `settings.conf` with an invalid layout sum and `keybinds.conf` with overrides.
189    ///
190    /// Output:
191    /// - Resulting `Settings` fall back to default layout percentages yet pick up configured keybinds.
192    ///
193    /// Details:
194    /// - Overrides `HOME` to a temp dir and restores it afterwards to avoid polluting the user environment.
195    fn settings_parse_values_and_keybinds_with_defaults_on_invalid_sum() {
196        let _guard = crate::theme::test_mutex()
197            .lock()
198            .expect("Test mutex poisoned");
199        let orig_home = std::env::var_os("HOME");
200        let base = std::env::temp_dir().join(format!(
201            "pacsea_test_settings_{}_{}",
202            std::process::id(),
203            std::time::SystemTime::now()
204                .duration_since(std::time::UNIX_EPOCH)
205                .expect("System time is before UNIX epoch")
206                .as_nanos()
207        ));
208        let cfg = base.join(".config").join("pacsea");
209        let _ = std::fs::create_dir_all(&cfg);
210        unsafe { std::env::set_var("HOME", base.display().to_string()) };
211
212        // Write settings.conf with values and bad sum (should reset to defaults)
213        let settings_path = cfg.join("settings.conf");
214        std::fs::write(
215            &settings_path,
216            "layout_left_pct=10\nlayout_center_pct=10\nlayout_right_pct=10\nsort_mode=aur_popularity\nclipboard_suffix=OK\nshow_search_history_pane=true\nshow_install_pane=false\nshow_keybinds_footer=true\n",
217        )
218        .expect("failed to write test settings file");
219        // Write keybinds.conf
220        let keybinds_path = cfg.join("keybinds.conf");
221        std::fs::write(&keybinds_path, "keybind_exit = Ctrl+Q\nkeybind_help = F1\n")
222            .expect("Failed to write test keybinds file");
223
224        let s = super::settings();
225        // Invalid layout sum -> defaults
226        assert_eq!(
227            s.layout_left_pct + s.layout_center_pct + s.layout_right_pct,
228            100
229        );
230        // Keybinds parsed
231        assert!(!s.keymap.exit.is_empty());
232        assert!(!s.keymap.help_overlay.is_empty());
233
234        unsafe {
235            if let Some(v) = orig_home {
236                std::env::set_var("HOME", v);
237            } else {
238                std::env::remove_var("HOME");
239            }
240        }
241        let _ = std::fs::remove_dir_all(&base);
242    }
243}