Skip to main content

chromasync_core/
packs.rs

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}