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