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