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