Skip to main content

pi/core/resources/
themes.rs

1//! Theme path collection (raw JSON load only; Theme type stays Phase 4).
2//!
3//! Port of theme path loading from
4//! `.references/pi/packages/coding-agent/src/core/resource-loader.ts`.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde_json::Value;
11
12use crate::core::config::{PathInputOptions, resolve_path_with};
13use crate::core::resources::diagnostics::{ResourceCollision, ResourceDiagnostic, ResourceType};
14use crate::core::resources::source_info::{
15    SourceInfo, SyntheticSourceInfoOptions, create_synthetic_source_info,
16};
17
18/// Loaded theme JSON with provenance (Phase 3 raw form).
19#[derive(Clone, Debug, PartialEq)]
20pub struct LoadedTheme {
21    /// Theme name from JSON `name`, or `"unnamed"`.
22    pub name: String,
23    /// Absolute source path.
24    pub source_path: String,
25    /// Provenance.
26    pub source_info: SourceInfo,
27    /// Raw JSON document.
28    pub raw: Value,
29}
30
31/// Result of loading themes from paths.
32#[derive(Clone, Debug, Default, PartialEq)]
33pub struct LoadThemesResult {
34    /// Deduped themes (first name wins).
35    pub themes: Vec<LoadedTheme>,
36    /// Load / collision diagnostics.
37    pub diagnostics: Vec<ResourceDiagnostic>,
38}
39
40/// Options for [`load_themes`].
41#[derive(Clone, Debug)]
42pub struct LoadThemesOptions {
43    /// Working directory for relative path resolution.
44    pub cwd: PathBuf,
45    /// Explicit theme paths (files or directories).
46    pub theme_paths: Vec<String>,
47}
48
49/// Load themes from paths/dirs (`.json`, non-recursive).
50///
51/// Collision key is `name ?? "unnamed"`. Exact diagnostic messages match TS.
52#[must_use]
53pub fn load_themes(options: &LoadThemesOptions) -> LoadThemesResult {
54    let mut themes = Vec::new();
55    let mut diagnostics = Vec::new();
56    let resolved_cwd = resolve_path_with(
57        &path_to_string(&options.cwd),
58        Path::new("."),
59        PathInputOptions::new(),
60    );
61
62    for raw in &options.theme_paths {
63        let resolved = resolve_path_with(raw, &resolved_cwd, PathInputOptions::new().trim(true));
64        if !resolved.exists() {
65            diagnostics.push(ResourceDiagnostic::warning(
66                "theme path does not exist",
67                Some(path_to_string(&resolved)),
68            ));
69            continue;
70        }
71        match fs::metadata(&resolved) {
72            Ok(meta) if meta.is_dir() => {
73                load_themes_from_dir(&resolved, &mut themes, &mut diagnostics);
74            }
75            Ok(meta)
76                if meta.is_file()
77                    && resolved
78                        .extension()
79                        .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) =>
80            {
81                load_theme_from_file(&resolved, &mut themes, &mut diagnostics);
82            }
83            Ok(_) => {
84                diagnostics.push(ResourceDiagnostic::warning(
85                    "theme path is not a json file",
86                    Some(path_to_string(&resolved)),
87                ));
88            }
89            Err(error) => {
90                diagnostics.push(ResourceDiagnostic::warning(
91                    error.to_string(),
92                    Some(path_to_string(&resolved)),
93                ));
94            }
95        }
96    }
97
98    let deduped = dedupe_themes(themes);
99    diagnostics.extend(deduped.diagnostics);
100    LoadThemesResult {
101        themes: deduped.themes,
102        diagnostics,
103    }
104}
105
106/// Load themes from a directory (non-recursive `.json` files).
107pub fn load_themes_from_dir(
108    dir: &Path,
109    themes: &mut Vec<LoadedTheme>,
110    diagnostics: &mut Vec<ResourceDiagnostic>,
111) {
112    if !dir.exists() {
113        return;
114    }
115    let Ok(read_dir) = fs::read_dir(dir) else {
116        diagnostics.push(ResourceDiagnostic::warning(
117            "failed to read theme directory",
118            Some(path_to_string(dir)),
119        ));
120        return;
121    };
122    let mut entries: Vec<_> = read_dir.filter_map(Result::ok).collect();
123    entries.sort_by_key(std::fs::DirEntry::file_name);
124    for entry in entries {
125        let full_path = entry.path();
126        let Ok(meta) = fs::metadata(&full_path) else {
127            continue;
128        };
129        if meta.is_file()
130            && full_path
131                .extension()
132                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
133        {
134            load_theme_from_file(&full_path, themes, diagnostics);
135        }
136    }
137}
138
139/// Load a single theme JSON file.
140pub fn load_theme_from_file(
141    file_path: &Path,
142    themes: &mut Vec<LoadedTheme>,
143    diagnostics: &mut Vec<ResourceDiagnostic>,
144) {
145    match load_theme_value(file_path) {
146        Ok(theme) => themes.push(theme),
147        Err(message) => {
148            diagnostics.push(ResourceDiagnostic::warning(
149                message,
150                Some(path_to_string(file_path)),
151            ));
152        }
153    }
154}
155
156fn load_theme_value(file_path: &Path) -> Result<LoadedTheme, String> {
157    let content = fs::read_to_string(file_path).map_err(|error| error.to_string())?;
158    let raw: Value = serde_json::from_str(&content).map_err(|error| error.to_string())?;
159    let name = raw
160        .get("name")
161        .and_then(Value::as_str)
162        .unwrap_or("unnamed")
163        .to_owned();
164    let source_path = path_to_string(file_path);
165    let source_info = create_synthetic_source_info(
166        source_path.clone(),
167        SyntheticSourceInfoOptions {
168            source: "local".into(),
169            scope: None,
170            origin: None,
171            base_dir: file_path.parent().map(path_to_string),
172        },
173    );
174    Ok(LoadedTheme {
175        name,
176        source_path,
177        source_info,
178        raw,
179    })
180}
181
182fn dedupe_themes(themes: Vec<LoadedTheme>) -> LoadThemesResult {
183    let mut winners: Vec<LoadedTheme> = Vec::new();
184    let mut index_by_name: HashMap<String, usize> = HashMap::new();
185    let mut diagnostics = Vec::new();
186    for theme in themes {
187        // TS: `const name = t.name ?? "unnamed"` — empty string is kept.
188        let name = theme.name.clone();
189        if let Some(&winner_idx) = index_by_name.get(&name) {
190            let winner = &winners[winner_idx];
191            diagnostics.push(ResourceDiagnostic::collision(
192                format!("name \"{name}\" collision"),
193                Some(theme.source_path.clone()),
194                ResourceCollision {
195                    resource_type: ResourceType::Theme,
196                    name: name.clone(),
197                    winner_path: winner.source_path.clone(),
198                    loser_path: theme.source_path.clone(),
199                    winner_source: None,
200                    loser_source: None,
201                },
202            ));
203        } else {
204            index_by_name.insert(name, winners.len());
205            winners.push(theme);
206        }
207    }
208    LoadThemesResult {
209        themes: winners,
210        diagnostics,
211    }
212}
213
214fn path_to_string(path: &Path) -> String {
215    path.to_string_lossy().into_owned()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::time::{SystemTime, UNIX_EPOCH};
222
223    fn temp_root(label: &str) -> std::io::Result<PathBuf> {
224        let nanos = SystemTime::now()
225            .duration_since(UNIX_EPOCH)
226            .map_or(0, |duration| duration.as_nanos());
227        let root = std::env::temp_dir().join(format!("pi-themes-{label}-{nanos}"));
228        let _ = fs::remove_dir_all(&root);
229        fs::create_dir_all(&root)?;
230        Ok(root)
231    }
232
233    #[test]
234    fn load_themes_nonrecursive_and_collision() -> std::io::Result<()> {
235        let root = temp_root("themes")?;
236        let dir = root.join("themes");
237        fs::create_dir_all(dir.join("nested"))?;
238        fs::write(dir.join("a.json"), r#"{"name":"alpha"}"#)?;
239        fs::write(dir.join("b.json"), r#"{"name":"alpha"}"#)?;
240        fs::write(dir.join("nested").join("c.json"), r#"{"name":"nested"}"#)?;
241        let result = load_themes(&LoadThemesOptions {
242            cwd: root.clone(),
243            theme_paths: vec![path_to_string(&dir)],
244        });
245        assert_eq!(result.themes.len(), 1);
246        assert!(
247            result
248                .diagnostics
249                .iter()
250                .any(|d| d.message == "name \"alpha\" collision")
251        );
252        assert!(!result.themes.iter().any(|t| t.name == "nested"));
253        let _ = fs::remove_dir_all(root);
254        Ok(())
255    }
256
257    #[test]
258    fn missing_theme_path_diagnostic() -> std::io::Result<()> {
259        let root = temp_root("missing-theme")?;
260        let result = load_themes(&LoadThemesOptions {
261            cwd: root.clone(),
262            theme_paths: vec![path_to_string(&root.join("nope.json"))],
263        });
264        assert!(result.themes.is_empty());
265        assert!(
266            result
267                .diagnostics
268                .iter()
269                .any(|d| d.message == "theme path does not exist")
270        );
271        let _ = fs::remove_dir_all(root);
272        Ok(())
273    }
274
275    #[test]
276    fn unnamed_collision_key() -> std::io::Result<()> {
277        let root = temp_root("unnamed")?;
278        let a = root.join("a.json");
279        let b = root.join("b.json");
280        fs::write(&a, r"{}")?;
281        fs::write(&b, r"{}")?;
282        let result = load_themes(&LoadThemesOptions {
283            cwd: root.clone(),
284            theme_paths: vec![path_to_string(&a), path_to_string(&b)],
285        });
286        assert_eq!(result.themes.len(), 1);
287        assert_eq!(result.themes[0].name, "unnamed");
288        assert!(
289            result
290                .diagnostics
291                .iter()
292                .any(|d| d.message == "name \"unnamed\" collision")
293        );
294        let _ = fs::remove_dir_all(root);
295        Ok(())
296    }
297}