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