Skip to main content

retch_cli/
config.rs

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