1use 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#[derive(Clone, Debug, PartialEq)]
20pub struct LoadedTheme {
21 pub name: String,
23 pub source_path: String,
25 pub source_info: SourceInfo,
27 pub raw: Value,
29}
30
31#[derive(Clone, Debug, Default, PartialEq)]
33pub struct LoadThemesResult {
34 pub themes: Vec<LoadedTheme>,
36 pub diagnostics: Vec<ResourceDiagnostic>,
38}
39
40#[derive(Clone, Debug)]
42pub struct LoadThemesOptions {
43 pub cwd: PathBuf,
45 pub theme_paths: Vec<String>,
47}
48
49#[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
106pub 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
139pub 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 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}