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# "memory", "swap", "uptime", "procs", "load",
178# "disk", "temp", "net", "battery",
179# "shell", "terminal", "desktop", "users", "packages"
180# ]"##;
181
182fn contains_key_line(content: &str, key: &str) -> bool {
183 for line in content.lines() {
184 let trimmed = line.trim();
185 let without_comment = trimmed
186 .strip_prefix('#')
187 .map(|s| s.trim())
188 .unwrap_or(trimmed);
189
190 if let Some(rest) = without_comment.strip_prefix(key) {
191 let rest = rest.trim();
192 if rest.starts_with('=') {
193 return true;
194 }
195 }
196 }
197 false
198}
199
200fn contains_custom_theme(content: &str) -> bool {
201 for line in content.lines() {
202 let trimmed = line.trim();
203 let without_comment = trimmed
204 .strip_prefix('#')
205 .map(|s| s.trim())
206 .unwrap_or(trimmed);
207
208 let cleaned = without_comment.replace(' ', "");
209 if cleaned.contains("[custom_theme]") {
210 return true;
211 }
212 }
213 false
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::cli::Cli;
220 use clap::Parser;
221
222 #[test]
223 fn test_config_merge_with_cli() {
224 let config = Config {
225 theme: Some("dark".to_string()),
226 show_logo: Some(true),
227 ascii_only: Some(false),
228 fields: Some(vec!["os".to_string(), "kernel".to_string()]),
229 ..Default::default()
230 };
231
232 let cli = Cli::try_parse_from(["retch", "--theme", "light"]).unwrap();
234 let merged = config.merge_with_cli(&cli);
235 assert_eq!(merged.theme, Some("light".to_string()));
236
237 let cli = Cli::try_parse_from(["retch", "--no-logo"]).unwrap();
239 let merged = config.merge_with_cli(&cli);
240 assert_eq!(merged.show_logo, Some(false));
241
242 let cli = Cli::try_parse_from(["retch", "--ascii-only"]).unwrap();
244 let merged = config.merge_with_cli(&cli);
245 assert_eq!(merged.ascii_only, Some(true));
246
247 let cli = Cli::try_parse_from(["retch", "--logo", "manjaro"]).unwrap();
249 let merged = config.merge_with_cli(&cli);
250 assert_eq!(merged.logo, Some("manjaro".to_string()));
251
252 let cli = Cli::try_parse_from(["retch", "--fields", "cpu,gpu,memory"]).unwrap();
254 let merged = config.merge_with_cli(&cli);
255 assert_eq!(
256 merged.fields,
257 Some(vec![
258 "cpu".to_string(),
259 "gpu".to_string(),
260 "memory".to_string()
261 ])
262 );
263 let cli = Cli::try_parse_from(["retch", "--fields", " cpu , , gpu "]).unwrap();
265 let merged = config.merge_with_cli(&cli);
266 assert_eq!(
267 merged.fields,
268 Some(vec!["cpu".to_string(), "gpu".to_string()])
269 );
270 }
271
272 #[test]
273 fn test_config_load_valid() {
274 let temp_dir = std::env::temp_dir();
275 let file_path = temp_dir.join("valid_config.toml");
276 std::fs::write(&file_path, "theme = \"dark\"\nshow_logo = true\n").unwrap();
277
278 let config = Config::load(Some(file_path.to_str().unwrap())).unwrap();
279 assert_eq!(config.theme, Some("dark".to_string()));
280 assert_eq!(config.show_logo, Some(true));
281
282 let _ = std::fs::remove_file(file_path);
283 }
284
285 #[test]
286 fn test_config_load_invalid() {
287 let temp_dir = std::env::temp_dir();
288 let file_path = temp_dir.join("invalid_config.toml");
289 std::fs::write(&file_path, "theme = dark\n").unwrap(); let config = Config::load(Some(file_path.to_str().unwrap()));
292 assert!(config.is_err());
293
294 let _ = std::fs::remove_file(file_path);
295 }
296
297 #[test]
298 fn test_config_load_missing() {
299 let config = Config::load(Some("non_existent_file.toml")).unwrap();
300 assert_eq!(config.theme, None);
301 assert_eq!(config.show_logo, None);
302 }
303
304 #[test]
305 fn test_merge_defaults_all_present() {
306 let existing = "theme = \"dark\"\nshow_logo = true\nascii_only = false\nlogo = \"fedora\"\nfields = [\"os\"]\n[custom_theme]\nlabel_color = \"red\"\n";
307 let (merged, additions) = Config::merge_defaults(existing);
308 assert!(additions.is_empty());
309 assert_eq!(merged.trim(), existing.trim());
310 }
311
312 #[test]
313 fn test_merge_defaults_commented_ignored() {
314 let existing = "# theme = \"auto\"\n# show_logo = true\n# ascii_only = false\n# logo = \"arch\"\n# fields = []\n# [custom_theme]\n";
315 let (merged, additions) = Config::merge_defaults(existing);
316 assert!(additions.is_empty());
317 assert_eq!(merged.trim(), existing.trim());
318 }
319
320 #[test]
321 fn test_merge_defaults_missing_some() {
322 let existing = "theme = \"light\"\n";
323 let (merged, additions) = Config::merge_defaults(existing);
324 assert_eq!(
325 additions,
326 vec!["show_logo", "ascii_only", "logo", "fields", "custom_theme"]
327 );
328 assert!(merged.contains("theme = \"light\""));
329 assert!(merged.contains("show_logo = true"));
330 assert!(merged.contains("ascii_only = false"));
331 assert!(merged.contains("logo = \"arch\""));
332 assert!(merged.contains("fields = ["));
333 assert!(merged.contains("[custom_theme]"));
334 }
335
336 #[test]
337 fn test_default_fields_include_battery() {
338 assert!(DEFAULT_FIELDS_BLOCK.contains("battery"));
339 }
340}