1use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13#[derive(Debug, Serialize, Deserialize, Default, Clone)]
17pub struct Config {
18 pub theme: Option<String>,
20 pub show_logo: Option<bool>,
22 pub ascii_only: Option<bool>,
24 pub chafa: Option<bool>,
26 pub logo: Option<String>,
28 pub fields: Option<Vec<String>>,
30 pub custom_theme: Option<CustomTheme>,
32}
33
34#[derive(Debug, Serialize, Deserialize, Default, Clone)]
38pub struct CustomTheme {
39 pub label_color: Option<String>,
41 pub value_color: Option<String>,
43 pub accent_color: Option<String>,
45 pub title_color: Option<String>,
47 pub separator_color: Option<String>,
49}
50
51impl Config {
52 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 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 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 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 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 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 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 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 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 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 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(); 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}