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