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