Skip to main content

native_theme_build/
error.rs

1use std::fmt;
2
3use crate::schema::THEME_TABLE;
4
5/// Build-time error for icon code generation.
6///
7/// Each variant provides structured context for actionable error messages
8/// suitable for `cargo::error=` output.
9#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub enum BuildError {
12    /// A role declared in the master TOML is missing from a theme mapping file.
13    MissingRole {
14        /// The role name that is missing.
15        role: String,
16        /// Path to the mapping file where the role is expected.
17        mapping_file: String,
18    },
19    /// An SVG file referenced by a bundled theme mapping does not exist.
20    MissingSvg {
21        /// Filesystem path to the missing SVG.
22        path: String,
23    },
24    /// A role in a mapping file is not declared in the master TOML.
25    UnknownRole {
26        /// The unexpected role name found in the mapping.
27        role: String,
28        /// Path to the mapping file containing the unknown role.
29        mapping_file: String,
30    },
31    /// A theme name does not match any known `IconSet` variant.
32    UnknownTheme {
33        /// The unrecognized theme name.
34        theme: String,
35        /// The source file where the unknown theme was found, if known.
36        source_file: Option<String>,
37    },
38    /// A DE-aware mapping value is missing the required `default` key.
39    MissingDefault {
40        /// The role whose DE-aware mapping lacks a default.
41        role: String,
42        /// Path to the mapping file.
43        mapping_file: String,
44    },
45    /// A role name appears in multiple TOML files.
46    DuplicateRole {
47        /// The duplicated role name.
48        role: String,
49        /// Path to the first file declaring the role.
50        file_a: String,
51        /// Path to the second file declaring the role.
52        file_b: String,
53    },
54    /// A file could not be read from disk.
55    IoRead {
56        /// Filesystem path that could not be read.
57        path: String,
58        /// The underlying OS error message.
59        reason: String,
60    },
61    /// A file's contents could not be parsed (TOML syntax error, etc.).
62    IoParse {
63        /// Filesystem path that failed to parse.
64        path: String,
65        /// The parse error message.
66        reason: String,
67    },
68    /// A required environment variable is missing or invalid.
69    IoEnv {
70        /// Name of the environment variable (e.g. `CARGO_MANIFEST_DIR`).
71        var: String,
72        /// The underlying error message.
73        reason: String,
74    },
75    /// An I/O-related error that does not fit the read/parse/env categories.
76    IoOther {
77        /// Human-readable description of the error.
78        message: String,
79    },
80    /// A role or enum name produces an invalid Rust identifier.
81    InvalidIdentifier {
82        /// The original name that failed validation.
83        name: String,
84        /// Why the identifier is invalid.
85        reason: String,
86    },
87    /// Two different role names produce the same PascalCase enum variant.
88    IdentifierCollision {
89        /// The first role name.
90        role_a: String,
91        /// The second role name.
92        role_b: String,
93        /// The PascalCase variant they both produce.
94        pascal: String,
95        /// The source file where the collision was detected, if known.
96        source_file: Option<String>,
97    },
98    /// A theme name appears in both `bundled_themes` and `system_themes`.
99    ThemeOverlap {
100        /// The overlapping theme name.
101        theme: String,
102    },
103    /// A role name appears more than once in a single config file.
104    DuplicateRoleInFile {
105        /// The duplicated role name.
106        role: String,
107        /// Path to the file containing the duplicate.
108        file: String,
109    },
110    /// A theme name appears more than once in the same list.
111    DuplicateTheme {
112        /// The duplicated theme name.
113        theme: String,
114        /// Which list contains the duplicate (`"bundled-themes"` or `"system-themes"`).
115        list: String,
116    },
117    /// A mapping value contains characters that are invalid for an icon name.
118    InvalidIconName {
119        /// The offending icon name.
120        name: String,
121        /// The role it belongs to.
122        role: String,
123        /// Path to the mapping file.
124        mapping_file: String,
125        /// The first offending character, if identified.
126        offending: Option<char>,
127    },
128    /// A bundled theme has a DE-aware mapping, creating a semantic mismatch
129    /// between `icon_name()` (which returns a DE-specific name) and `icon_svg()`
130    /// (which can only embed the default SVG).
131    BundledDeAware {
132        /// The bundled theme name.
133        theme: String,
134        /// The role with the DE-aware mapping.
135        role: String,
136    },
137    /// The `crate_path` value is not a valid Rust path.
138    InvalidCratePath {
139        /// The invalid crate path.
140        path: String,
141        /// Why it is invalid.
142        reason: String,
143    },
144    /// A `derive` value is not a valid Rust path.
145    InvalidDerive {
146        /// The invalid derive name.
147        name: String,
148        /// Why it is invalid.
149        reason: String,
150    },
151}
152
153impl fmt::Display for BuildError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::MissingRole { role, mapping_file } => {
157                write!(f, "role \"{role}\" is missing from {mapping_file}")
158            }
159            Self::MissingSvg { path } => {
160                write!(f, "SVG file not found: {path}")
161            }
162            Self::UnknownRole { role, mapping_file } => {
163                write!(
164                    f,
165                    "unknown role \"{role}\" in {mapping_file} (not declared in master TOML)"
166                )
167            }
168            Self::UnknownTheme { theme, source_file } => {
169                let expected: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
170                let list = expected.join(", ");
171                write!(f, "unknown theme \"{theme}\" (expected one of: {list})")?;
172                if let Some(file) = source_file {
173                    write!(f, " in {file}")?;
174                }
175                Ok(())
176            }
177            Self::MissingDefault { role, mapping_file } => {
178                write!(
179                    f,
180                    "DE-aware mapping for \"{role}\" in {mapping_file} is missing the required \"default\" key"
181                )
182            }
183            Self::DuplicateRole {
184                role,
185                file_a,
186                file_b,
187            } => {
188                write!(f, "role \"{role}\" defined in both {file_a} and {file_b}")
189            }
190            Self::IoRead { path, reason } => {
191                write!(f, "failed to read {path}: {reason}")
192            }
193            Self::IoParse { path, reason } => {
194                write!(f, "failed to parse {path}: {reason}")
195            }
196            Self::IoEnv { var, reason } => {
197                write!(f, "environment variable {var} not available: {reason}")
198            }
199            Self::IoOther { message } => {
200                write!(f, "{message}")
201            }
202            Self::InvalidIdentifier { name, reason } => {
203                write!(f, "invalid identifier \"{name}\": {reason}")
204            }
205            Self::IdentifierCollision {
206                role_a,
207                role_b,
208                pascal,
209                source_file,
210            } => {
211                write!(
212                    f,
213                    "roles \"{role_a}\" and \"{role_b}\" both produce the same PascalCase variant \"{pascal}\""
214                )?;
215                if let Some(file) = source_file {
216                    write!(f, " (in {file})")?;
217                }
218                Ok(())
219            }
220            Self::ThemeOverlap { theme } => {
221                write!(
222                    f,
223                    "theme \"{theme}\" appears in both bundled-themes and system-themes"
224                )
225            }
226            Self::DuplicateRoleInFile { role, file } => {
227                write!(f, "role \"{role}\" appears more than once in {file}")
228            }
229            Self::DuplicateTheme { theme, list } => {
230                write!(f, "theme \"{theme}\" appears more than once in {list}")
231            }
232            Self::InvalidIconName {
233                name,
234                role,
235                mapping_file,
236                offending,
237            } => {
238                write!(
239                    f,
240                    "invalid icon name \"{name}\" for role \"{role}\" in {mapping_file}"
241                )?;
242                if let Some(ch) = offending {
243                    write!(f, " (contains '\\u{{{:04X}}}')", *ch as u32)?;
244                }
245                write!(
246                    f,
247                    ": names must be non-empty and free of control characters"
248                )
249            }
250            Self::BundledDeAware { theme, role } => {
251                write!(
252                    f,
253                    "bundled theme \"{theme}\" has DE-aware mapping for role \"{role}\": \
254                     bundled themes can only embed one SVG per role, but DE-aware mappings \
255                     declare multiple icon names. Use a system theme for DE-aware icons"
256                )
257            }
258            Self::InvalidCratePath { path, reason } => {
259                write!(f, "invalid crate_path \"{path}\": {reason}")
260            }
261            Self::InvalidDerive { name, reason } => {
262                write!(f, "invalid derive \"{name}\": {reason}")
263            }
264        }
265    }
266}
267
268impl std::error::Error for BuildError {}
269
270/// A collection of [`BuildError`]s from a failed build pipeline.
271///
272/// Wraps a `Vec<BuildError>` and provides [`emit_cargo_errors()`](Self::emit_cargo_errors)
273/// for printing each error as a `cargo::error=` directive. Also carries
274/// `rerun_paths` so callers can emit `cargo::rerun-if-changed` directives
275/// even on failure.
276#[derive(Debug, Clone)]
277pub struct BuildErrors {
278    errors: Vec<BuildError>,
279    /// Paths that cargo should watch for changes, even when the build fails.
280    pub rerun_paths: Vec<std::path::PathBuf>,
281}
282
283impl fmt::Display for BuildErrors {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{} build error(s):", self.errors.len())?;
286        for e in &self.errors {
287            write!(f, "\n  - {e}")?;
288        }
289        Ok(())
290    }
291}
292
293impl std::error::Error for BuildErrors {}
294
295impl BuildErrors {
296    /// Create a `BuildErrors` from a `Vec<BuildError>`.
297    pub(crate) fn new(errors: Vec<BuildError>) -> Self {
298        debug_assert!(
299            !errors.is_empty(),
300            "BuildErrors created with empty error list"
301        );
302        Self {
303            errors,
304            rerun_paths: Vec::new(),
305        }
306    }
307
308    /// Create a `BuildErrors` with rerun paths.
309    pub(crate) fn with_rerun_paths(
310        errors: Vec<BuildError>,
311        rerun_paths: Vec<std::path::PathBuf>,
312    ) -> Self {
313        Self {
314            errors,
315            rerun_paths,
316        }
317    }
318
319    /// Create a single-error `BuildErrors` from a file read failure.
320    pub(crate) fn io_read(path: impl Into<String>, reason: impl Into<String>) -> Self {
321        Self {
322            errors: vec![BuildError::IoRead {
323                path: path.into(),
324                reason: reason.into(),
325            }],
326            rerun_paths: Vec::new(),
327        }
328    }
329
330    /// Create a single-error `BuildErrors` from a file parse failure.
331    pub(crate) fn io_parse(path: impl Into<String>, reason: impl Into<String>) -> Self {
332        Self {
333            errors: vec![BuildError::IoParse {
334                path: path.into(),
335                reason: reason.into(),
336            }],
337            rerun_paths: Vec::new(),
338        }
339    }
340
341    /// Create a single-error `BuildErrors` from a missing environment variable.
342    pub(crate) fn io_env(var: impl Into<String>, reason: impl Into<String>) -> Self {
343        Self {
344            errors: vec![BuildError::IoEnv {
345                var: var.into(),
346                reason: reason.into(),
347            }],
348            rerun_paths: Vec::new(),
349        }
350    }
351
352    /// Create a single-error `BuildErrors` from an I/O error that doesn't
353    /// fit read/parse/env categories.
354    pub(crate) fn io_other(message: impl Into<String>) -> Self {
355        Self {
356            errors: vec![BuildError::IoOther {
357                message: message.into(),
358            }],
359            rerun_paths: Vec::new(),
360        }
361    }
362
363    /// Return a borrowed slice of the contained errors.
364    pub fn errors(&self) -> &[BuildError] {
365        &self.errors
366    }
367
368    /// Consume this collection and return the inner `Vec<BuildError>`.
369    pub fn into_errors(self) -> Vec<BuildError> {
370        self.errors
371    }
372
373    /// Returns `true` if there are no errors.
374    pub fn is_empty(&self) -> bool {
375        self.errors.is_empty()
376    }
377
378    /// Returns the number of errors.
379    pub fn len(&self) -> usize {
380        self.errors.len()
381    }
382
383    /// Print each error as a `cargo::error=` directive to stdout.
384    ///
385    /// Also prints `cargo::rerun-if-changed` for all tracked paths so cargo
386    /// re-checks when the user fixes the files.
387    pub fn emit_cargo_errors(&self) {
388        for path in &self.rerun_paths {
389            println!("cargo::rerun-if-changed={}", path.display());
390        }
391        for e in &self.errors {
392            println!("cargo::error={e}");
393        }
394    }
395}
396
397impl IntoIterator for BuildErrors {
398    type Item = BuildError;
399    type IntoIter = std::vec::IntoIter<BuildError>;
400
401    fn into_iter(self) -> Self::IntoIter {
402        self.errors.into_iter()
403    }
404}
405
406impl<'a> IntoIterator for &'a BuildErrors {
407    type Item = &'a BuildError;
408    type IntoIter = std::slice::Iter<'a, BuildError>;
409
410    fn into_iter(self) -> Self::IntoIter {
411        self.errors.iter()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn build_errors_is_empty_and_len() {
421        let errors = BuildErrors::new(vec![BuildError::IoOther {
422            message: "test".into(),
423        }]);
424        assert!(!errors.is_empty());
425        assert_eq!(errors.len(), 1);
426    }
427
428    #[test]
429    fn build_errors_len_multiple() {
430        let errors = BuildErrors::new(vec![
431            BuildError::IoRead {
432                path: "file.toml".into(),
433                reason: "first".into(),
434            },
435            BuildError::IoParse {
436                path: "file.toml".into(),
437                reason: "second".into(),
438            },
439        ]);
440        assert!(!errors.is_empty());
441        assert_eq!(errors.len(), 2);
442    }
443
444    #[test]
445    fn build_errors_display_shows_count() {
446        let errors = BuildErrors::new(vec![BuildError::IoOther {
447            message: "oops".into(),
448        }]);
449        let display = format!("{errors}");
450        assert!(display.contains("1 build error(s)"));
451        assert!(display.contains("oops"));
452    }
453
454    #[test]
455    fn build_errors_into_iter() {
456        let errors = BuildErrors::new(vec![BuildError::IoOther {
457            message: "iter".into(),
458        }]);
459        let collected: Vec<BuildError> = errors.into_iter().collect();
460        assert_eq!(collected.len(), 1);
461    }
462
463    #[test]
464    fn build_errors_ref_iter() {
465        let errors = BuildErrors::new(vec![BuildError::IoOther {
466            message: "ref".into(),
467        }]);
468        let collected: Vec<&BuildError> = (&errors).into_iter().collect();
469        assert_eq!(collected.len(), 1);
470    }
471}