1use std::{
2 collections::BTreeSet,
3 fs,
4 path::{Component, Path, PathBuf},
5};
6
7use chromasync_types::ThemePack;
8use directories::ProjectDirs;
9use serde::Deserialize;
10use thiserror::Error;
11
12#[derive(Debug, Clone, Default)]
13pub struct PackRegistry {
14 packs: Vec<ThemePack>,
15}
16
17impl PackRegistry {
18 pub fn discover() -> Result<Self, PackError> {
19 Self::discover_in(&pack_search_roots())
20 }
21
22 pub fn discover_in(search_roots: &[PathBuf]) -> Result<Self, PackError> {
23 let mut packs = Vec::new();
24 let mut seen_names = std::collections::BTreeMap::new();
25
26 for root in search_roots {
27 if !root.exists() {
28 continue;
29 }
30
31 let mut candidates = Vec::new();
32
33 for entry in fs::read_dir(root).map_err(|source| PackError::ReadPacksDir {
34 path: root.to_path_buf(),
35 source,
36 })? {
37 let entry = entry.map_err(|source| PackError::ReadPacksDir {
38 path: root.to_path_buf(),
39 source,
40 })?;
41 let path = entry.path();
42
43 if path.is_dir() && path.join("pack.toml").is_file() {
44 candidates.push(path);
45 }
46 }
47
48 candidates.sort();
49
50 for candidate in candidates {
51 let pack = load_pack(&candidate)?;
52
53 if let Some(previous) =
54 seen_names.insert(pack.name.clone(), pack.root_dir.display().to_string())
55 {
56 return Err(PackError::DuplicatePackName {
57 name: pack.name,
58 first_source: previous,
59 second_source: candidate.display().to_string(),
60 });
61 }
62
63 packs.push(pack);
64 }
65 }
66
67 packs.sort_by(|left, right| left.name.cmp(&right.name));
68
69 Ok(Self { packs })
70 }
71
72 pub fn packs(&self) -> &[ThemePack] {
73 &self.packs
74 }
75
76 pub fn get(&self, name: &str) -> Option<&ThemePack> {
77 self.packs.iter().find(|pack| pack.name == name)
78 }
79}
80
81#[derive(Debug, Error)]
82pub enum PackError {
83 #[error("failed to read pack directory '{path}': {source}")]
84 ReadPacksDir {
85 path: PathBuf,
86 #[source]
87 source: std::io::Error,
88 },
89 #[error("failed to read pack manifest '{path}': {source}")]
90 ReadPackManifest {
91 path: PathBuf,
92 #[source]
93 source: std::io::Error,
94 },
95 #[error("failed to parse pack manifest '{path}': {error}")]
96 ParsePackManifest {
97 path: PathBuf,
98 #[source]
99 error: Box<toml::de::Error>,
100 },
101 #[error("pack manifest '{path}' uses an invalid pack name '{name}'")]
102 InvalidPackName { path: PathBuf, name: String },
103 #[error(
104 "pack '{name}' is defined multiple times: first at {first_source}, second at {second_source}"
105 )]
106 DuplicatePackName {
107 name: String,
108 first_source: String,
109 second_source: String,
110 },
111 #[error("pack '{pack}' {kind} path '{path}' is invalid: {reason}")]
112 InvalidAssetPath {
113 pack: String,
114 kind: &'static str,
115 path: String,
116 reason: String,
117 },
118 #[error("pack '{pack}' {kind} path '{path}' does not exist or is not a directory")]
119 MissingAssetDir {
120 pack: String,
121 kind: &'static str,
122 path: PathBuf,
123 },
124 #[error("pack '{pack}' does not define any templates or targets")]
125 PackHasNoAssets { pack: String },
126 #[error("pack '{pack}' was not found")]
127 PackNotFound { pack: String },
128}
129
130#[derive(Debug, Deserialize)]
131#[serde(deny_unknown_fields)]
132struct RawPackManifest {
133 name: String,
134 version: String,
135 description: Option<String>,
136 author: Option<String>,
137 license: Option<String>,
138 homepage: Option<String>,
139 templates: Option<RawPackAssets>,
140 targets: Option<RawPackAssets>,
141}
142
143#[derive(Debug, Deserialize)]
144#[serde(deny_unknown_fields)]
145struct RawPackAssets {
146 #[serde(default)]
147 paths: Vec<PathBuf>,
148}
149
150pub fn pack_search_roots() -> Vec<PathBuf> {
151 let mut roots = Vec::new();
152
153 if let Some(dirs) = ProjectDirs::from("io", "chromasync", "chromasync") {
154 roots.push(dirs.config_dir().join("packs"));
155 roots.push(dirs.data_local_dir().join("packs"));
156 }
157
158 if let Ok(current_dir) = std::env::current_dir() {
159 roots.push(current_dir.join(".chromasync").join("packs"));
160 }
161
162 let mut seen = BTreeSet::new();
163 roots.retain(|path| seen.insert(path.clone()));
164
165 roots
166}
167
168fn load_pack(path: &Path) -> Result<ThemePack, PackError> {
169 let manifest_path = path.join("pack.toml");
170 let content =
171 fs::read_to_string(&manifest_path).map_err(|source| PackError::ReadPackManifest {
172 path: manifest_path.clone(),
173 source,
174 })?;
175 let manifest: RawPackManifest =
176 toml::from_str(&content).map_err(|error| PackError::ParsePackManifest {
177 path: manifest_path.clone(),
178 error: Box::new(error),
179 })?;
180
181 validate_pack_name(&manifest_path, &manifest.name)?;
182
183 let template_dirs = resolve_asset_dirs(
184 path,
185 &manifest.name,
186 "template",
187 &manifest.templates,
188 "templates",
189 )?;
190 let target_dirs =
191 resolve_asset_dirs(path, &manifest.name, "target", &manifest.targets, "targets")?;
192
193 if template_dirs.is_empty() && target_dirs.is_empty() {
194 return Err(PackError::PackHasNoAssets {
195 pack: manifest.name.clone(),
196 });
197 }
198
199 Ok(ThemePack {
200 name: manifest.name,
201 version: manifest.version,
202 description: manifest.description,
203 author: manifest.author,
204 license: manifest.license,
205 homepage: manifest.homepage,
206 root_dir: path.to_path_buf(),
207 template_dirs,
208 target_dirs,
209 })
210}
211
212fn validate_pack_name(path: &Path, name: &str) -> Result<(), PackError> {
213 if !name.is_empty()
214 && name
215 .chars()
216 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
217 {
218 Ok(())
219 } else {
220 Err(PackError::InvalidPackName {
221 path: path.to_path_buf(),
222 name: name.to_owned(),
223 })
224 }
225}
226
227fn resolve_asset_dirs(
228 pack_root: &Path,
229 pack_name: &str,
230 kind: &'static str,
231 manifest_paths: &Option<RawPackAssets>,
232 default_dir: &'static str,
233) -> Result<Vec<PathBuf>, PackError> {
234 let declared = match manifest_paths {
235 Some(paths) => paths.paths.clone(),
236 None => {
237 let default_path = pack_root.join(default_dir);
238
239 if default_path.is_dir() {
240 vec![PathBuf::from(default_dir)]
241 } else {
242 Vec::new()
243 }
244 }
245 };
246 let mut resolved = Vec::new();
247 let mut seen = BTreeSet::new();
248
249 for relative in declared {
250 validate_relative_asset_path(pack_name, kind, &relative)?;
251 let absolute = pack_root.join(&relative);
252
253 if !absolute.is_dir() {
254 return Err(PackError::MissingAssetDir {
255 pack: pack_name.to_owned(),
256 kind,
257 path: absolute,
258 });
259 }
260
261 if seen.insert(absolute.clone()) {
262 resolved.push(absolute);
263 }
264 }
265
266 Ok(resolved)
267}
268
269fn validate_relative_asset_path(
270 pack_name: &str,
271 kind: &'static str,
272 path: &Path,
273) -> Result<(), PackError> {
274 if path.as_os_str().is_empty() {
275 return Err(PackError::InvalidAssetPath {
276 pack: pack_name.to_owned(),
277 kind,
278 path: path.display().to_string(),
279 reason: "expected a non-empty relative directory".to_owned(),
280 });
281 }
282
283 if path.is_absolute() {
284 return Err(PackError::InvalidAssetPath {
285 pack: pack_name.to_owned(),
286 kind,
287 path: path.display().to_string(),
288 reason: "absolute paths are not allowed".to_owned(),
289 });
290 }
291
292 for component in path.components() {
293 match component {
294 Component::Normal(_) | Component::CurDir => {}
295 Component::ParentDir => {
296 return Err(PackError::InvalidAssetPath {
297 pack: pack_name.to_owned(),
298 kind,
299 path: path.display().to_string(),
300 reason: "parent path components are not allowed".to_owned(),
301 });
302 }
303 Component::RootDir | Component::Prefix(_) => {
304 return Err(PackError::InvalidAssetPath {
305 pack: pack_name.to_owned(),
306 kind,
307 path: path.display().to_string(),
308 reason: "only relative subdirectories are allowed".to_owned(),
309 });
310 }
311 }
312 }
313
314 Ok(())
315}