pacsea/theme/config/
theme_loader.rs1use ratatui::style::Color;
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::Path;
5
6use crate::theme::parsing::{apply_override_to_map, canonical_to_preferred};
7use crate::theme::types::Theme;
8
9pub const THEME_REQUIRED_CANONICAL: [&str; 16] = [
11 "base", "mantle", "crust", "surface1", "surface2", "overlay1", "overlay2", "text", "subtext0",
12 "subtext1", "sapphire", "mauve", "green", "yellow", "red", "lavender",
13];
14
15fn build_theme_color_map(content: &str, errors: &mut Vec<String>) -> HashMap<String, Color> {
27 let mut map: HashMap<String, Color> = HashMap::new();
28 let mut seen_keys: HashSet<String> = HashSet::new();
29 for (idx, line) in content.lines().enumerate() {
30 let line_no = idx + 1;
31 let trimmed = line.trim();
32 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
33 continue;
34 }
35 if !trimmed.contains('=') {
36 errors.push(format!("- Missing '=' on line {line_no}"));
37 continue;
38 }
39 let mut parts = trimmed.splitn(2, '=');
40 let raw_key = parts.next().unwrap_or("");
41 let key = raw_key.trim();
42 let val = parts.next().unwrap_or("").trim();
43 if key.is_empty() {
44 errors.push(format!("- Missing key before '=' on line {line_no}"));
45 continue;
46 }
47 let norm = key.to_lowercase().replace(['.', '-', ' '], "_");
48 let is_pref_key = norm.starts_with("pref_")
50 || norm.starts_with("settings_")
51 || norm.starts_with("layout_")
52 || norm.starts_with("keybind_")
53 || norm.starts_with("app_")
54 || norm.starts_with("sort_")
55 || norm.starts_with("clipboard_")
56 || norm.starts_with("show_")
57 || norm == "results_sort";
58 if is_pref_key {
59 continue;
60 }
61 let canon_or_norm =
62 crate::theme::parsing::canonical_for_key(&norm).unwrap_or(norm.as_str());
63 if !seen_keys.insert(canon_or_norm.to_string()) {
64 errors.push(format!("- Duplicate key '{key}' on line {line_no}"));
65 }
66 apply_override_to_map(&mut map, key, val, errors, line_no);
67 }
68 map
69}
70
71pub fn resolved_theme_canonical_keys(content: &str) -> HashSet<String> {
79 let mut errors = Vec::new();
80 let map = build_theme_color_map(content, &mut errors);
81 map.into_keys().collect()
82}
83
84pub fn try_load_theme_with_diagnostics(path: &Path) -> Result<Theme, String> {
97 let content = fs::read_to_string(path).map_err(|e| format!("{e}"))?;
98 try_load_theme_from_content(&content)
99}
100
101pub fn try_load_theme_from_content(content: &str) -> Result<Theme, String> {
123 let mut errors: Vec<String> = Vec::new();
124 let map = build_theme_color_map(content, &mut errors);
125 let mut missing: Vec<&str> = Vec::new();
127 for k in THEME_REQUIRED_CANONICAL {
128 if !map.contains_key(k) {
129 missing.push(k);
130 }
131 }
132 if !missing.is_empty() {
133 let preferred: Vec<String> = missing.iter().map(|k| canonical_to_preferred(k)).collect();
134 errors.push(format!("- Missing required keys: {}", preferred.join(", ")));
135 }
136 if errors.is_empty() {
137 let get = |name: &str| {
138 map.get(name)
139 .copied()
140 .expect("all required keys should be present after validation")
141 };
142 Ok(Theme {
143 base: get("base"),
144 mantle: get("mantle"),
145 crust: get("crust"),
146 surface1: get("surface1"),
147 surface2: get("surface2"),
148 overlay1: get("overlay1"),
149 overlay2: get("overlay2"),
150 text: get("text"),
151 subtext0: get("subtext0"),
152 subtext1: get("subtext1"),
153 sapphire: get("sapphire"),
154 mauve: get("mauve"),
155 green: get("green"),
156 yellow: get("yellow"),
157 red: get("red"),
158 lavender: get("lavender"),
159 })
160 } else {
161 Err(errors.join("\n"))
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::try_load_theme_with_diagnostics;
168
169 #[test]
181 fn shipped_theme_conf_parses_successfully() {
182 let shipped_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
183 .join("config")
184 .join("theme.conf");
185 let shipped_content = std::fs::read_to_string(&shipped_path).unwrap_or_else(|error| {
186 panic!(
187 "failed reading shipped theme config {}: {error}",
188 shipped_path.display()
189 )
190 });
191
192 let temp_dir = tempfile::TempDir::new().expect("temp dir should be creatable");
193 let temp_theme_path = temp_dir.path().join("theme.conf");
194 std::fs::write(&temp_theme_path, shipped_content).unwrap_or_else(|error| {
195 panic!(
196 "failed writing temporary theme config {}: {error}",
197 temp_theme_path.display()
198 )
199 });
200
201 let result = try_load_theme_with_diagnostics(&temp_theme_path);
202 assert!(
203 result.is_ok(),
204 "shipped config/theme.conf should parse, got: {result:?}"
205 );
206 }
207}