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//!     .unwrap_or_exit();
69//!
70//! // Builder API (multiple TOML files, custom enum name):
71//! native_theme_build::IconGenerator::new()
72//!     .source("icons/media.toml")
73//!     .source("icons/navigation.toml")
74//!     .enum_name("AppIcon")
75//!     .generate()
76//!     .unwrap_or_exit();
77//! ```
78//!
79//! Both APIs resolve paths relative to `CARGO_MANIFEST_DIR`, and return a
80//! [`Result`] with a [`GenerateOutput`] on success or [`BuildErrors`] on
81//! failure. Call [`GenerateOutput::emit_cargo_directives()`] to write the
82//! output file and emit `cargo::rerun-if-changed` / `cargo::warning`
83//! directives.
84//!
85//! The [`UnwrapOrExit`] trait provides `.unwrap_or_exit()` as a drop-in
86//! replacement for the old `process::exit(1)` behaviour.
87//!
88//! # Using the Generated Code
89//!
90//! ```rust,ignore
91//! // In your lib.rs or main.rs:
92//! include!(concat!(env!("OUT_DIR"), "/app_icon.rs"));
93//!
94//! // The generated enum implements IconProvider:
95//! use native_theme::load_custom_icon;
96//! let icon_data = load_custom_icon(&AppIcon::PlayPause, "material");
97//! ```
98//!
99//! # What Gets Generated
100//!
101//! The output is a single `.rs` file containing:
102//!
103//! - A `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]` enum with one
104//!   variant per role.
105//! - An `IconProvider` implementation with `icon_name()` returning the
106//!   platform-specific identifier and `icon_svg()` returning
107//!   `include_bytes!(...)` data for bundled themes.
108//!
109//! # Validation
110//!
111//! Build errors are emitted at compile time for:
112//!
113//! - Missing roles in mapping files (every role must be present in every theme).
114//! - Missing SVG files for bundled themes.
115//! - Unknown role names in mapping files (not declared in the master TOML).
116//! - Duplicate roles across multiple TOML files (builder API).
117//! - Missing `default` key in DE-aware mapping entries.
118
119#![warn(missing_docs)]
120#![forbid(unsafe_code)]
121
122mod codegen;
123mod error;
124mod schema;
125mod validate;
126
127use std::collections::BTreeMap;
128use std::path::{Path, PathBuf};
129
130use heck::ToSnakeCase;
131
132pub use error::{BuildError, BuildErrors};
133use schema::{MasterConfig, ThemeMapping};
134
135#[cfg(test)]
136use schema::{MappingValue, THEME_TABLE};
137
138/// Output of a successful icon generation pipeline.
139///
140/// Contains the generated code, metadata about what was generated, and all
141/// information needed to emit cargo directives. Call
142/// [`emit_cargo_directives()`](Self::emit_cargo_directives) to write the
143/// output file and print `cargo::rerun-if-changed` / `cargo::warning` lines.
144#[derive(Debug)]
145pub struct GenerateOutput {
146    /// Path where the generated `.rs` file will be written.
147    pub output_path: PathBuf,
148    /// Warnings collected during generation (e.g., orphan SVGs, unknown DE keys).
149    pub warnings: Vec<String>,
150    /// Number of icon roles in the generated enum.
151    pub role_count: usize,
152    /// Number of bundled themes (themes with embedded SVGs).
153    pub bundled_theme_count: usize,
154    /// Total number of SVG files embedded.
155    pub svg_count: usize,
156    /// Total byte size of all embedded SVGs.
157    pub total_svg_bytes: u64,
158    /// Paths that cargo should watch for changes.
159    rerun_paths: Vec<PathBuf>,
160    /// The generated Rust source code.
161    pub code: String,
162}
163
164impl GenerateOutput {
165    /// Emit cargo directives, write the generated file, and print warnings.
166    ///
167    /// This prints `cargo::rerun-if-changed` for all tracked paths, writes the
168    /// generated code to [`output_path`](Self::output_path), and prints warnings.
169    ///
170    /// # Panics
171    ///
172    /// Panics if the output file cannot be written.
173    pub fn emit_cargo_directives(&self) {
174        for path in &self.rerun_paths {
175            println!("cargo::rerun-if-changed={}", path.display());
176        }
177        std::fs::write(&self.output_path, &self.code)
178            .unwrap_or_else(|e| panic!("failed to write {}: {e}", self.output_path.display()));
179        for w in &self.warnings {
180            println!("cargo::warning={w}");
181        }
182    }
183}
184
185/// Extension trait for converting `Result<GenerateOutput, BuildErrors>` into
186/// a direct output with `process::exit(1)` on error.
187///
188/// Provides a drop-in migration path from the old `generate_icons()` API
189/// that called `process::exit` internally.
190///
191/// # Example
192///
193/// ```rust,ignore
194/// use native_theme_build::UnwrapOrExit;
195///
196/// native_theme_build::generate_icons("icons/icons.toml")
197///     .unwrap_or_exit()
198///     .emit_cargo_directives();
199/// ```
200pub trait UnwrapOrExit<T> {
201    /// Unwrap the `Ok` value or emit cargo errors and exit the process.
202    fn unwrap_or_exit(self) -> T;
203}
204
205impl UnwrapOrExit<GenerateOutput> for Result<GenerateOutput, BuildErrors> {
206    fn unwrap_or_exit(self) -> GenerateOutput {
207        match self {
208            Ok(output) => output,
209            Err(errors) => {
210                // Emit rerun-if-changed even on error so cargo re-checks when
211                // the user fixes the files. We don't have the paths here, but
212                // the build.rs will re-run anyway since it exited with failure.
213                errors.emit_cargo_errors();
214                std::process::exit(1);
215            }
216        }
217    }
218}
219
220/// Simple API: generate icon code from a single TOML file.
221///
222/// Reads the master TOML at `toml_path` (relative to `CARGO_MANIFEST_DIR`),
223/// validates all referenced themes and SVG files, and returns a
224/// [`GenerateOutput`] on success or [`BuildErrors`] on failure.
225///
226/// Call [`GenerateOutput::emit_cargo_directives()`] on the result to write
227/// the generated file and emit cargo directives.
228///
229/// # Panics
230///
231/// Panics if `CARGO_MANIFEST_DIR` or `OUT_DIR` environment variables are
232/// not set (they are always set by cargo during a build).
233pub fn generate_icons(toml_path: impl AsRef<Path>) -> Result<GenerateOutput, BuildErrors> {
234    let toml_path = toml_path.as_ref();
235    let manifest_dir =
236        PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
237    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));
238    let resolved = manifest_dir.join(toml_path);
239
240    let content = std::fs::read_to_string(&resolved)
241        .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
242    let config: MasterConfig = toml::from_str(&content)
243        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
244
245    let base_dir = resolved
246        .parent()
247        .expect("TOML path has no parent")
248        .to_path_buf();
249    let file_path_str = resolved.to_string_lossy().to_string();
250
251    let result = run_pipeline(
252        &[(file_path_str, config)],
253        &[base_dir],
254        None,
255        Some(&manifest_dir),
256        None,
257        &[],
258    );
259
260    pipeline_result_to_output(result, &out_dir)
261}
262
263/// Builder API for composing multiple TOML icon definitions.
264pub struct IconGenerator {
265    sources: Vec<PathBuf>,
266    enum_name_override: Option<String>,
267    base_dir: Option<PathBuf>,
268    crate_path: Option<String>,
269    extra_derives: Vec<String>,
270    output_dir: Option<PathBuf>,
271}
272
273impl Default for IconGenerator {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl IconGenerator {
280    /// Create a new builder.
281    pub fn new() -> Self {
282        Self {
283            sources: Vec::new(),
284            enum_name_override: None,
285            base_dir: None,
286            crate_path: None,
287            extra_derives: Vec::new(),
288            output_dir: None,
289        }
290    }
291
292    /// Add a TOML icon definition file.
293    pub fn source(mut self, path: impl AsRef<Path>) -> Self {
294        self.sources.push(path.as_ref().to_path_buf());
295        self
296    }
297
298    /// Override the generated enum name.
299    pub fn enum_name(mut self, name: &str) -> Self {
300        self.enum_name_override = Some(name.to_string());
301        self
302    }
303
304    /// Set the base directory for theme resolution.
305    ///
306    /// When set, all theme directories (e.g., `material/`, `sf-symbols/`) are
307    /// resolved relative to this path instead of the parent directory of each
308    /// TOML source file.
309    ///
310    /// When not set and multiple sources have different parent directories,
311    /// `generate()` returns an error.
312    pub fn base_dir(mut self, path: impl AsRef<Path>) -> Self {
313        self.base_dir = Some(path.as_ref().to_path_buf());
314        self
315    }
316
317    /// Set the Rust crate path prefix used in generated code.
318    ///
319    /// Defaults to `"native_theme"`. When the default is used, the generated
320    /// file includes `extern crate native_theme;` to support Cargo aliases.
321    ///
322    /// Set this to a custom path (e.g. `"my_crate::native_theme"`) when
323    /// re-exporting native-theme from another crate.
324    pub fn crate_path(mut self, path: &str) -> Self {
325        self.crate_path = Some(path.to_string());
326        self
327    }
328
329    /// Add an extra `#[derive(...)]` trait to the generated enum.
330    ///
331    /// The base set (`Debug, Clone, Copy, PartialEq, Eq, Hash`) is always
332    /// emitted. Each call appends one additional derive.
333    ///
334    /// ```rust,ignore
335    /// native_theme_build::IconGenerator::new()
336    ///     .source("icons/icons.toml")
337    ///     .derive("Ord")
338    ///     .derive("serde::Serialize")
339    ///     .generate()
340    ///     .unwrap_or_exit();
341    /// ```
342    pub fn derive(mut self, name: &str) -> Self {
343        self.extra_derives.push(name.to_string());
344        self
345    }
346
347    /// Set an explicit output directory for the generated `.rs` file.
348    ///
349    /// When not set, the `OUT_DIR` environment variable is used (always
350    /// available during `cargo build`). Set this when running outside of
351    /// a build script context (e.g., in integration tests).
352    pub fn output_dir(mut self, path: impl AsRef<Path>) -> Self {
353        self.output_dir = Some(path.as_ref().to_path_buf());
354        self
355    }
356
357    /// Run the full pipeline: load, validate, generate.
358    ///
359    /// Returns a [`GenerateOutput`] on success or [`BuildErrors`] on failure.
360    /// Call [`GenerateOutput::emit_cargo_directives()`] on the result to write
361    /// the generated file and emit cargo directives.
362    ///
363    /// Source paths may be absolute or relative. Relative paths are resolved
364    /// against `CARGO_MANIFEST_DIR`. When all source paths are absolute,
365    /// `CARGO_MANIFEST_DIR` is not required.
366    ///
367    /// # Panics
368    ///
369    /// Panics if `CARGO_MANIFEST_DIR` is not set and a relative source path
370    /// is used. Panics if neither [`output_dir()`](Self::output_dir) nor
371    /// `OUT_DIR` is set.
372    pub fn generate(self) -> Result<GenerateOutput, BuildErrors> {
373        if self.sources.is_empty() {
374            return Err(BuildErrors(vec![BuildError::Io {
375                message:
376                    "no source files added to IconGenerator (call .source() before .generate())"
377                        .into(),
378            }]));
379        }
380
381        let needs_manifest_dir = self.sources.iter().any(|s| !s.is_absolute())
382            || self.base_dir.as_ref().is_some_and(|b| !b.is_absolute());
383        let manifest_dir = if needs_manifest_dir {
384            Some(PathBuf::from(
385                std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"),
386            ))
387        } else {
388            std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
389        };
390
391        let out_dir = self
392            .output_dir
393            .unwrap_or_else(|| PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set")));
394
395        let mut configs = Vec::new();
396        let mut base_dirs = Vec::new();
397
398        for source in &self.sources {
399            let resolved = if source.is_absolute() {
400                source.clone()
401            } else {
402                manifest_dir.as_ref().unwrap().join(source)
403            };
404            let content = std::fs::read_to_string(&resolved)
405                .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
406            let config: MasterConfig = toml::from_str(&content)
407                .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
408
409            let file_path_str = resolved.to_string_lossy().to_string();
410
411            if let Some(ref explicit_base) = self.base_dir {
412                let base = if explicit_base.is_absolute() {
413                    explicit_base.clone()
414                } else {
415                    manifest_dir.as_ref().unwrap().join(explicit_base)
416                };
417                base_dirs.push(base);
418            } else {
419                let parent = resolved
420                    .parent()
421                    .expect("TOML path has no parent")
422                    .to_path_buf();
423                base_dirs.push(parent);
424            }
425
426            configs.push((file_path_str, config));
427        }
428
429        // If no explicit base_dir and multiple sources have different parent dirs, error
430        if self.base_dir.is_none() && base_dirs.len() > 1 {
431            let first = &base_dirs[0];
432            let divergent = base_dirs.iter().any(|d| d != first);
433            if divergent {
434                return Err(BuildErrors(vec![BuildError::Io {
435                    message: "multiple source files have different parent directories; \
436                              use .base_dir() to specify a common base directory for theme resolution"
437                        .into(),
438                }]));
439            }
440        }
441
442        let result = run_pipeline(
443            &configs,
444            &base_dirs,
445            self.enum_name_override.as_deref(),
446            manifest_dir.as_deref(),
447            self.crate_path.as_deref(),
448            &self.extra_derives,
449        );
450
451        pipeline_result_to_output(result, &out_dir)
452    }
453}
454
455/// Result of running the pure pipeline core.
456///
457/// Contains the generated code, collected errors, and collected warnings.
458/// The thin outer layer ([`generate_icons()`] / [`IconGenerator::generate()`])
459/// converts this into `Result<GenerateOutput, BuildErrors>`.
460struct PipelineResult {
461    /// Generated Rust source code (empty if errors were found).
462    pub code: String,
463    /// Build errors found during validation.
464    pub errors: Vec<BuildError>,
465    /// Warnings (e.g., orphan SVGs).
466    pub warnings: Vec<String>,
467    /// Paths that should trigger rebuild when changed.
468    pub rerun_paths: Vec<PathBuf>,
469    /// Size report: (role_count, bundled_theme_count, svg_paths).
470    pub size_report: Option<SizeReport>,
471    /// The output filename (snake_case of config name + ".rs").
472    pub output_filename: String,
473}
474
475/// Size report for cargo::warning output.
476struct SizeReport {
477    /// Number of icon roles.
478    pub role_count: usize,
479    /// Number of bundled themes.
480    pub bundled_theme_count: usize,
481    /// Total bytes of all SVGs.
482    pub total_svg_bytes: u64,
483    /// Number of SVG files.
484    pub svg_count: usize,
485}
486
487/// Run the full pipeline on one or more loaded configs.
488///
489/// This is the pure core: it takes parsed configs, validates, generates code,
490/// and returns everything as data. No I/O, no process::exit.
491///
492/// When `manifest_dir` is `Some`, `base_dir` paths are stripped of the
493/// manifest prefix before being embedded in `include_bytes!` codegen,
494/// producing portable relative paths like `"/icons/material/play.svg"`
495/// instead of absolute filesystem paths.
496///
497/// `crate_path` controls the Rust path prefix used in generated code
498/// (e.g. `"native_theme"` or `"my_crate::native_theme"`).
499fn run_pipeline(
500    configs: &[(String, MasterConfig)],
501    base_dirs: &[PathBuf],
502    enum_name_override: Option<&str>,
503    manifest_dir: Option<&Path>,
504    crate_path: Option<&str>,
505    extra_derives: &[String],
506) -> PipelineResult {
507    assert_eq!(configs.len(), base_dirs.len());
508
509    let mut errors: Vec<BuildError> = Vec::new();
510    let mut warnings: Vec<String> = Vec::new();
511    let mut rerun_paths: Vec<PathBuf> = Vec::new();
512    let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
513    let mut svg_paths: Vec<PathBuf> = Vec::new();
514
515    // Determine output filename from first config or override
516    let first_name = enum_name_override
517        .map(|s| s.to_string())
518        .unwrap_or_else(|| configs[0].1.name.clone());
519    let output_filename = format!("{}.rs", first_name.to_snake_case());
520
521    // Step 0: Validate each config in isolation
522    for (file_path, config) in configs {
523        // Check for duplicate roles within a single file
524        let dup_in_file_errors = validate::validate_no_duplicate_roles_in_file(config, file_path);
525        errors.extend(dup_in_file_errors);
526
527        // Check for theme overlap (same theme in bundled and system)
528        let overlap_errors = validate::validate_theme_overlap(config);
529        errors.extend(overlap_errors);
530
531        // Check for duplicate theme names within the same list
532        let dup_theme_errors = validate::validate_no_duplicate_themes(config);
533        errors.extend(dup_theme_errors);
534    }
535
536    // Step 1: Check for duplicate roles across all files
537    if configs.len() > 1 {
538        let dup_errors = validate::validate_no_duplicate_roles(configs);
539        errors.extend(dup_errors);
540    }
541
542    // Step 2: Merge configs first so validation uses the merged role list
543    let merged = merge_configs(configs, enum_name_override);
544
545    // Step 2b: Validate identifiers (enum name + role names)
546    let id_errors = validate::validate_identifiers(&merged);
547    errors.extend(id_errors);
548
549    // Track rerun paths for all master TOML files
550    for (file_path, _config) in configs {
551        rerun_paths.push(PathBuf::from(file_path));
552    }
553
554    // Validate theme names on the merged config
555    let theme_errors = validate::validate_themes(&merged);
556    errors.extend(theme_errors);
557
558    // Use the first base_dir as the reference for loading themes.
559    // For multi-file, all configs sharing a theme must use the same base_dir.
560    let base_dir = &base_dirs[0];
561
562    // Process bundled themes
563    for theme_name in &merged.bundled_themes {
564        let theme_dir = base_dir.join(theme_name);
565        let mapping_path = theme_dir.join("mapping.toml");
566        let mapping_path_str = mapping_path.to_string_lossy().to_string();
567
568        // Add mapping TOML and theme directory to rerun paths
569        rerun_paths.push(mapping_path.clone());
570        rerun_paths.push(theme_dir.clone());
571
572        match std::fs::read_to_string(&mapping_path) {
573            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
574                Ok(mapping) => {
575                    // Validate mapping against merged roles
576                    let map_errors =
577                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
578                    errors.extend(map_errors);
579
580                    // Validate icon name values are well-formed
581                    let name_errors =
582                        validate::validate_mapping_values(&mapping, &mapping_path_str);
583                    errors.extend(name_errors);
584
585                    // Validate SVGs exist
586                    let svg_errors =
587                        validate::validate_svgs(&mapping, &theme_dir, &mapping_path_str);
588                    errors.extend(svg_errors);
589
590                    // Warn about unrecognized DE keys in DeAware values
591                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
592                    warnings.extend(de_warnings);
593
594                    // Issue 7: Warn when bundled themes have DE-aware mappings
595                    // (only the default SVG can be embedded).
596                    for (role_name, value) in &mapping {
597                        if matches!(value, schema::MappingValue::DeAware(_)) {
598                            warnings.push(format!(
599                                "bundled theme \"{}\" has DE-aware mapping for \"{}\": \
600                                 only the default SVG will be embedded",
601                                theme_name, role_name
602                            ));
603                        }
604                    }
605
606                    // Check orphan SVGs (warnings, not errors)
607                    let orphan_warnings = check_orphan_svgs_and_collect_paths(
608                        &mapping,
609                        &theme_dir,
610                        theme_name,
611                        &mut svg_paths,
612                        &mut rerun_paths,
613                    );
614                    warnings.extend(orphan_warnings);
615
616                    all_mappings.insert(theme_name.clone(), mapping);
617                }
618                Err(e) => {
619                    errors.push(BuildError::Io {
620                        message: format!("failed to parse {mapping_path_str}: {e}"),
621                    });
622                }
623            },
624            Err(e) => {
625                errors.push(BuildError::Io {
626                    message: format!("failed to read {mapping_path_str}: {e}"),
627                });
628            }
629        }
630    }
631
632    // Process system themes (no SVG validation)
633    for theme_name in &merged.system_themes {
634        let theme_dir = base_dir.join(theme_name);
635        let mapping_path = theme_dir.join("mapping.toml");
636        let mapping_path_str = mapping_path.to_string_lossy().to_string();
637
638        // Add mapping TOML to rerun paths
639        rerun_paths.push(mapping_path.clone());
640
641        match std::fs::read_to_string(&mapping_path) {
642            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
643                Ok(mapping) => {
644                    let map_errors =
645                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
646                    errors.extend(map_errors);
647
648                    // Validate icon name values are well-formed
649                    let name_errors =
650                        validate::validate_mapping_values(&mapping, &mapping_path_str);
651                    errors.extend(name_errors);
652
653                    // Warn about unrecognized DE keys in DeAware values
654                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
655                    warnings.extend(de_warnings);
656
657                    all_mappings.insert(theme_name.clone(), mapping);
658                }
659                Err(e) => {
660                    errors.push(BuildError::Io {
661                        message: format!("failed to parse {mapping_path_str}: {e}"),
662                    });
663                }
664            },
665            Err(e) => {
666                errors.push(BuildError::Io {
667                    message: format!("failed to read {mapping_path_str}: {e}"),
668                });
669            }
670        }
671    }
672
673    // If errors, return without generating code
674    if !errors.is_empty() {
675        return PipelineResult {
676            code: String::new(),
677            errors,
678            warnings,
679            rerun_paths,
680            size_report: None,
681            output_filename,
682        };
683    }
684
685    // Compute base_dir for codegen -- strip manifest_dir prefix when available
686    // so include_bytes! paths are relative (e.g., "icons/material/play.svg")
687    // instead of absolute (e.g., "/home/user/project/icons/material/play.svg")
688    let base_dir_str = if let Some(mdir) = manifest_dir {
689        base_dir
690            .strip_prefix(mdir)
691            .unwrap_or(base_dir)
692            .to_string_lossy()
693            .to_string()
694    } else {
695        base_dir.to_string_lossy().to_string()
696    };
697
698    // Step 4: Generate code
699    let effective_crate_path = crate_path.unwrap_or("native_theme");
700    let code = codegen::generate_code(
701        &merged,
702        &all_mappings,
703        &base_dir_str,
704        effective_crate_path,
705        extra_derives,
706    );
707
708    // Step 5: Compute size report
709    let total_svg_bytes: u64 = svg_paths
710        .iter()
711        .filter_map(|p| std::fs::metadata(p).ok())
712        .map(|m| m.len())
713        .sum();
714
715    let size_report = Some(SizeReport {
716        role_count: merged.roles.len(),
717        bundled_theme_count: merged.bundled_themes.len(),
718        total_svg_bytes,
719        svg_count: svg_paths.len(),
720    });
721
722    PipelineResult {
723        code,
724        errors,
725        warnings,
726        rerun_paths,
727        size_report,
728        output_filename,
729    }
730}
731
732/// Check orphan SVGs and simultaneously collect SVG paths and rerun paths.
733fn check_orphan_svgs_and_collect_paths(
734    mapping: &ThemeMapping,
735    theme_dir: &Path,
736    theme_name: &str,
737    svg_paths: &mut Vec<PathBuf>,
738    rerun_paths: &mut Vec<PathBuf>,
739) -> Vec<String> {
740    // Collect referenced SVG paths
741    for value in mapping.values() {
742        if let Some(name) = value.default_name() {
743            let svg_path = theme_dir.join(format!("{name}.svg"));
744            if svg_path.exists() {
745                rerun_paths.push(svg_path.clone());
746                svg_paths.push(svg_path);
747            }
748        }
749    }
750
751    validate::check_orphan_svgs(mapping, theme_dir, theme_name)
752}
753
754/// Merge multiple configs into a single MasterConfig for code generation.
755fn merge_configs(
756    configs: &[(String, MasterConfig)],
757    enum_name_override: Option<&str>,
758) -> MasterConfig {
759    let name = enum_name_override
760        .map(|s| s.to_string())
761        .unwrap_or_else(|| configs[0].1.name.clone());
762
763    let mut roles = Vec::new();
764    let mut bundled_themes = Vec::new();
765    let mut system_themes = Vec::new();
766    let mut seen_bundled = std::collections::BTreeSet::new();
767    let mut seen_system = std::collections::BTreeSet::new();
768
769    for (_path, config) in configs {
770        roles.extend(config.roles.iter().cloned());
771
772        for t in &config.bundled_themes {
773            if seen_bundled.insert(t.clone()) {
774                bundled_themes.push(t.clone());
775            }
776        }
777        for t in &config.system_themes {
778            if seen_system.insert(t.clone()) {
779                system_themes.push(t.clone());
780            }
781        }
782    }
783
784    MasterConfig {
785        name,
786        roles,
787        bundled_themes,
788        system_themes,
789    }
790}
791
792/// Convert a `PipelineResult` into `Result<GenerateOutput, BuildErrors>`.
793fn pipeline_result_to_output(
794    result: PipelineResult,
795    out_dir: &Path,
796) -> Result<GenerateOutput, BuildErrors> {
797    if !result.errors.is_empty() {
798        // Emit rerun-if-changed even on error so cargo re-checks when the user
799        // fixes the files.
800        for path in &result.rerun_paths {
801            println!("cargo::rerun-if-changed={}", path.display());
802        }
803        return Err(BuildErrors(result.errors));
804    }
805
806    let output_path = out_dir.join(&result.output_filename);
807
808    let (role_count, bundled_theme_count, svg_count, total_svg_bytes) = match &result.size_report {
809        Some(report) => (
810            report.role_count,
811            report.bundled_theme_count,
812            report.svg_count,
813            report.total_svg_bytes,
814        ),
815        None => (0, 0, 0, 0),
816    };
817
818    Ok(GenerateOutput {
819        output_path,
820        warnings: result.warnings,
821        role_count,
822        bundled_theme_count,
823        svg_count,
824        total_svg_bytes,
825        rerun_paths: result.rerun_paths,
826        code: result.code,
827    })
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use std::collections::BTreeMap;
834    use std::fs;
835
836    // === MasterConfig tests ===
837
838    #[test]
839    fn master_config_deserializes_full() {
840        let toml_str = r#"
841name = "app-icon"
842roles = ["play-pause", "skip-forward"]
843bundled-themes = ["material"]
844system-themes = ["sf-symbols"]
845"#;
846        let config: MasterConfig = toml::from_str(toml_str).unwrap();
847        assert_eq!(config.name, "app-icon");
848        assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
849        assert_eq!(config.bundled_themes, vec!["material"]);
850        assert_eq!(config.system_themes, vec!["sf-symbols"]);
851    }
852
853    #[test]
854    fn master_config_empty_optional_fields() {
855        let toml_str = r#"
856name = "x"
857roles = ["a"]
858"#;
859        let config: MasterConfig = toml::from_str(toml_str).unwrap();
860        assert_eq!(config.name, "x");
861        assert_eq!(config.roles, vec!["a"]);
862        assert!(config.bundled_themes.is_empty());
863        assert!(config.system_themes.is_empty());
864    }
865
866    #[test]
867    fn master_config_rejects_unknown_fields() {
868        let toml_str = r#"
869name = "x"
870roles = ["a"]
871bogus = "nope"
872"#;
873        let result = toml::from_str::<MasterConfig>(toml_str);
874        assert!(result.is_err());
875    }
876
877    // === MappingValue tests ===
878
879    #[test]
880    fn mapping_value_simple() {
881        let toml_str = r#"play-pause = "play_pause""#;
882        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
883        match &mapping["play-pause"] {
884            MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
885            _ => panic!("expected Simple variant"),
886        }
887    }
888
889    #[test]
890    fn mapping_value_de_aware() {
891        let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
892        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
893        match &mapping["play-pause"] {
894            MappingValue::DeAware(m) => {
895                assert_eq!(m["kde"], "media-playback-start");
896                assert_eq!(m["default"], "play");
897            }
898            _ => panic!("expected DeAware variant"),
899        }
900    }
901
902    #[test]
903    fn theme_mapping_mixed_values() {
904        let toml_str = r#"
905play-pause = "play_pause"
906bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
907skip-forward = "skip_next"
908"#;
909        let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
910        assert_eq!(mapping.len(), 3);
911        assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
912        assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
913        assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
914    }
915
916    // === MappingValue::default_name tests ===
917
918    #[test]
919    fn mapping_value_default_name_simple() {
920        let val = MappingValue::Simple("play_pause".to_string());
921        assert_eq!(val.default_name(), Some("play_pause"));
922    }
923
924    #[test]
925    fn mapping_value_default_name_de_aware() {
926        let mut m = BTreeMap::new();
927        m.insert("kde".to_string(), "media-playback-start".to_string());
928        m.insert("default".to_string(), "play".to_string());
929        let val = MappingValue::DeAware(m);
930        assert_eq!(val.default_name(), Some("play"));
931    }
932
933    #[test]
934    fn mapping_value_default_name_de_aware_missing_default() {
935        let mut m = BTreeMap::new();
936        m.insert("kde".to_string(), "media-playback-start".to_string());
937        let val = MappingValue::DeAware(m);
938        assert_eq!(val.default_name(), None);
939    }
940
941    // === BuildError Display tests ===
942
943    #[test]
944    fn build_error_missing_role_format() {
945        let err = BuildError::MissingRole {
946            role: "play-pause".into(),
947            mapping_file: "icons/material/mapping.toml".into(),
948        };
949        let msg = err.to_string();
950        assert!(msg.contains("play-pause"), "should contain role name");
951        assert!(
952            msg.contains("icons/material/mapping.toml"),
953            "should contain file path"
954        );
955    }
956
957    #[test]
958    fn build_error_missing_svg_format() {
959        let err = BuildError::MissingSvg {
960            path: "icons/material/play.svg".into(),
961        };
962        let msg = err.to_string();
963        assert!(
964            msg.contains("icons/material/play.svg"),
965            "should contain SVG path"
966        );
967    }
968
969    #[test]
970    fn build_error_unknown_role_format() {
971        let err = BuildError::UnknownRole {
972            role: "bogus".into(),
973            mapping_file: "icons/material/mapping.toml".into(),
974        };
975        let msg = err.to_string();
976        assert!(msg.contains("bogus"), "should contain role name");
977        assert!(
978            msg.contains("icons/material/mapping.toml"),
979            "should contain file path"
980        );
981    }
982
983    #[test]
984    fn build_error_unknown_theme_format() {
985        let err = BuildError::UnknownTheme {
986            theme: "nonexistent".into(),
987        };
988        let msg = err.to_string();
989        assert!(msg.contains("nonexistent"), "should contain theme name");
990    }
991
992    #[test]
993    fn build_error_missing_default_format() {
994        let err = BuildError::MissingDefault {
995            role: "bluetooth".into(),
996            mapping_file: "icons/freedesktop/mapping.toml".into(),
997        };
998        let msg = err.to_string();
999        assert!(msg.contains("bluetooth"), "should contain role name");
1000        assert!(
1001            msg.contains("icons/freedesktop/mapping.toml"),
1002            "should contain file path"
1003        );
1004    }
1005
1006    #[test]
1007    fn build_error_duplicate_role_format() {
1008        let err = BuildError::DuplicateRole {
1009            role: "play-pause".into(),
1010            file_a: "icons/a.toml".into(),
1011            file_b: "icons/b.toml".into(),
1012        };
1013        let msg = err.to_string();
1014        assert!(msg.contains("play-pause"), "should contain role name");
1015        assert!(
1016            msg.contains("icons/a.toml"),
1017            "should contain first file path"
1018        );
1019        assert!(
1020            msg.contains("icons/b.toml"),
1021            "should contain second file path"
1022        );
1023    }
1024
1025    // === THEME_TABLE tests ===
1026
1027    #[test]
1028    fn theme_table_has_all_five() {
1029        assert_eq!(THEME_TABLE.len(), 5);
1030        let names: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
1031        assert!(names.contains(&"sf-symbols"));
1032        assert!(names.contains(&"segoe-fluent"));
1033        assert!(names.contains(&"freedesktop"));
1034        assert!(names.contains(&"material"));
1035        assert!(names.contains(&"lucide"));
1036    }
1037
1038    // === Helper to create test fixture directories ===
1039
1040    fn create_fixture_dir(suffix: &str) -> PathBuf {
1041        let dir = std::env::temp_dir().join(format!("native_theme_test_pipeline_{suffix}"));
1042        let _ = fs::remove_dir_all(&dir);
1043        fs::create_dir_all(&dir).unwrap();
1044        dir
1045    }
1046
1047    fn write_fixture(dir: &Path, path: &str, content: &str) {
1048        let full_path = dir.join(path);
1049        if let Some(parent) = full_path.parent() {
1050            fs::create_dir_all(parent).unwrap();
1051        }
1052        fs::write(full_path, content).unwrap();
1053    }
1054
1055    const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
1056
1057    // === run_pipeline tests ===
1058
1059    #[test]
1060    fn pipeline_happy_path_generates_code() {
1061        let dir = create_fixture_dir("happy");
1062        write_fixture(
1063            &dir,
1064            "material/mapping.toml",
1065            r#"
1066play-pause = "play_pause"
1067skip-forward = "skip_next"
1068"#,
1069        );
1070        write_fixture(
1071            &dir,
1072            "sf-symbols/mapping.toml",
1073            r#"
1074play-pause = "play.fill"
1075skip-forward = "forward.fill"
1076"#,
1077        );
1078        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1079        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1080
1081        let config: MasterConfig = toml::from_str(
1082            r#"
1083name = "sample-icon"
1084roles = ["play-pause", "skip-forward"]
1085bundled-themes = ["material"]
1086system-themes = ["sf-symbols"]
1087"#,
1088        )
1089        .unwrap();
1090
1091        let result = run_pipeline(
1092            &[("sample-icons.toml".to_string(), config)],
1093            std::slice::from_ref(&dir),
1094            None,
1095            None,
1096            None,
1097            &[],
1098        );
1099
1100        assert!(
1101            result.errors.is_empty(),
1102            "expected no errors: {:?}",
1103            result.errors
1104        );
1105        assert!(!result.code.is_empty(), "expected generated code");
1106        assert!(result.code.contains("pub enum SampleIcon"));
1107        assert!(result.code.contains("PlayPause"));
1108        assert!(result.code.contains("SkipForward"));
1109
1110        let _ = fs::remove_dir_all(&dir);
1111    }
1112
1113    #[test]
1114    fn pipeline_output_filename_uses_snake_case() {
1115        let dir = create_fixture_dir("filename");
1116        write_fixture(
1117            &dir,
1118            "material/mapping.toml",
1119            "play-pause = \"play_pause\"\n",
1120        );
1121        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1122
1123        let config: MasterConfig = toml::from_str(
1124            r#"
1125name = "app-icon"
1126roles = ["play-pause"]
1127bundled-themes = ["material"]
1128"#,
1129        )
1130        .unwrap();
1131
1132        let result = run_pipeline(
1133            &[("app.toml".to_string(), config)],
1134            std::slice::from_ref(&dir),
1135            None,
1136            None,
1137            None,
1138            &[],
1139        );
1140
1141        assert_eq!(result.output_filename, "app_icon.rs");
1142
1143        let _ = fs::remove_dir_all(&dir);
1144    }
1145
1146    #[test]
1147    fn pipeline_collects_rerun_paths() {
1148        let dir = create_fixture_dir("rerun");
1149        write_fixture(
1150            &dir,
1151            "material/mapping.toml",
1152            r#"
1153play-pause = "play_pause"
1154"#,
1155        );
1156        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1157
1158        let config: MasterConfig = toml::from_str(
1159            r#"
1160name = "test"
1161roles = ["play-pause"]
1162bundled-themes = ["material"]
1163"#,
1164        )
1165        .unwrap();
1166
1167        let result = run_pipeline(
1168            &[("test.toml".to_string(), config)],
1169            std::slice::from_ref(&dir),
1170            None,
1171            None,
1172            None,
1173            &[],
1174        );
1175
1176        assert!(result.errors.is_empty());
1177        // Should include: master TOML, mapping TOML, theme dir, SVG files
1178        let path_strs: Vec<String> = result
1179            .rerun_paths
1180            .iter()
1181            .map(|p| p.to_string_lossy().to_string())
1182            .collect();
1183        assert!(
1184            path_strs.iter().any(|p| p.contains("test.toml")),
1185            "should track master TOML"
1186        );
1187        assert!(
1188            path_strs.iter().any(|p| p.contains("mapping.toml")),
1189            "should track mapping TOML"
1190        );
1191        assert!(
1192            path_strs.iter().any(|p| p.contains("play_pause.svg")),
1193            "should track SVG files"
1194        );
1195
1196        let _ = fs::remove_dir_all(&dir);
1197    }
1198
1199    #[test]
1200    fn pipeline_emits_size_report() {
1201        let dir = create_fixture_dir("size");
1202        write_fixture(
1203            &dir,
1204            "material/mapping.toml",
1205            "play-pause = \"play_pause\"\n",
1206        );
1207        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1208
1209        let config: MasterConfig = toml::from_str(
1210            r#"
1211name = "test"
1212roles = ["play-pause"]
1213bundled-themes = ["material"]
1214"#,
1215        )
1216        .unwrap();
1217
1218        let result = run_pipeline(
1219            &[("test.toml".to_string(), config)],
1220            std::slice::from_ref(&dir),
1221            None,
1222            None,
1223            None,
1224            &[],
1225        );
1226
1227        assert!(result.errors.is_empty());
1228        let report = result
1229            .size_report
1230            .as_ref()
1231            .expect("should have size report");
1232        assert_eq!(report.role_count, 1);
1233        assert_eq!(report.bundled_theme_count, 1);
1234        assert_eq!(report.svg_count, 1);
1235        assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
1236
1237        let _ = fs::remove_dir_all(&dir);
1238    }
1239
1240    #[test]
1241    fn pipeline_returns_errors_on_missing_role() {
1242        let dir = create_fixture_dir("missing_role");
1243        // Mapping is missing "skip-forward"
1244        write_fixture(
1245            &dir,
1246            "material/mapping.toml",
1247            "play-pause = \"play_pause\"\n",
1248        );
1249        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1250
1251        let config: MasterConfig = toml::from_str(
1252            r#"
1253name = "test"
1254roles = ["play-pause", "skip-forward"]
1255bundled-themes = ["material"]
1256"#,
1257        )
1258        .unwrap();
1259
1260        let result = run_pipeline(
1261            &[("test.toml".to_string(), config)],
1262            std::slice::from_ref(&dir),
1263            None,
1264            None,
1265            None,
1266            &[],
1267        );
1268
1269        assert!(!result.errors.is_empty(), "should have errors");
1270        assert!(
1271            result
1272                .errors
1273                .iter()
1274                .any(|e| e.to_string().contains("skip-forward")),
1275            "should mention missing role"
1276        );
1277        assert!(result.code.is_empty(), "no code on errors");
1278
1279        let _ = fs::remove_dir_all(&dir);
1280    }
1281
1282    #[test]
1283    fn pipeline_returns_errors_on_missing_svg() {
1284        let dir = create_fixture_dir("missing_svg");
1285        write_fixture(
1286            &dir,
1287            "material/mapping.toml",
1288            r#"
1289play-pause = "play_pause"
1290skip-forward = "skip_next"
1291"#,
1292        );
1293        // Only create one SVG, leave skip_next.svg missing
1294        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1295
1296        let config: MasterConfig = toml::from_str(
1297            r#"
1298name = "test"
1299roles = ["play-pause", "skip-forward"]
1300bundled-themes = ["material"]
1301"#,
1302        )
1303        .unwrap();
1304
1305        let result = run_pipeline(
1306            &[("test.toml".to_string(), config)],
1307            std::slice::from_ref(&dir),
1308            None,
1309            None,
1310            None,
1311            &[],
1312        );
1313
1314        assert!(!result.errors.is_empty(), "should have errors");
1315        assert!(
1316            result
1317                .errors
1318                .iter()
1319                .any(|e| e.to_string().contains("skip_next.svg")),
1320            "should mention missing SVG"
1321        );
1322
1323        let _ = fs::remove_dir_all(&dir);
1324    }
1325
1326    #[test]
1327    fn pipeline_orphan_svgs_are_warnings() {
1328        let dir = create_fixture_dir("orphan_warn");
1329        write_fixture(
1330            &dir,
1331            "material/mapping.toml",
1332            "play-pause = \"play_pause\"\n",
1333        );
1334        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1335        write_fixture(&dir, "material/unused.svg", SVG_STUB);
1336
1337        let config: MasterConfig = toml::from_str(
1338            r#"
1339name = "test"
1340roles = ["play-pause"]
1341bundled-themes = ["material"]
1342"#,
1343        )
1344        .unwrap();
1345
1346        let result = run_pipeline(
1347            &[("test.toml".to_string(), config)],
1348            std::slice::from_ref(&dir),
1349            None,
1350            None,
1351            None,
1352            &[],
1353        );
1354
1355        assert!(result.errors.is_empty(), "orphans are not errors");
1356        assert!(!result.warnings.is_empty(), "should have orphan warning");
1357        assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1358
1359        let _ = fs::remove_dir_all(&dir);
1360    }
1361
1362    // === merge_configs tests ===
1363
1364    #[test]
1365    fn merge_configs_combines_roles() {
1366        let config_a: MasterConfig = toml::from_str(
1367            r#"
1368name = "a"
1369roles = ["play-pause"]
1370bundled-themes = ["material"]
1371"#,
1372        )
1373        .unwrap();
1374        let config_b: MasterConfig = toml::from_str(
1375            r#"
1376name = "b"
1377roles = ["skip-forward"]
1378bundled-themes = ["material"]
1379system-themes = ["sf-symbols"]
1380"#,
1381        )
1382        .unwrap();
1383
1384        let configs = vec![
1385            ("a.toml".to_string(), config_a),
1386            ("b.toml".to_string(), config_b),
1387        ];
1388        let merged = merge_configs(&configs, None);
1389
1390        assert_eq!(merged.name, "a"); // uses first config's name
1391        assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1392        assert_eq!(merged.bundled_themes, vec!["material"]); // deduplicated
1393        assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1394    }
1395
1396    #[test]
1397    fn merge_configs_uses_enum_name_override() {
1398        let config: MasterConfig = toml::from_str(
1399            r#"
1400name = "original"
1401roles = ["x"]
1402"#,
1403        )
1404        .unwrap();
1405
1406        let configs = vec![("a.toml".to_string(), config)];
1407        let merged = merge_configs(&configs, Some("MyIcons"));
1408
1409        assert_eq!(merged.name, "MyIcons");
1410    }
1411
1412    // === Builder pipeline tests ===
1413
1414    #[test]
1415    fn pipeline_builder_merges_two_files() {
1416        let dir = create_fixture_dir("builder_merge");
1417        write_fixture(
1418            &dir,
1419            "material/mapping.toml",
1420            r#"
1421play-pause = "play_pause"
1422skip-forward = "skip_next"
1423"#,
1424        );
1425        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1426        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1427
1428        let config_a: MasterConfig = toml::from_str(
1429            r#"
1430name = "icons-a"
1431roles = ["play-pause"]
1432bundled-themes = ["material"]
1433"#,
1434        )
1435        .unwrap();
1436        let config_b: MasterConfig = toml::from_str(
1437            r#"
1438name = "icons-b"
1439roles = ["skip-forward"]
1440bundled-themes = ["material"]
1441"#,
1442        )
1443        .unwrap();
1444
1445        let result = run_pipeline(
1446            &[
1447                ("a.toml".to_string(), config_a),
1448                ("b.toml".to_string(), config_b),
1449            ],
1450            &[dir.clone(), dir.clone()],
1451            Some("AllIcons"),
1452            None,
1453            None,
1454            &[],
1455        );
1456
1457        assert!(
1458            result.errors.is_empty(),
1459            "expected no errors: {:?}",
1460            result.errors
1461        );
1462        assert!(
1463            result.code.contains("pub enum AllIcons"),
1464            "should use override name"
1465        );
1466        assert!(result.code.contains("PlayPause"));
1467        assert!(result.code.contains("SkipForward"));
1468        assert_eq!(result.output_filename, "all_icons.rs");
1469
1470        let _ = fs::remove_dir_all(&dir);
1471    }
1472
1473    #[test]
1474    fn pipeline_builder_detects_duplicate_roles() {
1475        let dir = create_fixture_dir("builder_dup");
1476        write_fixture(
1477            &dir,
1478            "material/mapping.toml",
1479            "play-pause = \"play_pause\"\n",
1480        );
1481        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1482
1483        let config_a: MasterConfig = toml::from_str(
1484            r#"
1485name = "a"
1486roles = ["play-pause"]
1487bundled-themes = ["material"]
1488"#,
1489        )
1490        .unwrap();
1491        let config_b: MasterConfig = toml::from_str(
1492            r#"
1493name = "b"
1494roles = ["play-pause"]
1495bundled-themes = ["material"]
1496"#,
1497        )
1498        .unwrap();
1499
1500        let result = run_pipeline(
1501            &[
1502                ("a.toml".to_string(), config_a),
1503                ("b.toml".to_string(), config_b),
1504            ],
1505            &[dir.clone(), dir.clone()],
1506            None,
1507            None,
1508            None,
1509            &[],
1510        );
1511
1512        assert!(!result.errors.is_empty(), "should detect duplicate roles");
1513        assert!(
1514            result
1515                .errors
1516                .iter()
1517                .any(|e| e.to_string().contains("play-pause"))
1518        );
1519
1520        let _ = fs::remove_dir_all(&dir);
1521    }
1522
1523    #[test]
1524    fn pipeline_generates_relative_include_bytes_paths() {
1525        // Simulate what generate_icons does: manifest_dir + "icons/icons.toml"
1526        // The tmpdir acts as CARGO_MANIFEST_DIR.
1527        // base_dir is absolute (tmpdir/icons), but run_pipeline should strip
1528        // the manifest_dir prefix for codegen, producing relative paths.
1529        let tmpdir = create_fixture_dir("rel_paths");
1530        write_fixture(
1531            &tmpdir,
1532            "icons/material/mapping.toml",
1533            "play-pause = \"play_pause\"\n",
1534        );
1535        write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1536
1537        let config: MasterConfig = toml::from_str(
1538            r#"
1539name = "test"
1540roles = ["play-pause"]
1541bundled-themes = ["material"]
1542"#,
1543        )
1544        .unwrap();
1545
1546        // base_dir is absolute (as generate_icons would compute)
1547        let abs_base_dir = tmpdir.join("icons");
1548
1549        let result = run_pipeline(
1550            &[("icons/icons.toml".to_string(), config)],
1551            &[abs_base_dir],
1552            None,
1553            Some(&tmpdir), // manifest_dir for stripping prefix
1554            None,
1555            &[],
1556        );
1557
1558        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1559        // The include_bytes path should contain "/icons/material/play_pause.svg"
1560        assert!(
1561            result.code.contains("\"/icons/material/play_pause.svg\""),
1562            "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1563            result.code,
1564        );
1565        // The include_bytes path should NOT contain the absolute tmpdir
1566        let tmpdir_str = tmpdir.to_string_lossy();
1567        assert!(
1568            !result.code.contains(&*tmpdir_str),
1569            "include_bytes path should NOT contain absolute tmpdir path",
1570        );
1571
1572        let _ = fs::remove_dir_all(&tmpdir);
1573    }
1574
1575    #[test]
1576    fn pipeline_no_system_svg_check() {
1577        // System themes should NOT validate SVGs
1578        let dir = create_fixture_dir("no_sys_svg");
1579        // sf-symbols has mapping but NO SVG files -- should be fine
1580        write_fixture(
1581            &dir,
1582            "sf-symbols/mapping.toml",
1583            r#"
1584play-pause = "play.fill"
1585"#,
1586        );
1587
1588        let config: MasterConfig = toml::from_str(
1589            r#"
1590name = "test"
1591roles = ["play-pause"]
1592system-themes = ["sf-symbols"]
1593"#,
1594        )
1595        .unwrap();
1596
1597        let result = run_pipeline(
1598            &[("test.toml".to_string(), config)],
1599            std::slice::from_ref(&dir),
1600            None,
1601            None,
1602            None,
1603            &[],
1604        );
1605
1606        assert!(
1607            result.errors.is_empty(),
1608            "system themes should not require SVGs: {:?}",
1609            result.errors
1610        );
1611
1612        let _ = fs::remove_dir_all(&dir);
1613    }
1614
1615    // === BuildErrors tests ===
1616
1617    #[test]
1618    fn build_errors_display_format() {
1619        let errors = BuildErrors(vec![
1620            BuildError::MissingRole {
1621                role: "play-pause".into(),
1622                mapping_file: "mapping.toml".into(),
1623            },
1624            BuildError::MissingSvg {
1625                path: "play.svg".into(),
1626            },
1627        ]);
1628        let msg = errors.to_string();
1629        assert!(msg.contains("2 build error(s):"));
1630        assert!(msg.contains("play-pause"));
1631        assert!(msg.contains("play.svg"));
1632    }
1633
1634    // === New BuildError Display tests ===
1635
1636    #[test]
1637    fn build_error_invalid_identifier_format() {
1638        let err = BuildError::InvalidIdentifier {
1639            name: "---".into(),
1640            reason: "PascalCase conversion produces an empty string".into(),
1641        };
1642        let msg = err.to_string();
1643        assert!(msg.contains("---"), "should contain the name");
1644        assert!(msg.contains("empty"), "should contain the reason");
1645    }
1646
1647    #[test]
1648    fn build_error_identifier_collision_format() {
1649        let err = BuildError::IdentifierCollision {
1650            role_a: "play_pause".into(),
1651            role_b: "play-pause".into(),
1652            pascal: "PlayPause".into(),
1653        };
1654        let msg = err.to_string();
1655        assert!(msg.contains("play_pause"), "should mention first role");
1656        assert!(msg.contains("play-pause"), "should mention second role");
1657        assert!(msg.contains("PlayPause"), "should mention PascalCase");
1658    }
1659
1660    #[test]
1661    fn build_error_theme_overlap_format() {
1662        let err = BuildError::ThemeOverlap {
1663            theme: "material".into(),
1664        };
1665        let msg = err.to_string();
1666        assert!(msg.contains("material"), "should mention theme");
1667        assert!(msg.contains("bundled"), "should mention bundled");
1668        assert!(msg.contains("system"), "should mention system");
1669    }
1670
1671    #[test]
1672    fn build_error_duplicate_role_in_file_format() {
1673        let err = BuildError::DuplicateRoleInFile {
1674            role: "play-pause".into(),
1675            file: "icons.toml".into(),
1676        };
1677        let msg = err.to_string();
1678        assert!(msg.contains("play-pause"), "should mention role");
1679        assert!(msg.contains("icons.toml"), "should mention file");
1680    }
1681
1682    // === Pipeline validation integration tests ===
1683
1684    #[test]
1685    fn pipeline_detects_theme_overlap() {
1686        let dir = create_fixture_dir("theme_overlap");
1687        write_fixture(
1688            &dir,
1689            "material/mapping.toml",
1690            "play-pause = \"play_pause\"\n",
1691        );
1692        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1693
1694        let config: MasterConfig = toml::from_str(
1695            r#"
1696name = "test"
1697roles = ["play-pause"]
1698bundled-themes = ["material"]
1699system-themes = ["material"]
1700"#,
1701        )
1702        .unwrap();
1703
1704        let result = run_pipeline(
1705            &[("test.toml".to_string(), config)],
1706            std::slice::from_ref(&dir),
1707            None,
1708            None,
1709            None,
1710            &[],
1711        );
1712
1713        assert!(!result.errors.is_empty(), "should detect theme overlap");
1714        assert!(
1715            result.errors.iter().any(|e| matches!(
1716                e,
1717                BuildError::ThemeOverlap { theme } if theme == "material"
1718            )),
1719            "should have ThemeOverlap error for 'material': {:?}",
1720            result.errors
1721        );
1722
1723        let _ = fs::remove_dir_all(&dir);
1724    }
1725
1726    #[test]
1727    fn pipeline_detects_identifier_collision() {
1728        let dir = create_fixture_dir("id_collision");
1729        write_fixture(
1730            &dir,
1731            "material/mapping.toml",
1732            "play_pause = \"pp\"\nplay-pause = \"pp2\"\n",
1733        );
1734        write_fixture(&dir, "material/pp.svg", SVG_STUB);
1735
1736        let config: MasterConfig = toml::from_str(
1737            r#"
1738name = "test"
1739roles = ["play_pause", "play-pause"]
1740bundled-themes = ["material"]
1741"#,
1742        )
1743        .unwrap();
1744
1745        let result = run_pipeline(
1746            &[("test.toml".to_string(), config)],
1747            std::slice::from_ref(&dir),
1748            None,
1749            None,
1750            None,
1751            &[],
1752        );
1753
1754        assert!(
1755            result.errors.iter().any(|e| matches!(
1756                e,
1757                BuildError::IdentifierCollision { pascal, .. } if pascal == "PlayPause"
1758            )),
1759            "should detect PascalCase collision: {:?}",
1760            result.errors
1761        );
1762
1763        let _ = fs::remove_dir_all(&dir);
1764    }
1765
1766    #[test]
1767    fn pipeline_detects_invalid_identifier() {
1768        let dir = create_fixture_dir("id_invalid");
1769        write_fixture(&dir, "material/mapping.toml", "self = \"self_icon\"\n");
1770        write_fixture(&dir, "material/self_icon.svg", SVG_STUB);
1771
1772        let config: MasterConfig = toml::from_str(
1773            r#"
1774name = "test"
1775roles = ["self"]
1776bundled-themes = ["material"]
1777"#,
1778        )
1779        .unwrap();
1780
1781        let result = run_pipeline(
1782            &[("test.toml".to_string(), config)],
1783            std::slice::from_ref(&dir),
1784            None,
1785            None,
1786            None,
1787            &[],
1788        );
1789
1790        assert!(
1791            result.errors.iter().any(|e| matches!(
1792                e,
1793                BuildError::InvalidIdentifier { name, .. } if name == "self"
1794            )),
1795            "should detect keyword identifier: {:?}",
1796            result.errors
1797        );
1798
1799        let _ = fs::remove_dir_all(&dir);
1800    }
1801
1802    #[test]
1803    fn pipeline_detects_duplicate_role_in_file() {
1804        let dir = create_fixture_dir("dup_in_file");
1805        write_fixture(
1806            &dir,
1807            "material/mapping.toml",
1808            "play-pause = \"play_pause\"\n",
1809        );
1810        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1811
1812        // MasterConfig with duplicate role -- manually construct since TOML
1813        // arrays allow duplicates
1814        let config = MasterConfig {
1815            name: "test".to_string(),
1816            roles: vec!["play-pause".to_string(), "play-pause".to_string()],
1817            bundled_themes: vec!["material".to_string()],
1818            system_themes: Vec::new(),
1819        };
1820
1821        let result = run_pipeline(
1822            &[("test.toml".to_string(), config)],
1823            std::slice::from_ref(&dir),
1824            None,
1825            None,
1826            None,
1827            &[],
1828        );
1829
1830        assert!(
1831            result.errors.iter().any(|e| matches!(
1832                e,
1833                BuildError::DuplicateRoleInFile { role, file }
1834                    if role == "play-pause" && file == "test.toml"
1835            )),
1836            "should detect duplicate role in file: {:?}",
1837            result.errors
1838        );
1839
1840        let _ = fs::remove_dir_all(&dir);
1841    }
1842
1843    // === Issue 7: Bundled DE-aware warning tests ===
1844
1845    #[test]
1846    fn pipeline_bundled_de_aware_produces_warning() {
1847        let dir = create_fixture_dir("bundled_de_aware");
1848        // Bundled theme with a DE-aware mapping
1849        write_fixture(
1850            &dir,
1851            "material/mapping.toml",
1852            r#"play-pause = { kde = "media-playback-start", default = "play_pause" }"#,
1853        );
1854        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1855
1856        let config: MasterConfig = toml::from_str(
1857            r#"
1858name = "test-icon"
1859roles = ["play-pause"]
1860bundled-themes = ["material"]
1861"#,
1862        )
1863        .unwrap();
1864
1865        let result = run_pipeline(
1866            &[("test.toml".to_string(), config)],
1867            std::slice::from_ref(&dir),
1868            None,
1869            None,
1870            None,
1871            &[],
1872        );
1873
1874        assert!(
1875            result.errors.is_empty(),
1876            "bundled DE-aware should not be an error: {:?}",
1877            result.errors
1878        );
1879        assert!(
1880            result.warnings.iter().any(|w| {
1881                w.contains("bundled theme \"material\"")
1882                    && w.contains("play-pause")
1883                    && w.contains("only the default SVG will be embedded")
1884            }),
1885            "should warn about bundled DE-aware mapping. warnings: {:?}",
1886            result.warnings
1887        );
1888
1889        let _ = fs::remove_dir_all(&dir);
1890    }
1891
1892    #[test]
1893    fn pipeline_system_de_aware_no_bundled_warning() {
1894        let dir = create_fixture_dir("system_de_aware");
1895        // System theme with DE-aware mapping should NOT produce the bundled warning
1896        write_fixture(
1897            &dir,
1898            "freedesktop/mapping.toml",
1899            r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
1900        );
1901
1902        let config: MasterConfig = toml::from_str(
1903            r#"
1904name = "test-icon"
1905roles = ["play-pause"]
1906system-themes = ["freedesktop"]
1907"#,
1908        )
1909        .unwrap();
1910
1911        let result = run_pipeline(
1912            &[("test.toml".to_string(), config)],
1913            std::slice::from_ref(&dir),
1914            None,
1915            None,
1916            None,
1917            &[],
1918        );
1919
1920        assert!(
1921            result.errors.is_empty(),
1922            "system DE-aware should not be an error: {:?}",
1923            result.errors
1924        );
1925        assert!(
1926            !result
1927                .warnings
1928                .iter()
1929                .any(|w| w.contains("only the default SVG will be embedded")),
1930            "system themes should NOT produce bundled DE-aware warning. warnings: {:?}",
1931            result.warnings
1932        );
1933
1934        let _ = fs::remove_dir_all(&dir);
1935    }
1936
1937    // === Issue 14: crate_path tests ===
1938
1939    #[test]
1940    fn pipeline_custom_crate_path() {
1941        let dir = create_fixture_dir("crate_path");
1942        write_fixture(
1943            &dir,
1944            "material/mapping.toml",
1945            "play-pause = \"play_pause\"\n",
1946        );
1947        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1948
1949        let config: MasterConfig = toml::from_str(
1950            r#"
1951name = "test-icon"
1952roles = ["play-pause"]
1953bundled-themes = ["material"]
1954"#,
1955        )
1956        .unwrap();
1957
1958        let result = run_pipeline(
1959            &[("test.toml".to_string(), config)],
1960            std::slice::from_ref(&dir),
1961            None,
1962            None,
1963            Some("my_crate::native_theme"),
1964            &[],
1965        );
1966
1967        assert!(
1968            result.errors.is_empty(),
1969            "custom crate path should not cause errors: {:?}",
1970            result.errors
1971        );
1972        assert!(
1973            result
1974                .code
1975                .contains("impl my_crate::native_theme::IconProvider"),
1976            "should use custom crate path in impl. code:\n{}",
1977            result.code
1978        );
1979        assert!(
1980            !result.code.contains("extern crate"),
1981            "custom crate path should not emit extern crate. code:\n{}",
1982            result.code
1983        );
1984
1985        let _ = fs::remove_dir_all(&dir);
1986    }
1987
1988    #[test]
1989    fn pipeline_default_crate_path_emits_extern_crate() {
1990        let dir = create_fixture_dir("default_crate_path");
1991        write_fixture(
1992            &dir,
1993            "material/mapping.toml",
1994            "play-pause = \"play_pause\"\n",
1995        );
1996        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1997
1998        let config: MasterConfig = toml::from_str(
1999            r#"
2000name = "test-icon"
2001roles = ["play-pause"]
2002bundled-themes = ["material"]
2003"#,
2004        )
2005        .unwrap();
2006
2007        let result = run_pipeline(
2008            &[("test.toml".to_string(), config)],
2009            std::slice::from_ref(&dir),
2010            None,
2011            None,
2012            None,
2013            &[],
2014        );
2015
2016        assert!(
2017            result.errors.is_empty(),
2018            "default crate path should not cause errors: {:?}",
2019            result.errors
2020        );
2021        assert!(
2022            result.code.contains("extern crate native_theme;"),
2023            "default crate path should emit extern crate. code:\n{}",
2024            result.code
2025        );
2026
2027        let _ = fs::remove_dir_all(&dir);
2028    }
2029}