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