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> {
61 let path = if let Some(p) = custom_path {
62 Some(PathBuf::from(p))
63 } else {
64 Self::config_path()
65 };
66
67 if let Some(path) = path {
68 if path.exists() {
69 let contents = fs::read_to_string(&path)?;
70 let config: Config = toml::from_str(&contents)?;
71 return Ok(config);
72 }
73 }
74 Ok(Self::default())
75 }
76
77 pub fn config_path() -> Option<PathBuf> {
79 dirs::config_dir().map(|mut p| {
80 p.push("retch");
81 p.push("config.toml");
82 p
83 })
84 }
85
86 pub fn merge_with_cli(&self, cli: &crate::cli::Cli) -> Self {
90 let mut merged = self.clone();
91
92 if let Some(theme) = &cli.theme {
93 merged.theme = Some(theme.clone());
94 }
95 if cli.no_logo {
96 merged.show_logo = Some(false);
97 }
98 if cli.ascii_logo {
99 merged.ascii_only = Some(true);
100 }
101 if cli.chafa_logo {
102 merged.chafa = Some(true);
103 }
104 if let Some(logo) = &cli.logo {
105 merged.logo = Some(logo.clone());
106 }
107 if let Some(fields_str) = &cli.fields {
108 let fields = fields_str
110 .split(',')
111 .map(|s| s.trim().to_string())
112 .filter(|s| !s.is_empty())
113 .collect::<Vec<String>>();
114 merged.fields = Some(fields);
115 }
116 if let Some(loc) = &cli.weather_location {
117 merged.weather_location = Some(loc.clone());
118 }
119 if let Some(unit) = &cli.weather_unit {
120 merged.weather_unit = Some(unit.clone());
121 }
122
123 merged
124 }
125
126 pub fn merge_defaults(existing: &str) -> (String, Vec<&'static str>) {
130 let mut new_content = existing.trim_end().to_string();
131 let mut additions = Vec::new();
132
133 let fields_block = crate::fields::config_fields_block();
138
139 let checks = [
140 ("theme", DEFAULT_THEME_BLOCK),
141 ("show_logo", DEFAULT_SHOW_LOGO_BLOCK),
142 ("ascii_only", DEFAULT_ASCII_ONLY_BLOCK),
143 ("chafa", DEFAULT_CHAFA_BLOCK),
144 ("logo", DEFAULT_LOGO_BLOCK),
145 ("fields", fields_block.as_str()),
146 ("weather_location", DEFAULT_WEATHER_LOCATION_BLOCK),
147 ("weather_unit", DEFAULT_WEATHER_UNIT_BLOCK),
148 ];
149
150 for &(key, block) in &checks {
151 if !contains_key_line(existing, key) {
152 if !new_content.is_empty() {
153 new_content.push_str("\n\n");
154 }
155 new_content.push_str(block);
156 additions.push(key);
157 }
158 }
159
160 if !contains_custom_theme(existing) {
161 if !new_content.is_empty() {
162 new_content.push_str("\n\n");
163 }
164 new_content.push_str(DEFAULT_CUSTOM_THEME_BLOCK);
165 additions.push("custom_theme");
166 }
167
168 if !new_content.is_empty() && !new_content.ends_with('\n') {
169 new_content.push('\n');
170 }
171
172 (new_content, additions)
173 }
174}
175
176const DEFAULT_THEME_BLOCK: &str = r##"# Theme to use. Defaults to "auto" (follows system dark/light preference).
177# Other options: "neutral", "dark", "light", "custom",
178# or popular themes: "catppuccin-mocha", "solarized-dark", etc.
179# theme = "auto""##;
180
181const DEFAULT_CUSTOM_THEME_BLOCK: &str = r##"# Custom theme color overrides (used when theme = "custom" or when partial overrides are provided)
182# Colors can be named (e.g. "bright_cyan") or hex (e.g. "#89b4fa")
183# [custom_theme]
184# label_color = "bright_cyan"
185# value_color = "white"
186# accent_color = "bright_green"
187# title_color = "bright_yellow"
188# separator_color = "bright_black""##;
189
190const DEFAULT_SHOW_LOGO_BLOCK: &str = r##"# Whether to show the ASCII logo
191# show_logo = true"##;
192
193const DEFAULT_ASCII_ONLY_BLOCK: &str = r##"# Force ASCII-only output (even if graphical logos are supported)
194# ascii_only = false"##;
195
196const DEFAULT_CHAFA_BLOCK: &str = r##"# Force Chafa symbols output (even if graphical logos are supported)
197# chafa = false"##;
198
199const DEFAULT_LOGO_BLOCK: &str = r##"# Force a specific distribution logo by name/ID
200# logo = "arch""##;
201
202const DEFAULT_WEATHER_LOCATION_BLOCK: &str = r##"# Location for weather lookup (city name, ZIP code, airport code, or lat/lon coordinates).
203# If unset, your location is auto-detected from your IP address.
204# Examples: "London", "10001", "SFO", "48.8566,2.3522"
205# weather_location = """##;
206
207const DEFAULT_WEATHER_UNIT_BLOCK: &str = r##"# Temperature unit for weather: "fahrenheit" or "celsius"
208# weather_unit = "fahrenheit""##;
209
210fn contains_key_line(content: &str, key: &str) -> bool {
211 for line in content.lines() {
212 let trimmed = line.trim();
213 let without_comment = trimmed
214 .strip_prefix('#')
215 .map(|s| s.trim())
216 .unwrap_or(trimmed);
217
218 if let Some(rest) = without_comment.strip_prefix(key) {
219 let rest = rest.trim();
220 if rest.starts_with('=') {
221 return true;
222 }
223 }
224 }
225 false
226}
227
228fn contains_custom_theme(content: &str) -> bool {
229 for line in content.lines() {
230 let trimmed = line.trim();
231 let without_comment = trimmed
232 .strip_prefix('#')
233 .map(|s| s.trim())
234 .unwrap_or(trimmed);
235
236 let cleaned = without_comment.replace(' ', "");
237 if cleaned.contains("[custom_theme]") {
238 return true;
239 }
240 }
241 false
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::cli::Cli;
248 use clap::Parser;
249
250 #[test]
251 fn test_config_merge_with_cli() {
252 let config = Config {
253 theme: Some("dark".to_string()),
254 show_logo: Some(true),
255 ascii_only: Some(false),
256 fields: Some(vec!["os".to_string(), "kernel".to_string()]),
257 ..Default::default()
258 };
259
260 let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
262 let merged = config.merge_with_cli(&cli);
263 assert_eq!(merged.theme, Some("light".to_string()));
264
265 let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
267 let merged = config.merge_with_cli(&cli);
268 assert_eq!(merged.show_logo, Some(false));
269
270 let cli = Cli::try_parse_from(["retch", "--ascii-logo"]).unwrap();
272 let merged = config.merge_with_cli(&cli);
273 assert_eq!(merged.ascii_only, Some(true));
274
275 let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
277 let merged = config.merge_with_cli(&cli);
278 assert_eq!(merged.logo, Some("manjaro".to_string()));
279
280 let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
282 let merged = config.merge_with_cli(&cli);
283 assert_eq!(
284 merged.fields,
285 Some(vec![
286 "cpu".to_string(),
287 "gpu".to_string(),
288 "memory".to_string()
289 ])
290 );
291 let cli = Cli::try_parse_from(["retch", "--fields", " cpu , , gpu "]).unwrap();
293 let merged = config.merge_with_cli(&cli);
294 assert_eq!(
295 merged.fields,
296 Some(vec!["cpu".to_string(), "gpu".to_string()])
297 );
298 }
299
300 #[test]
301 fn test_config_load_valid() {
302 let temp_dir = std::env::temp_dir();
303 let file_path = temp_dir.join("valid_config.toml");
304 std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
305
306 let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
307 assert_eq!(config.theme, Some("dark".to_string()));
308 assert_eq!(config.show_logo, Some(true));
309
310 let _ = std::fs::remove_file(file_path);
311 }
312
313 #[test]
314 fn test_config_load_invalid() {
315 let temp_dir = std::env::temp_dir();
316 let file_path = temp_dir.join("invalid_config.toml");
317 std::fs::write(&file_path, "theme = dark\n").unwrap(); let config = Config::load(Some(file_path.to_str().unwrap()));
320 assert!(config.is_err());
321
322 let _ = std::fs::remove_file(file_path);
323 }
324
325 #[test]
326 fn test_config_load_missing() {
327 let config = Config::load(Some("non_existent_file.toml")).unwrap();
328 assert_eq!(config.theme, None);
329 assert_eq!(config.show_logo, None);
330 }
331
332 #[test]
333 fn test_merge_defaults_all_present() {
334 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";
335 let (merged, additions) = Config::merge_defaults(existing);
336 assert!(additions.is_empty());
337 assert_eq!(merged.trim(), existing.trim());
338 }
339
340 #[test]
341 fn test_merge_defaults_commented_ignored() {
342 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";
343 let (merged, additions) = Config::merge_defaults(existing);
344 assert!(additions.is_empty());
345 assert_eq!(merged.trim(), existing.trim());
346 }
347
348 #[test]
349 fn test_merge_defaults_missing_some() {
350 let existing = "theme = \"light\"\n";
351 let (merged, additions) = Config::merge_defaults(existing);
352 assert_eq!(
353 additions,
354 vec![
355 "show_logo",
356 "ascii_only",
357 "chafa",
358 "logo",
359 "fields",
360 "weather_location",
361 "weather_unit",
362 "custom_theme"
363 ]
364 );
365 assert!(merged.contains("theme = \"light\""));
366 assert!(merged.contains("show_logo = true"));
367 assert!(merged.contains("ascii_only = false"));
368 assert!(merged.contains("chafa = false"));
369 assert!(merged.contains("logo = \"arch\""));
370 assert!(merged.contains("fields = ["));
371 assert!(merged.contains("[custom_theme]"));
372 }
373
374 #[test]
375 fn test_default_fields_include_battery() {
376 assert!(crate::fields::config_fields_block().contains("battery"));
377 }
378}