Skip to main content

retch_cli/
config.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Configuration management and parsing.
5//!
6//! This module handles loading, parsing, and merging of user-defined
7//! TOML configurations with the default settings.
8
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13/// Configuration for the retch CLI.
14///
15/// This struct represents the options that can be set in the `config.toml` file.
16#[derive(Debug, Serialize, Deserialize, Default, Clone)]
17pub struct Config {
18    /// The name of the theme to use (e.g., "dark", "catppuccin-mocha").
19    pub theme: Option<String>,
20    /// Whether to display the distro logo.
21    pub show_logo: Option<bool>,
22    /// Whether to force ASCII-only logo output.
23    pub ascii_only: Option<bool>,
24    /// Whether to force Chafa symbols output.
25    pub chafa: Option<bool>,
26    /// Force a specific distribution logo by name/ID.
27    pub logo: Option<String>,
28    /// List of fields to display, in order.
29    pub fields: Option<Vec<String>>,
30    /// Custom theme color overrides.
31    pub custom_theme: Option<CustomTheme>,
32    /// Location for weather lookup (city name, ZIP code, airport code, or coordinates).
33    pub weather_location: Option<String>,
34}
35
36/// Custom color overrides for themes.
37///
38/// Allows users to specify hex codes or color names for specific UI elements.
39#[derive(Debug, Serialize, Deserialize, Default, Clone)]
40pub struct CustomTheme {
41    /// Color for field labels.
42    pub label_color: Option<String>,
43    /// Color for field values.
44    pub value_color: Option<String>,
45    /// Color for accent elements.
46    pub accent_color: Option<String>,
47    /// Color for the title/username line.
48    pub title_color: Option<String>,
49    /// Color for separators.
50    pub separator_color: Option<String>,
51}
52
53impl Config {
54    /// Loads the configuration from the default system path.
55    ///
56    /// Typically looks in `~/.config/retch/config.toml`.
57    pub fn load(custom_path: Option<&str>) -> anyhow::Result<Self> {
58        let path = if let Some(p) = custom_path {
59            Some(PathBuf::from(p))
60        } else {
61            Self::config_path()
62        };
63
64        if let Some(path) = path {
65            if path.exists() {
66                let contents = fs::read_to_string(&path)?;
67                let config: Config = toml::from_str(&contents)?;
68                return Ok(config);
69            }
70        }
71        Ok(Self::default())
72    }
73
74    /// Returns the expected path to the configuration file.
75    pub fn config_path() -> Option<PathBuf> {
76        dirs::config_dir().map(|mut p| {
77            p.push("retch");
78            p.push("config.toml");
79            p
80        })
81    }
82
83    /// Merges CLI options into the configuration.
84    ///
85    /// CLI arguments take precedence over values defined in the config file.
86    pub fn merge_with_cli(&self, cli: &crate::cli::Cli) -> Self {
87        let mut merged = self.clone();
88
89        if let Some(theme) = &cli.theme {
90            merged.theme = Some(theme.clone());
91        }
92        if cli.no_logo {
93            merged.show_logo = Some(false);
94        }
95        if cli.ascii_logo {
96            merged.ascii_only = Some(true);
97        }
98        if cli.chafa_logo {
99            merged.chafa = Some(true);
100        }
101        if let Some(logo) = &cli.logo {
102            merged.logo = Some(logo.clone());
103        }
104        if let Some(fields_str) = &cli.fields {
105            // Split comma-separated string into Vec<String>
106            let fields = fields_str
107                .split(',')
108                .map(|s| s.trim().to_string())
109                .filter(|s| !s.is_empty())
110                .collect::<Vec<String>>();
111            merged.fields = Some(fields);
112        }
113        if let Some(loc) = &cli.weather_location {
114            merged.weather_location = Some(loc.clone());
115        }
116
117        merged
118    }
119
120    /// Merges missing default options as commented blocks into the existing configuration string.
121    ///
122    /// Returns the updated configuration content and a vector of the names of settings that were added.
123    pub fn merge_defaults(existing: &str) -> (String, Vec<&'static str>) {
124        let mut new_content = existing.trim_end().to_string();
125        let mut additions = Vec::new();
126
127        let checks = [
128            ("theme", DEFAULT_THEME_BLOCK),
129            ("show_logo", DEFAULT_SHOW_LOGO_BLOCK),
130            ("ascii_only", DEFAULT_ASCII_ONLY_BLOCK),
131            ("chafa", DEFAULT_CHAFA_BLOCK),
132            ("logo", DEFAULT_LOGO_BLOCK),
133            ("fields", DEFAULT_FIELDS_BLOCK),
134            ("weather_location", DEFAULT_WEATHER_LOCATION_BLOCK),
135        ];
136
137        for &(key, block) in &checks {
138            if !contains_key_line(existing, key) {
139                if !new_content.is_empty() {
140                    new_content.push_str("\n\n");
141                }
142                new_content.push_str(block);
143                additions.push(key);
144            }
145        }
146
147        if !contains_custom_theme(existing) {
148            if !new_content.is_empty() {
149                new_content.push_str("\n\n");
150            }
151            new_content.push_str(DEFAULT_CUSTOM_THEME_BLOCK);
152            additions.push("custom_theme");
153        }
154
155        if !new_content.is_empty() && !new_content.ends_with('\n') {
156            new_content.push('\n');
157        }
158
159        (new_content, additions)
160    }
161}
162
163const DEFAULT_THEME_BLOCK: &str = r##"# Theme to use. Defaults to "auto" (follows system dark/light preference).
164# Other options: "neutral", "dark", "light", "custom",
165# or popular themes: "catppuccin-mocha", "solarized-dark", etc.
166# theme = "auto""##;
167
168const DEFAULT_CUSTOM_THEME_BLOCK: &str = r##"# Custom theme color overrides (used when theme = "custom" or when partial overrides are provided)
169# Colors can be named (e.g. "bright_cyan") or hex (e.g. "#89b4fa")
170# [custom_theme]
171# label_color = "bright_cyan"
172# value_color = "white"
173# accent_color = "bright_green"
174# title_color = "bright_yellow"
175# separator_color = "bright_black""##;
176
177const DEFAULT_SHOW_LOGO_BLOCK: &str = r##"# Whether to show the ASCII logo
178# show_logo = true"##;
179
180const DEFAULT_ASCII_ONLY_BLOCK: &str = r##"# Force ASCII-only output (even if graphical logos are supported)
181# ascii_only = false"##;
182
183const DEFAULT_CHAFA_BLOCK: &str = r##"# Force Chafa symbols output (even if graphical logos are supported)
184# chafa = false"##;
185
186const DEFAULT_LOGO_BLOCK: &str = r##"# Force a specific distribution logo by name/ID
187# logo = "arch""##;
188
189const DEFAULT_WEATHER_LOCATION_BLOCK: &str = r##"# Location for weather lookup (city name, ZIP code, airport code, or lat/lon coordinates).
190# If unset, wttr.in auto-detects your location from your IP address.
191# Examples: "London", "10001", "SFO", "48.8566,2.3522"
192# weather_location = """##;
193
194const DEFAULT_FIELDS_BLOCK: &str = r##"# List of fields to display (leave empty or omit to show all)
195# Note: "phys-mem" requires running as root (sudo) on Linux to read DMI memory tables.
196# Note: "weather" requires network access and is shown in long mode by default.
197# fields = [
198#     "os", "kernel", "host", "chassis", "init", "locale",
199#     "arch", "cpu", "cpu-freq", "cpu-cache", "cpu-usage",
200#     "gpu", "motherboard", "bios", "bootmgr", "display", "audio",
201#     "camera", "gamepad", "memory", "phys-mem", "swap", "uptime", "procs", "load",
202#     "disk", "phys-disk", "temp", "net", "public-ip", "wifi", "bluetooth", "battery",
203#     "shell", "editor", "terminal", "terminal-font", "desktop",
204#     "theme", "icons", "cursor", "font", "users", "packages", "weather"
205# ]"##;
206
207fn contains_key_line(content: &str, key: &str) -> bool {
208    for line in content.lines() {
209        let trimmed = line.trim();
210        let without_comment = trimmed
211            .strip_prefix('#')
212            .map(|s| s.trim())
213            .unwrap_or(trimmed);
214
215        if let Some(rest) = without_comment.strip_prefix(key) {
216            let rest = rest.trim();
217            if rest.starts_with('=') {
218                return true;
219            }
220        }
221    }
222    false
223}
224
225fn contains_custom_theme(content: &str) -> bool {
226    for line in content.lines() {
227        let trimmed = line.trim();
228        let without_comment = trimmed
229            .strip_prefix('#')
230            .map(|s| s.trim())
231            .unwrap_or(trimmed);
232
233        let cleaned = without_comment.replace(' ', "");
234        if cleaned.contains("[custom_theme]") {
235            return true;
236        }
237    }
238    false
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::cli::Cli;
245    use clap::Parser;
246
247    #[test]
248    fn test_config_merge_with_cli() {
249        let config = Config {
250            theme: Some("dark".to_string()),
251            show_logo: Some(true),
252            ascii_only: Some(false),
253            fields: Some(vec!["os".to_string(), "kernel".to_string()]),
254            ..Default::default()
255        };
256
257        // Test theme override
258        let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
259        let merged = config.merge_with_cli(&cli);
260        assert_eq!(merged.theme, Some("light".to_string()));
261
262        // Test no-logo override
263        let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
264        let merged = config.merge_with_cli(&cli);
265        assert_eq!(merged.show_logo, Some(false));
266
267        // Test ascii-logo override
268        let cli = Cli::try_parse_from(["retch", "--ascii-logo"]).unwrap();
269        let merged = config.merge_with_cli(&cli);
270        assert_eq!(merged.ascii_only, Some(true));
271
272        // Test logo override
273        let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
274        let merged = config.merge_with_cli(&cli);
275        assert_eq!(merged.logo, Some("manjaro".to_string()));
276
277        // Test fields override
278        let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
279        let merged = config.merge_with_cli(&cli);
280        assert_eq!(
281            merged.fields,
282            Some(vec![
283                "cpu".to_string(),
284                "gpu".to_string(),
285                "memory".to_string()
286            ])
287        );
288        // Test fields edge case (spaces, empty values)
289        let cli = Cli::try_parse_from(["retch", "--fields", "  cpu , , gpu "]).unwrap();
290        let merged = config.merge_with_cli(&cli);
291        assert_eq!(
292            merged.fields,
293            Some(vec!["cpu".to_string(), "gpu".to_string()])
294        );
295    }
296
297    #[test]
298    fn test_config_load_valid() {
299        let temp_dir = std::env::temp_dir();
300        let file_path = temp_dir.join("valid_config.toml");
301        std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
302
303        let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
304        assert_eq!(config.theme, Some("dark".to_string()));
305        assert_eq!(config.show_logo, Some(true));
306
307        let _ = std::fs::remove_file(file_path);
308    }
309
310    #[test]
311    fn test_config_load_invalid() {
312        let temp_dir = std::env::temp_dir();
313        let file_path = temp_dir.join("invalid_config.toml");
314        std::fs::write(&file_path, "theme = dark\n").unwrap(); // Missing quotes makes it invalid TOML
315
316        let config = Config::load(Some(file_path.to_str().unwrap()));
317        assert!(config.is_err());
318
319        let _ = std::fs::remove_file(file_path);
320    }
321
322    #[test]
323    fn test_config_load_missing() {
324        let config = Config::load(Some("non_existent_file.toml")).unwrap();
325        assert_eq!(config.theme, None);
326        assert_eq!(config.show_logo, None);
327    }
328
329    #[test]
330    fn test_merge_defaults_all_present() {
331        let existing = "theme = \"dark\"\nshow_logo = true\nascii_only = false\nchafa = false\nlogo = \"fedora\"\nfields = [\"os\"]\nweather_location = \"London\"\n[custom_theme]\nlabel_color = \"red\"\n";
332        let (merged, additions) = Config::merge_defaults(existing);
333        assert!(additions.is_empty());
334        assert_eq!(merged.trim(), existing.trim());
335    }
336
337    #[test]
338    fn test_merge_defaults_commented_ignored() {
339        let existing = "# theme = \"auto\"\n# show_logo = true\n# ascii_only = false\n# chafa = false\n# logo = \"arch\"\n# fields = []\n# weather_location = \"\"\n# [custom_theme]\n";
340        let (merged, additions) = Config::merge_defaults(existing);
341        assert!(additions.is_empty());
342        assert_eq!(merged.trim(), existing.trim());
343    }
344
345    #[test]
346    fn test_merge_defaults_missing_some() {
347        let existing = "theme = \"light\"\n";
348        let (merged, additions) = Config::merge_defaults(existing);
349        assert_eq!(
350            additions,
351            vec![
352                "show_logo",
353                "ascii_only",
354                "chafa",
355                "logo",
356                "fields",
357                "weather_location",
358                "custom_theme"
359            ]
360        );
361        assert!(merged.contains("theme = \"light\""));
362        assert!(merged.contains("show_logo = true"));
363        assert!(merged.contains("ascii_only = false"));
364        assert!(merged.contains("chafa = false"));
365        assert!(merged.contains("logo = \"arch\""));
366        assert!(merged.contains("fields = ["));
367        assert!(merged.contains("[custom_theme]"));
368    }
369
370    #[test]
371    fn test_default_fields_include_battery() {
372        assert!(DEFAULT_FIELDS_BLOCK.contains("battery"));
373    }
374}