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 pub weather_location: Option<String>,
34}
35
36#[derive(Debug, Serialize, Deserialize, Default, Clone)]
40pub struct CustomTheme {
41 pub label_color: Option<String>,
43 pub value_color: Option<String>,
45 pub accent_color: Option<String>,
47 pub title_color: Option<String>,
49 pub separator_color: Option<String>,
51}
52
53impl Config {
54 pub fn load(custom_path: Option<&str>) -> anyhow::Result<Self> {
58 let path = if let Some(p) = custom_path {
59 Some(PathBuf::from(p))
60 } else {
61 Self::config_path()
62 };
63
64 if let Some(path) = path {
65 if path.exists() {
66 let contents = fs::read_to_string(&path)?;
67 let config: Config = toml::from_str(&contents)?;
68 return Ok(config);
69 }
70 }
71 Ok(Self::default())
72 }
73
74 pub fn config_path() -> Option<PathBuf> {
76 dirs::config_dir().map(|mut p| {
77 p.push("retch");
78 p.push("config.toml");
79 p
80 })
81 }
82
83 pub fn merge_with_cli(&self, cli: &crate::cli::Cli) -> Self {
87 let mut merged = self.clone();
88
89 if let Some(theme) = &cli.theme {
90 merged.theme = Some(theme.clone());
91 }
92 if cli.no_logo {
93 merged.show_logo = Some(false);
94 }
95 if cli.ascii_logo {
96 merged.ascii_only = Some(true);
97 }
98 if cli.chafa_logo {
99 merged.chafa = Some(true);
100 }
101 if let Some(logo) = &cli.logo {
102 merged.logo = Some(logo.clone());
103 }
104 if let Some(fields_str) = &cli.fields {
105 let fields = fields_str
107 .split(',')
108 .map(|s| s.trim().to_string())
109 .filter(|s| !s.is_empty())
110 .collect::<Vec<String>>();
111 merged.fields = Some(fields);
112 }
113 if let Some(loc) = &cli.weather_location {
114 merged.weather_location = Some(loc.clone());
115 }
116
117 merged
118 }
119
120 pub fn merge_defaults(existing: &str) -> (String, Vec<&'static str>) {
124 let mut new_content = existing.trim_end().to_string();
125 let mut additions = Vec::new();
126
127 let checks = [
128 ("theme", DEFAULT_THEME_BLOCK),
129 ("show_logo", DEFAULT_SHOW_LOGO_BLOCK),
130 ("ascii_only", DEFAULT_ASCII_ONLY_BLOCK),
131 ("chafa", DEFAULT_CHAFA_BLOCK),
132 ("logo", DEFAULT_LOGO_BLOCK),
133 ("fields", DEFAULT_FIELDS_BLOCK),
134 ("weather_location", DEFAULT_WEATHER_LOCATION_BLOCK),
135 ];
136
137 for &(key, block) in &checks {
138 if !contains_key_line(existing, key) {
139 if !new_content.is_empty() {
140 new_content.push_str("\n\n");
141 }
142 new_content.push_str(block);
143 additions.push(key);
144 }
145 }
146
147 if !contains_custom_theme(existing) {
148 if !new_content.is_empty() {
149 new_content.push_str("\n\n");
150 }
151 new_content.push_str(DEFAULT_CUSTOM_THEME_BLOCK);
152 additions.push("custom_theme");
153 }
154
155 if !new_content.is_empty() && !new_content.ends_with('\n') {
156 new_content.push('\n');
157 }
158
159 (new_content, additions)
160 }
161}
162
163const DEFAULT_THEME_BLOCK: &str = r##"# Theme to use. Defaults to "auto" (follows system dark/light preference).
164# Other options: "neutral", "dark", "light", "custom",
165# or popular themes: "catppuccin-mocha", "solarized-dark", etc.
166# theme = "auto""##;
167
168const DEFAULT_CUSTOM_THEME_BLOCK: &str = r##"# Custom theme color overrides (used when theme = "custom" or when partial overrides are provided)
169# Colors can be named (e.g. "bright_cyan") or hex (e.g. "#89b4fa")
170# [custom_theme]
171# label_color = "bright_cyan"
172# value_color = "white"
173# accent_color = "bright_green"
174# title_color = "bright_yellow"
175# separator_color = "bright_black""##;
176
177const DEFAULT_SHOW_LOGO_BLOCK: &str = r##"# Whether to show the ASCII logo
178# show_logo = true"##;
179
180const DEFAULT_ASCII_ONLY_BLOCK: &str = r##"# Force ASCII-only output (even if graphical logos are supported)
181# ascii_only = false"##;
182
183const DEFAULT_CHAFA_BLOCK: &str = r##"# Force Chafa symbols output (even if graphical logos are supported)
184# chafa = false"##;
185
186const DEFAULT_LOGO_BLOCK: &str = r##"# Force a specific distribution logo by name/ID
187# logo = "arch""##;
188
189const DEFAULT_WEATHER_LOCATION_BLOCK: &str = r##"# Location for weather lookup (city name, ZIP code, airport code, or lat/lon coordinates).
190# If unset, wttr.in auto-detects your location from your IP address.
191# Examples: "London", "10001", "SFO", "48.8566,2.3522"
192# weather_location = """##;
193
194const DEFAULT_FIELDS_BLOCK: &str = r##"# List of fields to display (leave empty or omit to show all)
195# Note: "phys-mem" requires running as root (sudo) on Linux to read DMI memory tables.
196# Note: "weather" requires network access and is shown in long mode by default.
197# fields = [
198# "os", "kernel", "host", "chassis", "init", "locale",
199# "arch", "cpu", "cpu-freq", "cpu-cache", "cpu-usage",
200# "gpu", "motherboard", "bios", "bootmgr", "display", "audio",
201# "camera", "gamepad", "memory", "phys-mem", "swap", "uptime", "procs", "load",
202# "disk", "phys-disk", "temp", "net", "public-ip", "wifi", "bluetooth", "battery",
203# "shell", "editor", "terminal", "terminal-font", "desktop",
204# "theme", "icons", "cursor", "font", "users", "packages", "weather"
205# ]"##;
206
207fn contains_key_line(content: &str, key: &str) -> bool {
208 for line in content.lines() {
209 let trimmed = line.trim();
210 let without_comment = trimmed
211 .strip_prefix('#')
212 .map(|s| s.trim())
213 .unwrap_or(trimmed);
214
215 if let Some(rest) = without_comment.strip_prefix(key) {
216 let rest = rest.trim();
217 if rest.starts_with('=') {
218 return true;
219 }
220 }
221 }
222 false
223}
224
225fn contains_custom_theme(content: &str) -> bool {
226 for line in content.lines() {
227 let trimmed = line.trim();
228 let without_comment = trimmed
229 .strip_prefix('#')
230 .map(|s| s.trim())
231 .unwrap_or(trimmed);
232
233 let cleaned = without_comment.replace(' ', "");
234 if cleaned.contains("[custom_theme]") {
235 return true;
236 }
237 }
238 false
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use crate::cli::Cli;
245 use clap::Parser;
246
247 #[test]
248 fn test_config_merge_with_cli() {
249 let config = Config {
250 theme: Some("dark".to_string()),
251 show_logo: Some(true),
252 ascii_only: Some(false),
253 fields: Some(vec!["os".to_string(), "kernel".to_string()]),
254 ..Default::default()
255 };
256
257 let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
259 let merged = config.merge_with_cli(&cli);
260 assert_eq!(merged.theme, Some("light".to_string()));
261
262 let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
264 let merged = config.merge_with_cli(&cli);
265 assert_eq!(merged.show_logo, Some(false));
266
267 let cli = Cli::try_parse_from(["retch", "--ascii-logo"]).unwrap();
269 let merged = config.merge_with_cli(&cli);
270 assert_eq!(merged.ascii_only, Some(true));
271
272 let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
274 let merged = config.merge_with_cli(&cli);
275 assert_eq!(merged.logo, Some("manjaro".to_string()));
276
277 let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
279 let merged = config.merge_with_cli(&cli);
280 assert_eq!(
281 merged.fields,
282 Some(vec![
283 "cpu".to_string(),
284 "gpu".to_string(),
285 "memory".to_string()
286 ])
287 );
288 let cli = Cli::try_parse_from(["retch", "--fields", " cpu , , gpu "]).unwrap();
290 let merged = config.merge_with_cli(&cli);
291 assert_eq!(
292 merged.fields,
293 Some(vec!["cpu".to_string(), "gpu".to_string()])
294 );
295 }
296
297 #[test]
298 fn test_config_load_valid() {
299 let temp_dir = std::env::temp_dir();
300 let file_path = temp_dir.join("valid_config.toml");
301 std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
302
303 let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
304 assert_eq!(config.theme, Some("dark".to_string()));
305 assert_eq!(config.show_logo, Some(true));
306
307 let _ = std::fs::remove_file(file_path);
308 }
309
310 #[test]
311 fn test_config_load_invalid() {
312 let temp_dir = std::env::temp_dir();
313 let file_path = temp_dir.join("invalid_config.toml");
314 std::fs::write(&file_path, "theme = dark\n").unwrap(); let config = Config::load(Some(file_path.to_str().unwrap()));
317 assert!(config.is_err());
318
319 let _ = std::fs::remove_file(file_path);
320 }
321
322 #[test]
323 fn test_config_load_missing() {
324 let config = Config::load(Some("non_existent_file.toml")).unwrap();
325 assert_eq!(config.theme, None);
326 assert_eq!(config.show_logo, None);
327 }
328
329 #[test]
330 fn test_merge_defaults_all_present() {
331 let existing = "theme = \"dark\"\nshow_logo = true\nascii_only = false\nchafa = false\nlogo = \"fedora\"\nfields = [\"os\"]\nweather_location = \"London\"\n[custom_theme]\nlabel_color = \"red\"\n";
332 let (merged, additions) = Config::merge_defaults(existing);
333 assert!(additions.is_empty());
334 assert_eq!(merged.trim(), existing.trim());
335 }
336
337 #[test]
338 fn test_merge_defaults_commented_ignored() {
339 let existing = "# theme = \"auto\"\n# show_logo = true\n# ascii_only = false\n# chafa = false\n# logo = \"arch\"\n# fields = []\n# weather_location = \"\"\n# [custom_theme]\n";
340 let (merged, additions) = Config::merge_defaults(existing);
341 assert!(additions.is_empty());
342 assert_eq!(merged.trim(), existing.trim());
343 }
344
345 #[test]
346 fn test_merge_defaults_missing_some() {
347 let existing = "theme = \"light\"\n";
348 let (merged, additions) = Config::merge_defaults(existing);
349 assert_eq!(
350 additions,
351 vec![
352 "show_logo",
353 "ascii_only",
354 "chafa",
355 "logo",
356 "fields",
357 "weather_location",
358 "custom_theme"
359 ]
360 );
361 assert!(merged.contains("theme = \"light\""));
362 assert!(merged.contains("show_logo = true"));
363 assert!(merged.contains("ascii_only = false"));
364 assert!(merged.contains("chafa = false"));
365 assert!(merged.contains("logo = \"arch\""));
366 assert!(merged.contains("fields = ["));
367 assert!(merged.contains("[custom_theme]"));
368 }
369
370 #[test]
371 fn test_default_fields_include_battery() {
372 assert!(DEFAULT_FIELDS_BLOCK.contains("battery"));
373 }
374}