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