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,no_run
66//! use native_theme_build::UnwrapOrExit;
67//!
68//! // Simple API (single TOML file):
69//! native_theme_build::generate_icons("icons/icons.toml")
70//!     .unwrap_or_exit()
71//!     .emit_cargo_directives()
72//!     .expect("failed to write generated code");
73//!
74//! // Builder API (multiple TOML files, custom enum name):
75//! native_theme_build::IconGenerator::new()
76//!     .source("icons/media.toml")
77//!     .source("icons/navigation.toml")
78//!     .enum_name("AppIcon")
79//!     .generate()
80//!     .unwrap_or_exit()
81//!     .emit_cargo_directives()
82//!     .expect("failed to write generated code");
83//! ```
84//!
85//! Both APIs resolve paths relative to `CARGO_MANIFEST_DIR`, and return a
86//! [`Result`] with a [`GenerateOutput`] on success or [`BuildErrors`] on
87//! failure. Call [`GenerateOutput::emit_cargo_directives()`] to write the
88//! output file and emit `cargo::rerun-if-changed` / `cargo::warning`
89//! directives.
90//!
91//! The [`UnwrapOrExit`] trait provides `.unwrap_or_exit()` as a drop-in
92//! replacement for the old `process::exit(1)` behaviour.
93//!
94//! # Using the Generated Code
95//!
96//! ```rust,ignore
97//! // In your lib.rs or main.rs:
98//! include!(concat!(env!("OUT_DIR"), "/app_icon.rs"));
99//!
100//! // The generated enum implements IconProvider:
101//! use native_theme::load_custom_icon;
102//! let icon_data = load_custom_icon(&AppIcon::PlayPause, native_theme::IconSet::Material);
103//! ```
104//!
105//! # What Gets Generated
106//!
107//! The output is a single `.rs` file containing:
108//!
109//! - A `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]` enum with one
110//!   variant per role.
111//! - An `IconProvider` implementation with `icon_name()` returning the
112//!   platform-specific identifier and `icon_svg()` returning
113//!   `include_bytes!(...)` data for bundled themes.
114//!
115//! # Validation
116//!
117//! Build errors are emitted at compile time for:
118//!
119//! - Missing roles in mapping files (every role must be present in every theme).
120//! - Missing SVG files for bundled themes.
121//! - Unknown role names in mapping files (not declared in the master TOML).
122//! - Duplicate roles across multiple TOML files (builder API).
123//! - Missing `default` key in DE-aware mapping entries.
124
125#![warn(missing_docs)]
126#![forbid(unsafe_code)]
127
128mod codegen;
129mod error;
130mod schema;
131mod validate;
132
133use std::collections::BTreeMap;
134use std::path::{Path, PathBuf};
135
136use heck::ToSnakeCase;
137
138pub use error::{BuildError, BuildErrors};
139use schema::{MasterConfig, ThemeMapping};
140
141/// Validate that a string is a valid Rust path (e.g. `"native_theme"` or
142/// `"my_crate::nested::module"`).
143///
144/// Returns `None` if valid, or `Some(reason)` describing the problem.
145/// Each segment between `::` must match `[a-zA-Z_][a-zA-Z0-9_]*`.
146fn validate_rust_path(path: &str) -> Option<String> {
147    if path.is_empty() {
148        return Some("must be non-empty".to_string());
149    }
150    let segments: Vec<&str> = path.split("::").collect();
151    for segment in &segments {
152        if segment.is_empty() {
153            return Some(
154                "contains empty segment (leading, trailing, or consecutive `::`)".to_string(),
155            );
156        }
157        let mut chars = segment.chars();
158        if let Some(first) = chars.next()
159            && !first.is_ascii_alphabetic()
160            && first != '_'
161        {
162            return Some(format!(
163                "segment \"{segment}\" must start with a letter or underscore"
164            ));
165        }
166        for c in chars {
167            if !c.is_ascii_alphanumeric() && c != '_' {
168                return Some(format!(
169                    "segment \"{segment}\" contains invalid character '{c}'"
170                ));
171            }
172        }
173    }
174    None
175}
176
177#[cfg(test)]
178use schema::{MappingValue, THEME_TABLE};
179
180/// Output of a successful icon generation pipeline.
181///
182/// Contains the generated code, metadata about what was generated, and all
183/// information needed to emit cargo directives. Call
184/// [`emit_cargo_directives()`](Self::emit_cargo_directives) to write the
185/// output file and print `cargo::rerun-if-changed` / `cargo::warning` lines.
186#[derive(Debug, Clone)]
187#[must_use = "call .emit_cargo_directives() to write the file and emit cargo directives"]
188pub struct GenerateOutput {
189    /// Path where the generated `.rs` file will be written.
190    pub output_path: PathBuf,
191    /// Warnings collected during generation (e.g., orphan SVGs, unknown DE keys).
192    pub warnings: Vec<String>,
193    /// Number of icon roles in the generated enum.
194    pub role_count: usize,
195    /// Number of bundled themes (themes with embedded SVGs).
196    pub bundled_theme_count: usize,
197    /// Total number of SVG files embedded.
198    pub svg_count: usize,
199    /// Total byte size of all embedded SVGs.
200    pub total_svg_bytes: u64,
201    /// Paths that cargo should watch for changes.
202    rerun_paths: Vec<PathBuf>,
203    /// The generated Rust source code.
204    pub code: String,
205}
206
207impl GenerateOutput {
208    /// Return the paths that cargo should watch for changes.
209    pub fn rerun_paths(&self) -> &[PathBuf] {
210        &self.rerun_paths
211    }
212
213    /// Emit cargo directives, write the generated file, and print warnings.
214    ///
215    /// This prints `cargo::rerun-if-changed` for all tracked paths, writes the
216    /// generated code to [`output_path`](Self::output_path), and prints warnings.
217    ///
218    /// # Errors
219    ///
220    /// Returns `std::io::Error` if writing the generated file fails. Cargo
221    /// directives and warnings are printed before the write, so they are
222    /// emitted even on failure.
223    pub fn emit_cargo_directives(&self) -> Result<(), std::io::Error> {
224        for path in &self.rerun_paths {
225            println!("cargo::rerun-if-changed={}", path.display());
226        }
227        std::fs::write(&self.output_path, &self.code)?;
228        for w in &self.warnings {
229            println!("cargo::warning={w}");
230        }
231        Ok(())
232    }
233}
234
235/// Extension trait for converting `Result<GenerateOutput, BuildErrors>` into
236/// a direct output with `process::exit(1)` on error.
237///
238/// Provides a drop-in migration path from the old `generate_icons()` API
239/// that called `process::exit` internally.
240///
241/// # Example
242///
243/// ```rust,no_run
244/// use native_theme_build::UnwrapOrExit;
245///
246/// native_theme_build::generate_icons("icons/icons.toml")
247///     .unwrap_or_exit()
248///     .emit_cargo_directives()
249///     .expect("failed to write generated code");
250/// ```
251pub trait UnwrapOrExit<T> {
252    /// Unwrap the `Ok` value or emit cargo errors and exit the process.
253    fn unwrap_or_exit(self) -> T;
254}
255
256impl UnwrapOrExit<GenerateOutput> for Result<GenerateOutput, BuildErrors> {
257    fn unwrap_or_exit(self) -> GenerateOutput {
258        match self {
259            Ok(output) => output,
260            Err(errors) => {
261                // Emit rerun-if-changed even on error so cargo re-checks when
262                // the user fixes the files. We don't have the paths here, but
263                // the build.rs will re-run anyway since it exited with failure.
264                errors.emit_cargo_errors();
265                std::process::exit(1);
266            }
267        }
268    }
269}
270
271/// Simple API: generate icon code from a single TOML file.
272///
273/// Reads the master TOML at `toml_path` (relative to `CARGO_MANIFEST_DIR`),
274/// validates all referenced themes and SVG files, and returns a
275/// [`GenerateOutput`] on success or [`BuildErrors`] on failure.
276///
277/// Call [`GenerateOutput::emit_cargo_directives()`] on the result to write
278/// the generated file and emit cargo directives.
279///
280/// # Limitations
281///
282/// This simple API always uses the default crate path (`"native_theme"`) and
283/// does not support extra derives. For custom crate paths, extra derives,
284/// multiple source files, or explicit base directories, use
285/// [`IconGenerator`] instead.
286///
287/// # Errors
288///
289/// Returns [`BuildErrors`] if `CARGO_MANIFEST_DIR` or `OUT_DIR` environment
290/// variables are not set, if the TOML file cannot be read or parsed, or if
291/// the icon pipeline detects missing roles, SVGs, or invalid mappings.
292#[must_use = "this returns the generated output; call .emit_cargo_directives() to complete the build"]
293pub fn generate_icons(toml_path: impl AsRef<Path>) -> Result<GenerateOutput, BuildErrors> {
294    let toml_path = toml_path.as_ref();
295    let manifest_dir = PathBuf::from(
296        std::env::var("CARGO_MANIFEST_DIR")
297            .map_err(|e| BuildErrors::io_env("CARGO_MANIFEST_DIR", e.to_string()))?,
298    );
299    let out_dir = PathBuf::from(
300        std::env::var("OUT_DIR").map_err(|e| BuildErrors::io_env("OUT_DIR", e.to_string()))?,
301    );
302    let resolved = manifest_dir.join(toml_path);
303
304    let content = std::fs::read_to_string(&resolved)
305        .map_err(|e| BuildErrors::io_read(resolved.display().to_string(), e.to_string()))?;
306    let config: MasterConfig = toml::from_str(&content)
307        .map_err(|e| BuildErrors::io_parse(resolved.display().to_string(), e.to_string()))?;
308
309    let base_dir = resolved
310        .parent()
311        .ok_or_else(|| {
312            BuildErrors::io_other(format!("{} has no parent directory", resolved.display()))
313        })?
314        .to_path_buf();
315    let file_path_str = resolved.to_string_lossy().to_string();
316
317    let result = run_pipeline(
318        &[(file_path_str, config)],
319        &[base_dir],
320        None,
321        Some(&manifest_dir),
322        None,
323        &[],
324    );
325
326    pipeline_result_to_output(result, &out_dir)
327}
328
329/// Builder API for composing multiple TOML icon definitions.
330#[derive(Debug)]
331#[must_use = "a configured builder does nothing until .generate() is called"]
332pub struct IconGenerator {
333    sources: Vec<PathBuf>,
334    enum_name_override: Option<String>,
335    base_dir: Option<PathBuf>,
336    crate_path: Option<String>,
337    extra_derives: Vec<String>,
338    output_dir: Option<PathBuf>,
339}
340
341impl Default for IconGenerator {
342    fn default() -> Self {
343        Self::new()
344    }
345}
346
347impl IconGenerator {
348    /// Create a new builder.
349    pub fn new() -> Self {
350        Self {
351            sources: Vec::new(),
352            enum_name_override: None,
353            base_dir: None,
354            crate_path: None,
355            extra_derives: Vec::new(),
356            output_dir: None,
357        }
358    }
359
360    /// Adds a source file. Multiple calls accumulate.
361    pub fn source(mut self, path: impl AsRef<Path>) -> Self {
362        self.sources.push(path.as_ref().to_path_buf());
363        self
364    }
365
366    /// Sets the enum name override. Replaces any previous value.
367    pub fn enum_name(mut self, name: &str) -> Self {
368        self.enum_name_override = Some(name.to_string());
369        self
370    }
371
372    /// Sets the base directory. Replaces any previous value.
373    ///
374    /// When set, all theme directories (e.g., `material/`, `sf-symbols/`) are
375    /// resolved relative to this path instead of the parent directory of each
376    /// TOML source file.
377    ///
378    /// When not set and multiple sources have different parent directories,
379    /// `generate()` returns an error.
380    pub fn base_dir(mut self, path: impl AsRef<Path>) -> Self {
381        self.base_dir = Some(path.as_ref().to_path_buf());
382        self
383    }
384
385    /// Sets the crate path. Replaces any previous value.
386    ///
387    /// Defaults to `"native_theme"`. When the default is used, the generated
388    /// file includes `extern crate native_theme;` to support Cargo aliases.
389    ///
390    /// Set this to a custom path (e.g. `"my_crate::native_theme"`) when
391    /// re-exporting native-theme from another crate.
392    pub fn crate_path(mut self, path: &str) -> Self {
393        // Issue 2: store raw input, validate in generate()
394        self.crate_path = Some(path.to_string());
395        self
396    }
397
398    /// Adds an extra derive. Multiple calls accumulate. Duplicates with the
399    /// base set (`Debug, Clone, Copy, PartialEq, Eq, Hash`) are deduplicated.
400    ///
401    /// ```rust,no_run
402    /// use native_theme_build::UnwrapOrExit;
403    ///
404    /// native_theme_build::IconGenerator::new()
405    ///     .source("icons/icons.toml")
406    ///     .derive("Ord")
407    ///     .derive("serde::Serialize")
408    ///     .generate()
409    ///     .unwrap_or_exit()
410    ///     .emit_cargo_directives()
411    ///     .expect("failed to write generated code");
412    /// ```
413    pub fn derive(mut self, name: &str) -> Self {
414        // Issue 2: store raw input, validate in generate()
415        self.extra_derives.push(name.to_string());
416        self
417    }
418
419    /// Sets the output directory. Replaces any previous value.
420    ///
421    /// When not set, the `OUT_DIR` environment variable is used (always
422    /// available during `cargo build`). Set this when running outside of
423    /// a build script context (e.g., in integration tests).
424    pub fn output_dir(mut self, path: impl AsRef<Path>) -> Self {
425        self.output_dir = Some(path.as_ref().to_path_buf());
426        self
427    }
428
429    /// Run the full pipeline: load, validate, generate.
430    ///
431    /// Returns a [`GenerateOutput`] on success or [`BuildErrors`] on failure.
432    /// Call [`GenerateOutput::emit_cargo_directives()`] on the result to write
433    /// the generated file and emit cargo directives.
434    ///
435    /// Source paths may be absolute or relative. Relative paths are resolved
436    /// against `CARGO_MANIFEST_DIR`. When all source paths are absolute,
437    /// `CARGO_MANIFEST_DIR` is not required.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`BuildErrors`] if `CARGO_MANIFEST_DIR` is not set and a
442    /// relative source path is used, or if neither
443    /// [`output_dir()`](Self::output_dir) nor `OUT_DIR` is set.
444    pub fn generate(self) -> Result<GenerateOutput, BuildErrors> {
445        if self.sources.is_empty() {
446            return Err(BuildErrors::io_other(
447                "no source files added to IconGenerator (call .source() before .generate())",
448            ));
449        }
450
451        // Issue 2/5: Validate crate_path in generate(), not in builder
452        if let Some(ref path) = self.crate_path
453            && let Some(reason) = validate_rust_path(path)
454        {
455            return Err(BuildErrors::new(vec![BuildError::InvalidCratePath {
456                path: path.clone(),
457                reason,
458            }]));
459        }
460
461        // Issue 2/6: Validate derives in generate(), not in builder
462        {
463            let mut errors = Vec::new();
464            for name in &self.extra_derives {
465                if let Some(reason) = validate_rust_path(name) {
466                    errors.push(BuildError::InvalidDerive {
467                        name: name.clone(),
468                        reason,
469                    });
470                }
471            }
472            if !errors.is_empty() {
473                return Err(BuildErrors::new(errors));
474            }
475        }
476
477        let needs_manifest_dir = self.sources.iter().any(|s| !s.is_absolute())
478            || self.base_dir.as_ref().is_some_and(|b| !b.is_absolute());
479        let manifest_dir = if needs_manifest_dir {
480            Some(PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").map_err(
481                |e| BuildErrors::io_env("CARGO_MANIFEST_DIR", e.to_string()),
482            )?))
483        } else {
484            std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
485        };
486
487        let out_dir = match self.output_dir {
488            Some(dir) => dir,
489            None => PathBuf::from(
490                std::env::var("OUT_DIR")
491                    .map_err(|e| BuildErrors::io_env("OUT_DIR", e.to_string()))?,
492            ),
493        };
494
495        let mut configs = Vec::new();
496        let mut base_dirs = Vec::new();
497
498        for source in &self.sources {
499            let resolved = if source.is_absolute() {
500                source.clone()
501            } else {
502                manifest_dir
503                    .as_ref()
504                    .ok_or_else(|| {
505                        BuildErrors::io_env(
506                            "CARGO_MANIFEST_DIR",
507                            format!("required for relative path {}", source.display()),
508                        )
509                    })?
510                    .join(source)
511            };
512            // Issue 103: Reject directories early with a clear message
513            if resolved.is_dir() {
514                return Err(BuildErrors::io_other(format!(
515                    "source path '{}' is a directory; expected a TOML file",
516                    resolved.display()
517                )));
518            }
519
520            let content = std::fs::read_to_string(&resolved)
521                .map_err(|e| BuildErrors::io_read(resolved.display().to_string(), e.to_string()))?;
522            let config: MasterConfig = toml::from_str(&content).map_err(|e| {
523                BuildErrors::io_parse(resolved.display().to_string(), e.to_string())
524            })?;
525
526            let file_path_str = resolved.to_string_lossy().to_string();
527
528            if let Some(ref explicit_base) = self.base_dir {
529                let base = if explicit_base.is_absolute() {
530                    explicit_base.clone()
531                } else {
532                    manifest_dir
533                        .as_ref()
534                        .ok_or_else(|| {
535                            BuildErrors::io_env(
536                                "CARGO_MANIFEST_DIR",
537                                format!(
538                                    "required for relative base_dir {}",
539                                    explicit_base.display()
540                                ),
541                            )
542                        })?
543                        .join(explicit_base)
544                };
545                base_dirs.push(base);
546            } else {
547                let parent = resolved
548                    .parent()
549                    .ok_or_else(|| {
550                        BuildErrors::io_other(format!(
551                            "{} has no parent directory",
552                            resolved.display()
553                        ))
554                    })?
555                    .to_path_buf();
556                base_dirs.push(parent);
557            }
558
559            configs.push((file_path_str, config));
560        }
561
562        // If no explicit base_dir and multiple sources have different parent dirs, error
563        if self.base_dir.is_none() && base_dirs.len() > 1 {
564            let first = &base_dirs[0];
565            let divergent = base_dirs.iter().any(|d| d != first);
566            if divergent {
567                return Err(BuildErrors::io_other(
568                    "multiple source files have different parent directories; \
569                     use .base_dir() to specify a common base directory for theme resolution",
570                ));
571            }
572        }
573
574        let result = run_pipeline(
575            &configs,
576            &base_dirs,
577            self.enum_name_override.as_deref(),
578            manifest_dir.as_deref(),
579            self.crate_path.as_deref(),
580            &self.extra_derives,
581        );
582
583        pipeline_result_to_output(result, &out_dir)
584    }
585}
586
587/// Result of running the pure pipeline core.
588///
589/// Contains the generated code, collected errors, and collected warnings.
590/// The thin outer layer ([`generate_icons()`] / [`IconGenerator::generate()`])
591/// converts this into `Result<GenerateOutput, BuildErrors>`.
592struct PipelineResult {
593    /// Generated Rust source code (empty if errors were found).
594    pub code: String,
595    /// Build errors found during validation.
596    pub errors: Vec<BuildError>,
597    /// Warnings (e.g., orphan SVGs).
598    pub warnings: Vec<String>,
599    /// Paths that should trigger rebuild when changed.
600    pub rerun_paths: Vec<PathBuf>,
601    /// Size report: (role_count, bundled_theme_count, svg_paths).
602    pub size_report: Option<SizeReport>,
603    /// The output filename (snake_case of config name + ".rs").
604    pub output_filename: String,
605}
606
607/// Size report for cargo::warning output.
608struct SizeReport {
609    /// Number of icon roles.
610    pub role_count: usize,
611    /// Number of bundled themes.
612    pub bundled_theme_count: usize,
613    /// Total bytes of all SVGs.
614    pub total_svg_bytes: u64,
615    /// Number of SVG files.
616    pub svg_count: usize,
617}
618
619/// Run the full pipeline on one or more loaded configs.
620///
621/// This is the pure core: it takes parsed configs, validates, generates code,
622/// and returns everything as data. No I/O, no process::exit.
623///
624/// When `manifest_dir` is `Some`, `base_dir` paths are stripped of the
625/// manifest prefix before being embedded in `include_bytes!` codegen,
626/// producing portable relative paths like `"/icons/material/play.svg"`
627/// instead of absolute filesystem paths.
628///
629/// `crate_path` controls the Rust path prefix used in generated code
630/// (e.g. `"native_theme"` or `"my_crate::native_theme"`).
631fn run_pipeline(
632    configs: &[(String, MasterConfig)],
633    base_dirs: &[PathBuf],
634    enum_name_override: Option<&str>,
635    manifest_dir: Option<&Path>,
636    crate_path: Option<&str>,
637    extra_derives: &[String],
638) -> PipelineResult {
639    if configs.is_empty() {
640        return PipelineResult {
641            code: String::new(),
642            errors: vec![BuildError::IoOther {
643                message: "no icon configs provided".into(),
644            }],
645            warnings: Vec::new(),
646            rerun_paths: Vec::new(),
647            size_report: None,
648            output_filename: String::new(),
649        };
650    }
651
652    debug_assert_eq!(configs.len(), base_dirs.len());
653
654    let mut errors: Vec<BuildError> = Vec::new();
655    let mut warnings: Vec<String> = Vec::new();
656    let mut rerun_paths: Vec<PathBuf> = Vec::new();
657    let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
658    let mut svg_paths: Vec<PathBuf> = Vec::new();
659
660    // Issue 39: output_filename is derived from merged.name after merge below.
661
662    // Step 0: Validate each config in isolation
663    for (file_path, config) in configs {
664        // Warn about empty roles (likely misconfiguration)
665        if config.roles.is_empty() {
666            warnings.push(format!(
667                "{file_path}: roles list is empty; generated enum will have no variants"
668            ));
669        }
670
671        // Check for duplicate roles within a single file
672        let dup_in_file_errors = validate::validate_no_duplicate_roles_in_file(config, file_path);
673        errors.extend(dup_in_file_errors);
674
675        // Check for theme overlap (same theme in bundled and system)
676        let overlap_errors = validate::validate_theme_overlap(config);
677        errors.extend(overlap_errors);
678
679        // Check for duplicate theme names within the same list
680        let dup_theme_errors = validate::validate_no_duplicate_themes(config);
681        errors.extend(dup_theme_errors);
682
683        // Per-file identifier validation (provides file context for collisions)
684        let file_id_errors = validate::validate_identifiers(config, Some(file_path));
685        errors.extend(file_id_errors);
686
687        // Per-file theme validation (provides file context for unknown themes)
688        let file_theme_errors = validate::validate_themes(config, Some(file_path));
689        errors.extend(file_theme_errors);
690    }
691
692    // Step 1: Check for duplicate roles across all files
693    if configs.len() > 1 {
694        let dup_errors = validate::validate_no_duplicate_roles(configs);
695        errors.extend(dup_errors);
696    }
697
698    // Step 2: Merge configs first so validation uses the merged role list
699    let merged = merge_configs(configs, enum_name_override, &mut warnings);
700
701    // Issue 8: Post-merge theme overlap validation (catches cross-file overlap)
702    let overlap_errors = validate::validate_theme_overlap(&merged);
703    errors.extend(overlap_errors);
704
705    // Warn about empty themes (likely misconfiguration)
706    if merged.bundled_themes.is_empty() && merged.system_themes.is_empty() {
707        warnings.push(
708            "no bundled-themes or system-themes configured; \
709             generated IconProvider will always return None"
710                .to_string(),
711        );
712    }
713
714    // Issue 39: Derive output_filename from merged.name (single source of truth)
715    let output_filename = format!("{}.rs", merged.name.to_snake_case());
716
717    // Issue 37: Validate output_filename is not just ".rs" after snake_case
718    if output_filename == ".rs" {
719        errors.push(BuildError::InvalidIdentifier {
720            name: merged.name.clone(),
721            reason: "snake_case conversion produces an empty filename".to_string(),
722        });
723    }
724
725    // Issue 19: Warn when enum name normalization changes the input
726    {
727        let pascal = heck::ToUpperCamelCase::to_upper_camel_case(merged.name.as_str());
728        if !pascal.is_empty() && pascal != merged.name {
729            warnings.push(format!(
730                "name \"{}\" will be used as \"{}\" (PascalCase normalization)",
731                merged.name, pascal
732            ));
733        }
734    }
735
736    // Issue 46: Warn when a role's PascalCase matches the enum name
737    {
738        let enum_pascal = heck::ToUpperCamelCase::to_upper_camel_case(merged.name.as_str());
739        for role in &merged.roles {
740            let role_pascal = heck::ToUpperCamelCase::to_upper_camel_case(role.as_str());
741            if role_pascal == enum_pascal && !role_pascal.is_empty() {
742                warnings.push(format!(
743                    "role \"{role}\" produces the same PascalCase name \"{role_pascal}\" \
744                     as the enum; this creates `enum {enum_pascal} {{ {role_pascal}, ... }}` \
745                     which may be confusing"
746                ));
747            }
748        }
749    }
750
751    // Step 2b: Validate identifiers on merged config (catches cross-file collisions).
752    // Per-file validation above provides file context for within-file collisions;
753    // this merged call catches collisions between roles from different files.
754    let id_errors = validate::validate_identifiers(&merged, None);
755    errors.extend(id_errors);
756
757    // Track rerun paths for all master TOML files
758    for (file_path, _config) in configs {
759        rerun_paths.push(PathBuf::from(file_path));
760    }
761
762    // Validate theme names on the merged config (catches cross-file theme issues).
763    // Per-file validation above provides file context for per-file unknown themes;
764    // this merged call validates the combined theme lists after merge.
765    let theme_errors = validate::validate_themes(&merged, None);
766    errors.extend(theme_errors);
767
768    // Use the first base_dir as the reference for loading themes.
769    // For multi-file, all configs sharing a theme must use the same base_dir.
770    let base_dir = &base_dirs[0];
771
772    // Process bundled themes
773    for theme_name in &merged.bundled_themes {
774        let theme_dir = base_dir.join(theme_name);
775
776        // Issue 20: Early check for theme directory existence
777        if !theme_dir.exists() {
778            errors.push(BuildError::IoRead {
779                path: theme_dir.display().to_string(),
780                reason: format!(
781                    "theme directory not found (expected for bundled theme \"{theme_name}\")"
782                ),
783            });
784            continue;
785        }
786
787        let mapping_path = theme_dir.join("mapping.toml");
788        let mapping_path_str = mapping_path.to_string_lossy().to_string();
789
790        // Add mapping TOML and theme directory to rerun paths
791        rerun_paths.push(mapping_path.clone());
792        rerun_paths.push(theme_dir.clone());
793
794        match std::fs::read_to_string(&mapping_path) {
795            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
796                Ok(mapping) => {
797                    // Validate mapping against merged roles
798                    let map_errors =
799                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
800                    errors.extend(map_errors);
801
802                    // Validate icon name values are well-formed
803                    let name_errors =
804                        validate::validate_mapping_values(&mapping, &mapping_path_str);
805                    errors.extend(name_errors);
806
807                    // Validate SVGs exist (only for declared master roles)
808                    let svg_errors = validate::validate_svgs(&mapping, &theme_dir, &merged.roles);
809                    errors.extend(svg_errors);
810
811                    // Warn about unrecognized DE keys in DeAware values
812                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
813                    warnings.extend(de_warnings);
814
815                    // Issue 7: Bundled themes with DE-aware mappings are a
816                    // semantic mismatch -- icon_name() returns a DE-specific name
817                    // but icon_svg() can only embed the default SVG.
818                    for (role_name, value) in &mapping {
819                        if matches!(value, schema::MappingValue::DeAware(_)) {
820                            errors.push(BuildError::BundledDeAware {
821                                theme: theme_name.clone(),
822                                role: role_name.clone(),
823                            });
824                        }
825                    }
826
827                    // Check orphan SVGs (warnings, not errors)
828                    let orphan_warnings = check_orphan_svgs_and_collect_paths(
829                        &mapping,
830                        &theme_dir,
831                        theme_name,
832                        &mut svg_paths,
833                        &mut rerun_paths,
834                    );
835                    warnings.extend(orphan_warnings);
836
837                    all_mappings.insert(theme_name.clone(), mapping);
838                }
839                Err(e) => {
840                    errors.push(BuildError::IoParse {
841                        path: mapping_path_str,
842                        reason: e.to_string(),
843                    });
844                }
845            },
846            Err(e) => {
847                errors.push(BuildError::IoRead {
848                    path: mapping_path_str,
849                    reason: e.to_string(),
850                });
851            }
852        }
853    }
854
855    // Process system themes (no SVG validation)
856    for theme_name in &merged.system_themes {
857        let theme_dir = base_dir.join(theme_name);
858
859        // Issue 20: Early check for theme directory existence
860        if !theme_dir.exists() {
861            errors.push(BuildError::IoRead {
862                path: theme_dir.display().to_string(),
863                reason: format!(
864                    "theme directory not found (expected for system theme \"{theme_name}\")"
865                ),
866            });
867            continue;
868        }
869
870        let mapping_path = theme_dir.join("mapping.toml");
871        let mapping_path_str = mapping_path.to_string_lossy().to_string();
872
873        // Add mapping TOML to rerun paths
874        rerun_paths.push(mapping_path.clone());
875
876        match std::fs::read_to_string(&mapping_path) {
877            Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
878                Ok(mapping) => {
879                    let map_errors =
880                        validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
881                    errors.extend(map_errors);
882
883                    // Validate icon name values are well-formed
884                    let name_errors =
885                        validate::validate_mapping_values(&mapping, &mapping_path_str);
886                    errors.extend(name_errors);
887
888                    // Warn about unrecognized DE keys in DeAware values
889                    let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
890                    warnings.extend(de_warnings);
891
892                    all_mappings.insert(theme_name.clone(), mapping);
893                }
894                Err(e) => {
895                    errors.push(BuildError::IoParse {
896                        path: mapping_path_str,
897                        reason: e.to_string(),
898                    });
899                }
900            },
901            Err(e) => {
902                errors.push(BuildError::IoRead {
903                    path: mapping_path_str,
904                    reason: e.to_string(),
905                });
906            }
907        }
908    }
909
910    // If errors, return without generating code
911    if !errors.is_empty() {
912        return PipelineResult {
913            code: String::new(),
914            errors,
915            warnings,
916            rerun_paths,
917            size_report: None,
918            output_filename,
919        };
920    }
921
922    // Compute base_dir for codegen -- strip manifest_dir prefix when available
923    // so include_bytes! paths are relative (e.g., "icons/material/play.svg")
924    // instead of absolute (e.g., "/home/user/project/icons/material/play.svg")
925    // Normalize to forward slashes so generated include_bytes! paths are valid
926    // on all platforms (backslashes in Rust string literals are escape sequences).
927    let base_dir_str = if let Some(mdir) = manifest_dir {
928        base_dir
929            .strip_prefix(mdir)
930            .unwrap_or(base_dir)
931            .to_string_lossy()
932            .replace('\\', "/")
933    } else {
934        base_dir.to_string_lossy().replace('\\', "/")
935    };
936
937    // Step 4: Generate code
938    let effective_crate_path = crate_path.unwrap_or("native_theme");
939    let code = codegen::generate_code(
940        &merged,
941        &all_mappings,
942        &base_dir_str,
943        effective_crate_path,
944        extra_derives,
945    );
946
947    // Step 5: Compute size report
948    let total_svg_bytes: u64 = svg_paths
949        .iter()
950        .filter_map(|p| std::fs::metadata(p).ok())
951        .map(|m| m.len())
952        .sum();
953
954    let size_report = Some(SizeReport {
955        role_count: merged.roles.len(),
956        bundled_theme_count: merged.bundled_themes.len(),
957        total_svg_bytes,
958        svg_count: svg_paths.len(),
959    });
960
961    PipelineResult {
962        code,
963        errors,
964        warnings,
965        rerun_paths,
966        size_report,
967        output_filename,
968    }
969}
970
971/// Check orphan SVGs and simultaneously collect SVG paths and rerun paths.
972///
973/// Issue 12: Tracks all icon names (including DE-specific names), not just
974/// the default name, for both rerun path tracking and size reporting.
975fn check_orphan_svgs_and_collect_paths(
976    mapping: &ThemeMapping,
977    theme_dir: &Path,
978    theme_name: &str,
979    svg_paths: &mut Vec<PathBuf>,
980    rerun_paths: &mut Vec<PathBuf>,
981) -> Vec<String> {
982    // Collect referenced SVG paths for all icon names (default + DE-specific)
983    for value in mapping.values() {
984        let names = value.all_names();
985        for name in names {
986            let svg_path = theme_dir.join(format!("{name}.svg"));
987            if svg_path.exists() {
988                rerun_paths.push(svg_path.clone());
989                svg_paths.push(svg_path);
990            }
991        }
992    }
993
994    validate::check_orphan_svgs(mapping, theme_dir, theme_name)
995}
996
997/// Merge multiple configs into a single MasterConfig for code generation.
998///
999/// Issue 38: Emits a warning when cross-file theme deduplication occurs.
1000fn merge_configs(
1001    configs: &[(String, MasterConfig)],
1002    enum_name_override: Option<&str>,
1003    warnings: &mut Vec<String>,
1004) -> MasterConfig {
1005    let name = enum_name_override
1006        .map(|s| s.to_string())
1007        .unwrap_or_else(|| configs[0].1.name.clone());
1008
1009    // Issue 84: Warn when multiple configs have different non-empty names
1010    // and no enum_name_override is set -- the user may not realize only the
1011    // first config's name is used.
1012    if enum_name_override.is_none() && configs.len() > 1 {
1013        let first_name = &configs[0].1.name;
1014        for (path, config) in &configs[1..] {
1015            if !config.name.is_empty() && config.name != *first_name {
1016                warnings.push(format!(
1017                    "config \"{path}\" has name \"{}\" which differs from \
1018                     the first config's name \"{first_name}\"; using \"{first_name}\" \
1019                     (set .enum_name() to override)",
1020                    config.name
1021                ));
1022            }
1023        }
1024    }
1025
1026    let mut roles = Vec::new();
1027    let mut bundled_themes = Vec::new();
1028    let mut system_themes = Vec::new();
1029    let mut seen_roles = std::collections::BTreeSet::new();
1030    let mut seen_bundled = std::collections::BTreeSet::new();
1031    let mut seen_system = std::collections::BTreeSet::new();
1032
1033    for (_path, config) in configs {
1034        for role in &config.roles {
1035            if seen_roles.insert(role.clone()) {
1036                roles.push(role.clone());
1037            }
1038        }
1039
1040        for t in &config.bundled_themes {
1041            if !seen_bundled.insert(t.clone()) {
1042                // Issue 38: warn on cross-file theme deduplication
1043                warnings.push(format!(
1044                    "bundled theme \"{t}\" appears in multiple source files; using first occurrence"
1045                ));
1046            } else {
1047                bundled_themes.push(t.clone());
1048            }
1049        }
1050        for t in &config.system_themes {
1051            if !seen_system.insert(t.clone()) {
1052                // Issue 38: warn on cross-file theme deduplication
1053                warnings.push(format!(
1054                    "system theme \"{t}\" appears in multiple source files; using first occurrence"
1055                ));
1056            } else {
1057                system_themes.push(t.clone());
1058            }
1059        }
1060    }
1061
1062    MasterConfig {
1063        name,
1064        roles,
1065        bundled_themes,
1066        system_themes,
1067    }
1068}
1069
1070/// Convert a `PipelineResult` into `Result<GenerateOutput, BuildErrors>`.
1071fn pipeline_result_to_output(
1072    result: PipelineResult,
1073    out_dir: &Path,
1074) -> Result<GenerateOutput, BuildErrors> {
1075    if !result.errors.is_empty() {
1076        // Issue 9: Carry rerun paths in the error so the caller can emit them.
1077        // No hidden I/O in this function.
1078        return Err(BuildErrors::with_rerun_paths(
1079            result.errors,
1080            result.rerun_paths,
1081        ));
1082    }
1083
1084    let output_path = out_dir.join(&result.output_filename);
1085
1086    let (role_count, bundled_theme_count, svg_count, total_svg_bytes) = match &result.size_report {
1087        Some(report) => (
1088            report.role_count,
1089            report.bundled_theme_count,
1090            report.svg_count,
1091            report.total_svg_bytes,
1092        ),
1093        None => (0, 0, 0, 0),
1094    };
1095
1096    Ok(GenerateOutput {
1097        output_path,
1098        warnings: result.warnings,
1099        role_count,
1100        bundled_theme_count,
1101        svg_count,
1102        total_svg_bytes,
1103        rerun_paths: result.rerun_paths,
1104        code: result.code,
1105    })
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use std::collections::BTreeMap;
1112    use std::fs;
1113
1114    // === MasterConfig tests ===
1115
1116    #[test]
1117    fn master_config_deserializes_full() {
1118        let toml_str = r#"
1119name = "app-icon"
1120roles = ["play-pause", "skip-forward"]
1121bundled-themes = ["material"]
1122system-themes = ["sf-symbols"]
1123"#;
1124        let config: MasterConfig = toml::from_str(toml_str).unwrap();
1125        assert_eq!(config.name, "app-icon");
1126        assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
1127        assert_eq!(config.bundled_themes, vec!["material"]);
1128        assert_eq!(config.system_themes, vec!["sf-symbols"]);
1129    }
1130
1131    #[test]
1132    fn master_config_empty_optional_fields() {
1133        let toml_str = r#"
1134name = "x"
1135roles = ["a"]
1136"#;
1137        let config: MasterConfig = toml::from_str(toml_str).unwrap();
1138        assert_eq!(config.name, "x");
1139        assert_eq!(config.roles, vec!["a"]);
1140        assert!(config.bundled_themes.is_empty());
1141        assert!(config.system_themes.is_empty());
1142    }
1143
1144    #[test]
1145    fn master_config_rejects_unknown_fields() {
1146        let toml_str = r#"
1147name = "x"
1148roles = ["a"]
1149bogus = "nope"
1150"#;
1151        let result = toml::from_str::<MasterConfig>(toml_str);
1152        assert!(result.is_err());
1153    }
1154
1155    // === MappingValue tests ===
1156
1157    #[test]
1158    fn mapping_value_simple() {
1159        let toml_str = r#"play-pause = "play_pause""#;
1160        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
1161        match &mapping["play-pause"] {
1162            MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
1163            _ => panic!("expected Simple variant"),
1164        }
1165    }
1166
1167    #[test]
1168    fn mapping_value_de_aware() {
1169        let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
1170        let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
1171        match &mapping["play-pause"] {
1172            MappingValue::DeAware(m) => {
1173                assert_eq!(m["kde"], "media-playback-start");
1174                assert_eq!(m["default"], "play");
1175            }
1176            _ => panic!("expected DeAware variant"),
1177        }
1178    }
1179
1180    #[test]
1181    fn theme_mapping_mixed_values() {
1182        let toml_str = r#"
1183play-pause = "play_pause"
1184bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
1185skip-forward = "skip_next"
1186"#;
1187        let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
1188        assert_eq!(mapping.len(), 3);
1189        assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
1190        assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
1191        assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
1192    }
1193
1194    // === MappingValue::default_name tests ===
1195
1196    #[test]
1197    fn mapping_value_default_name_simple() {
1198        let val = MappingValue::Simple("play_pause".to_string());
1199        assert_eq!(val.default_name(), Some("play_pause"));
1200    }
1201
1202    #[test]
1203    fn mapping_value_default_name_de_aware() {
1204        let mut m = BTreeMap::new();
1205        m.insert("kde".to_string(), "media-playback-start".to_string());
1206        m.insert("default".to_string(), "play".to_string());
1207        let val = MappingValue::DeAware(m);
1208        assert_eq!(val.default_name(), Some("play"));
1209    }
1210
1211    #[test]
1212    fn mapping_value_default_name_de_aware_missing_default() {
1213        let mut m = BTreeMap::new();
1214        m.insert("kde".to_string(), "media-playback-start".to_string());
1215        let val = MappingValue::DeAware(m);
1216        assert_eq!(val.default_name(), None);
1217    }
1218
1219    // === BuildError Display tests ===
1220
1221    #[test]
1222    fn build_error_missing_role_format() {
1223        let err = BuildError::MissingRole {
1224            role: "play-pause".into(),
1225            mapping_file: "icons/material/mapping.toml".into(),
1226        };
1227        let msg = err.to_string();
1228        assert!(msg.contains("play-pause"), "should contain role name");
1229        assert!(
1230            msg.contains("icons/material/mapping.toml"),
1231            "should contain file path"
1232        );
1233    }
1234
1235    #[test]
1236    fn build_error_missing_svg_format() {
1237        let err = BuildError::MissingSvg {
1238            path: "icons/material/play.svg".into(),
1239        };
1240        let msg = err.to_string();
1241        assert!(
1242            msg.contains("icons/material/play.svg"),
1243            "should contain SVG path"
1244        );
1245    }
1246
1247    #[test]
1248    fn build_error_unknown_role_format() {
1249        let err = BuildError::UnknownRole {
1250            role: "bogus".into(),
1251            mapping_file: "icons/material/mapping.toml".into(),
1252        };
1253        let msg = err.to_string();
1254        assert!(msg.contains("bogus"), "should contain role name");
1255        assert!(
1256            msg.contains("icons/material/mapping.toml"),
1257            "should contain file path"
1258        );
1259    }
1260
1261    #[test]
1262    fn build_error_unknown_theme_format() {
1263        let err = BuildError::UnknownTheme {
1264            theme: "nonexistent".into(),
1265            source_file: None,
1266        };
1267        let msg = err.to_string();
1268        assert!(msg.contains("nonexistent"), "should contain theme name");
1269    }
1270
1271    #[test]
1272    fn build_error_missing_default_format() {
1273        let err = BuildError::MissingDefault {
1274            role: "bluetooth".into(),
1275            mapping_file: "icons/freedesktop/mapping.toml".into(),
1276        };
1277        let msg = err.to_string();
1278        assert!(msg.contains("bluetooth"), "should contain role name");
1279        assert!(
1280            msg.contains("icons/freedesktop/mapping.toml"),
1281            "should contain file path"
1282        );
1283    }
1284
1285    #[test]
1286    fn build_error_duplicate_role_format() {
1287        let err = BuildError::DuplicateRole {
1288            role: "play-pause".into(),
1289            file_a: "icons/a.toml".into(),
1290            file_b: "icons/b.toml".into(),
1291        };
1292        let msg = err.to_string();
1293        assert!(msg.contains("play-pause"), "should contain role name");
1294        assert!(
1295            msg.contains("icons/a.toml"),
1296            "should contain first file path"
1297        );
1298        assert!(
1299            msg.contains("icons/b.toml"),
1300            "should contain second file path"
1301        );
1302    }
1303
1304    // === THEME_TABLE tests ===
1305
1306    #[test]
1307    fn theme_table_has_all_five() {
1308        assert_eq!(THEME_TABLE.len(), 5);
1309        let names: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
1310        assert!(names.contains(&"sf-symbols"));
1311        assert!(names.contains(&"segoe-fluent"));
1312        assert!(names.contains(&"freedesktop"));
1313        assert!(names.contains(&"material"));
1314        assert!(names.contains(&"lucide"));
1315    }
1316
1317    // === Helper to create test fixture directories ===
1318
1319    fn create_fixture_dir(suffix: &str) -> PathBuf {
1320        let dir = std::env::temp_dir().join(format!(
1321            "native_theme_test_pipeline_{suffix}_{}",
1322            std::process::id()
1323        ));
1324        let _ = fs::remove_dir_all(&dir);
1325        fs::create_dir_all(&dir).unwrap();
1326        dir
1327    }
1328
1329    fn write_fixture(dir: &Path, path: &str, content: &str) {
1330        let full_path = dir.join(path);
1331        if let Some(parent) = full_path.parent() {
1332            fs::create_dir_all(parent).unwrap();
1333        }
1334        fs::write(full_path, content).unwrap();
1335    }
1336
1337    const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
1338
1339    // === run_pipeline tests ===
1340
1341    #[test]
1342    fn pipeline_happy_path_generates_code() {
1343        let dir = create_fixture_dir("happy");
1344        write_fixture(
1345            &dir,
1346            "material/mapping.toml",
1347            r#"
1348play-pause = "play_pause"
1349skip-forward = "skip_next"
1350"#,
1351        );
1352        write_fixture(
1353            &dir,
1354            "sf-symbols/mapping.toml",
1355            r#"
1356play-pause = "play.fill"
1357skip-forward = "forward.fill"
1358"#,
1359        );
1360        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1361        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1362
1363        let config: MasterConfig = toml::from_str(
1364            r#"
1365name = "sample-icon"
1366roles = ["play-pause", "skip-forward"]
1367bundled-themes = ["material"]
1368system-themes = ["sf-symbols"]
1369"#,
1370        )
1371        .unwrap();
1372
1373        let result = run_pipeline(
1374            &[("sample-icons.toml".to_string(), config)],
1375            std::slice::from_ref(&dir),
1376            None,
1377            None,
1378            None,
1379            &[],
1380        );
1381
1382        assert!(
1383            result.errors.is_empty(),
1384            "expected no errors: {:?}",
1385            result.errors
1386        );
1387        assert!(!result.code.is_empty(), "expected generated code");
1388        assert!(result.code.contains("pub enum SampleIcon"));
1389        assert!(result.code.contains("PlayPause"));
1390        assert!(result.code.contains("SkipForward"));
1391
1392        let _ = fs::remove_dir_all(&dir);
1393    }
1394
1395    #[test]
1396    fn pipeline_output_filename_uses_snake_case() {
1397        let dir = create_fixture_dir("filename");
1398        write_fixture(
1399            &dir,
1400            "material/mapping.toml",
1401            "play-pause = \"play_pause\"\n",
1402        );
1403        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1404
1405        let config: MasterConfig = toml::from_str(
1406            r#"
1407name = "app-icon"
1408roles = ["play-pause"]
1409bundled-themes = ["material"]
1410"#,
1411        )
1412        .unwrap();
1413
1414        let result = run_pipeline(
1415            &[("app.toml".to_string(), config)],
1416            std::slice::from_ref(&dir),
1417            None,
1418            None,
1419            None,
1420            &[],
1421        );
1422
1423        assert_eq!(result.output_filename, "app_icon.rs");
1424
1425        let _ = fs::remove_dir_all(&dir);
1426    }
1427
1428    #[test]
1429    fn pipeline_collects_rerun_paths() {
1430        let dir = create_fixture_dir("rerun");
1431        write_fixture(
1432            &dir,
1433            "material/mapping.toml",
1434            r#"
1435play-pause = "play_pause"
1436"#,
1437        );
1438        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1439
1440        let config: MasterConfig = toml::from_str(
1441            r#"
1442name = "test"
1443roles = ["play-pause"]
1444bundled-themes = ["material"]
1445"#,
1446        )
1447        .unwrap();
1448
1449        let result = run_pipeline(
1450            &[("test.toml".to_string(), config)],
1451            std::slice::from_ref(&dir),
1452            None,
1453            None,
1454            None,
1455            &[],
1456        );
1457
1458        assert!(result.errors.is_empty());
1459        // Should include: master TOML, mapping TOML, theme dir, SVG files
1460        let path_strs: Vec<String> = result
1461            .rerun_paths
1462            .iter()
1463            .map(|p| p.to_string_lossy().to_string())
1464            .collect();
1465        assert!(
1466            path_strs.iter().any(|p| p.contains("test.toml")),
1467            "should track master TOML"
1468        );
1469        assert!(
1470            path_strs.iter().any(|p| p.contains("mapping.toml")),
1471            "should track mapping TOML"
1472        );
1473        assert!(
1474            path_strs.iter().any(|p| p.contains("play_pause.svg")),
1475            "should track SVG files"
1476        );
1477
1478        let _ = fs::remove_dir_all(&dir);
1479    }
1480
1481    #[test]
1482    fn pipeline_emits_size_report() {
1483        let dir = create_fixture_dir("size");
1484        write_fixture(
1485            &dir,
1486            "material/mapping.toml",
1487            "play-pause = \"play_pause\"\n",
1488        );
1489        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1490
1491        let config: MasterConfig = toml::from_str(
1492            r#"
1493name = "test"
1494roles = ["play-pause"]
1495bundled-themes = ["material"]
1496"#,
1497        )
1498        .unwrap();
1499
1500        let result = run_pipeline(
1501            &[("test.toml".to_string(), config)],
1502            std::slice::from_ref(&dir),
1503            None,
1504            None,
1505            None,
1506            &[],
1507        );
1508
1509        assert!(result.errors.is_empty());
1510        let report = result
1511            .size_report
1512            .as_ref()
1513            .expect("should have size report");
1514        assert_eq!(report.role_count, 1);
1515        assert_eq!(report.bundled_theme_count, 1);
1516        assert_eq!(report.svg_count, 1);
1517        assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
1518
1519        let _ = fs::remove_dir_all(&dir);
1520    }
1521
1522    #[test]
1523    fn pipeline_returns_errors_on_missing_role() {
1524        let dir = create_fixture_dir("missing_role");
1525        // Mapping is missing "skip-forward"
1526        write_fixture(
1527            &dir,
1528            "material/mapping.toml",
1529            "play-pause = \"play_pause\"\n",
1530        );
1531        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1532
1533        let config: MasterConfig = toml::from_str(
1534            r#"
1535name = "test"
1536roles = ["play-pause", "skip-forward"]
1537bundled-themes = ["material"]
1538"#,
1539        )
1540        .unwrap();
1541
1542        let result = run_pipeline(
1543            &[("test.toml".to_string(), config)],
1544            std::slice::from_ref(&dir),
1545            None,
1546            None,
1547            None,
1548            &[],
1549        );
1550
1551        assert!(!result.errors.is_empty(), "should have errors");
1552        assert!(
1553            result
1554                .errors
1555                .iter()
1556                .any(|e| e.to_string().contains("skip-forward")),
1557            "should mention missing role"
1558        );
1559        assert!(result.code.is_empty(), "no code on errors");
1560
1561        let _ = fs::remove_dir_all(&dir);
1562    }
1563
1564    #[test]
1565    fn pipeline_returns_errors_on_missing_svg() {
1566        let dir = create_fixture_dir("missing_svg");
1567        write_fixture(
1568            &dir,
1569            "material/mapping.toml",
1570            r#"
1571play-pause = "play_pause"
1572skip-forward = "skip_next"
1573"#,
1574        );
1575        // Only create one SVG, leave skip_next.svg missing
1576        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1577
1578        let config: MasterConfig = toml::from_str(
1579            r#"
1580name = "test"
1581roles = ["play-pause", "skip-forward"]
1582bundled-themes = ["material"]
1583"#,
1584        )
1585        .unwrap();
1586
1587        let result = run_pipeline(
1588            &[("test.toml".to_string(), config)],
1589            std::slice::from_ref(&dir),
1590            None,
1591            None,
1592            None,
1593            &[],
1594        );
1595
1596        assert!(!result.errors.is_empty(), "should have errors");
1597        assert!(
1598            result
1599                .errors
1600                .iter()
1601                .any(|e| e.to_string().contains("skip_next.svg")),
1602            "should mention missing SVG"
1603        );
1604
1605        let _ = fs::remove_dir_all(&dir);
1606    }
1607
1608    #[test]
1609    fn pipeline_orphan_svgs_are_warnings() {
1610        let dir = create_fixture_dir("orphan_warn");
1611        write_fixture(
1612            &dir,
1613            "material/mapping.toml",
1614            "play-pause = \"play_pause\"\n",
1615        );
1616        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1617        write_fixture(&dir, "material/unused.svg", SVG_STUB);
1618
1619        let config: MasterConfig = toml::from_str(
1620            r#"
1621name = "test"
1622roles = ["play-pause"]
1623bundled-themes = ["material"]
1624"#,
1625        )
1626        .unwrap();
1627
1628        let result = run_pipeline(
1629            &[("test.toml".to_string(), config)],
1630            std::slice::from_ref(&dir),
1631            None,
1632            None,
1633            None,
1634            &[],
1635        );
1636
1637        assert!(result.errors.is_empty(), "orphans are not errors");
1638        assert!(!result.warnings.is_empty(), "should have orphan warning");
1639        assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1640
1641        let _ = fs::remove_dir_all(&dir);
1642    }
1643
1644    // === merge_configs tests ===
1645
1646    #[test]
1647    fn merge_configs_combines_roles() {
1648        let config_a: MasterConfig = toml::from_str(
1649            r#"
1650name = "a"
1651roles = ["play-pause"]
1652bundled-themes = ["material"]
1653"#,
1654        )
1655        .unwrap();
1656        let config_b: MasterConfig = toml::from_str(
1657            r#"
1658name = "b"
1659roles = ["skip-forward"]
1660bundled-themes = ["material"]
1661system-themes = ["sf-symbols"]
1662"#,
1663        )
1664        .unwrap();
1665
1666        let configs = vec![
1667            ("a.toml".to_string(), config_a),
1668            ("b.toml".to_string(), config_b),
1669        ];
1670        let mut warnings = Vec::new();
1671        let merged = merge_configs(&configs, None, &mut warnings);
1672
1673        assert_eq!(merged.name, "a"); // uses first config's name
1674        assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1675        assert_eq!(merged.bundled_themes, vec!["material"]); // deduplicated
1676        assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1677    }
1678
1679    #[test]
1680    fn merge_configs_uses_enum_name_override() {
1681        let config: MasterConfig = toml::from_str(
1682            r#"
1683name = "original"
1684roles = ["x"]
1685"#,
1686        )
1687        .unwrap();
1688
1689        let configs = vec![("a.toml".to_string(), config)];
1690        let mut warnings = Vec::new();
1691        let merged = merge_configs(&configs, Some("MyIcons"), &mut warnings);
1692
1693        assert_eq!(merged.name, "MyIcons");
1694    }
1695
1696    // === Builder pipeline tests ===
1697
1698    #[test]
1699    fn pipeline_builder_merges_two_files() {
1700        let dir = create_fixture_dir("builder_merge");
1701        write_fixture(
1702            &dir,
1703            "material/mapping.toml",
1704            r#"
1705play-pause = "play_pause"
1706skip-forward = "skip_next"
1707"#,
1708        );
1709        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1710        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1711
1712        let config_a: MasterConfig = toml::from_str(
1713            r#"
1714name = "icons-a"
1715roles = ["play-pause"]
1716bundled-themes = ["material"]
1717"#,
1718        )
1719        .unwrap();
1720        let config_b: MasterConfig = toml::from_str(
1721            r#"
1722name = "icons-b"
1723roles = ["skip-forward"]
1724bundled-themes = ["material"]
1725"#,
1726        )
1727        .unwrap();
1728
1729        let result = run_pipeline(
1730            &[
1731                ("a.toml".to_string(), config_a),
1732                ("b.toml".to_string(), config_b),
1733            ],
1734            &[dir.clone(), dir.clone()],
1735            Some("AllIcons"),
1736            None,
1737            None,
1738            &[],
1739        );
1740
1741        assert!(
1742            result.errors.is_empty(),
1743            "expected no errors: {:?}",
1744            result.errors
1745        );
1746        assert!(
1747            result.code.contains("pub enum AllIcons"),
1748            "should use override name"
1749        );
1750        assert!(result.code.contains("PlayPause"));
1751        assert!(result.code.contains("SkipForward"));
1752        assert_eq!(result.output_filename, "all_icons.rs");
1753
1754        let _ = fs::remove_dir_all(&dir);
1755    }
1756
1757    #[test]
1758    fn pipeline_builder_detects_duplicate_roles() {
1759        let dir = create_fixture_dir("builder_dup");
1760        write_fixture(
1761            &dir,
1762            "material/mapping.toml",
1763            "play-pause = \"play_pause\"\n",
1764        );
1765        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1766
1767        let config_a: MasterConfig = toml::from_str(
1768            r#"
1769name = "a"
1770roles = ["play-pause"]
1771bundled-themes = ["material"]
1772"#,
1773        )
1774        .unwrap();
1775        let config_b: MasterConfig = toml::from_str(
1776            r#"
1777name = "b"
1778roles = ["play-pause"]
1779bundled-themes = ["material"]
1780"#,
1781        )
1782        .unwrap();
1783
1784        let result = run_pipeline(
1785            &[
1786                ("a.toml".to_string(), config_a),
1787                ("b.toml".to_string(), config_b),
1788            ],
1789            &[dir.clone(), dir.clone()],
1790            None,
1791            None,
1792            None,
1793            &[],
1794        );
1795
1796        assert!(!result.errors.is_empty(), "should detect duplicate roles");
1797        assert!(
1798            result
1799                .errors
1800                .iter()
1801                .any(|e| e.to_string().contains("play-pause"))
1802        );
1803
1804        let _ = fs::remove_dir_all(&dir);
1805    }
1806
1807    #[test]
1808    fn pipeline_generates_relative_include_bytes_paths() {
1809        // Simulate what generate_icons does: manifest_dir + "icons/icons.toml"
1810        // The tmpdir acts as CARGO_MANIFEST_DIR.
1811        // base_dir is absolute (tmpdir/icons), but run_pipeline should strip
1812        // the manifest_dir prefix for codegen, producing relative paths.
1813        let tmpdir = create_fixture_dir("rel_paths");
1814        write_fixture(
1815            &tmpdir,
1816            "icons/material/mapping.toml",
1817            "play-pause = \"play_pause\"\n",
1818        );
1819        write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1820
1821        let config: MasterConfig = toml::from_str(
1822            r#"
1823name = "test"
1824roles = ["play-pause"]
1825bundled-themes = ["material"]
1826"#,
1827        )
1828        .unwrap();
1829
1830        // base_dir is absolute (as generate_icons would compute)
1831        let abs_base_dir = tmpdir.join("icons");
1832
1833        let result = run_pipeline(
1834            &[("icons/icons.toml".to_string(), config)],
1835            &[abs_base_dir],
1836            None,
1837            Some(&tmpdir), // manifest_dir for stripping prefix
1838            None,
1839            &[],
1840        );
1841
1842        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1843        // The include_bytes path should contain "/icons/material/play_pause.svg"
1844        assert!(
1845            result.code.contains("\"/icons/material/play_pause.svg\""),
1846            "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1847            result.code,
1848        );
1849        // The include_bytes path should NOT contain the absolute tmpdir
1850        let tmpdir_str = tmpdir.to_string_lossy();
1851        assert!(
1852            !result.code.contains(&*tmpdir_str),
1853            "include_bytes path should NOT contain absolute tmpdir path",
1854        );
1855
1856        let _ = fs::remove_dir_all(&tmpdir);
1857    }
1858
1859    #[test]
1860    fn pipeline_no_system_svg_check() {
1861        // System themes should NOT validate SVGs
1862        let dir = create_fixture_dir("no_sys_svg");
1863        // sf-symbols has mapping but NO SVG files -- should be fine
1864        write_fixture(
1865            &dir,
1866            "sf-symbols/mapping.toml",
1867            r#"
1868play-pause = "play.fill"
1869"#,
1870        );
1871
1872        let config: MasterConfig = toml::from_str(
1873            r#"
1874name = "test"
1875roles = ["play-pause"]
1876system-themes = ["sf-symbols"]
1877"#,
1878        )
1879        .unwrap();
1880
1881        let result = run_pipeline(
1882            &[("test.toml".to_string(), config)],
1883            std::slice::from_ref(&dir),
1884            None,
1885            None,
1886            None,
1887            &[],
1888        );
1889
1890        assert!(
1891            result.errors.is_empty(),
1892            "system themes should not require SVGs: {:?}",
1893            result.errors
1894        );
1895
1896        let _ = fs::remove_dir_all(&dir);
1897    }
1898
1899    // === BuildErrors tests ===
1900
1901    #[test]
1902    fn build_errors_display_format() {
1903        let errors = BuildErrors::new(vec![
1904            BuildError::MissingRole {
1905                role: "play-pause".into(),
1906                mapping_file: "mapping.toml".into(),
1907            },
1908            BuildError::MissingSvg {
1909                path: "play.svg".into(),
1910            },
1911        ]);
1912        let msg = errors.to_string();
1913        assert!(msg.contains("2 build error(s):"));
1914        assert!(msg.contains("play-pause"));
1915        assert!(msg.contains("play.svg"));
1916    }
1917
1918    // === New BuildError Display tests ===
1919
1920    #[test]
1921    fn build_error_invalid_identifier_format() {
1922        let err = BuildError::InvalidIdentifier {
1923            name: "---".into(),
1924            reason: "PascalCase conversion produces an empty string".into(),
1925        };
1926        let msg = err.to_string();
1927        assert!(msg.contains("---"), "should contain the name");
1928        assert!(msg.contains("empty"), "should contain the reason");
1929    }
1930
1931    #[test]
1932    fn build_error_identifier_collision_format() {
1933        let err = BuildError::IdentifierCollision {
1934            role_a: "play_pause".into(),
1935            role_b: "play-pause".into(),
1936            pascal: "PlayPause".into(),
1937            source_file: None,
1938        };
1939        let msg = err.to_string();
1940        assert!(msg.contains("play_pause"), "should mention first role");
1941        assert!(msg.contains("play-pause"), "should mention second role");
1942        assert!(msg.contains("PlayPause"), "should mention PascalCase");
1943    }
1944
1945    #[test]
1946    fn build_error_theme_overlap_format() {
1947        let err = BuildError::ThemeOverlap {
1948            theme: "material".into(),
1949        };
1950        let msg = err.to_string();
1951        assert!(msg.contains("material"), "should mention theme");
1952        assert!(msg.contains("bundled"), "should mention bundled");
1953        assert!(msg.contains("system"), "should mention system");
1954    }
1955
1956    #[test]
1957    fn build_error_duplicate_role_in_file_format() {
1958        let err = BuildError::DuplicateRoleInFile {
1959            role: "play-pause".into(),
1960            file: "icons.toml".into(),
1961        };
1962        let msg = err.to_string();
1963        assert!(msg.contains("play-pause"), "should mention role");
1964        assert!(msg.contains("icons.toml"), "should mention file");
1965    }
1966
1967    // === Pipeline validation integration tests ===
1968
1969    #[test]
1970    fn pipeline_detects_theme_overlap() {
1971        let dir = create_fixture_dir("theme_overlap");
1972        write_fixture(
1973            &dir,
1974            "material/mapping.toml",
1975            "play-pause = \"play_pause\"\n",
1976        );
1977        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1978
1979        let config: MasterConfig = toml::from_str(
1980            r#"
1981name = "test"
1982roles = ["play-pause"]
1983bundled-themes = ["material"]
1984system-themes = ["material"]
1985"#,
1986        )
1987        .unwrap();
1988
1989        let result = run_pipeline(
1990            &[("test.toml".to_string(), config)],
1991            std::slice::from_ref(&dir),
1992            None,
1993            None,
1994            None,
1995            &[],
1996        );
1997
1998        assert!(!result.errors.is_empty(), "should detect theme overlap");
1999        assert!(
2000            result.errors.iter().any(|e| matches!(
2001                e,
2002                BuildError::ThemeOverlap { theme } if theme == "material"
2003            )),
2004            "should have ThemeOverlap error for 'material': {:?}",
2005            result.errors
2006        );
2007
2008        let _ = fs::remove_dir_all(&dir);
2009    }
2010
2011    #[test]
2012    fn pipeline_detects_identifier_collision() {
2013        let dir = create_fixture_dir("id_collision");
2014        write_fixture(
2015            &dir,
2016            "material/mapping.toml",
2017            "play_pause = \"pp\"\nplay-pause = \"pp2\"\n",
2018        );
2019        write_fixture(&dir, "material/pp.svg", SVG_STUB);
2020
2021        let config: MasterConfig = toml::from_str(
2022            r#"
2023name = "test"
2024roles = ["play_pause", "play-pause"]
2025bundled-themes = ["material"]
2026"#,
2027        )
2028        .unwrap();
2029
2030        let result = run_pipeline(
2031            &[("test.toml".to_string(), config)],
2032            std::slice::from_ref(&dir),
2033            None,
2034            None,
2035            None,
2036            &[],
2037        );
2038
2039        assert!(
2040            result.errors.iter().any(|e| matches!(
2041                e,
2042                BuildError::IdentifierCollision { pascal, .. } if pascal == "PlayPause"
2043            )),
2044            "should detect PascalCase collision: {:?}",
2045            result.errors
2046        );
2047
2048        let _ = fs::remove_dir_all(&dir);
2049    }
2050
2051    #[test]
2052    fn pipeline_detects_invalid_identifier() {
2053        let dir = create_fixture_dir("id_invalid");
2054        write_fixture(&dir, "material/mapping.toml", "self = \"self_icon\"\n");
2055        write_fixture(&dir, "material/self_icon.svg", SVG_STUB);
2056
2057        let config: MasterConfig = toml::from_str(
2058            r#"
2059name = "test"
2060roles = ["self"]
2061bundled-themes = ["material"]
2062"#,
2063        )
2064        .unwrap();
2065
2066        let result = run_pipeline(
2067            &[("test.toml".to_string(), config)],
2068            std::slice::from_ref(&dir),
2069            None,
2070            None,
2071            None,
2072            &[],
2073        );
2074
2075        assert!(
2076            result.errors.iter().any(|e| matches!(
2077                e,
2078                BuildError::InvalidIdentifier { name, .. } if name == "self"
2079            )),
2080            "should detect keyword identifier: {:?}",
2081            result.errors
2082        );
2083
2084        let _ = fs::remove_dir_all(&dir);
2085    }
2086
2087    #[test]
2088    fn pipeline_detects_duplicate_role_in_file() {
2089        let dir = create_fixture_dir("dup_in_file");
2090        write_fixture(
2091            &dir,
2092            "material/mapping.toml",
2093            "play-pause = \"play_pause\"\n",
2094        );
2095        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2096
2097        // MasterConfig with duplicate role -- manually construct since TOML
2098        // arrays allow duplicates
2099        let config = MasterConfig {
2100            name: "test".to_string(),
2101            roles: vec!["play-pause".to_string(), "play-pause".to_string()],
2102            bundled_themes: vec!["material".to_string()],
2103            system_themes: Vec::new(),
2104        };
2105
2106        let result = run_pipeline(
2107            &[("test.toml".to_string(), config)],
2108            std::slice::from_ref(&dir),
2109            None,
2110            None,
2111            None,
2112            &[],
2113        );
2114
2115        assert!(
2116            result.errors.iter().any(|e| matches!(
2117                e,
2118                BuildError::DuplicateRoleInFile { role, file }
2119                    if role == "play-pause" && file == "test.toml"
2120            )),
2121            "should detect duplicate role in file: {:?}",
2122            result.errors
2123        );
2124
2125        let _ = fs::remove_dir_all(&dir);
2126    }
2127
2128    // === Issue 7: Bundled DE-aware warning tests ===
2129
2130    #[test]
2131    fn pipeline_bundled_de_aware_produces_warning() {
2132        let dir = create_fixture_dir("bundled_de_aware");
2133        // Bundled theme with a DE-aware mapping
2134        write_fixture(
2135            &dir,
2136            "material/mapping.toml",
2137            r#"play-pause = { kde = "media-playback-start", default = "play_pause" }"#,
2138        );
2139        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2140
2141        let config: MasterConfig = toml::from_str(
2142            r#"
2143name = "test-icon"
2144roles = ["play-pause"]
2145bundled-themes = ["material"]
2146"#,
2147        )
2148        .unwrap();
2149
2150        let result = run_pipeline(
2151            &[("test.toml".to_string(), config)],
2152            std::slice::from_ref(&dir),
2153            None,
2154            None,
2155            None,
2156            &[],
2157        );
2158
2159        // Issue 7: Bundled DE-aware is now a build error, not a warning
2160        assert!(
2161            !result.errors.is_empty(),
2162            "bundled DE-aware should be an error"
2163        );
2164        assert!(
2165            result.errors.iter().any(|e| matches!(
2166                e,
2167                BuildError::BundledDeAware { theme, role }
2168                    if theme == "material" && role == "play-pause"
2169            )),
2170            "should have BundledDeAware error for material/play-pause: {:?}",
2171            result.errors
2172        );
2173
2174        let _ = fs::remove_dir_all(&dir);
2175    }
2176
2177    #[test]
2178    fn pipeline_system_de_aware_no_bundled_warning() {
2179        let dir = create_fixture_dir("system_de_aware");
2180        // System theme with DE-aware mapping should NOT produce the bundled warning
2181        write_fixture(
2182            &dir,
2183            "freedesktop/mapping.toml",
2184            r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
2185        );
2186
2187        let config: MasterConfig = toml::from_str(
2188            r#"
2189name = "test-icon"
2190roles = ["play-pause"]
2191system-themes = ["freedesktop"]
2192"#,
2193        )
2194        .unwrap();
2195
2196        let result = run_pipeline(
2197            &[("test.toml".to_string(), config)],
2198            std::slice::from_ref(&dir),
2199            None,
2200            None,
2201            None,
2202            &[],
2203        );
2204
2205        assert!(
2206            result.errors.is_empty(),
2207            "system DE-aware should not be an error: {:?}",
2208            result.errors
2209        );
2210        assert!(
2211            !result
2212                .warnings
2213                .iter()
2214                .any(|w| w.contains("only the default SVG will be embedded")),
2215            "system themes should NOT produce bundled DE-aware warning. warnings: {:?}",
2216            result.warnings
2217        );
2218
2219        let _ = fs::remove_dir_all(&dir);
2220    }
2221
2222    // === Issue 14: crate_path tests ===
2223
2224    #[test]
2225    fn pipeline_custom_crate_path() {
2226        let dir = create_fixture_dir("crate_path");
2227        write_fixture(
2228            &dir,
2229            "material/mapping.toml",
2230            "play-pause = \"play_pause\"\n",
2231        );
2232        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2233
2234        let config: MasterConfig = toml::from_str(
2235            r#"
2236name = "test-icon"
2237roles = ["play-pause"]
2238bundled-themes = ["material"]
2239"#,
2240        )
2241        .unwrap();
2242
2243        let result = run_pipeline(
2244            &[("test.toml".to_string(), config)],
2245            std::slice::from_ref(&dir),
2246            None,
2247            None,
2248            Some("my_crate::native_theme"),
2249            &[],
2250        );
2251
2252        assert!(
2253            result.errors.is_empty(),
2254            "custom crate path should not cause errors: {:?}",
2255            result.errors
2256        );
2257        assert!(
2258            result
2259                .code
2260                .contains("impl my_crate::native_theme::IconProvider"),
2261            "should use custom crate path in impl. code:\n{}",
2262            result.code
2263        );
2264        assert!(
2265            !result.code.contains("extern crate"),
2266            "custom crate path should not emit extern crate. code:\n{}",
2267            result.code
2268        );
2269
2270        let _ = fs::remove_dir_all(&dir);
2271    }
2272
2273    #[test]
2274    fn pipeline_default_crate_path_emits_extern_crate() {
2275        let dir = create_fixture_dir("default_crate_path");
2276        write_fixture(
2277            &dir,
2278            "material/mapping.toml",
2279            "play-pause = \"play_pause\"\n",
2280        );
2281        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2282
2283        let config: MasterConfig = toml::from_str(
2284            r#"
2285name = "test-icon"
2286roles = ["play-pause"]
2287bundled-themes = ["material"]
2288"#,
2289        )
2290        .unwrap();
2291
2292        let result = run_pipeline(
2293            &[("test.toml".to_string(), config)],
2294            std::slice::from_ref(&dir),
2295            None,
2296            None,
2297            None,
2298            &[],
2299        );
2300
2301        assert!(
2302            result.errors.is_empty(),
2303            "default crate path should not cause errors: {:?}",
2304            result.errors
2305        );
2306        assert!(
2307            result.code.contains("extern crate native_theme;"),
2308            "default crate path should emit extern crate. code:\n{}",
2309            result.code
2310        );
2311
2312        let _ = fs::remove_dir_all(&dir);
2313    }
2314
2315    // === Builder input validation tests (Issue 2: deferred to generate()) ===
2316
2317    /// Helper to run generate() on a builder with a dummy source file.
2318    /// Since we're testing builder validation (crate_path/derive), not the
2319    /// pipeline, we just need any source path -- the error should fire before I/O.
2320    fn generate_with_dummy_source(builder: IconGenerator) -> Result<GenerateOutput, BuildErrors> {
2321        // Use a nonexistent path; the crate_path/derive validation fires first.
2322        builder
2323            .source("/nonexistent/icons.toml")
2324            .output_dir("/tmp/native_theme_test_dummy")
2325            .generate()
2326    }
2327
2328    #[test]
2329    fn derive_rejects_empty_string() {
2330        let result = generate_with_dummy_source(IconGenerator::new().derive(""));
2331        let errors = result.unwrap_err();
2332        assert!(
2333            errors.errors().iter().any(|e| matches!(
2334                e,
2335                BuildError::InvalidDerive { name, .. } if name.is_empty()
2336            )),
2337            "should reject empty derive: {errors:?}"
2338        );
2339    }
2340
2341    #[test]
2342    fn derive_rejects_whitespace() {
2343        let result = generate_with_dummy_source(IconGenerator::new().derive("Ord PartialOrd"));
2344        let errors = result.unwrap_err();
2345        assert!(
2346            errors.errors().iter().any(|e| matches!(
2347                e,
2348                BuildError::InvalidDerive { name, .. } if name == "Ord PartialOrd"
2349            )),
2350            "should reject whitespace derive: {errors:?}"
2351        );
2352    }
2353
2354    #[test]
2355    fn derive_rejects_tab() {
2356        let result = generate_with_dummy_source(IconGenerator::new().derive("Ord\t"));
2357        let errors = result.unwrap_err();
2358        assert!(
2359            errors
2360                .errors()
2361                .iter()
2362                .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2363            "should reject tab derive: {errors:?}"
2364        );
2365    }
2366
2367    #[test]
2368    fn derive_accepts_valid_name() {
2369        // These should not produce InvalidDerive errors (may still fail on missing file)
2370        let r1 = generate_with_dummy_source(IconGenerator::new().derive("Ord"));
2371        if let Err(ref e) = r1 {
2372            assert!(
2373                !e.errors()
2374                    .iter()
2375                    .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2376                "Ord should be valid: {e:?}"
2377            );
2378        }
2379        let r2 = generate_with_dummy_source(IconGenerator::new().derive("serde::Serialize"));
2380        if let Err(ref e) = r2 {
2381            assert!(
2382                !e.errors()
2383                    .iter()
2384                    .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2385                "serde::Serialize should be valid: {e:?}"
2386            );
2387        }
2388    }
2389
2390    #[test]
2391    fn crate_path_rejects_empty_string() {
2392        let result = generate_with_dummy_source(IconGenerator::new().crate_path(""));
2393        let errors = result.unwrap_err();
2394        assert!(
2395            errors.errors().iter().any(|e| matches!(
2396                e,
2397                BuildError::InvalidCratePath { path, .. } if path.is_empty()
2398            )),
2399            "should reject empty crate_path: {errors:?}"
2400        );
2401    }
2402
2403    #[test]
2404    fn crate_path_rejects_spaces() {
2405        let result = generate_with_dummy_source(IconGenerator::new().crate_path("foo bar"));
2406        let errors = result.unwrap_err();
2407        assert!(
2408            errors.errors().iter().any(|e| matches!(
2409                e,
2410                BuildError::InvalidCratePath { path, .. } if path == "foo bar"
2411            )),
2412            "should reject spaces in crate_path: {errors:?}"
2413        );
2414    }
2415
2416    #[test]
2417    fn crate_path_accepts_valid_path() {
2418        let r1 = generate_with_dummy_source(IconGenerator::new().crate_path("native_theme"));
2419        if let Err(ref e) = r1 {
2420            assert!(
2421                !e.errors()
2422                    .iter()
2423                    .any(|e| matches!(e, BuildError::InvalidCratePath { .. })),
2424                "native_theme should be valid: {e:?}"
2425            );
2426        }
2427        let r2 =
2428            generate_with_dummy_source(IconGenerator::new().crate_path("my_crate::native_theme"));
2429        if let Err(ref e) = r2 {
2430            assert!(
2431                !e.errors()
2432                    .iter()
2433                    .any(|e| matches!(e, BuildError::InvalidCratePath { .. })),
2434                "my_crate::native_theme should be valid: {e:?}"
2435            );
2436        }
2437    }
2438
2439    // === validate_rust_path tests ===
2440
2441    #[test]
2442    fn validate_rust_path_valid() {
2443        assert!(validate_rust_path("native_theme").is_none());
2444        assert!(validate_rust_path("my_crate::native_theme").is_none());
2445        assert!(validate_rust_path("a::b::c").is_none());
2446        assert!(validate_rust_path("_private").is_none());
2447    }
2448
2449    #[test]
2450    fn validate_rust_path_rejects_empty() {
2451        assert!(validate_rust_path("").is_some());
2452    }
2453
2454    #[test]
2455    fn validate_rust_path_rejects_empty_segment() {
2456        assert!(validate_rust_path("::foo").is_some());
2457        assert!(validate_rust_path("foo::").is_some());
2458        assert!(validate_rust_path("foo::::bar").is_some());
2459    }
2460
2461    #[test]
2462    fn validate_rust_path_rejects_digit_start() {
2463        assert!(validate_rust_path("3crate").is_some());
2464        assert!(validate_rust_path("foo::3bar").is_some());
2465    }
2466
2467    #[test]
2468    fn validate_rust_path_rejects_special_chars() {
2469        assert!(validate_rust_path("foo bar").is_some());
2470        assert!(validate_rust_path("foo-bar").is_some());
2471        assert!(validate_rust_path("foo.bar").is_some());
2472    }
2473
2474    // === Issue 36: Missing Display tests for DuplicateTheme and InvalidIconName ===
2475
2476    #[test]
2477    fn build_error_duplicate_theme_format() {
2478        let err = BuildError::DuplicateTheme {
2479            theme: "material".into(),
2480            list: "bundled-themes".into(),
2481        };
2482        let msg = err.to_string();
2483        assert!(msg.contains("material"), "should contain theme name");
2484        assert!(msg.contains("bundled-themes"), "should contain list name");
2485    }
2486
2487    #[test]
2488    fn build_error_invalid_icon_name_format() {
2489        let err = BuildError::InvalidIconName {
2490            name: "bad\x00name".into(),
2491            role: "play-pause".into(),
2492            mapping_file: "mapping.toml".into(),
2493            offending: Some('\x00'),
2494        };
2495        let msg = err.to_string();
2496        assert!(msg.contains("play-pause"), "should contain role name");
2497        assert!(msg.contains("mapping.toml"), "should contain file path");
2498        assert!(
2499            msg.contains("\\u{0000}"),
2500            "should show offending character: {msg}"
2501        );
2502    }
2503
2504    // === Issue 36: Display tests for new error variants ===
2505
2506    #[test]
2507    fn build_error_bundled_de_aware_format() {
2508        let err = BuildError::BundledDeAware {
2509            theme: "material".into(),
2510            role: "play-pause".into(),
2511        };
2512        let msg = err.to_string();
2513        assert!(msg.contains("material"), "should contain theme name");
2514        assert!(msg.contains("play-pause"), "should contain role name");
2515        assert!(
2516            msg.contains("system theme"),
2517            "should suggest using system theme"
2518        );
2519    }
2520
2521    #[test]
2522    fn build_error_invalid_crate_path_format() {
2523        let err = BuildError::InvalidCratePath {
2524            path: "foo bar".into(),
2525            reason: "contains space".into(),
2526        };
2527        let msg = err.to_string();
2528        assert!(msg.contains("foo bar"), "should contain the path");
2529        assert!(msg.contains("contains space"), "should contain reason");
2530    }
2531
2532    #[test]
2533    fn build_error_invalid_derive_format() {
2534        let err = BuildError::InvalidDerive {
2535            name: "".into(),
2536            reason: "must be non-empty".into(),
2537        };
2538        let msg = err.to_string();
2539        assert!(msg.contains("must be non-empty"), "should contain reason");
2540    }
2541
2542    // === Issue 24: Empty roles list behavior ===
2543
2544    #[test]
2545    fn pipeline_empty_roles_list() {
2546        let dir = create_fixture_dir("empty_roles");
2547        write_fixture(&dir, "material/mapping.toml", "");
2548
2549        let config: MasterConfig = toml::from_str(
2550            r#"
2551name = "test"
2552roles = []
2553bundled-themes = ["material"]
2554"#,
2555        )
2556        .unwrap();
2557
2558        let result = run_pipeline(
2559            &[("test.toml".to_string(), config)],
2560            std::slice::from_ref(&dir),
2561            None,
2562            None,
2563            None,
2564            &[],
2565        );
2566
2567        assert!(
2568            result.errors.is_empty(),
2569            "empty roles should not produce errors: {:?}",
2570            result.errors
2571        );
2572        assert!(
2573            result
2574                .warnings
2575                .iter()
2576                .any(|w| w.contains("roles list is empty")),
2577            "should warn about empty roles: {:?}",
2578            result.warnings
2579        );
2580        // Generated code should still be valid (empty enum with #[non_exhaustive])
2581        assert!(result.code.contains("pub enum Test {"));
2582        assert!(result.code.contains("#[non_exhaustive]"));
2583
2584        let _ = fs::remove_dir_all(&dir);
2585    }
2586
2587    // === Issue 25: Multiple DE overrides ===
2588
2589    #[test]
2590    fn pipeline_multiple_de_overrides() {
2591        let dir = create_fixture_dir("multi_de");
2592        write_fixture(
2593            &dir,
2594            "freedesktop/mapping.toml",
2595            r#"reveal = { kde = "view-kde", gnome = "view-gnome", xfce = "view-xfce", default = "view-default" }"#,
2596        );
2597
2598        let config: MasterConfig = toml::from_str(
2599            r#"
2600name = "test"
2601roles = ["reveal"]
2602system-themes = ["freedesktop"]
2603"#,
2604        )
2605        .unwrap();
2606
2607        let result = run_pipeline(
2608            &[("test.toml".to_string(), config)],
2609            std::slice::from_ref(&dir),
2610            None,
2611            None,
2612            None,
2613            &[],
2614        );
2615
2616        assert!(
2617            result.errors.is_empty(),
2618            "multiple DE overrides should not produce errors: {:?}",
2619            result.errors
2620        );
2621        // Each DE should produce a separate match arm
2622        assert!(
2623            result
2624                .code
2625                .contains("LinuxDesktop::Kde => Some(\"view-kde\")"),
2626            "should have KDE arm. code:\n{}",
2627            result.code
2628        );
2629        assert!(
2630            result
2631                .code
2632                .contains("LinuxDesktop::Gnome => Some(\"view-gnome\")"),
2633            "should have GNOME arm. code:\n{}",
2634            result.code
2635        );
2636        assert!(
2637            result
2638                .code
2639                .contains("LinuxDesktop::Xfce => Some(\"view-xfce\")"),
2640            "should have XFCE arm. code:\n{}",
2641            result.code
2642        );
2643        assert!(
2644            result.code.contains("_ => Some(\"view-default\")"),
2645            "should have default arm. code:\n{}",
2646            result.code
2647        );
2648
2649        let _ = fs::remove_dir_all(&dir);
2650    }
2651
2652    // === Issue 26: Empty themes warning ===
2653
2654    #[test]
2655    fn pipeline_empty_themes_warning() {
2656        let config: MasterConfig = toml::from_str(
2657            r#"
2658name = "test"
2659roles = ["play-pause"]
2660"#,
2661        )
2662        .unwrap();
2663
2664        let dir = create_fixture_dir("empty_themes");
2665
2666        let result = run_pipeline(
2667            &[("test.toml".to_string(), config)],
2668            std::slice::from_ref(&dir),
2669            None,
2670            None,
2671            None,
2672            &[],
2673        );
2674
2675        assert!(
2676            result.errors.is_empty(),
2677            "empty themes should not be an error: {:?}",
2678            result.errors
2679        );
2680        assert!(
2681            result
2682                .warnings
2683                .iter()
2684                .any(|w| w.contains("no bundled-themes or system-themes")),
2685            "should warn about no themes. warnings: {:?}",
2686            result.warnings
2687        );
2688
2689        let _ = fs::remove_dir_all(&dir);
2690    }
2691
2692    // === Issue 27: DE-specific SVG non-requirement ===
2693
2694    #[test]
2695    fn pipeline_de_specific_svgs_not_required() {
2696        let dir = create_fixture_dir("de_svgs_not_required");
2697        // Bundled theme with DE-aware mapping has been moved to an error (Issue 7),
2698        // so test with a system theme that no SVG is required for DE-specific names
2699        write_fixture(
2700            &dir,
2701            "freedesktop/mapping.toml",
2702            r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
2703        );
2704
2705        let config: MasterConfig = toml::from_str(
2706            r#"
2707name = "test"
2708roles = ["play-pause"]
2709system-themes = ["freedesktop"]
2710"#,
2711        )
2712        .unwrap();
2713
2714        let result = run_pipeline(
2715            &[("test.toml".to_string(), config)],
2716            std::slice::from_ref(&dir),
2717            None,
2718            None,
2719            None,
2720            &[],
2721        );
2722
2723        // No MissingSvg error for DE-specific names in system themes
2724        assert!(
2725            !result
2726                .errors
2727                .iter()
2728                .any(|e| matches!(e, BuildError::MissingSvg { .. })),
2729            "should not require SVGs for system theme DE-specific names: {:?}",
2730            result.errors
2731        );
2732
2733        let _ = fs::remove_dir_all(&dir);
2734    }
2735
2736    // === Issue 30: Backslash path normalization ===
2737
2738    #[test]
2739    fn pipeline_backslash_path_normalized() {
2740        let dir = create_fixture_dir("backslash_path");
2741        write_fixture(
2742            &dir,
2743            "material/mapping.toml",
2744            "play-pause = \"play_pause\"\n",
2745        );
2746        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2747
2748        let config: MasterConfig = toml::from_str(
2749            r#"
2750name = "test"
2751roles = ["play-pause"]
2752bundled-themes = ["material"]
2753"#,
2754        )
2755        .unwrap();
2756
2757        // Create a base_dir path with backslashes (as Windows would produce)
2758        let base_with_backslash = PathBuf::from(dir.to_string_lossy().replace('/', "\\"));
2759
2760        let result = run_pipeline(
2761            &[("test.toml".to_string(), config)],
2762            // Use normal dir for filesystem operations, but verify normalization logic
2763            std::slice::from_ref(&dir),
2764            None,
2765            None,
2766            None,
2767            &[],
2768        );
2769
2770        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2771        // Verify no backslashes in generated include_bytes! paths
2772        let include_bytes_paths: Vec<&str> = result
2773            .code
2774            .lines()
2775            .filter(|l| l.contains("include_bytes!"))
2776            .collect();
2777        for path_line in &include_bytes_paths {
2778            // The string literal inside include_bytes should not contain raw backslashes
2779            // (escaped \\ is different from raw \)
2780            assert!(
2781                !path_line.contains("\\\\"),
2782                "include_bytes path should use forward slashes: {path_line}"
2783            );
2784        }
2785
2786        let _ = fs::remove_dir_all(&base_with_backslash);
2787        let _ = fs::remove_dir_all(&dir);
2788    }
2789
2790    // === Issue 34: enum_name normalization in codegen ===
2791
2792    #[test]
2793    fn pipeline_enum_name_override_normalized() {
2794        let dir = create_fixture_dir("enum_name_norm");
2795        write_fixture(
2796            &dir,
2797            "material/mapping.toml",
2798            "play-pause = \"play_pause\"\n",
2799        );
2800        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2801
2802        let config: MasterConfig = toml::from_str(
2803            r#"
2804name = "original"
2805roles = ["play-pause"]
2806bundled-themes = ["material"]
2807"#,
2808        )
2809        .unwrap();
2810
2811        let result = run_pipeline(
2812            &[("test.toml".to_string(), config)],
2813            std::slice::from_ref(&dir),
2814            Some("my-custom-icons"),
2815            None,
2816            None,
2817            &[],
2818        );
2819
2820        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2821        assert!(
2822            result.code.contains("pub enum MyCustomIcons"),
2823            "enum_name should be PascalCase of 'my-custom-icons'. code:\n{}",
2824            result.code
2825        );
2826        assert_eq!(
2827            result.output_filename, "my_custom_icons.rs",
2828            "output filename should be snake_case"
2829        );
2830
2831        let _ = fs::remove_dir_all(&dir);
2832    }
2833
2834    // === Issue 4: Path traversal rejection ===
2835
2836    #[test]
2837    fn pipeline_rejects_path_traversal_in_icon_names() {
2838        let dir = create_fixture_dir("path_traversal");
2839        write_fixture(
2840            &dir,
2841            "material/mapping.toml",
2842            "play-pause = \"../../etc/passwd\"\n",
2843        );
2844
2845        let config: MasterConfig = toml::from_str(
2846            r#"
2847name = "test"
2848roles = ["play-pause"]
2849bundled-themes = ["material"]
2850"#,
2851        )
2852        .unwrap();
2853
2854        let result = run_pipeline(
2855            &[("test.toml".to_string(), config)],
2856            std::slice::from_ref(&dir),
2857            None,
2858            None,
2859            None,
2860            &[],
2861        );
2862
2863        assert!(
2864            result.errors.iter().any(|e| matches!(
2865                e,
2866                BuildError::InvalidIconName { name, .. } if name.contains("..")
2867            )),
2868            "should reject path traversal in icon names: {:?}",
2869            result.errors
2870        );
2871
2872        let _ = fs::remove_dir_all(&dir);
2873    }
2874
2875    #[test]
2876    fn pipeline_rejects_slash_in_icon_names() {
2877        let dir = create_fixture_dir("slash_icon");
2878        write_fixture(&dir, "material/mapping.toml", "play-pause = \"sub/dir\"\n");
2879
2880        let config: MasterConfig = toml::from_str(
2881            r#"
2882name = "test"
2883roles = ["play-pause"]
2884bundled-themes = ["material"]
2885"#,
2886        )
2887        .unwrap();
2888
2889        let result = run_pipeline(
2890            &[("test.toml".to_string(), config)],
2891            std::slice::from_ref(&dir),
2892            None,
2893            None,
2894            None,
2895            &[],
2896        );
2897
2898        assert!(
2899            result.errors.iter().any(|e| matches!(
2900                e,
2901                BuildError::InvalidIconName { name, .. } if name == "sub/dir"
2902            )),
2903            "should reject slash in icon names: {:?}",
2904            result.errors
2905        );
2906
2907        let _ = fs::remove_dir_all(&dir);
2908    }
2909
2910    // === Issue 53: Collapse redundant DE-aware mappings ===
2911
2912    #[test]
2913    fn pipeline_collapses_redundant_de_aware() {
2914        let dir = create_fixture_dir("collapse_de");
2915        write_fixture(
2916            &dir,
2917            "freedesktop/mapping.toml",
2918            r#"play-pause = { kde = "play", gnome = "play", default = "play" }"#,
2919        );
2920
2921        let config: MasterConfig = toml::from_str(
2922            r#"
2923name = "test"
2924roles = ["play-pause"]
2925system-themes = ["freedesktop"]
2926"#,
2927        )
2928        .unwrap();
2929
2930        let result = run_pipeline(
2931            &[("test.toml".to_string(), config)],
2932            std::slice::from_ref(&dir),
2933            None,
2934            None,
2935            None,
2936            &[],
2937        );
2938
2939        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2940        // Should collapse to a simple arm since all values equal default
2941        assert!(
2942            !result.code.contains("detect_linux_de"),
2943            "all-same DE-aware should collapse to simple arm. code:\n{}",
2944            result.code
2945        );
2946        assert!(
2947            result.code.contains("Some(\"play\")"),
2948            "should contain simple play arm. code:\n{}",
2949            result.code
2950        );
2951
2952        let _ = fs::remove_dir_all(&dir);
2953    }
2954
2955    // === Issue 62: Invisible Unicode rejection ===
2956
2957    #[test]
2958    fn pipeline_rejects_invisible_unicode_in_icon_names() {
2959        let dir = create_fixture_dir("invisible_unicode");
2960        write_fixture(
2961            &dir,
2962            "material/mapping.toml",
2963            "play-pause = \"play\u{200B}pause\"\n",
2964        );
2965
2966        let config: MasterConfig = toml::from_str(
2967            r#"
2968name = "test"
2969roles = ["play-pause"]
2970bundled-themes = ["material"]
2971"#,
2972        )
2973        .unwrap();
2974
2975        let result = run_pipeline(
2976            &[("test.toml".to_string(), config)],
2977            std::slice::from_ref(&dir),
2978            None,
2979            None,
2980            None,
2981            &[],
2982        );
2983
2984        assert!(
2985            result
2986                .errors
2987                .iter()
2988                .any(|e| matches!(e, BuildError::InvalidIconName { .. })),
2989            "should reject invisible Unicode in icon names: {:?}",
2990            result.errors
2991        );
2992
2993        let _ = fs::remove_dir_all(&dir);
2994    }
2995
2996    // === Issue 8: Post-merge theme overlap ===
2997
2998    #[test]
2999    fn pipeline_cross_file_theme_overlap() {
3000        let dir = create_fixture_dir("cross_overlap");
3001        write_fixture(
3002            &dir,
3003            "material/mapping.toml",
3004            "play-pause = \"play_pause\"\nskip-forward = \"skip_next\"\n",
3005        );
3006        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3007        write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
3008
3009        let config_a: MasterConfig = toml::from_str(
3010            r#"
3011name = "a"
3012roles = ["play-pause"]
3013bundled-themes = ["material"]
3014"#,
3015        )
3016        .unwrap();
3017        let config_b: MasterConfig = toml::from_str(
3018            r#"
3019name = "b"
3020roles = ["skip-forward"]
3021system-themes = ["material"]
3022"#,
3023        )
3024        .unwrap();
3025
3026        let result = run_pipeline(
3027            &[
3028                ("a.toml".to_string(), config_a),
3029                ("b.toml".to_string(), config_b),
3030            ],
3031            &[dir.clone(), dir.clone()],
3032            Some("AllIcons"),
3033            None,
3034            None,
3035            &[],
3036        );
3037
3038        assert!(
3039            result.errors.iter().any(|e| matches!(
3040                e,
3041                BuildError::ThemeOverlap { theme } if theme == "material"
3042            )),
3043            "should detect cross-file theme overlap: {:?}",
3044            result.errors
3045        );
3046
3047        let _ = fs::remove_dir_all(&dir);
3048    }
3049
3050    // === Issue 46: Enum variant vs name collision ===
3051
3052    #[test]
3053    fn pipeline_warns_variant_vs_enum_name_collision() {
3054        let dir = create_fixture_dir("variant_enum_collision");
3055        write_fixture(
3056            &dir,
3057            "material/mapping.toml",
3058            "play-pause = \"play_pause\"\n",
3059        );
3060        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3061
3062        let config: MasterConfig = toml::from_str(
3063            r#"
3064name = "play-pause"
3065roles = ["play-pause"]
3066bundled-themes = ["material"]
3067"#,
3068        )
3069        .unwrap();
3070
3071        let result = run_pipeline(
3072            &[("test.toml".to_string(), config)],
3073            std::slice::from_ref(&dir),
3074            None,
3075            None,
3076            None,
3077            &[],
3078        );
3079
3080        assert!(
3081            result
3082                .warnings
3083                .iter()
3084                .any(|w| w.contains("same PascalCase name")),
3085            "should warn about variant/enum name collision. warnings: {:?}",
3086            result.warnings
3087        );
3088
3089        let _ = fs::remove_dir_all(&dir);
3090    }
3091
3092    // === Issue 19: Name normalization warning ===
3093
3094    #[test]
3095    fn pipeline_warns_on_name_normalization() {
3096        let dir = create_fixture_dir("name_norm");
3097        write_fixture(
3098            &dir,
3099            "material/mapping.toml",
3100            "play-pause = \"play_pause\"\n",
3101        );
3102        write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3103
3104        let config: MasterConfig = toml::from_str(
3105            r#"
3106name = "my-app-icon"
3107roles = ["play-pause"]
3108bundled-themes = ["material"]
3109"#,
3110        )
3111        .unwrap();
3112
3113        let result = run_pipeline(
3114            &[("test.toml".to_string(), config)],
3115            std::slice::from_ref(&dir),
3116            None,
3117            None,
3118            None,
3119            &[],
3120        );
3121
3122        assert!(
3123            result
3124                .warnings
3125                .iter()
3126                .any(|w| { w.contains("my-app-icon") && w.contains("MyAppIcon") }),
3127            "should warn about name normalization. warnings: {:?}",
3128            result.warnings
3129        );
3130
3131        let _ = fs::remove_dir_all(&dir);
3132    }
3133
3134    // === Issue 37: output_filename edge case ===
3135
3136    #[test]
3137    fn pipeline_rejects_empty_output_filename() {
3138        let dir = create_fixture_dir("empty_filename");
3139
3140        let config: MasterConfig = toml::from_str(
3141            r#"
3142name = "---"
3143roles = ["play-pause"]
3144"#,
3145        )
3146        .unwrap();
3147
3148        let result = run_pipeline(
3149            &[("test.toml".to_string(), config)],
3150            std::slice::from_ref(&dir),
3151            None,
3152            None,
3153            None,
3154            &[],
3155        );
3156
3157        assert!(
3158            result.errors.iter().any(|e| matches!(
3159                e,
3160                BuildError::InvalidIdentifier { name, reason }
3161                    if name == "---" && reason.contains("empty")
3162            )),
3163            "should reject name that produces empty filename: {:?}",
3164            result.errors
3165        );
3166
3167        let _ = fs::remove_dir_all(&dir);
3168    }
3169
3170    // === Issue 20: Theme directory existence ===
3171
3172    #[test]
3173    fn pipeline_missing_theme_directory() {
3174        let dir = create_fixture_dir("missing_theme_dir");
3175        // Do NOT create material/ directory
3176
3177        let config: MasterConfig = toml::from_str(
3178            r#"
3179name = "test"
3180roles = ["play-pause"]
3181bundled-themes = ["material"]
3182"#,
3183        )
3184        .unwrap();
3185
3186        let result = run_pipeline(
3187            &[("test.toml".to_string(), config)],
3188            std::slice::from_ref(&dir),
3189            None,
3190            None,
3191            None,
3192            &[],
3193        );
3194
3195        assert!(
3196            result.errors.iter().any(|e| {
3197                matches!(e, BuildError::IoRead { reason, .. } if reason.contains("theme directory not found"))
3198            }),
3199            "should report missing theme directory: {:?}",
3200            result.errors
3201        );
3202
3203        let _ = fs::remove_dir_all(&dir);
3204    }
3205}