Skip to main content

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()
89        .or_else(|| {
90            crate::theme::paths::config_dir_override().map(|root| root.join("settings.conf"))
91        })
92        .or_else(|| {
93            env::var("XDG_CONFIG_HOME")
94                .ok()
95                .map(PathBuf::from)
96                .or_else(|| env::var("HOME").ok().map(|h| Path::new(&h).join(".config")))
97                .map(|base| base.join("pacsea").join("settings.conf"))
98        });
99
100    let settings_metadata = settings_path.as_ref().and_then(|p| fs::metadata(p).ok());
101    let settings_mtime = settings_metadata.as_ref().and_then(|m| m.modified().ok());
102    let settings_size = settings_metadata.as_ref().map(std::fs::Metadata::len);
103
104    let keybinds_path = resolve_keybinds_config_path();
105    let keybinds_metadata = keybinds_path.as_ref().and_then(|p| fs::metadata(p).ok());
106    let keybinds_mtime = keybinds_metadata.as_ref().and_then(|m| m.modified().ok());
107    let keybinds_size = keybinds_metadata.as_ref().map(std::fs::Metadata::len);
108
109    let cache_initialized = cache.initialized;
110    let mtimes_match = cache_initialized
111        && cache.settings_mtime == settings_mtime
112        && cache.settings_size == settings_size
113        && cache.keybinds_mtime == keybinds_mtime
114        && cache.keybinds_size == keybinds_size;
115    if mtimes_match {
116        if tracing::enabled!(tracing::Level::TRACE) {
117            debug!("[Config] Using cached settings (unchanged files)");
118        }
119        return cache.settings.clone();
120    }
121
122    if let Some(p) = settings_path.as_ref()
123        && let Ok(content) = fs::read_to_string(p)
124    {
125        debug!(path = %p.display(), bytes = content.len(), "[Config] Loaded settings.conf");
126        parse_settings(&content, p, &mut out);
127    } else if let Some(p) = settings_path.as_ref() {
128        warn!(
129            path = %p.display(),
130            "[Config] settings.conf missing or unreadable, using defaults"
131        );
132    }
133
134    // Normalize settings
135    normalize(&mut out);
136
137    // Load keybinds from keybinds.conf if available; otherwise fall back to legacy keys in settings file
138    if let Some(kp) = keybinds_path.as_ref() {
139        if let Ok(content) = fs::read_to_string(kp) {
140            debug!(path = %kp.display(), bytes = content.len(), "[Config] Loaded keybinds.conf");
141            parse_keybinds(&content, &mut out);
142            // Done; keybinds loaded from dedicated file, so we can return now after validation
143        }
144    } else if let Some(p) = settings_path.as_ref() {
145        // Fallback: parse legacy keybind_* from settings file if keybinds.conf not present
146        if let Ok(content) = fs::read_to_string(p) {
147            debug!(
148                path = %p.display(),
149                bytes = content.len(),
150                "[Config] Loaded legacy keybinds from settings.conf"
151            );
152            parse_keybinds(&content, &mut out);
153        }
154    }
155
156    // Validate sum; if invalid, revert layout to defaults but preserve keybinds
157    let sum = out
158        .layout_left_pct
159        .saturating_add(out.layout_center_pct)
160        .saturating_add(out.layout_right_pct);
161    if sum != 100
162        || out.layout_left_pct == 0
163        || out.layout_center_pct == 0
164        || out.layout_right_pct == 0
165    {
166        // Preserve keybinds when resetting layout defaults
167        let keymap = out.keymap.clone();
168        out = Settings::default();
169        out.keymap = keymap;
170        debug!(
171            layout_left = out.layout_left_pct,
172            layout_center = out.layout_center_pct,
173            layout_right = out.layout_right_pct,
174            "[Config] Layout percentages invalid, reset to defaults while preserving keybinds"
175        );
176    }
177    cache.settings_mtime = settings_mtime;
178    cache.settings_size = settings_size;
179    cache.keybinds_mtime = keybinds_mtime;
180    cache.keybinds_size = keybinds_size;
181    cache.settings = out.clone();
182    cache.initialized = true;
183    out
184}
185
186#[cfg(test)]
187mod tests {
188    #[test]
189    /// What: Ensure settings parsing applies defaults when layout percentages sum incorrectly while still loading keybinds.
190    ///
191    /// Inputs:
192    /// - Temporary configuration directory containing `settings.conf` with an invalid layout sum and `keybinds.conf` with overrides.
193    ///
194    /// Output:
195    /// - Resulting `Settings` fall back to default layout percentages yet pick up configured keybinds.
196    ///
197    /// Details:
198    /// - Overrides `HOME` to a temp dir and restores it afterwards to avoid polluting the user environment.
199    fn settings_parse_values_and_keybinds_with_defaults_on_invalid_sum() {
200        let _guard = crate::theme::test_mutex()
201            .lock()
202            .expect("Test mutex poisoned");
203        let orig_home = std::env::var_os("HOME");
204        let base = std::env::temp_dir().join(format!(
205            "pacsea_test_settings_{}_{}",
206            std::process::id(),
207            std::time::SystemTime::now()
208                .duration_since(std::time::UNIX_EPOCH)
209                .expect("System time is before UNIX epoch")
210                .as_nanos()
211        ));
212        let cfg = base.join(".config").join("pacsea");
213        let _ = std::fs::create_dir_all(&cfg);
214        unsafe { std::env::set_var("HOME", base.display().to_string()) };
215
216        // Write settings.conf with values and bad sum (should reset to defaults)
217        let settings_path = cfg.join("settings.conf");
218        std::fs::write(
219            &settings_path,
220            "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",
221        )
222        .expect("failed to write test settings file");
223        // Write keybinds.conf
224        let keybinds_path = cfg.join("keybinds.conf");
225        std::fs::write(&keybinds_path, "keybind_exit = Ctrl+Q\nkeybind_help = F1\n")
226            .expect("Failed to write test keybinds file");
227
228        let s = super::settings();
229        // Invalid layout sum -> defaults
230        assert_eq!(
231            s.layout_left_pct + s.layout_center_pct + s.layout_right_pct,
232            100
233        );
234        // Keybinds parsed
235        assert!(!s.keymap.exit.is_empty());
236        assert!(!s.keymap.help_overlay.is_empty());
237
238        unsafe {
239            if let Some(v) = orig_home {
240                std::env::set_var("HOME", v);
241            } else {
242                std::env::remove_var("HOME");
243            }
244        }
245        let _ = std::fs::remove_dir_all(&base);
246    }
247}