Skip to main content

tairitsu_packager/icons/
resolver.rs

1use serde::Deserialize;
2use std::{
3    collections::HashMap,
4    path::{Path, PathBuf},
5};
6
7use super::{
8    cache::{CacheManifest, IconCache, IconData},
9    sources::{self, IconOrigin, IconSourceDef},
10};
11
12#[derive(Debug, Clone, Deserialize, Default)]
13pub struct HikariIconsMetadata {
14    #[serde(default)]
15    pub sets: Vec<String>,
16    #[serde(default)]
17    pub mode: Option<String>,
18    #[serde(default)]
19    pub set: HashMap<String, SetConfig>,
20}
21
22#[derive(Debug, Clone, Deserialize, Default)]
23pub struct SetConfig {
24    #[serde(default)]
25    pub version: Option<String>,
26    #[serde(default)]
27    pub formats: Vec<String>,
28    #[serde(default)]
29    pub subscripts: Vec<Subscript>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct Subscript {
34    #[serde(default)]
35    pub names: Vec<String>,
36    #[serde(default)]
37    pub globs: Vec<String>,
38    #[serde(default)]
39    pub tags: Vec<String>,
40}
41
42#[derive(Debug)]
43pub struct ResolvedSet {
44    pub source: &'static IconSourceDef,
45    pub version: String,
46    pub formats: Vec<String>,
47    pub subscripts: Vec<Subscript>,
48    pub icons: HashMap<String, IconData>,
49}
50
51#[derive(Debug)]
52pub struct ResolveResult {
53    pub sets: Vec<ResolvedSet>,
54    pub mode: String,
55}
56
57pub fn read_consumer_metadata(manifest_dir: &Path) -> crate::Result<HikariIconsMetadata> {
58    let cargo_toml = manifest_dir.join("Cargo.toml");
59    let content = std::fs::read_to_string(&cargo_toml).map_err(|e| {
60        crate::TairitsuPackagerError::InvalidConfig(format!(
61            "Failed to read {}: {}",
62            cargo_toml.display(),
63            e
64        ))
65    })?;
66
67    let value: toml::Value = toml::from_str(&content).map_err(|e| {
68        crate::TairitsuPackagerError::InvalidConfig(format!("Failed to parse Cargo.toml: {}", e))
69    })?;
70
71    let icons_meta = value
72        .get("package")
73        .and_then(|p| p.get("metadata"))
74        .and_then(|m| m.get("hikari"))
75        .and_then(|h| h.get("icons"));
76
77    match icons_meta {
78        Some(v) => {
79            let meta: HikariIconsMetadata = v.clone().try_into().map_err(|e| {
80                crate::TairitsuPackagerError::InvalidConfig(format!(
81                    "Invalid [package.metadata.hikari.icons]: {}",
82                    e
83                ))
84            })?;
85            Ok(meta)
86        }
87        None => Ok(HikariIconsMetadata::default()),
88    }
89}
90
91pub fn resolve(metadata: &HikariIconsMetadata, cache: &IconCache) -> crate::Result<ResolveResult> {
92    let mode = metadata.mode.as_deref().unwrap_or("embed-svg").to_string();
93
94    if metadata.sets.is_empty() {
95        return Ok(ResolveResult {
96            sets: Vec::new(),
97            mode,
98        });
99    }
100
101    let mut resolved = Vec::with_capacity(metadata.sets.len());
102
103    for set_name in &metadata.sets {
104        let source = sources::find_source(set_name).ok_or_else(|| {
105            crate::TairitsuPackagerError::InvalidConfig(format!(
106                "Unknown icon set: '{}'. Available: {}",
107                set_name,
108                sources::ICON_SOURCES
109                    .iter()
110                    .map(|s| s.name)
111                    .collect::<Vec<_>>()
112                    .join(", ")
113            ))
114        })?;
115
116        let set_cfg = metadata.set.get(set_name);
117        let version = set_cfg
118            .and_then(|c| c.version.clone())
119            .unwrap_or_else(|| "latest".to_string());
120        let formats = set_cfg
121            .map(|c| c.formats.clone())
122            .unwrap_or_else(|| vec!["svg".to_string()]);
123        let subscripts = set_cfg.map(|c| c.subscripts.clone()).unwrap_or_default();
124
125        let manifest = if cache.has_cache(set_name, &version) {
126            cache.load_manifest(set_name, &version)
127        } else {
128            None
129        };
130
131        let (resolved_version, icons) = match manifest {
132            Some(m) => (version.clone(), m.icons),
133            None => {
134                if cache.is_offline() {
135                    crate::log_warn!(
136                        "Icon set '{}' v{} not in cache and offline mode is active — skipping",
137                        set_name,
138                        version
139                    );
140                    continue;
141                }
142
143                match fetch_set(source, &version, cache) {
144                    Ok(result) => result,
145                    Err(e) => {
146                        crate::log_warn!(
147                            "Failed to fetch icon set '{}' v{}: {} — skipping",
148                            set_name,
149                            version,
150                            e
151                        );
152                        continue;
153                    }
154                }
155            }
156        };
157
158        let filtered = if subscripts.is_empty() {
159            icons
160        } else {
161            apply_subscripts(&icons, &subscripts)
162        };
163
164        crate::log_info!(
165            "Resolved {} icons for set '{}' ({} subscripts)",
166            filtered.len(),
167            set_name,
168            subscripts.len()
169        );
170
171        resolved.push(ResolvedSet {
172            source,
173            version: resolved_version,
174            formats,
175            subscripts,
176            icons: filtered,
177        });
178    }
179
180    Ok(ResolveResult {
181        sets: resolved,
182        mode,
183    })
184}
185
186fn apply_subscripts(
187    all: &HashMap<String, IconData>,
188    subscripts: &[Subscript],
189) -> HashMap<String, IconData> {
190    let mut result = HashMap::new();
191
192    for sub in subscripts {
193        for name in &sub.names {
194            if let Some(icon) = all.get(name) {
195                result.insert(name.clone(), icon.clone());
196            }
197        }
198
199        if !sub.globs.is_empty() {
200            for (name, icon) in all {
201                let mut matches = false;
202                for glob in &sub.globs {
203                    if glob_match(glob, name) {
204                        matches = true;
205                        break;
206                    }
207                }
208                if matches {
209                    result.insert(name.clone(), icon.clone());
210                }
211            }
212        }
213
214        if !sub.tags.is_empty() {
215            for (name, icon) in all {
216                for tag in &sub.tags {
217                    if icon.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
218                        result.insert(name.clone(), icon.clone());
219                        break;
220                    }
221                }
222            }
223        }
224    }
225
226    result
227}
228
229fn glob_match(pattern: &str, name: &str) -> bool {
230    if !pattern.contains('*') {
231        return pattern == name;
232    }
233
234    let parts: Vec<&str> = pattern.split('*').collect();
235
236    if parts.len() == 1 {
237        if pattern.starts_with('*') {
238            return name.ends_with(parts[0]);
239        }
240        if pattern.ends_with('*') {
241            return name.starts_with(parts[0]);
242        }
243        return pattern == name;
244    }
245
246    if parts.len() == 2 {
247        let prefix = parts[0];
248        let suffix = parts[1];
249        if !prefix.is_empty() && !name.starts_with(prefix) {
250            return false;
251        }
252        if !suffix.is_empty() && !name.ends_with(suffix) {
253            return false;
254        }
255        let min_len = prefix.len() + suffix.len();
256        return name.len() >= min_len;
257    }
258
259    let mut rest = name;
260    for (i, part) in parts.iter().enumerate() {
261        if part.is_empty() {
262            continue;
263        }
264        match rest.find(part) {
265            Some(pos) => {
266                if i == 0 && pos != 0 {
267                    return false;
268                }
269                rest = &rest[pos + part.len()..];
270            }
271            None => return false,
272        }
273    }
274    if let Some(last) = parts.last() {
275        if !last.is_empty() && !name.ends_with(last) {
276            return false;
277        }
278    }
279    true
280}
281
282fn fetch_set(
283    source: &IconSourceDef,
284    version: &str,
285    cache: &IconCache,
286) -> crate::Result<(String, HashMap<String, IconData>)> {
287    #[cfg(feature = "icon-fetch")]
288    let ver = if version == "latest" {
289        resolve_latest_version(source)?
290    } else {
291        version.to_string()
292    };
293    #[cfg(not(feature = "icon-fetch"))]
294    let ver = version.to_string();
295
296    cache.ensure_dir(source.name, &ver)?;
297
298    for origin in source.origins {
299        match try_fetch_from_origin(source, origin, &ver, cache) {
300            Ok(icons) => return Ok((ver, icons)),
301            Err(e) => {
302                crate::log_warn!(
303                    "Failed to fetch '{}' from {:?}: {} — trying next origin",
304                    source.name,
305                    origin,
306                    e
307                );
308            }
309        }
310    }
311
312    Err(crate::TairitsuPackagerError::IconFetchError(format!(
313        "Could not fetch '{}' — all origins failed",
314        source.name
315    )))
316}
317
318#[cfg(feature = "icon-fetch")]
319fn resolve_latest_version(source: &IconSourceDef) -> crate::Result<String> {
320    for origin in source.origins {
321        if let Some(pkg) = origin.npm_package() {
322            let url = format!("https://registry.npmjs.org/{}/latest", pkg);
323            if let Ok(resp) = reqwest::blocking::get(&url) {
324                if let Ok(json) = resp.json::<serde_json::Value>() {
325                    if let Some(v) = json.get("version").and_then(|v| v.as_str()) {
326                        return Ok(v.to_string());
327                    }
328                }
329            }
330        }
331    }
332    Err(crate::TairitsuPackagerError::IconFetchError(format!(
333        "Could not resolve latest version for '{}' — all registry lookups failed",
334        source.name
335    )))
336}
337
338fn try_fetch_from_origin(
339    source: &IconSourceDef,
340    origin: &IconOrigin,
341    version: &str,
342    cache: &IconCache,
343) -> crate::Result<HashMap<String, IconData>> {
344    #[cfg(feature = "icon-fetch")]
345    {
346        match origin {
347            IconOrigin::Npm(pkg, subpath) => fetch_from_npm(source, pkg, subpath, version, cache),
348            IconOrigin::Github(owner, repo, branch) => {
349                fetch_from_github(source, owner, repo, branch, version, cache)
350            }
351            IconOrigin::GithubMirror(mirror, owner, repo, branch) => {
352                fetch_from_github_mirror(source, mirror, owner, repo, branch, version, cache)
353            }
354        }
355    }
356    #[cfg(not(feature = "icon-fetch"))]
357    {
358        let _ = (source, origin, version, cache);
359        Err(crate::TairitsuPackagerError::IconFetchError(
360            "icon-fetch feature not enabled".to_string(),
361        ))
362    }
363}
364
365#[cfg(feature = "icon-fetch")]
366fn fetch_from_npm(
367    source: &IconSourceDef,
368    pkg: &str,
369    _subpath: &str,
370    version: &str,
371    cache: &IconCache,
372) -> crate::Result<HashMap<String, IconData>> {
373    let tarball_url = {
374        let pkg_url = if pkg.starts_with('@') {
375            pkg.replace('/', "%2F")
376        } else {
377            pkg.to_string()
378        };
379        let registry_url = format!("https://registry.npmjs.org/{}", pkg_url);
380        let resp = reqwest::blocking::get(&registry_url)
381            .map_err(|e| crate::TairitsuPackagerError::HttpError(e.to_string()))?;
382        let json: serde_json::Value = resp
383            .json()
384            .map_err(|e| crate::TairitsuPackagerError::HttpError(e.to_string()))?;
385        json.pointer(&format!("/versions/{}/dist/tarball", version))
386            .and_then(|v| v.as_str())
387            .map(|s| s.to_string())
388            .unwrap_or_else(|| {
389                let name = pkg.split('/').last().unwrap_or(pkg);
390                format!(
391                    "https://registry.npmjs.org/{}/-/{}-{}.tgz",
392                    pkg, name, version
393                )
394            })
395    };
396    let dir = cache.set_dir(source.name, version);
397    let tgz_path = dir.join("package.tgz");
398
399    crate::log_info!("Downloading {}@{} from npm...", pkg, version);
400
401    download_file(&tarball_url, &tgz_path)?;
402
403    let extract_dir = dir.join("extracted");
404    if extract_dir.exists() {
405        std::fs::remove_dir_all(&extract_dir)?;
406    }
407    std::fs::create_dir_all(&extract_dir)?;
408
409    extract_tgz(&tgz_path, &extract_dir)?;
410
411    let package_dir = extract_dir.join("package");
412    scan_and_build_cache(source, &package_dir, version, cache)
413}
414
415#[cfg(feature = "icon-fetch")]
416fn fetch_from_github(
417    source: &IconSourceDef,
418    owner: &str,
419    repo: &str,
420    branch: &str,
421    version: &str,
422    cache: &IconCache,
423) -> crate::Result<HashMap<String, IconData>> {
424    let archive_url = format!(
425        "https://github.com/{}/{}/archive/refs/heads/{}.zip",
426        owner, repo, branch
427    );
428    let dir = cache.set_dir(source.name, version);
429    let zip_path = dir.join("source.zip");
430
431    crate::log_info!("Downloading {}/{} from GitHub...", owner, repo);
432
433    download_file(&archive_url, &zip_path)?;
434
435    let extract_dir = dir.join("extracted");
436    if extract_dir.exists() {
437        std::fs::remove_dir_all(&extract_dir)?;
438    }
439    std::fs::create_dir_all(&extract_dir)?;
440
441    extract_zip(&zip_path, &extract_dir)?;
442
443    let base_dir = find_extracted_dir(&extract_dir);
444    scan_and_build_cache(source, &base_dir, version, cache)
445}
446
447#[cfg(feature = "icon-fetch")]
448fn fetch_from_github_mirror(
449    source: &IconSourceDef,
450    mirror: &str,
451    owner: &str,
452    repo: &str,
453    branch: &str,
454    version: &str,
455    cache: &IconCache,
456) -> crate::Result<HashMap<String, IconData>> {
457    let archive_url = format!(
458        "https://{}/{}/{}/archive/refs/heads/{}.zip",
459        mirror, owner, repo, branch
460    );
461    let dir = cache.set_dir(source.name, version);
462    let zip_path = dir.join("source.zip");
463
464    crate::log_info!("Downloading {}/{} from mirror {}...", owner, repo, mirror);
465
466    download_file(&archive_url, &zip_path)?;
467
468    let extract_dir = dir.join("extracted");
469    if extract_dir.exists() {
470        std::fs::remove_dir_all(&extract_dir)?;
471    }
472    std::fs::create_dir_all(&extract_dir)?;
473
474    extract_zip(&zip_path, &extract_dir)?;
475
476    let base_dir = find_extracted_dir(&extract_dir);
477    scan_and_build_cache(source, &base_dir, version, cache)
478}
479
480fn scan_and_build_cache(
481    source: &IconSourceDef,
482    base_dir: &Path,
483    version: &str,
484    cache: &IconCache,
485) -> crate::Result<HashMap<String, IconData>> {
486    let mut icons = HashMap::new();
487    let mut svg_entries = Vec::new();
488
489    let svg_pattern = source.svg_glob;
490    let svg_base = base_dir.join(svg_pattern.split_once('/').map(|(p, _)| p).unwrap_or("."));
491
492    if svg_base.exists() {
493        scan_svg_dir(&svg_base, source, &mut icons, &mut svg_entries);
494    }
495
496    scan_svg_dir(base_dir, source, &mut icons, &mut svg_entries);
497
498    if let Some(meta_file) = &source.meta_file {
499        let meta_path = base_dir.join(meta_file);
500        if meta_path.exists() {
501            load_meta_tags(&meta_path, &mut icons, source.name);
502        }
503    }
504
505    let source_data = serde_json::to_vec(&icons).unwrap_or_default();
506    let source_hash = CacheManifest::compute_hash(&source_data);
507
508    let manifest = CacheManifest {
509        set_name: source.name.to_string(),
510        version: version.to_string(),
511        source_hash,
512        icon_count: icons.len(),
513        icons: icons.clone(),
514    };
515
516    cache.save_manifest(&manifest)?;
517    cache.save_svg_data(source.name, version, &svg_entries)?;
518
519    crate::log_info!(
520        "Cached {} icons for {} v{}",
521        icons.len(),
522        source.name,
523        version
524    );
525
526    Ok(icons)
527}
528
529fn scan_svg_dir(
530    dir: &Path,
531    source: &IconSourceDef,
532    icons: &mut HashMap<String, IconData>,
533    svg_entries: &mut Vec<(String, String)>,
534) {
535    if !dir.exists() {
536        return;
537    }
538
539    let entries = match std::fs::read_dir(dir) {
540        Ok(e) => e,
541        Err(_) => return,
542    };
543
544    for entry in entries.flatten() {
545        let path = entry.path();
546        if path.is_dir() {
547            let glob_pattern = source.svg_glob;
548            if glob_pattern.contains("**") {
549                scan_svg_dir(&path, source, icons, svg_entries);
550            }
551            continue;
552        }
553
554        if path.extension().map(|e| e == "svg").unwrap_or(false) {
555            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
556                if icons.contains_key(name) {
557                    continue;
558                }
559
560                if let Ok(content) = std::fs::read_to_string(&path) {
561                    if let Some(path_d) = extract_path_d(&content) {
562                        let icon_data = IconData {
563                            path_d: path_d.clone(),
564                            tags: Vec::new(),
565                            aliases: Vec::new(),
566                        };
567                        icons.insert(name.to_string(), icon_data);
568                        svg_entries.push((name.to_string(), path_d));
569                    }
570                }
571            }
572        }
573    }
574}
575
576fn extract_path_d(svg: &str) -> Option<String> {
577    let mut paths = Vec::new();
578    let mut search_from = 0;
579    while let Some(offset) = svg[search_from..].find("<path") {
580        let abs_start = search_from + offset;
581        let rest = &svg[abs_start..];
582
583        let tag_end = rest.find('>').unwrap_or(rest.len());
584        let tag_content = &rest[..tag_end];
585
586        let mut attr_search = 0;
587        let mut found = false;
588        while let Some(pos) = tag_content[attr_search..].find("d=\"") {
589            let abs_pos = attr_search + pos;
590            if abs_pos == 0
591                || tag_content.as_bytes().get(abs_pos - 1) == Some(&b' ')
592                || tag_content.as_bytes().get(abs_pos - 1) == Some(&b'\t')
593            {
594                let d_start = abs_pos + 3;
595                let d_rest = &tag_content[d_start..];
596                if let Some(d_end) = d_rest.find('"') {
597                    let path_d = &d_rest[..d_end];
598                    if !path_d.is_empty() {
599                        paths.push(path_d.to_string());
600                        found = true;
601                        break;
602                    }
603                }
604            }
605            attr_search = abs_pos + 1;
606        }
607
608        if !found {
609            let mut attr_search = 0;
610            while let Some(pos) = tag_content[attr_search..].find("d='") {
611                let abs_pos = attr_search + pos;
612                if abs_pos == 0
613                    || tag_content.as_bytes().get(abs_pos - 1) == Some(&b' ')
614                    || tag_content.as_bytes().get(abs_pos - 1) == Some(&b'\t')
615                {
616                    let d_start = abs_pos + 3;
617                    let d_rest = &tag_content[d_start..];
618                    if let Some(d_end) = d_rest.find('\'') {
619                        let path_d = &d_rest[..d_end];
620                        if !path_d.is_empty() {
621                            paths.push(path_d.to_string());
622                            break;
623                        }
624                    }
625                }
626                attr_search = abs_pos + 1;
627            }
628        }
629
630        search_from = abs_start + 6;
631    }
632
633    if paths.is_empty() {
634        None
635    } else {
636        Some(paths.join(" "))
637    }
638}
639
640fn load_meta_tags(meta_path: &Path, icons: &mut HashMap<String, IconData>, _set_name: &str) {
641    let content = match std::fs::read_to_string(meta_path) {
642        Ok(c) => c,
643        Err(_) => return,
644    };
645
646    let parsed: serde_json::Value = match serde_json::from_str(&content) {
647        Ok(v) => v,
648        Err(_) => return,
649    };
650
651    let entries = match parsed.as_array() {
652        Some(arr) => arr,
653        None => {
654            if let Some(obj) = parsed.as_object() {
655                for (name, val) in obj {
656                    if let Some(icon) = icons.get_mut(name) {
657                        if let Some(tags) = val.get("tags").and_then(|t| t.as_array()) {
658                            icon.tags = tags
659                                .iter()
660                                .filter_map(|v| v.as_str().map(String::from))
661                                .collect();
662                        }
663                        if let Some(aliases) = val.get("aliases").and_then(|a| a.as_array()) {
664                            icon.aliases = aliases
665                                .iter()
666                                .filter_map(|v| v.as_str().map(String::from))
667                                .collect();
668                        }
669                    }
670                }
671            }
672            return;
673        }
674    };
675
676    for entry in entries {
677        let name = match entry.get("name").and_then(|n| n.as_str()) {
678            Some(n) => n,
679            None => continue,
680        };
681
682        if let Some(icon) = icons.get_mut(name) {
683            if let Some(tags) = entry.get("tags").and_then(|t| t.as_array()) {
684                icon.tags = tags
685                    .iter()
686                    .filter_map(|v| v.as_str().map(String::from))
687                    .collect();
688            }
689            if let Some(aliases) = entry.get("aliases").and_then(|a| a.as_array()) {
690                icon.aliases = aliases
691                    .iter()
692                    .filter_map(|v| v.as_str().map(String::from))
693                    .collect();
694            }
695        }
696    }
697}
698
699#[cfg(feature = "icon-fetch")]
700fn download_file(url: &str, dest: &Path) -> crate::Result<()> {
701    #[cfg(feature = "icon-fetch")]
702    {
703        let client = reqwest::blocking::Client::builder()
704            .user_agent(format!("tairitsu-packager/{}", crate::VERSION))
705            .timeout(std::time::Duration::from_secs(300))
706            .build()
707            .map_err(|e| crate::TairitsuPackagerError::HttpError(e.to_string()))?;
708
709        let mut resp = client.get(url).send().map_err(|e| {
710            crate::TairitsuPackagerError::HttpError(format!("Failed to download {}: {}", url, e))
711        })?;
712
713        if !resp.status().is_success() {
714            return Err(crate::TairitsuPackagerError::HttpError(format!(
715                "HTTP {} for {}",
716                resp.status(),
717                url
718            )));
719        }
720
721        if let Some(parent) = dest.parent() {
722            std::fs::create_dir_all(parent)?;
723        }
724
725        let mut f = std::fs::File::create(dest)?;
726        resp.copy_to(&mut f)
727            .map_err(|e| crate::TairitsuPackagerError::HttpError(e.to_string()))?;
728        Ok(())
729    }
730
731    #[cfg(not(feature = "icon-fetch"))]
732    {
733        let _ = (url, dest);
734        Err(crate::TairitsuPackagerError::IconFetchError(
735            "icon-fetch feature not enabled".to_string(),
736        ))
737    }
738}
739
740fn extract_tgz(tgz_path: &Path, dest: &Path) -> crate::Result<()> {
741    #[cfg(feature = "icon-fetch")]
742    {
743        use std::process::Command;
744
745        let output = Command::new("tar")
746            .args([
747                "xzf",
748                &tgz_path.to_string_lossy(),
749                "-C",
750                &dest.to_string_lossy(),
751            ])
752            .output()
753            .map_err(|e| crate::TairitsuPackagerError::IconFetchError(e.to_string()))?;
754
755        if !output.status.success() {
756            return Err(crate::TairitsuPackagerError::IconFetchError(
757                String::from_utf8_lossy(&output.stderr).to_string(),
758            ));
759        }
760
761        Ok(())
762    }
763
764    #[cfg(not(feature = "icon-fetch"))]
765    {
766        let _ = (tgz_path, dest);
767        Err(crate::TairitsuPackagerError::IconFetchError(
768            "icon-fetch feature not enabled".to_string(),
769        ))
770    }
771}
772
773fn extract_zip(zip_path: &Path, dest: &Path) -> crate::Result<()> {
774    #[cfg(feature = "icon-fetch")]
775    {
776        let file = std::fs::File::open(zip_path)?;
777        let mut archive = zip::ZipArchive::new(file).map_err(|e| {
778            crate::TairitsuPackagerError::IconFetchError(format!("Failed to open zip: {}", e))
779        })?;
780
781        for i in 0..archive.len() {
782            let mut entry = archive.by_index(i).map_err(|e| {
783                crate::TairitsuPackagerError::IconFetchError(format!(
784                    "Failed to read zip entry {}: {}",
785                    i, e
786                ))
787            })?;
788
789            let outpath = match entry.enclosed_name() {
790                Some(path) => dest.join(path),
791                None => continue,
792            };
793
794            if entry.is_dir() {
795                std::fs::create_dir_all(&outpath)?;
796            } else {
797                if let Some(p) = outpath.parent() {
798                    if !p.exists() {
799                        std::fs::create_dir_all(p)?;
800                    }
801                }
802                let mut outfile = std::fs::File::create(&outpath)?;
803                std::io::copy(&mut entry, &mut outfile)?;
804            }
805        }
806
807        Ok(())
808    }
809
810    #[cfg(not(feature = "icon-fetch"))]
811    {
812        let _ = (zip_path, dest);
813        Err(crate::TairitsuPackagerError::IconFetchError(
814            "icon-fetch feature not enabled".to_string(),
815        ))
816    }
817}
818
819fn find_extracted_dir(base: &Path) -> PathBuf {
820    if let Ok(entries) = std::fs::read_dir(base) {
821        let mut dirs: Vec<_> = entries.flatten().filter(|e| e.path().is_dir()).collect();
822        if dirs.len() == 1 {
823            return dirs.remove(0).path();
824        }
825    }
826    base.to_path_buf()
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn test_glob_match_star_suffix() {
835        assert!(glob_match("arrow-*", "arrow-left"));
836        assert!(glob_match("arrow-*", "arrow-right"));
837        assert!(!glob_match("arrow-*", "chevron-left"));
838    }
839
840    #[test]
841    fn test_glob_match_star_prefix() {
842        assert!(glob_match("*-outline", "home-outline"));
843        assert!(!glob_match("*-outline", "home-filled"));
844    }
845
846    #[test]
847    fn test_glob_match_star_both() {
848        assert!(glob_match("*-*", "arrow-left"));
849        assert!(glob_match("*", "anything"));
850    }
851
852    #[test]
853    fn test_glob_match_exact() {
854        assert!(glob_match("home", "home"));
855        assert!(!glob_match("home", "home-outline"));
856    }
857
858    #[test]
859    fn test_find_source() {
860        assert!(sources::find_source("mdi").is_some());
861        assert!(sources::find_source("lucide").is_some());
862        assert!(sources::find_source("nonexistent").is_none());
863    }
864
865    #[test]
866    fn test_all_15_sources() {
867        assert_eq!(sources::ICON_SOURCES.len(), 15);
868    }
869
870    #[test]
871    fn test_extract_path_d() {
872        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/></svg>"#;
873        assert_eq!(
874            extract_path_d(svg),
875            Some("M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z".to_string())
876        );
877    }
878
879    #[test]
880    fn test_extract_path_d_fill_first() {
881        let svg =
882            r#"<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4V4z"/></svg>"#;
883        assert_eq!(extract_path_d(svg), Some("M4 4h16v16H4V4z".to_string()));
884    }
885
886    #[test]
887    fn test_read_consumer_metadata_empty() {
888        let tmp = tempfile::tempdir().unwrap();
889        let cargo_toml = tmp.path().join("Cargo.toml");
890        std::fs::write(
891            &cargo_toml,
892            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
893        )
894        .unwrap();
895        let meta = read_consumer_metadata(tmp.path()).unwrap();
896        assert!(meta.sets.is_empty());
897    }
898}