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 pub weather_unit: Option<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize, Default, Clone)]
42pub struct CustomTheme {
43 pub label_color: Option<String>,
45 pub value_color: Option<String>,
47 pub accent_color: Option<String>,
49 pub title_color: Option<String>,
51 pub separator_color: Option<String>,
53}
54
55impl Config {
56 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 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 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 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 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 let checks = [
133 ("theme", DEFAULT_THEME_BLOCK),
134 ("show_logo", DEFAULT_SHOW_LOGO_BLOCK),
135 ("ascii_only", DEFAULT_ASCII_ONLY_BLOCK),
136 ("chafa", DEFAULT_CHAFA_BLOCK),
137 ("logo", DEFAULT_LOGO_BLOCK),
138 ("fields", DEFAULT_FIELDS_BLOCK),
139 ("weather_location", DEFAULT_WEATHER_LOCATION_BLOCK),
140 ("weather_unit", DEFAULT_WEATHER_UNIT_BLOCK),
141 ];
142
143 for &(key, block) in &checks {
144 if !contains_key_line(existing, key) {
145 if !new_content.is_empty() {
146 new_content.push_str("\n\n");
147 }
148 new_content.push_str(block);
149 additions.push(key);
150 }
151 }
152
153 if !contains_custom_theme(existing) {
154 if !new_content.is_empty() {
155 new_content.push_str("\n\n");
156 }
157 new_content.push_str(DEFAULT_CUSTOM_THEME_BLOCK);
158 additions.push("custom_theme");
159 }
160
161 if !new_content.is_empty() && !new_content.ends_with('\n') {
162 new_content.push('\n');
163 }
164
165 (new_content, additions)
166 }
167}
168
169const DEFAULT_THEME_BLOCK: &str = r##"# Theme to use. Defaults to "auto" (follows system dark/light preference).
170# Other options: "neutral", "dark", "light", "custom",
171# or popular themes: "catppuccin-mocha", "solarized-dark", etc.
172# theme = "auto""##;
173
174const DEFAULT_CUSTOM_THEME_BLOCK: &str = r##"# Custom theme color overrides (used when theme = "custom" or when partial overrides are provided)
175# Colors can be named (e.g. "bright_cyan") or hex (e.g. "#89b4fa")
176# [custom_theme]
177# label_color = "bright_cyan"
178# value_color = "white"
179# accent_color = "bright_green"
180# title_color = "bright_yellow"
181# separator_color = "bright_black""##;
182
183const DEFAULT_SHOW_LOGO_BLOCK: &str = r##"# Whether to show the ASCII logo
184# show_logo = true"##;
185
186const DEFAULT_ASCII_ONLY_BLOCK: &str = r##"# Force ASCII-only output (even if graphical logos are supported)
187# ascii_only = false"##;
188
189const DEFAULT_CHAFA_BLOCK: &str = r##"# Force Chafa symbols output (even if graphical logos are supported)
190# chafa = false"##;
191
192const DEFAULT_LOGO_BLOCK: &str = r##"# Force a specific distribution logo by name/ID
193# logo = "arch""##;
194
195const DEFAULT_WEATHER_LOCATION_BLOCK: &str = r##"# Location for weather lookup (city name, ZIP code, or lat/lon coordinates).
196# If unset, your location is auto-detected from your IP address.
197# Examples: "London", "10001", "48.8566,2.3522"
198# weather_location = """##;
199
200const DEFAULT_WEATHER_UNIT_BLOCK: &str = r##"# Temperature unit for weather: "fahrenheit" or "celsius"
201# weather_unit = "fahrenheit""##;
202
203const DEFAULT_FIELDS_BLOCK: &str = r##"# List of fields to display (leave empty or omit to show all)
204# Note: "phys-mem" requires running as root (sudo) on Linux to read DMI memory tables.
205# Note: "weather" requires network access and is shown in long mode by default.
206# fields = [
207# "os", "kernel", "host", "chassis", "init", "locale",
208# "arch", "cpu", "cpu-freq", "cpu-cache", "cpu-usage",
209# "gpu", "motherboard", "bios", "bootmgr", "display", "audio",
210# "camera", "gamepad", "memory", "phys-mem", "swap", "uptime", "procs", "load",
211# "disk", "phys-disk", "temp", "net", "public-ip", "wifi", "bluetooth", "battery",
212# "shell", "editor", "terminal", "terminal-font", "desktop",
213# "theme", "icons", "cursor", "font", "users", "packages", "weather"
214# ]"##;
215
216fn contains_key_line(content: &str, key: &str) -> bool {
217 for line in content.lines() {
218 let trimmed = line.trim();
219 let without_comment = trimmed
220 .strip_prefix('#')
221 .map(|s| s.trim())
222 .unwrap_or(trimmed);
223
224 if let Some(rest) = without_comment.strip_prefix(key) {
225 let rest = rest.trim();
226 if rest.starts_with('=') {
227 return true;
228 }
229 }
230 }
231 false
232}
233
234fn contains_custom_theme(content: &str) -> bool {
235 for line in content.lines() {
236 let trimmed = line.trim();
237 let without_comment = trimmed
238 .strip_prefix('#')
239 .map(|s| s.trim())
240 .unwrap_or(trimmed);
241
242 let cleaned = without_comment.replace(' ', "");
243 if cleaned.contains("[custom_theme]") {
244 return true;
245 }
246 }
247 false
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::cli::Cli;
254 use clap::Parser;
255
256 #[test]
257 fn test_config_merge_with_cli() {
258 let config = Config {
259 theme: Some("dark".to_string()),
260 show_logo: Some(true),
261 ascii_only: Some(false),
262 fields: Some(vec!["os".to_string(), "kernel".to_string()]),
263 ..Default::default()
264 };
265
266 let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
268 let merged = config.merge_with_cli(&cli);
269 assert_eq!(merged.theme, Some("light".to_string()));
270
271 let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
273 let merged = config.merge_with_cli(&cli);
274 assert_eq!(merged.show_logo, Some(false));
275
276 let cli = Cli::try_parse_from(["retch", "--ascii-logo"]).unwrap();
278 let merged = config.merge_with_cli(&cli);
279 assert_eq!(merged.ascii_only, Some(true));
280
281 let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
283 let merged = config.merge_with_cli(&cli);
284 assert_eq!(merged.logo, Some("manjaro".to_string()));
285
286 let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
288 let merged = config.merge_with_cli(&cli);
289 assert_eq!(
290 merged.fields,
291 Some(vec![
292 "cpu".to_string(),
293 "gpu".to_string(),
294 "memory".to_string()
295 ])
296 );
297 let cli = Cli::try_parse_from(["retch", "--fields", " cpu , , gpu "]).unwrap();
299 let merged = config.merge_with_cli(&cli);
300 assert_eq!(
301 merged.fields,
302 Some(vec!["cpu".to_string(), "gpu".to_string()])
303 );
304 }
305
306 #[test]
307 fn test_config_load_valid() {
308 let temp_dir = std::env::temp_dir();
309 let file_path = temp_dir.join("valid_config.toml");
310 std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
311
312 let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
313 assert_eq!(config.theme, Some("dark".to_string()));
314 assert_eq!(config.show_logo, Some(true));
315
316 let _ = std::fs::remove_file(file_path);
317 }
318
319 #[test]
320 fn test_config_load_invalid() {
321 let temp_dir = std::env::temp_dir();
322 let file_path = temp_dir.join("invalid_config.toml");
323 std::fs::write(&file_path, "theme = dark\n").unwrap(); let config = Config::load(Some(file_path.to_str().unwrap()));
326 assert!(config.is_err());
327
328 let _ = std::fs::remove_file(file_path);
329 }
330
331 #[test]
332 fn test_config_load_missing() {
333 let config = Config::load(Some("non_existent_file.toml")).unwrap();
334 assert_eq!(config.theme, None);
335 assert_eq!(config.show_logo, None);
336 }
337
338 #[test]
339 fn test_merge_defaults_all_present() {
340 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";
341 let (merged, additions) = Config::merge_defaults(existing);
342 assert!(additions.is_empty());
343 assert_eq!(merged.trim(), existing.trim());
344 }
345
346 #[test]
347 fn test_merge_defaults_commented_ignored() {
348 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";
349 let (merged, additions) = Config::merge_defaults(existing);
350 assert!(additions.is_empty());
351 assert_eq!(merged.trim(), existing.trim());
352 }
353
354 #[test]
355 fn test_merge_defaults_missing_some() {
356 let existing = "theme = \"light\"\n";
357 let (merged, additions) = Config::merge_defaults(existing);
358 assert_eq!(
359 additions,
360 vec![
361 "show_logo",
362 "ascii_only",
363 "chafa",
364 "logo",
365 "fields",
366 "weather_location",
367 "weather_unit",
368 "custom_theme"
369 ]
370 );
371 assert!(merged.contains("theme = \"light\""));
372 assert!(merged.contains("show_logo = true"));
373 assert!(merged.contains("ascii_only = false"));
374 assert!(merged.contains("chafa = false"));
375 assert!(merged.contains("logo = \"arch\""));
376 assert!(merged.contains("fields = ["));
377 assert!(merged.contains("[custom_theme]"));
378 }
379
380 #[test]
381 fn test_default_fields_include_battery() {
382 assert!(DEFAULT_FIELDS_BLOCK.contains("battery"));
383 }
384}