Skip to main content

pacsea/theme/config/
theme_loader.rs

1use 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
9/// Canonical theme color keys required for a complete palette.
10pub 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
15/// What: Parse theme file content into a canonical-key color map (first phase of theme loading).
16///
17/// Inputs:
18/// - `content`: Full file text.
19/// - `errors`: Diagnostic buffer (duplicates, bad lines, invalid colors, unknown keys).
20///
21/// Output:
22/// - Map from canonical key to parsed color for every successfully applied assignment.
23///
24/// Details:
25/// - Mirrors `try_load_theme_with_diagnostics` line handling so ensure-keys and load stay consistent.
26fn 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        // Allow non-theme preference keys to live in pacsea.conf without erroring
49        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
71/// What: Return the set of canonical theme keys that successfully resolved from file content.
72///
73/// Inputs:
74/// - `content`: Theme configuration text (e.g. `theme.conf`).
75///
76/// Output:
77/// - Canonical keys present with valid colors after parsing.
78pub 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
84/// What: Parse a theme configuration file containing `key = value` color pairs into a `Theme`.
85///
86/// Inputs:
87/// - `path`: Filesystem location of the theme configuration.
88///
89/// Output:
90/// - `Ok(Theme)` when all required colors are present and valid.
91/// - `Err(String)` containing newline-separated diagnostics when parsing fails.
92///
93/// Details:
94/// - Ignores preference keys that belong to other config files for backwards compatibility.
95/// - Detects duplicates, missing required keys, and invalid color formats with precise line info.
96pub 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
101/// What: Parse theme configuration text into a `Theme` without touching disk.
102///
103/// Inputs:
104/// - `content`: Full theme configuration text (`key = value` color pairs).
105///
106/// Output:
107/// - `Ok(Theme)` when all required colors are present and valid.
108/// - `Err(String)` containing newline-separated diagnostics when parsing fails.
109///
110/// Details:
111/// - In-memory twin of [`try_load_theme_with_diagnostics`]; used by the
112///   integrated config editor to validate proposed content before committing
113///   a theme write, so an invalid edit never reaches `theme.conf`.
114///
115/// # Errors
116/// - Returns newline-separated diagnostics when required keys are missing,
117///   duplicated, or colors fail to parse.
118///
119/// # Panics
120/// - Never in practice: the internal `expect` runs only after the
121///   missing-required-keys check guarantees every canonical key is present.
122pub 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    // Check missing required keys
126    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    /// What: Verify the shipped `config/theme.conf` parses successfully via diagnostics loader.
170    ///
171    /// Inputs:
172    /// - Theme config text loaded from `CARGO_MANIFEST_DIR/config/theme.conf`.
173    ///
174    /// Output:
175    /// - Loader returns `Ok(Theme)` for the shipped configuration.
176    ///
177    /// Details:
178    /// - Copies shipped file content to a temporary file path so the loader exercises
179    ///   real file I/O behavior instead of in-memory parsing.
180    #[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}