Skip to main content

native_theme_build/
lib.rs

1//! Build-time code generation for native-theme custom icon roles.
2//!
3//! This crate reads TOML icon definitions at build time and generates a Rust
4//! enum that implements `native_theme::IconProvider`. The generated enum maps
5//! each icon role to platform-specific identifiers (SF Symbols, Segoe Fluent,
6//! freedesktop, Material, Lucide) and optionally embeds bundled SVG data via
7//! `include_bytes!`.
8//!
9//! # TOML Schema
10//!
11//! The master TOML file declares the icon set name, roles, and which themes to
12//! support:
13//!
14//! ```toml
15//! name = "app-icon"
16//! roles = ["play-pause", "skip-forward", "volume-up"]
17//! bundled-themes = ["material"]
18//! system-themes = ["sf-symbols", "segoe-fluent", "freedesktop"]
19//! ```
20//!
21//! - **`name`** -- used to derive the generated enum name (`AppIcon`).
22//! - **`roles`** -- kebab-case role names; each becomes a PascalCase enum variant.
23//! - **`bundled-themes`** -- themes whose SVGs are embedded via `include_bytes!`.
24//! - **`system-themes`** -- themes resolved at runtime by the OS (no embedded SVGs).
25//!
26//! # Directory Layout
27//!
28//! ```text
29//! icons/
30//!   icons.toml           # Master TOML (the file passed to generate_icons)
31//!   material/
32//!     mapping.toml       # Role -> SVG filename mappings
33//!     play_pause.svg
34//!     skip_next.svg
35//!     volume_up.svg
36//!   sf-symbols/
37//!     mapping.toml       # Role -> SF Symbol name mappings
38//!   segoe-fluent/
39//!     mapping.toml       # Role -> Segoe codepoint mappings
40//!   freedesktop/
41//!     mapping.toml       # Role -> freedesktop icon name mappings
42//! ```
43//!
44//! # Mapping TOML
45//!
46//! Each theme directory contains a `mapping.toml` that maps roles to
47//! theme-specific identifiers. Simple form:
48//!
49//! ```toml
50//! play-pause = "play_pause"
51//! skip-forward = "skip_next"
52//! volume-up = "volume_up"
53//! ```
54//!
55//! DE-aware form (for freedesktop themes that vary by desktop environment):
56//!
57//! ```toml
58//! play-pause = { kde = "media-playback-start", default = "media-play" }
59//! ```
60//!
61//! A `default` key is required for every DE-aware entry.
62//!
63//! # build.rs Setup
64//!
65//! ```rust,ignore
66//! // Simple API (single TOML file):
67//! native_theme_build::generate_icons("icons/icons.toml");
68//!
69//! // Builder API (multiple TOML files, custom enum name):
70//! native_theme_build::IconGenerator::new()
71//!     .add("icons/media.toml")
72//!     .add("icons/navigation.toml")
73//!     .enum_name("AppIcon")
74//!     .generate();
75//! ```
76//!
77//! Both APIs resolve paths relative to `CARGO_MANIFEST_DIR`, emit
78//! `cargo::rerun-if-changed` directives for all referenced files, and write
79//! the generated code to `OUT_DIR`.
80//!
81//! # Using the Generated Code
82//!
83//! ```rust,ignore
84//! // In your lib.rs or main.rs:
85//! include!(concat!(env!("OUT_DIR"), "/app_icon.rs"));
86//!
87//! // The generated enum implements IconProvider:
88//! use native_theme::load_custom_icon;
89//! let icon_data = load_custom_icon(&AppIcon::PlayPause, "material");
90//! ```
91//!
92//! # What Gets Generated
93//!
94//! The output is a single `.rs` file containing:
95//!
96//! - A `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]` enum with one
97//!   variant per role.
98//! - An `IconProvider` implementation with `icon_name()` returning the
99//!   platform-specific identifier and `icon_svg()` returning
100//!   `include_bytes!(...)` data for bundled themes.
101//!
102//! # Validation
103//!
104//! Build errors are emitted at compile time for:
105//!
106//! - Missing roles in mapping files (every role must be present in every theme).
107//! - Missing SVG files for bundled themes.
108//! - Unknown role names in mapping files (not declared in the master TOML).
109//! - Duplicate roles across multiple TOML files (builder API).
110//! - Missing `default` key in DE-aware mapping entries.
111
112#![warn(missing_docs)]
113#![forbid(unsafe_code)]
114
115mod codegen;
116mod error;
117mod schema;
118mod validate;
119
120use std::collections::BTreeMap;
121use std::path::{Path, PathBuf};
122
123use heck::ToSnakeCase;
124
125use schema::{MasterConfig, ThemeMapping};
126
127// Re-exported for unit tests via `use super::*`
128#[cfg(test)]
129use error::BuildError;
130#[cfg(test)]
131use schema::{KNOWN_THEMES, MappingValue};
132
133/// Load a TOML file and run the pipeline on it. For integration testing only.
134#[doc(hidden)]
135pub fn __run_pipeline_on_files(
136    toml_paths: &[&Path],
137    enum_name_override: Option<&str>,
138) -> PipelineResult {
139    let mut configs = Vec::new();
140    let mut base_dirs = Vec::new();
141
142    for path in toml_paths {
143        let content = std::fs::read_to_string(path)
144            .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
145        let config: MasterConfig = toml::from_str(&content)
146            .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
147        let base_dir = path
148            .parent()
149            .expect("TOML path has no parent")
150            .to_path_buf();
151        configs.push((path.to_string_lossy().to_string(), config));
152        base_dirs.push(base_dir);
153    }
154
155    run_pipeline(&configs, &base_dirs, enum_name_override, None)
156}
157
158/// Result of running the pure pipeline core.
159///
160/// Contains the generated code, collected errors, and collected warnings.
161/// The thin outer layer (generate_icons / IconGenerator::generate) handles
162/// printing cargo directives, writing files, and calling process::exit.
163#[doc(hidden)]
164pub struct PipelineResult {
165    /// Generated Rust source code (empty if errors were found).
166    pub code: String,
167    /// Build errors found during validation.
168    pub errors: Vec<String>,
169    /// Warnings (e.g., orphan SVGs).
170    pub warnings: Vec<String>,
171    /// Paths that should trigger rebuild when changed.
172    pub rerun_paths: Vec<PathBuf>,
173    /// Size report: (role_count, bundled_theme_count, svg_paths).
174    pub size_report: Option<SizeReport>,
175    /// The output filename (snake_case of config name + ".rs").
176    pub output_filename: String,
177}
178
179/// Size report for cargo::warning output.
180#[doc(hidden)]
181pub struct SizeReport {
182    pub role_count: usize,
183    pub bundled_theme_count: usize,
184    pub total_svg_bytes: u64,
185    pub svg_count: usize,
186}
187
188/// Run the full pipeline on one or more loaded configs.
189///
190/// This is the pure core: it takes parsed configs, validates, generates code,
191/// and returns everything as data. No I/O, no process::exit.
192///
193/// When `manifest_dir` is `Some`, `base_dir` paths are stripped of the
194/// manifest prefix before being embedded in `include_bytes!` codegen,
195/// producing portable relative paths like `"/icons/material/play.svg"`
196/// instead of absolute filesystem paths.
197#[doc(hidden)]
198pub fn run_pipeline(
199    configs: &[(String, MasterConfig)],
200    base_dirs: &[PathBuf],
201    enum_name_override: Option<&str>,
202    manifest_dir: Option<&Path>,
203) -> PipelineResult {
204    assert_eq!(configs.len(), base_dirs.len());
205
206    let mut errors: Vec<String> = Vec::new();
207    let mut warnings: Vec<String> = Vec::new();
208    let mut rerun_paths: Vec<PathBuf> = Vec::new();
209    let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
210    let mut svg_paths: Vec<PathBuf> = Vec::new();
211
212    // Determine output filename from first config or override
213    let first_name = enum_name_override
214        .map(|s| s.to_string())
215        .unwrap_or_else(|| configs[0].1.name.clone());
216    let output_filename = format!("{}.rs", first_name.to_snake_case());
217
218    // Step 1: Check for duplicate roles across all files
219    if configs.len() > 1 {
220        let dup_errors = validate::validate_no_duplicate_roles(configs);
221        for e in dup_errors {
222            errors.push(e.to_string());
223        }
224    }
225
226    // Step 2: Merge configs first so validation uses the merged role list
227    let merged = merge_configs(configs, enum_name_override);
228
229    // Track rerun paths for all master TOML files
230    for (file_path, _config) in configs {
231        rerun_paths.push(PathBuf::from(file_path));
232    }
233
234    // Validate theme names on the merged config
235    let theme_errors = validate::validate_themes(&merged);
236    for e in theme_errors {
237        errors.push(e.to_string());
238    }
239
240    // Use the first base_dir as the reference for loading themes.
241    // For multi-file, all configs sharing a theme must use the same base_dir.
242    let base_dir = &base_dirs[0];
243
244    // Process bundled themes
245    for theme_name in &merged.bundled_themes {
246        let theme_dir = base_dir.join(theme_name);
247        let mapping_path = theme_dir.join("mapping.toml");
248        let mapping_path_str = mapping_path.to_string_lossy().to_string();
249
250        // Add mapping TOML and theme directory to rerun paths
251        rerun_paths.push(mapping_path.clone());
252        rerun_paths.push(theme_dir.clone());
253
254        match std::fs::read_to_string(&mapping_path) {
255            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
256                Ok(mapping) => {
257                    // Validate mapping against merged roles
258                    let map_errors =
259                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
260                    for e in map_errors {
261                        errors.push(e.to_string());
262                    }
263
264                    // Validate SVGs exist
265                    let svg_errors =
266                        validate::validate_svgs(&mapping, &theme_dir, &mapping_path_str);
267                    for e in svg_errors {
268                        errors.push(e.to_string());
269                    }
270
271                    // Warn about unrecognized DE keys in DeAware values
272                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
273                    warnings.extend(de_warnings);
274
275                    // Check orphan SVGs (warnings, not errors)
276                    let orphan_warnings = check_orphan_svgs_and_collect_paths(
277                        &mapping,
278                        &theme_dir,
279                        theme_name,
280                        &mut svg_paths,
281                        &mut rerun_paths,
282                    );
283                    warnings.extend(orphan_warnings);
284
285                    all_mappings.insert(theme_name.clone(), mapping);
286                }
287                Err(e) => {
288                    errors.push(format!("failed to parse {mapping_path_str}: {e}"));
289                }
290            },
291            Err(e) => {
292                errors.push(format!("failed to read {mapping_path_str}: {e}"));
293            }
294        }
295    }
296
297    // Process system themes (no SVG validation)
298    for theme_name in &merged.system_themes {
299        let theme_dir = base_dir.join(theme_name);
300        let mapping_path = theme_dir.join("mapping.toml");
301        let mapping_path_str = mapping_path.to_string_lossy().to_string();
302
303        // Add mapping TOML to rerun paths
304        rerun_paths.push(mapping_path.clone());
305
306        match std::fs::read_to_string(&mapping_path) {
307            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
308                Ok(mapping) => {
309                    let map_errors =
310                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
311                    for e in map_errors {
312                        errors.push(e.to_string());
313                    }
314
315                    // Warn about unrecognized DE keys in DeAware values
316                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
317                    warnings.extend(de_warnings);
318
319                    all_mappings.insert(theme_name.clone(), mapping);
320                }
321                Err(e) => {
322                    errors.push(format!("failed to parse {mapping_path_str}: {e}"));
323                }
324            },
325            Err(e) => {
326                errors.push(format!("failed to read {mapping_path_str}: {e}"));
327            }
328        }
329    }
330
331    // If errors, return without generating code
332    if !errors.is_empty() {
333        return PipelineResult {
334            code: String::new(),
335            errors,
336            warnings,
337            rerun_paths,
338            size_report: None,
339            output_filename,
340        };
341    }
342
343    // Compute base_dir for codegen -- strip manifest_dir prefix when available
344    // so include_bytes! paths are relative (e.g., "icons/material/play.svg")
345    // instead of absolute (e.g., "/home/user/project/icons/material/play.svg")
346    let base_dir_str = if let Some(mdir) = manifest_dir {
347        base_dir
348            .strip_prefix(mdir)
349            .unwrap_or(base_dir)
350            .to_string_lossy()
351            .to_string()
352    } else {
353        base_dir.to_string_lossy().to_string()
354    };
355
356    // Step 4: Generate code
357    let code = codegen::generate_code(&merged, &all_mappings, &base_dir_str);
358
359    // Step 5: Compute size report
360    let total_svg_bytes: u64 = svg_paths
361        .iter()
362        .filter_map(|p| std::fs::metadata(p).ok())
363        .map(|m| m.len())
364        .sum();
365
366    let size_report = Some(SizeReport {
367        role_count: merged.roles.len(),
368        bundled_theme_count: merged.bundled_themes.len(),
369        total_svg_bytes,
370        svg_count: svg_paths.len(),
371    });
372
373    PipelineResult {
374        code,
375        errors,
376        warnings,
377        rerun_paths,
378        size_report,
379        output_filename,
380    }
381}
382
383/// Check orphan SVGs and simultaneously collect SVG paths and rerun paths.
384fn check_orphan_svgs_and_collect_paths(
385    mapping: &ThemeMapping,
386    theme_dir: &Path,
387    theme_name: &str,
388    svg_paths: &mut Vec<PathBuf>,
389    rerun_paths: &mut Vec<PathBuf>,
390) -> Vec<String> {
391    // Collect referenced SVG paths
392    for value in mapping.values() {
393        if let Some(name) = value.default_name() {
394            let svg_path = theme_dir.join(format!("{name}.svg"));
395            if svg_path.exists() {
396                rerun_paths.push(svg_path.clone());
397                svg_paths.push(svg_path);
398            }
399        }
400    }
401
402    validate::check_orphan_svgs(mapping, theme_dir, theme_name)
403}
404
405/// Merge multiple configs into a single MasterConfig for code generation.
406fn merge_configs(
407    configs: &[(String, MasterConfig)],
408    enum_name_override: Option<&str>,
409) -> MasterConfig {
410    let name = enum_name_override
411        .map(|s| s.to_string())
412        .unwrap_or_else(|| configs[0].1.name.clone());
413
414    let mut roles = Vec::new();
415    let mut bundled_themes = Vec::new();
416    let mut system_themes = Vec::new();
417    let mut seen_bundled = std::collections::BTreeSet::new();
418    let mut seen_system = std::collections::BTreeSet::new();
419
420    for (_path, config) in configs {
421        roles.extend(config.roles.iter().cloned());
422
423        for t in &config.bundled_themes {
424            if seen_bundled.insert(t.clone()) {
425                bundled_themes.push(t.clone());
426            }
427        }
428        for t in &config.system_themes {
429            if seen_system.insert(t.clone()) {
430                system_themes.push(t.clone());
431            }
432        }
433    }
434
435    MasterConfig {
436        name,
437        roles,
438        bundled_themes,
439        system_themes,
440    }
441}
442
443/// Simple API: generate icon code from a single TOML file.
444///
445/// Reads the master TOML at `toml_path`, validates all referenced themes
446/// and SVG files, and writes generated Rust code to `OUT_DIR`.
447///
448/// # Panics
449///
450/// Calls `process::exit(1)` if validation errors are found.
451pub fn generate_icons(toml_path: impl AsRef<Path>) {
452    let toml_path = toml_path.as_ref();
453    let manifest_dir =
454        PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
455    let resolved = manifest_dir.join(toml_path);
456
457    let content = std::fs::read_to_string(&resolved)
458        .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
459    let config: MasterConfig = toml::from_str(&content)
460        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
461
462    let base_dir = resolved
463        .parent()
464        .expect("TOML path has no parent")
465        .to_path_buf();
466    let file_path_str = resolved.to_string_lossy().to_string();
467
468    let result = run_pipeline(
469        &[(file_path_str, config)],
470        &[base_dir],
471        None,
472        Some(&manifest_dir),
473    );
474
475    emit_result(result);
476}
477
478/// Builder API for composing multiple TOML icon definitions.
479pub struct IconGenerator {
480    sources: Vec<PathBuf>,
481    enum_name_override: Option<String>,
482}
483
484impl Default for IconGenerator {
485    fn default() -> Self {
486        Self::new()
487    }
488}
489
490impl IconGenerator {
491    /// Create a new builder.
492    pub fn new() -> Self {
493        Self {
494            sources: Vec::new(),
495            enum_name_override: None,
496        }
497    }
498
499    /// Add a TOML icon definition file.
500    #[allow(clippy::should_implement_trait)]
501    pub fn add(mut self, path: impl AsRef<Path>) -> Self {
502        self.sources.push(path.as_ref().to_path_buf());
503        self
504    }
505
506    /// Override the generated enum name.
507    pub fn enum_name(mut self, name: &str) -> Self {
508        self.enum_name_override = Some(name.to_string());
509        self
510    }
511
512    /// Run the full pipeline: load, validate, generate, write.
513    ///
514    /// # Panics
515    ///
516    /// Calls `process::exit(1)` if validation errors are found.
517    pub fn generate(self) {
518        let manifest_dir =
519            PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
520
521        let mut configs = Vec::new();
522        let mut base_dirs = Vec::new();
523
524        for source in &self.sources {
525            let resolved = manifest_dir.join(source);
526            let content = std::fs::read_to_string(&resolved)
527                .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
528            let config: MasterConfig = toml::from_str(&content)
529                .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
530
531            let base_dir = resolved
532                .parent()
533                .expect("TOML path has no parent")
534                .to_path_buf();
535            let file_path_str = resolved.to_string_lossy().to_string();
536
537            configs.push((file_path_str, config));
538            base_dirs.push(base_dir);
539        }
540
541        let result = run_pipeline(
542            &configs,
543            &base_dirs,
544            self.enum_name_override.as_deref(),
545            Some(&manifest_dir),
546        );
547
548        emit_result(result);
549    }
550}
551
552/// Emit cargo directives, write output file, or exit on errors.
553fn emit_result(result: PipelineResult) {
554    // Emit rerun-if-changed for all tracked paths
555    for path in &result.rerun_paths {
556        println!("cargo::rerun-if-changed={}", path.display());
557    }
558
559    // Emit errors and exit if any
560    if !result.errors.is_empty() {
561        for e in &result.errors {
562            println!("cargo::error={e}");
563        }
564        std::process::exit(1);
565    }
566
567    // Emit warnings
568    for w in &result.warnings {
569        println!("cargo::warning={w}");
570    }
571
572    // Write output file
573    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));
574    let out_path = out_dir.join(&result.output_filename);
575    std::fs::write(&out_path, &result.code)
576        .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
577
578    // Emit size report
579    if let Some(report) = &result.size_report {
580        let kb = report.total_svg_bytes as f64 / 1024.0;
581        println!(
582            "cargo::warning={} roles x {} bundled themes = {} SVGs, {:.1} KB total",
583            report.role_count, report.bundled_theme_count, report.svg_count, kb
584        );
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use std::collections::BTreeMap;
592    use std::fs;
593
594    // === MasterConfig tests ===
595
596    #[test]
597    fn master_config_deserializes_full() {
598        let toml_str = r#"
599name = "app-icon"
600roles = ["play-pause", "skip-forward"]
601bundled-themes = ["material"]
602system-themes = ["sf-symbols"]
603"#;
604        let config: MasterConfig = toml::from_str(toml_str).unwrap();
605        assert_eq!(config.name, "app-icon");
606        assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
607        assert_eq!(config.bundled_themes, vec!["material"]);
608        assert_eq!(config.system_themes, vec!["sf-symbols"]);
609    }
610
611    #[test]
612    fn master_config_empty_optional_fields() {
613        let toml_str = r#"
614name = "x"
615roles = ["a"]
616"#;
617        let config: MasterConfig = toml::from_str(toml_str).unwrap();
618        assert_eq!(config.name, "x");
619        assert_eq!(config.roles, vec!["a"]);
620        assert!(config.bundled_themes.is_empty());
621        assert!(config.system_themes.is_empty());
622    }
623
624    #[test]
625    fn master_config_rejects_unknown_fields() {
626        let toml_str = r#"
627name = "x"
628roles = ["a"]
629bogus = "nope"
630"#;
631        let result = toml::from_str::<MasterConfig>(toml_str);
632        assert!(result.is_err());
633    }
634
635    // === MappingValue tests ===
636
637    #[test]
638    fn mapping_value_simple() {
639        let toml_str = r#"play-pause = "play_pause""#;
640        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
641        match &mapping["play-pause"] {
642            MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
643            _ => panic!("expected Simple variant"),
644        }
645    }
646
647    #[test]
648    fn mapping_value_de_aware() {
649        let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
650        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
651        match &mapping["play-pause"] {
652            MappingValue::DeAware(m) => {
653                assert_eq!(m["kde"], "media-playback-start");
654                assert_eq!(m["default"], "play");
655            }
656            _ => panic!("expected DeAware variant"),
657        }
658    }
659
660    #[test]
661    fn theme_mapping_mixed_values() {
662        let toml_str = r#"
663play-pause = "play_pause"
664bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
665skip-forward = "skip_next"
666"#;
667        let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
668        assert_eq!(mapping.len(), 3);
669        assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
670        assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
671        assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
672    }
673
674    // === MappingValue::default_name tests ===
675
676    #[test]
677    fn mapping_value_default_name_simple() {
678        let val = MappingValue::Simple("play_pause".to_string());
679        assert_eq!(val.default_name(), Some("play_pause"));
680    }
681
682    #[test]
683    fn mapping_value_default_name_de_aware() {
684        let mut m = BTreeMap::new();
685        m.insert("kde".to_string(), "media-playback-start".to_string());
686        m.insert("default".to_string(), "play".to_string());
687        let val = MappingValue::DeAware(m);
688        assert_eq!(val.default_name(), Some("play"));
689    }
690
691    #[test]
692    fn mapping_value_default_name_de_aware_missing_default() {
693        let mut m = BTreeMap::new();
694        m.insert("kde".to_string(), "media-playback-start".to_string());
695        let val = MappingValue::DeAware(m);
696        assert_eq!(val.default_name(), None);
697    }
698
699    // === BuildError Display tests ===
700
701    #[test]
702    fn build_error_missing_role_format() {
703        let err = BuildError::MissingRole {
704            role: "play-pause".into(),
705            mapping_file: "icons/material/mapping.toml".into(),
706        };
707        let msg = err.to_string();
708        assert!(msg.contains("play-pause"), "should contain role name");
709        assert!(
710            msg.contains("icons/material/mapping.toml"),
711            "should contain file path"
712        );
713    }
714
715    #[test]
716    fn build_error_missing_svg_format() {
717        let err = BuildError::MissingSvg {
718            path: "icons/material/play.svg".into(),
719        };
720        let msg = err.to_string();
721        assert!(
722            msg.contains("icons/material/play.svg"),
723            "should contain SVG path"
724        );
725    }
726
727    #[test]
728    fn build_error_unknown_role_format() {
729        let err = BuildError::UnknownRole {
730            role: "bogus".into(),
731            mapping_file: "icons/material/mapping.toml".into(),
732        };
733        let msg = err.to_string();
734        assert!(msg.contains("bogus"), "should contain role name");
735        assert!(
736            msg.contains("icons/material/mapping.toml"),
737            "should contain file path"
738        );
739    }
740
741    #[test]
742    fn build_error_unknown_theme_format() {
743        let err = BuildError::UnknownTheme {
744            theme: "nonexistent".into(),
745        };
746        let msg = err.to_string();
747        assert!(msg.contains("nonexistent"), "should contain theme name");
748    }
749
750    #[test]
751    fn build_error_missing_default_format() {
752        let err = BuildError::MissingDefault {
753            role: "bluetooth".into(),
754            mapping_file: "icons/freedesktop/mapping.toml".into(),
755        };
756        let msg = err.to_string();
757        assert!(msg.contains("bluetooth"), "should contain role name");
758        assert!(
759            msg.contains("icons/freedesktop/mapping.toml"),
760            "should contain file path"
761        );
762    }
763
764    #[test]
765    fn build_error_duplicate_role_format() {
766        let err = BuildError::DuplicateRole {
767            role: "play-pause".into(),
768            file_a: "icons/a.toml".into(),
769            file_b: "icons/b.toml".into(),
770        };
771        let msg = err.to_string();
772        assert!(msg.contains("play-pause"), "should contain role name");
773        assert!(
774            msg.contains("icons/a.toml"),
775            "should contain first file path"
776        );
777        assert!(
778            msg.contains("icons/b.toml"),
779            "should contain second file path"
780        );
781    }
782
783    // === KNOWN_THEMES tests ===
784
785    #[test]
786    fn known_themes_has_all_five() {
787        assert_eq!(KNOWN_THEMES.len(), 5);
788        assert!(KNOWN_THEMES.contains(&"sf-symbols"));
789        assert!(KNOWN_THEMES.contains(&"segoe-fluent"));
790        assert!(KNOWN_THEMES.contains(&"freedesktop"));
791        assert!(KNOWN_THEMES.contains(&"material"));
792        assert!(KNOWN_THEMES.contains(&"lucide"));
793    }
794
795    // === Helper to create test fixture directories ===
796
797    fn create_fixture_dir(suffix: &str) -> PathBuf {
798        let dir = std::env::temp_dir().join(format!("native_theme_test_pipeline_{suffix}"));
799        let _ = fs::remove_dir_all(&dir);
800        fs::create_dir_all(&dir).unwrap();
801        dir
802    }
803
804    fn write_fixture(dir: &Path, path: &str, content: &str) {
805        let full_path = dir.join(path);
806        if let Some(parent) = full_path.parent() {
807            fs::create_dir_all(parent).unwrap();
808        }
809        fs::write(full_path, content).unwrap();
810    }
811
812    const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
813
814    // === run_pipeline tests ===
815
816    #[test]
817    fn pipeline_happy_path_generates_code() {
818        let dir = create_fixture_dir("happy");
819        write_fixture(
820            &dir,
821            "material/mapping.toml",
822            r#"
823play-pause = "play_pause"
824skip-forward = "skip_next"
825"#,
826        );
827        write_fixture(
828            &dir,
829            "sf-symbols/mapping.toml",
830            r#"
831play-pause = "play.fill"
832skip-forward = "forward.fill"
833"#,
834        );
835        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
836        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
837
838        let config: MasterConfig = toml::from_str(
839            r#"
840name = "sample-icon"
841roles = ["play-pause", "skip-forward"]
842bundled-themes = ["material"]
843system-themes = ["sf-symbols"]
844"#,
845        )
846        .unwrap();
847
848        let result = run_pipeline(
849            &[("sample-icons.toml".to_string(), config)],
850            std::slice::from_ref(&dir),
851            None,
852            None,
853        );
854
855        assert!(
856            result.errors.is_empty(),
857            "expected no errors: {:?}",
858            result.errors
859        );
860        assert!(!result.code.is_empty(), "expected generated code");
861        assert!(result.code.contains("pub enum SampleIcon"));
862        assert!(result.code.contains("PlayPause"));
863        assert!(result.code.contains("SkipForward"));
864
865        let _ = fs::remove_dir_all(&dir);
866    }
867
868    #[test]
869    fn pipeline_output_filename_uses_snake_case() {
870        let dir = create_fixture_dir("filename");
871        write_fixture(
872            &dir,
873            "material/mapping.toml",
874            "play-pause = \"play_pause\"\n",
875        );
876        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
877
878        let config: MasterConfig = toml::from_str(
879            r#"
880name = "app-icon"
881roles = ["play-pause"]
882bundled-themes = ["material"]
883"#,
884        )
885        .unwrap();
886
887        let result = run_pipeline(
888            &[("app.toml".to_string(), config)],
889            std::slice::from_ref(&dir),
890            None,
891            None,
892        );
893
894        assert_eq!(result.output_filename, "app_icon.rs");
895
896        let _ = fs::remove_dir_all(&dir);
897    }
898
899    #[test]
900    fn pipeline_collects_rerun_paths() {
901        let dir = create_fixture_dir("rerun");
902        write_fixture(
903            &dir,
904            "material/mapping.toml",
905            r#"
906play-pause = "play_pause"
907"#,
908        );
909        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
910
911        let config: MasterConfig = toml::from_str(
912            r#"
913name = "test"
914roles = ["play-pause"]
915bundled-themes = ["material"]
916"#,
917        )
918        .unwrap();
919
920        let result = run_pipeline(
921            &[("test.toml".to_string(), config)],
922            std::slice::from_ref(&dir),
923            None,
924            None,
925        );
926
927        assert!(result.errors.is_empty());
928        // Should include: master TOML, mapping TOML, theme dir, SVG files
929        let path_strs: Vec<String> = result
930            .rerun_paths
931            .iter()
932            .map(|p| p.to_string_lossy().to_string())
933            .collect();
934        assert!(
935            path_strs.iter().any(|p| p.contains("test.toml")),
936            "should track master TOML"
937        );
938        assert!(
939            path_strs.iter().any(|p| p.contains("mapping.toml")),
940            "should track mapping TOML"
941        );
942        assert!(
943            path_strs.iter().any(|p| p.contains("play_pause.svg")),
944            "should track SVG files"
945        );
946
947        let _ = fs::remove_dir_all(&dir);
948    }
949
950    #[test]
951    fn pipeline_emits_size_report() {
952        let dir = create_fixture_dir("size");
953        write_fixture(
954            &dir,
955            "material/mapping.toml",
956            "play-pause = \"play_pause\"\n",
957        );
958        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
959
960        let config: MasterConfig = toml::from_str(
961            r#"
962name = "test"
963roles = ["play-pause"]
964bundled-themes = ["material"]
965"#,
966        )
967        .unwrap();
968
969        let result = run_pipeline(
970            &[("test.toml".to_string(), config)],
971            std::slice::from_ref(&dir),
972            None,
973            None,
974        );
975
976        assert!(result.errors.is_empty());
977        let report = result
978            .size_report
979            .as_ref()
980            .expect("should have size report");
981        assert_eq!(report.role_count, 1);
982        assert_eq!(report.bundled_theme_count, 1);
983        assert_eq!(report.svg_count, 1);
984        assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
985
986        let _ = fs::remove_dir_all(&dir);
987    }
988
989    #[test]
990    fn pipeline_returns_errors_on_missing_role() {
991        let dir = create_fixture_dir("missing_role");
992        // Mapping is missing "skip-forward"
993        write_fixture(
994            &dir,
995            "material/mapping.toml",
996            "play-pause = \"play_pause\"\n",
997        );
998        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
999
1000        let config: MasterConfig = toml::from_str(
1001            r#"
1002name = "test"
1003roles = ["play-pause", "skip-forward"]
1004bundled-themes = ["material"]
1005"#,
1006        )
1007        .unwrap();
1008
1009        let result = run_pipeline(
1010            &[("test.toml".to_string(), config)],
1011            std::slice::from_ref(&dir),
1012            None,
1013            None,
1014        );
1015
1016        assert!(!result.errors.is_empty(), "should have errors");
1017        assert!(
1018            result.errors.iter().any(|e| e.contains("skip-forward")),
1019            "should mention missing role"
1020        );
1021        assert!(result.code.is_empty(), "no code on errors");
1022
1023        let _ = fs::remove_dir_all(&dir);
1024    }
1025
1026    #[test]
1027    fn pipeline_returns_errors_on_missing_svg() {
1028        let dir = create_fixture_dir("missing_svg");
1029        write_fixture(
1030            &dir,
1031            "material/mapping.toml",
1032            r#"
1033play-pause = "play_pause"
1034skip-forward = "skip_next"
1035"#,
1036        );
1037        // Only create one SVG, leave skip_next.svg missing
1038        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1039
1040        let config: MasterConfig = toml::from_str(
1041            r#"
1042name = "test"
1043roles = ["play-pause", "skip-forward"]
1044bundled-themes = ["material"]
1045"#,
1046        )
1047        .unwrap();
1048
1049        let result = run_pipeline(
1050            &[("test.toml".to_string(), config)],
1051            std::slice::from_ref(&dir),
1052            None,
1053            None,
1054        );
1055
1056        assert!(!result.errors.is_empty(), "should have errors");
1057        assert!(
1058            result.errors.iter().any(|e| e.contains("skip_next.svg")),
1059            "should mention missing SVG"
1060        );
1061
1062        let _ = fs::remove_dir_all(&dir);
1063    }
1064
1065    #[test]
1066    fn pipeline_orphan_svgs_are_warnings() {
1067        let dir = create_fixture_dir("orphan_warn");
1068        write_fixture(
1069            &dir,
1070            "material/mapping.toml",
1071            "play-pause = \"play_pause\"\n",
1072        );
1073        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1074        write_fixture(&dir, "material/unused.svg", SVG_STUB);
1075
1076        let config: MasterConfig = toml::from_str(
1077            r#"
1078name = "test"
1079roles = ["play-pause"]
1080bundled-themes = ["material"]
1081"#,
1082        )
1083        .unwrap();
1084
1085        let result = run_pipeline(
1086            &[("test.toml".to_string(), config)],
1087            std::slice::from_ref(&dir),
1088            None,
1089            None,
1090        );
1091
1092        assert!(result.errors.is_empty(), "orphans are not errors");
1093        assert!(!result.warnings.is_empty(), "should have orphan warning");
1094        assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1095
1096        let _ = fs::remove_dir_all(&dir);
1097    }
1098
1099    // === merge_configs tests ===
1100
1101    #[test]
1102    fn merge_configs_combines_roles() {
1103        let config_a: MasterConfig = toml::from_str(
1104            r#"
1105name = "a"
1106roles = ["play-pause"]
1107bundled-themes = ["material"]
1108"#,
1109        )
1110        .unwrap();
1111        let config_b: MasterConfig = toml::from_str(
1112            r#"
1113name = "b"
1114roles = ["skip-forward"]
1115bundled-themes = ["material"]
1116system-themes = ["sf-symbols"]
1117"#,
1118        )
1119        .unwrap();
1120
1121        let configs = vec![
1122            ("a.toml".to_string(), config_a),
1123            ("b.toml".to_string(), config_b),
1124        ];
1125        let merged = merge_configs(&configs, None);
1126
1127        assert_eq!(merged.name, "a"); // uses first config's name
1128        assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1129        assert_eq!(merged.bundled_themes, vec!["material"]); // deduplicated
1130        assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1131    }
1132
1133    #[test]
1134    fn merge_configs_uses_enum_name_override() {
1135        let config: MasterConfig = toml::from_str(
1136            r#"
1137name = "original"
1138roles = ["x"]
1139"#,
1140        )
1141        .unwrap();
1142
1143        let configs = vec![("a.toml".to_string(), config)];
1144        let merged = merge_configs(&configs, Some("MyIcons"));
1145
1146        assert_eq!(merged.name, "MyIcons");
1147    }
1148
1149    // === Builder pipeline tests ===
1150
1151    #[test]
1152    fn pipeline_builder_merges_two_files() {
1153        let dir = create_fixture_dir("builder_merge");
1154        write_fixture(
1155            &dir,
1156            "material/mapping.toml",
1157            r#"
1158play-pause = "play_pause"
1159skip-forward = "skip_next"
1160"#,
1161        );
1162        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1163        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1164
1165        let config_a: MasterConfig = toml::from_str(
1166            r#"
1167name = "icons-a"
1168roles = ["play-pause"]
1169bundled-themes = ["material"]
1170"#,
1171        )
1172        .unwrap();
1173        let config_b: MasterConfig = toml::from_str(
1174            r#"
1175name = "icons-b"
1176roles = ["skip-forward"]
1177bundled-themes = ["material"]
1178"#,
1179        )
1180        .unwrap();
1181
1182        let result = run_pipeline(
1183            &[
1184                ("a.toml".to_string(), config_a),
1185                ("b.toml".to_string(), config_b),
1186            ],
1187            &[dir.clone(), dir.clone()],
1188            Some("AllIcons"),
1189            None,
1190        );
1191
1192        assert!(
1193            result.errors.is_empty(),
1194            "expected no errors: {:?}",
1195            result.errors
1196        );
1197        assert!(
1198            result.code.contains("pub enum AllIcons"),
1199            "should use override name"
1200        );
1201        assert!(result.code.contains("PlayPause"));
1202        assert!(result.code.contains("SkipForward"));
1203        assert_eq!(result.output_filename, "all_icons.rs");
1204
1205        let _ = fs::remove_dir_all(&dir);
1206    }
1207
1208    #[test]
1209    fn pipeline_builder_detects_duplicate_roles() {
1210        let dir = create_fixture_dir("builder_dup");
1211        write_fixture(
1212            &dir,
1213            "material/mapping.toml",
1214            "play-pause = \"play_pause\"\n",
1215        );
1216        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1217
1218        let config_a: MasterConfig = toml::from_str(
1219            r#"
1220name = "a"
1221roles = ["play-pause"]
1222bundled-themes = ["material"]
1223"#,
1224        )
1225        .unwrap();
1226        let config_b: MasterConfig = toml::from_str(
1227            r#"
1228name = "b"
1229roles = ["play-pause"]
1230bundled-themes = ["material"]
1231"#,
1232        )
1233        .unwrap();
1234
1235        let result = run_pipeline(
1236            &[
1237                ("a.toml".to_string(), config_a),
1238                ("b.toml".to_string(), config_b),
1239            ],
1240            &[dir.clone(), dir.clone()],
1241            None,
1242            None,
1243        );
1244
1245        assert!(!result.errors.is_empty(), "should detect duplicate roles");
1246        assert!(result.errors.iter().any(|e| e.contains("play-pause")));
1247
1248        let _ = fs::remove_dir_all(&dir);
1249    }
1250
1251    #[test]
1252    fn pipeline_generates_relative_include_bytes_paths() {
1253        // Simulate what generate_icons does: manifest_dir + "icons/icons.toml"
1254        // The tmpdir acts as CARGO_MANIFEST_DIR.
1255        // base_dir is absolute (tmpdir/icons), but run_pipeline should strip
1256        // the manifest_dir prefix for codegen, producing relative paths.
1257        let tmpdir = create_fixture_dir("rel_paths");
1258        write_fixture(
1259            &tmpdir,
1260            "icons/material/mapping.toml",
1261            "play-pause = \"play_pause\"\n",
1262        );
1263        write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1264
1265        let config: MasterConfig = toml::from_str(
1266            r#"
1267name = "test"
1268roles = ["play-pause"]
1269bundled-themes = ["material"]
1270"#,
1271        )
1272        .unwrap();
1273
1274        // base_dir is absolute (as generate_icons would compute)
1275        let abs_base_dir = tmpdir.join("icons");
1276
1277        let result = run_pipeline(
1278            &[("icons/icons.toml".to_string(), config)],
1279            &[abs_base_dir],
1280            None,
1281            Some(&tmpdir), // manifest_dir for stripping prefix
1282        );
1283
1284        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1285        // The include_bytes path should contain "/icons/material/play_pause.svg"
1286        assert!(
1287            result.code.contains("\"/icons/material/play_pause.svg\""),
1288            "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1289            result.code,
1290        );
1291        // The include_bytes path should NOT contain the absolute tmpdir
1292        let tmpdir_str = tmpdir.to_string_lossy();
1293        assert!(
1294            !result.code.contains(&*tmpdir_str),
1295            "include_bytes path should NOT contain absolute tmpdir path",
1296        );
1297
1298        let _ = fs::remove_dir_all(&tmpdir);
1299    }
1300
1301    #[test]
1302    fn pipeline_no_system_svg_check() {
1303        // System themes should NOT validate SVGs
1304        let dir = create_fixture_dir("no_sys_svg");
1305        // sf-symbols has mapping but NO SVG files -- should be fine
1306        write_fixture(
1307            &dir,
1308            "sf-symbols/mapping.toml",
1309            r#"
1310play-pause = "play.fill"
1311"#,
1312        );
1313
1314        let config: MasterConfig = toml::from_str(
1315            r#"
1316name = "test"
1317roles = ["play-pause"]
1318system-themes = ["sf-symbols"]
1319"#,
1320        )
1321        .unwrap();
1322
1323        let result = run_pipeline(
1324            &[("test.toml".to_string(), config)],
1325            std::slice::from_ref(&dir),
1326            None,
1327            None,
1328        );
1329
1330        assert!(
1331            result.errors.is_empty(),
1332            "system themes should not require SVGs: {:?}",
1333            result.errors
1334        );
1335
1336        let _ = fs::remove_dir_all(&dir);
1337    }
1338}