Skip to main content

native_theme/model/
icons.rs

1// Icon type definitions: IconRole, IconData, IconSet
2//
3// These are the core icon types for the native-theme icon system.
4
5use std::borrow::Cow;
6
7use serde::{Deserialize, Serialize};
8
9/// Semantic icon roles for cross-platform icon resolution.
10///
11/// Each variant represents a conceptual icon role (not a specific icon image).
12/// Platform-specific icon identifiers are resolved via
13/// [`icon_name()`](crate::icon_name) using an [`IconSet`].
14///
15/// # Categories
16///
17/// Variants are grouped by prefix into 7 categories:
18/// - **Dialog** (6): Alerts and dialog indicators
19/// - **Window** (4): Window control buttons
20/// - **Action** (14): Common user actions
21/// - **Navigation** (6): Directional and structural navigation
22/// - **Files** (5): File and folder representations
23/// - **Status** (3): State indicators
24/// - **System** (4): System-level UI elements
25///
26/// # Examples
27///
28/// ```
29/// use native_theme::theme::IconRole;
30///
31/// let role = IconRole::ActionSave;
32/// match role {
33///     IconRole::ActionSave => println!("save icon"),
34///     _ => println!("other icon"),
35/// }
36///
37/// // Iterate all roles
38/// assert_eq!(IconRole::ALL.len(), 42);
39/// ```
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum IconRole {
43    // Dialog / Alert (6)
44    /// Warning indicator for dialogs
45    DialogWarning,
46    /// Error indicator for dialogs
47    DialogError,
48    /// Informational indicator for dialogs
49    DialogInfo,
50    /// Question indicator for dialogs
51    DialogQuestion,
52    /// Success/confirmation indicator for dialogs
53    DialogSuccess,
54    /// Security/shield indicator
55    Shield,
56
57    // Window Controls (4)
58    /// Close window button
59    WindowClose,
60    /// Minimize window button
61    WindowMinimize,
62    /// Maximize window button
63    WindowMaximize,
64    /// Restore window button (from maximized state)
65    WindowRestore,
66
67    // Common Actions (14)
68    /// Save action
69    ActionSave,
70    /// Delete action
71    ActionDelete,
72    /// Copy to clipboard
73    ActionCopy,
74    /// Paste from clipboard
75    ActionPaste,
76    /// Cut to clipboard
77    ActionCut,
78    /// Undo last action
79    ActionUndo,
80    /// Redo last undone action
81    ActionRedo,
82    /// Search / find
83    ActionSearch,
84    /// Settings / preferences
85    ActionSettings,
86    /// Edit / modify
87    ActionEdit,
88    /// Add / create new item
89    ActionAdd,
90    /// Remove item
91    ActionRemove,
92    /// Refresh / reload
93    ActionRefresh,
94    /// Print
95    ActionPrint,
96
97    // Navigation (6)
98    /// Navigate backward
99    NavBack,
100    /// Navigate forward
101    NavForward,
102    /// Navigate up in hierarchy
103    NavUp,
104    /// Navigate down in hierarchy
105    NavDown,
106    /// Navigate to home / root
107    NavHome,
108    /// Open menu / hamburger
109    NavMenu,
110
111    // Files / Places (5)
112    /// Generic file icon
113    FileGeneric,
114    /// Closed folder
115    FolderClosed,
116    /// Open folder
117    FolderOpen,
118    /// Empty trash / recycle bin
119    TrashEmpty,
120    /// Full trash / recycle bin
121    TrashFull,
122
123    // Status (3)
124    /// Busy / working state indicator
125    StatusBusy,
126    /// Check / success indicator
127    StatusCheck,
128    /// Error state indicator
129    StatusError,
130
131    // System (4)
132    /// User account / profile
133    UserAccount,
134    /// Notification / bell
135    Notification,
136    /// Help / question mark
137    Help,
138    /// Lock / security
139    Lock,
140}
141
142impl std::fmt::Display for IconRole {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.write_str(self.name())
145    }
146}
147
148impl IconRole {
149    /// The kebab-case string identifier for this icon role.
150    ///
151    /// Returns a stable string suitable for logs, serialization, and
152    /// display. Category prefix and variant name are joined with hyphens
153    /// in lowercase (e.g., `DialogWarning` -> `"dialog-warning"`).
154    ///
155    /// # Examples
156    ///
157    /// ```
158    /// use native_theme::theme::IconRole;
159    ///
160    /// assert_eq!(IconRole::ActionSave.name(), "action-save");
161    /// assert_eq!(IconRole::DialogWarning.name(), "dialog-warning");
162    /// ```
163    #[must_use]
164    pub fn name(&self) -> &'static str {
165        match self {
166            // Dialog (6)
167            Self::DialogWarning => "dialog-warning",
168            Self::DialogError => "dialog-error",
169            Self::DialogInfo => "dialog-info",
170            Self::DialogQuestion => "dialog-question",
171            Self::DialogSuccess => "dialog-success",
172            Self::Shield => "shield",
173            // Window (4)
174            Self::WindowClose => "window-close",
175            Self::WindowMinimize => "window-minimize",
176            Self::WindowMaximize => "window-maximize",
177            Self::WindowRestore => "window-restore",
178            // Action (14)
179            Self::ActionSave => "action-save",
180            Self::ActionDelete => "action-delete",
181            Self::ActionCopy => "action-copy",
182            Self::ActionPaste => "action-paste",
183            Self::ActionCut => "action-cut",
184            Self::ActionUndo => "action-undo",
185            Self::ActionRedo => "action-redo",
186            Self::ActionSearch => "action-search",
187            Self::ActionSettings => "action-settings",
188            Self::ActionEdit => "action-edit",
189            Self::ActionAdd => "action-add",
190            Self::ActionRemove => "action-remove",
191            Self::ActionRefresh => "action-refresh",
192            Self::ActionPrint => "action-print",
193            // Navigation (6)
194            Self::NavBack => "nav-back",
195            Self::NavForward => "nav-forward",
196            Self::NavUp => "nav-up",
197            Self::NavDown => "nav-down",
198            Self::NavHome => "nav-home",
199            Self::NavMenu => "nav-menu",
200            // Files (5)
201            Self::FileGeneric => "file-generic",
202            Self::FolderClosed => "folder-closed",
203            Self::FolderOpen => "folder-open",
204            Self::TrashEmpty => "trash-empty",
205            Self::TrashFull => "trash-full",
206            // Status (3)
207            Self::StatusBusy => "status-busy",
208            Self::StatusCheck => "status-check",
209            Self::StatusError => "status-error",
210            // System (4)
211            Self::UserAccount => "user-account",
212            Self::Notification => "notification",
213            Self::Help => "help",
214            Self::Lock => "lock",
215        }
216    }
217
218    /// All icon role variants, useful for iteration and exhaustive testing.
219    ///
220    /// Contains exactly 42 variants, one for each role, in declaration order.
221    pub const ALL: [IconRole; 42] = [
222        // Dialog (6)
223        Self::DialogWarning,
224        Self::DialogError,
225        Self::DialogInfo,
226        Self::DialogQuestion,
227        Self::DialogSuccess,
228        Self::Shield,
229        // Window (4)
230        Self::WindowClose,
231        Self::WindowMinimize,
232        Self::WindowMaximize,
233        Self::WindowRestore,
234        // Action (14)
235        Self::ActionSave,
236        Self::ActionDelete,
237        Self::ActionCopy,
238        Self::ActionPaste,
239        Self::ActionCut,
240        Self::ActionUndo,
241        Self::ActionRedo,
242        Self::ActionSearch,
243        Self::ActionSettings,
244        Self::ActionEdit,
245        Self::ActionAdd,
246        Self::ActionRemove,
247        Self::ActionRefresh,
248        Self::ActionPrint,
249        // Navigation (6)
250        Self::NavBack,
251        Self::NavForward,
252        Self::NavUp,
253        Self::NavDown,
254        Self::NavHome,
255        Self::NavMenu,
256        // Files (5)
257        Self::FileGeneric,
258        Self::FolderClosed,
259        Self::FolderOpen,
260        Self::TrashEmpty,
261        Self::TrashFull,
262        // Status (3)
263        Self::StatusBusy,
264        Self::StatusCheck,
265        Self::StatusError,
266        // System (4)
267        Self::UserAccount,
268        Self::Notification,
269        Self::Help,
270        Self::Lock,
271    ];
272}
273
274/// Icon data returned by loading functions.
275///
276/// Represents the actual pixel or vector data for an icon. This type is
277/// produced by platform icon loaders and bundled icon accessors.
278///
279/// # Examples
280///
281/// ```
282/// use native_theme::theme::IconData;
283/// use std::borrow::Cow;
284///
285/// let svg = IconData::Svg(Cow::Borrowed(b"<svg></svg>"));
286/// assert_eq!(svg.bytes(), b"<svg></svg>");
287///
288/// let rgba = IconData::Rgba { width: 16, height: 16, data: vec![0; 16*16*4] };
289/// assert_eq!(rgba.bytes().len(), 16 * 16 * 4);
290/// ```
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[non_exhaustive]
293#[must_use]
294pub enum IconData {
295    /// SVG content as raw bytes (from freedesktop themes, bundled icon sets).
296    ///
297    /// Bundled icons use `Cow::Borrowed` for zero-copy access to compile-time
298    /// embedded data. Runtime-loaded icons (freedesktop, custom providers)
299    /// use `Cow::Owned`.
300    Svg(Cow<'static, [u8]>),
301
302    /// Rasterized RGBA pixels (from macOS/Windows system APIs).
303    Rgba {
304        /// Image width in pixels.
305        width: u32,
306        /// Image height in pixels.
307        height: u32,
308        /// Raw RGBA pixel data (4 bytes per pixel, row-major).
309        data: Vec<u8>,
310    },
311}
312
313impl IconData {
314    /// Borrow the underlying bytes regardless of variant or ownership.
315    ///
316    /// For `Svg`, returns the SVG content bytes.
317    /// For `Rgba`, returns the raw pixel data.
318    #[must_use]
319    pub fn bytes(&self) -> &[u8] {
320        match self {
321            IconData::Svg(cow) => cow,
322            IconData::Rgba { data, .. } => data,
323        }
324    }
325}
326
327/// Known icon sets that provide platform-specific icon identifiers.
328///
329/// Each variant corresponds to a well-known icon naming system.
330/// Use [`from_name`](IconSet::from_name) to parse from TOML strings
331/// and [`name`](IconSet::name) to serialize back to kebab-case.
332///
333/// # Examples
334///
335/// ```
336/// use native_theme::theme::IconSet;
337///
338/// let set = IconSet::from_name("sf-symbols").unwrap();
339/// assert_eq!(set, IconSet::SfSymbols);
340/// assert_eq!(set.name(), "sf-symbols");
341///
342/// // Round-trip
343/// let name = IconSet::Material.name();
344/// assert_eq!(IconSet::from_name(name), Some(IconSet::Material));
345///
346/// // Unknown names return None
347/// assert_eq!(IconSet::from_name("unknown"), None);
348/// ```
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
350#[serde(rename_all = "kebab-case")]
351#[non_exhaustive]
352pub enum IconSet {
353    /// Apple SF Symbols (macOS, iOS).
354    SfSymbols,
355    /// Microsoft Segoe Fluent Icons (Windows).
356    #[serde(rename = "segoe-fluent")]
357    SegoeIcons,
358    /// freedesktop Icon Naming Specification (Linux).
359    Freedesktop,
360    /// Google Material Symbols.
361    Material,
362    /// Lucide Icons (fork of Feather).
363    Lucide,
364}
365
366impl std::fmt::Display for IconSet {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        f.write_str(self.name())
369    }
370}
371
372impl IconSet {
373    /// Parse an icon set from its kebab-case string identifier.
374    ///
375    /// Accepts the names used in TOML configuration:
376    /// `"sf-symbols"`, `"segoe-fluent"`, `"freedesktop"`, `"material"`, `"lucide"`.
377    ///
378    /// Returns `None` for unrecognized names.
379    #[must_use]
380    pub fn from_name(name: &str) -> Option<Self> {
381        match name {
382            "sf-symbols" => Some(Self::SfSymbols),
383            "segoe-fluent" => Some(Self::SegoeIcons),
384            "freedesktop" => Some(Self::Freedesktop),
385            "material" => Some(Self::Material),
386            "lucide" => Some(Self::Lucide),
387            _ => None,
388        }
389    }
390
391    /// The kebab-case string identifier for this icon set, as used in TOML.
392    #[must_use]
393    pub fn name(&self) -> &'static str {
394        match self {
395            Self::SfSymbols => "sf-symbols",
396            Self::SegoeIcons => "segoe-fluent",
397            Self::Freedesktop => "freedesktop",
398            Self::Material => "material",
399            Self::Lucide => "lucide",
400        }
401    }
402}
403
404/// Trait for types that map icon identifiers to platform-specific names and SVG data.
405///
406/// Implement this trait on an enum to make its variants loadable via
407/// the typed per-set loaders in [`crate::icons`]. The typical pattern is
408/// for each enum variant to represent an icon role, with `icon_name()` returning
409/// the platform-specific identifier and `icon_svg()` returning embedded SVG bytes.
410///
411/// The `native-theme-build` crate can auto-generate implementations from TOML
412/// definitions at build time, so manual implementation is only needed for
413/// special cases.
414///
415/// [`IconRole`] implements this trait, delegating to the built-in icon mappings.
416///
417/// # Object Safety
418///
419/// This trait is object-safe (only requires [`Debug`] as a supertrait).
420/// `Box<dyn IconProvider>` works for dynamic dispatch.
421///
422/// # Examples
423///
424/// ```
425/// use native_theme::theme::{IconProvider, IconSet};
426/// use std::borrow::Cow;
427///
428/// #[derive(Debug)]
429/// enum MyIcon { Play, Pause }
430///
431/// impl IconProvider for MyIcon {
432///     fn icon_name(&self, set: IconSet) -> Option<&str> {
433///         match (self, set) {
434///             (MyIcon::Play, IconSet::SfSymbols) => Some("play.fill"),
435///             (MyIcon::Play, IconSet::Material) => Some("play_arrow"),
436///             (MyIcon::Pause, IconSet::SfSymbols) => Some("pause.fill"),
437///             (MyIcon::Pause, IconSet::Material) => Some("pause"),
438///             _ => None,
439///         }
440///     }
441///     fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
442///         None // No bundled SVGs in this example
443///     }
444/// }
445/// ```
446pub trait IconProvider: std::fmt::Debug {
447    /// Return the platform/theme-specific icon name for this icon in the given set.
448    fn icon_name(&self, set: IconSet) -> Option<&str>;
449
450    /// Return bundled SVG bytes for this icon in the given set.
451    ///
452    /// Bundled providers should return `Cow::Borrowed` for zero-copy access.
453    /// Runtime providers (e.g. loading from disk) should return `Cow::Owned`.
454    fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>>;
455}
456
457impl IconProvider for IconRole {
458    fn icon_name(&self, set: IconSet) -> Option<&str> {
459        icon_name(*self, set)
460    }
461
462    fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
463        crate::model::bundled::bundled_icon_svg(*self, set).map(Cow::Borrowed)
464    }
465}
466
467/// Look up the platform-specific icon identifier for a given icon set and role.
468///
469/// Returns `Some(name)` if the icon set has a standard icon for the role,
470/// or `None` if no standard icon exists (e.g., SF Symbols has no open-folder
471/// variant).
472///
473/// # Examples
474///
475/// ```
476/// use native_theme::theme::{IconSet, IconRole, icon_name};
477///
478/// assert_eq!(icon_name(IconRole::ActionCopy, IconSet::SfSymbols), Some("doc.on.doc"));
479/// assert_eq!(icon_name(IconRole::ActionCopy, IconSet::Freedesktop), Some("edit-copy"));
480/// assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
481/// ```
482#[must_use]
483#[allow(unreachable_patterns)] // wildcard arm kept for #[non_exhaustive] forward compat
484pub fn icon_name(role: IconRole, set: IconSet) -> Option<&'static str> {
485    match set {
486        IconSet::SfSymbols => sf_symbols_name(role),
487        IconSet::SegoeIcons => segoe_name(role),
488        IconSet::Freedesktop => freedesktop_name(role),
489        IconSet::Material => material_name(role),
490        IconSet::Lucide => lucide_name(role),
491        _ => None,
492    }
493}
494
495/// Detect the native icon set for the current operating system.
496///
497/// Returns the platform-appropriate icon set at runtime using `cfg!()` macros:
498/// - macOS / iOS: [`IconSet::SfSymbols`]
499/// - Windows: [`IconSet::SegoeIcons`]
500/// - Linux: [`IconSet::Freedesktop`]
501/// - Other: [`IconSet::Material`] (safe cross-platform fallback)
502///
503/// # Examples
504///
505/// ```
506/// use native_theme::theme::{IconSet, system_icon_set};
507///
508/// let set = system_icon_set();
509/// // On Linux, this returns Freedesktop
510/// ```
511#[must_use]
512pub fn system_icon_set() -> IconSet {
513    if cfg!(any(target_os = "macos", target_os = "ios")) {
514        IconSet::SfSymbols
515    } else if cfg!(target_os = "windows") {
516        IconSet::SegoeIcons
517    } else if cfg!(target_os = "linux") {
518        IconSet::Freedesktop
519    } else {
520        IconSet::Material
521    }
522}
523
524/// Detect the icon theme name for the current platform (cached).
525///
526/// Returns the name of the icon theme that provides the actual icon files:
527/// - **macOS / iOS:** `"sf-symbols"` (no user-configurable icon theme)
528/// - **Windows:** `"segoe-fluent"` (no user-configurable icon theme)
529/// - **Linux:** DE-specific detection (e.g., `"breeze-dark"`, `"Adwaita"`)
530/// - **Other:** an error; there is no icon theme to detect
531///
532/// On Linux, the detection method depends on the desktop environment:
533/// - KDE: reads `[Icons] Theme` from `kdeglobals`, then from
534///   `kdedefaults/kdeglobals`
535/// - GNOME/Budgie: `gsettings get org.gnome.desktop.interface icon-theme`
536/// - Cinnamon: `gsettings get org.cinnamon.desktop.interface icon-theme`
537/// - XFCE: `xfconf-query -c xsettings -p /Net/IconThemeName`
538/// - MATE: `gsettings get org.mate.interface icon-theme`
539/// - LXQt: reads `icon_theme` from `~/.config/lxqt/lxqt.conf`
540/// - Unknown: tries KDE, then GNOME gsettings
541///
542/// No theme stands in for one that cannot be detected: the error says why,
543/// and what to do without a theme is the caller's decision.
544///
545/// Delegates to [`DetectionContext::icon_theme()`](crate::detect::DetectionContext::icon_theme)
546/// which caches the outcome, a failure included, and supports per-field
547/// invalidation via [`invalidate_caches()`](crate::detect::invalidate_caches).
548///
549/// # Errors
550///
551/// - [`Error::ReaderFailed`](crate::Error::ReaderFailed) with `reader`
552///   `"icon-theme"` on Linux when no source names a theme. Its message names
553///   each source tried and why it gave no name: the config directory could
554///   not be resolved (`$XDG_CONFIG_HOME` and `$HOME` both unset or empty), a
555///   file could not be read or lacks the key, or a command was not found,
556///   exited unsuccessfully or printed no name. On an unrecognised desktop it
557///   names both attempts, KDE's and GNOME's.
558/// - [`Error::PlatformUnsupported`](crate::Error::PlatformUnsupported) on a
559///   platform other than Linux, macOS, iOS and Windows.
560///
561/// # Examples
562///
563/// ```
564/// use native_theme::theme::system_icon_theme;
565///
566/// match system_icon_theme() {
567///     // On a KDE system with Breeze Dark: "breeze-dark"; on macOS: "sf-symbols"
568///     Ok(theme) => println!("icon theme: {theme}"),
569///     Err(e) => println!("no icon theme: {e}"),
570/// }
571/// ```
572pub fn system_icon_theme() -> crate::Result<String> {
573    crate::detect::system().icon_theme()
574}
575
576/// Detect the icon theme name for the current platform without caching.
577///
578/// Unlike [`system_icon_theme()`], this function queries the OS every time it
579/// is called and never caches the result. Use this when polling for icon theme
580/// changes (e.g., the user switches from Breeze to Breeze Dark in system
581/// settings).
582///
583/// See [`system_icon_theme()`] for platform behavior details.
584///
585/// # Errors
586///
587/// As [`system_icon_theme()`].
588pub fn detect_icon_theme() -> crate::Result<String> {
589    detect_icon_theme_outcome().map_err(|failure| failure.to_error())
590}
591
592/// [`detect_icon_theme()`]'s outcome in the form the detection cache keeps.
593#[allow(unreachable_code)]
594pub(crate) fn detect_icon_theme_outcome() -> Result<String, IconThemeFailure> {
595    #[cfg(any(target_os = "macos", target_os = "ios"))]
596    {
597        return Ok("sf-symbols".to_string());
598    }
599
600    #[cfg(target_os = "windows")]
601    {
602        return Ok("segoe-fluent".to_string());
603    }
604
605    #[cfg(target_os = "linux")]
606    {
607        detect_linux_icon_theme()
608    }
609
610    #[cfg(not(any(
611        target_os = "linux",
612        target_os = "windows",
613        target_os = "macos",
614        target_os = "ios"
615    )))]
616    {
617        Err(IconThemeFailure::Unsupported(std::env::consts::OS))
618    }
619}
620
621/// One source's failure to name an icon theme: the source, and why it gave
622/// no name.
623#[derive(Debug, Clone, PartialEq, Eq)]
624#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
625pub(crate) struct SourceFailure {
626    source: String,
627    reason: String,
628}
629
630impl std::fmt::Display for SourceFailure {
631    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
632        write!(f, "{}: {}", self.source, self.reason)
633    }
634}
635
636/// Why no icon theme was detected: each source tried, in order, with why it
637/// gave no name. The source of the
638/// [`Error::ReaderFailed`](crate::Error::ReaderFailed) a failed detection
639/// reports.
640#[derive(Debug, Clone, PartialEq, Eq)]
641#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
642pub(crate) struct IconThemeUndetected(Vec<SourceFailure>);
643
644impl std::fmt::Display for IconThemeUndetected {
645    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646        let mut sources = self.0.iter();
647        if let Some(first) = sources.next() {
648            write!(f, "{first}")?;
649        }
650        for failure in sources {
651            write!(f, "; {failure}")?;
652        }
653        Ok(())
654    }
655}
656
657impl std::error::Error for IconThemeUndetected {}
658
659/// A failed icon-theme detection as the detection cache keeps it. Unlike
660/// [`crate::Error`] it is `Clone`, so the cache can hand out the failure it
661/// holds; [`to_error`](Self::to_error) builds the error from it.
662#[derive(Debug, Clone, PartialEq, Eq)]
663pub(crate) enum IconThemeFailure {
664    /// No source named a theme.
665    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
666    Undetected(IconThemeUndetected),
667    /// The platform has no icon theme to detect.
668    #[cfg_attr(
669        any(
670            target_os = "linux",
671            target_os = "windows",
672            target_os = "macos",
673            target_os = "ios"
674        ),
675        allow(dead_code)
676    )]
677    Unsupported(&'static str),
678}
679
680impl IconThemeFailure {
681    /// The error [`system_icon_theme()`] and [`detect_icon_theme()`] report.
682    pub(crate) fn to_error(&self) -> crate::Error {
683        match self {
684            Self::Undetected(undetected) => crate::Error::ReaderFailed {
685                reader: "icon-theme",
686                source: Box::new(undetected.clone()),
687            },
688            Self::Unsupported(platform) => crate::Error::PlatformUnsupported { platform },
689        }
690    }
691
692    /// A failure with `reason`, for tests of the code that keeps and
693    /// reports failures.
694    #[cfg(test)]
695    pub(crate) fn for_tests(reason: &str) -> Self {
696        Self::Undetected(IconThemeUndetected(vec![SourceFailure {
697            source: "test".to_string(),
698            reason: reason.to_string(),
699        }]))
700    }
701}
702
703/// The schema GNOME, and the desktops that follow its settings, keep the
704/// icon theme in.
705#[cfg(target_os = "linux")]
706const GNOME_INTERFACE_SCHEMA: &str = "org.gnome.desktop.interface";
707
708/// Linux icon theme detection, dispatched by desktop environment.
709#[cfg(target_os = "linux")]
710fn detect_linux_icon_theme() -> Result<String, IconThemeFailure> {
711    use crate::detect::LinuxDesktop;
712
713    let found = match crate::detect::detect_linux_desktop() {
714        LinuxDesktop::Kde => detect_kde_icon_theme(),
715        LinuxDesktop::Gnome
716        | LinuxDesktop::Budgie
717        | LinuxDesktop::Hyprland
718        | LinuxDesktop::Sway
719        | LinuxDesktop::River
720        | LinuxDesktop::Niri
721        | LinuxDesktop::Wayfire
722        | LinuxDesktop::CosmicDe => gsettings_icon_theme(GNOME_INTERFACE_SCHEMA),
723        LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
724        LinuxDesktop::Xfce => detect_xfce_icon_theme(),
725        LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
726        LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
727        LinuxDesktop::Unknown => {
728            return unknown_desktop_icon_theme(detect_kde_icon_theme, || {
729                gsettings_icon_theme(GNOME_INTERFACE_SCHEMA)
730            });
731        }
732    };
733    found.map_err(|failure| IconThemeFailure::Undetected(IconThemeUndetected(vec![failure])))
734}
735
736/// The icon theme of a desktop that is not recognised: KDE's, else GNOME's.
737/// Where both fail, the failure names both attempts.
738#[cfg(target_os = "linux")]
739fn unknown_desktop_icon_theme(
740    kde: impl FnOnce() -> Result<String, SourceFailure>,
741    gnome: impl FnOnce() -> Result<String, SourceFailure>,
742) -> Result<String, IconThemeFailure> {
743    let kde_failure = match kde() {
744        Ok(theme) => return Ok(theme),
745        Err(failure) => failure,
746    };
747    let gnome_failure = match gnome() {
748        Ok(theme) => return Ok(theme),
749        Err(failure) => failure,
750    };
751    Err(IconThemeFailure::Undetected(IconThemeUndetected(vec![
752        kde_failure,
753        gnome_failure,
754    ])))
755}
756
757/// The failure of a config-file source when there is no config directory.
758#[cfg(target_os = "linux")]
759fn config_dir_unresolved(source: &str) -> SourceFailure {
760    SourceFailure {
761        source: source.to_string(),
762        reason: "the config directory is unresolved: $XDG_CONFIG_HOME and $HOME are both \
763                 unset or empty"
764            .to_string(),
765    }
766}
767
768/// Read the icon theme from KDE's kdeglobals INI file.
769#[cfg(target_os = "linux")]
770fn detect_kde_icon_theme() -> Result<String, SourceFailure> {
771    kde_icon_theme(xdg_config_dir().as_deref(), |path| {
772        std::fs::read_to_string(path)
773    })
774}
775
776/// `[Icons] Theme` from `kdeglobals` in `config_dir`, then from
777/// `kdedefaults/kdeglobals` (Plasma 6 stores distro defaults there,
778/// including the icon theme), each file read by `read`.
779///
780/// Uses simple line parsing — no `configparser` dependency required — so this
781/// works without the `kde` feature enabled.
782#[cfg(target_os = "linux")]
783fn kde_icon_theme(
784    config_dir: Option<&std::path::Path>,
785    read: impl Fn(&std::path::Path) -> std::io::Result<String>,
786) -> Result<String, SourceFailure> {
787    const SOURCE: &str = "KDE kdeglobals";
788    let config_dir = config_dir.ok_or_else(|| config_dir_unresolved(SOURCE))?;
789    let paths = [
790        config_dir.join("kdeglobals"),
791        config_dir.join("kdedefaults").join("kdeglobals"),
792    ];
793
794    let mut reasons = Vec::new();
795    for path in &paths {
796        match read(path) {
797            Ok(content) => match ini_value(&content, "Icons", "Theme") {
798                Some(theme) => return Ok(theme),
799                None => reasons.push(format!(
800                    "{}: `[Icons] Theme` is absent or empty",
801                    path.display()
802                )),
803            },
804            Err(e) => reasons.push(format!("{}: unreadable ({e})", path.display())),
805        }
806    }
807    Err(SourceFailure {
808        source: SOURCE.to_string(),
809        reason: reasons.join(", "),
810    })
811}
812
813/// Query gsettings for icon-theme with the given schema.
814#[cfg(target_os = "linux")]
815fn gsettings_icon_theme(schema: &str) -> Result<String, SourceFailure> {
816    gsettings_icon_theme_from(
817        schema,
818        std::process::Command::new("gsettings")
819            .args(["get", schema, "icon-theme"])
820            .output(),
821    )
822}
823
824/// The icon theme in `gsettings get <schema> icon-theme`'s `output`, which
825/// prints it quoted (`'Adwaita'`).
826#[cfg(target_os = "linux")]
827fn gsettings_icon_theme_from(
828    schema: &str,
829    output: std::io::Result<std::process::Output>,
830) -> Result<String, SourceFailure> {
831    command_icon_theme(
832        format!("`gsettings get {schema} icon-theme`"),
833        output,
834        |printed| printed.trim().trim_matches('\''),
835    )
836}
837
838/// `xfconf-query`'s arguments that print XFCE's icon theme.
839#[cfg(target_os = "linux")]
840const XFCONF_ICON_THEME_ARGS: [&str; 4] = ["-c", "xsettings", "-p", "/Net/IconThemeName"];
841
842/// Read icon theme from XFCE's xfconf-query.
843#[cfg(target_os = "linux")]
844fn detect_xfce_icon_theme() -> Result<String, SourceFailure> {
845    xfce_icon_theme_from(
846        std::process::Command::new("xfconf-query")
847            .args(XFCONF_ICON_THEME_ARGS)
848            .output(),
849    )
850}
851
852/// The icon theme in `xfconf-query -c xsettings -p /Net/IconThemeName`'s
853/// `output`.
854#[cfg(target_os = "linux")]
855fn xfce_icon_theme_from(
856    output: std::io::Result<std::process::Output>,
857) -> Result<String, SourceFailure> {
858    command_icon_theme(
859        format!("`xfconf-query {}`", XFCONF_ICON_THEME_ARGS.join(" ")),
860        output,
861        str::trim,
862    )
863}
864
865/// The icon theme the command `source` names printed, taken out of its
866/// `output` by `parse`, or why there is none.
867#[cfg(target_os = "linux")]
868fn command_icon_theme(
869    source: String,
870    output: std::io::Result<std::process::Output>,
871    parse: fn(&str) -> &str,
872) -> Result<String, SourceFailure> {
873    let reason = match output {
874        Err(e) if e.kind() == std::io::ErrorKind::NotFound => "command not found".to_string(),
875        Err(e) => format!("could not run ({e})"),
876        Ok(output) if !output.status.success() => {
877            let stderr = String::from_utf8_lossy(&output.stderr);
878            match stderr.trim() {
879                "" => format!("unsuccessful ({})", output.status),
880                stderr => format!("unsuccessful ({}): {stderr}", output.status),
881            }
882        }
883        Ok(output) => match String::from_utf8(output.stdout) {
884            Err(_) => "printed output that is not UTF-8".to_string(),
885            Ok(printed) => match parse(&printed) {
886                "" => "printed no theme name".to_string(),
887                theme => return Ok(theme.to_string()),
888            },
889        },
890    };
891    Err(SourceFailure { source, reason })
892}
893
894/// Read icon theme from LXQt's config file.
895#[cfg(target_os = "linux")]
896fn detect_lxqt_icon_theme() -> Result<String, SourceFailure> {
897    lxqt_icon_theme(xdg_config_dir().as_deref(), |path| {
898        std::fs::read_to_string(path)
899    })
900}
901
902/// `icon_theme` from `lxqt/lxqt.conf` in `config_dir`, read by `read`.
903///
904/// LXQt uses a flat `key=value` format (no section headers for the icon_theme
905/// key), so we scan for the bare `icon_theme=` prefix.
906#[cfg(target_os = "linux")]
907fn lxqt_icon_theme(
908    config_dir: Option<&std::path::Path>,
909    read: impl Fn(&std::path::Path) -> std::io::Result<String>,
910) -> Result<String, SourceFailure> {
911    const SOURCE: &str = "LXQt lxqt.conf";
912    let config_dir = config_dir.ok_or_else(|| config_dir_unresolved(SOURCE))?;
913    let path = config_dir.join("lxqt").join("lxqt.conf");
914    let failure = |reason: String| SourceFailure {
915        source: SOURCE.to_string(),
916        reason: format!("{}: {reason}", path.display()),
917    };
918
919    let content = read(&path).map_err(|e| failure(format!("unreadable ({e})")))?;
920    content
921        .lines()
922        .filter_map(|line| line.trim().strip_prefix("icon_theme="))
923        .map(str::trim)
924        .find(|value| !value.is_empty())
925        .map(str::to_string)
926        .ok_or_else(|| failure("`icon_theme` is absent or empty".to_string()))
927}
928
929/// Resolve `$XDG_CONFIG_HOME`, falling back to `$HOME/.config`.
930///
931/// Returns `None` when both `$XDG_CONFIG_HOME` and `$HOME` are unset,
932/// avoiding a bogus `/tmp/.config` fallback.
933#[cfg(target_os = "linux")]
934fn xdg_config_dir() -> Option<std::path::PathBuf> {
935    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
936        && !config_home.is_empty()
937    {
938        return Some(std::path::PathBuf::from(config_home));
939    }
940    std::env::var("HOME")
941        .ok()
942        .filter(|h| !h.is_empty())
943        .map(|h| std::path::PathBuf::from(h).join(".config"))
944}
945
946/// Read a value from INI `content` by section and key.
947///
948/// Simple line-based parser — no external crate needed. Handles `[Section]`
949/// headers and `Key=Value` lines. Returns `None` if the section/key is
950/// missing or the value is empty.
951#[cfg(target_os = "linux")]
952fn ini_value(content: &str, section: &str, key: &str) -> Option<String> {
953    let target_section = format!("[{}]", section);
954    let mut in_section = false;
955
956    for line in content.lines() {
957        let trimmed = line.trim();
958        if trimmed.starts_with('[') {
959            in_section = trimmed == target_section;
960            continue;
961        }
962        if in_section && let Some(value) = trimmed.strip_prefix(key) {
963            let value = value.trim_start();
964            if let Some(value) = value.strip_prefix('=') {
965                let value = value.trim();
966                if !value.is_empty() {
967                    return Some(value.to_string());
968                }
969            }
970        }
971    }
972    None
973}
974
975// --- Private mapping functions ---
976
977#[allow(unreachable_patterns)]
978fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
979    Some(match role {
980        // Dialog / Alert
981        IconRole::DialogWarning => "exclamationmark.triangle.fill",
982        IconRole::DialogError => "xmark.circle.fill",
983        IconRole::DialogInfo => "info.circle.fill",
984        IconRole::DialogQuestion => "questionmark.circle.fill",
985        IconRole::DialogSuccess => "checkmark.circle.fill",
986        IconRole::Shield => "shield.fill",
987
988        // Window Controls
989        IconRole::WindowClose => "xmark",
990        IconRole::WindowMinimize => "minus",
991        IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
992        IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
993
994        // Common Actions
995        IconRole::ActionSave => "square.and.arrow.down",
996        IconRole::ActionDelete => "trash",
997        IconRole::ActionCopy => "doc.on.doc",
998        IconRole::ActionPaste => "doc.on.clipboard",
999        IconRole::ActionCut => "scissors",
1000        IconRole::ActionUndo => "arrow.uturn.backward",
1001        IconRole::ActionRedo => "arrow.uturn.forward",
1002        IconRole::ActionSearch => "magnifyingglass",
1003        IconRole::ActionSettings => "gearshape",
1004        IconRole::ActionEdit => "pencil",
1005        IconRole::ActionAdd => "plus",
1006        IconRole::ActionRemove => "minus",
1007        IconRole::ActionRefresh => "arrow.clockwise",
1008        IconRole::ActionPrint => "printer",
1009
1010        // Navigation
1011        IconRole::NavBack => "chevron.backward",
1012        IconRole::NavForward => "chevron.forward",
1013        IconRole::NavUp => "chevron.up",
1014        IconRole::NavDown => "chevron.down",
1015        IconRole::NavHome => "house",
1016        IconRole::NavMenu => "line.horizontal.3",
1017
1018        // Files / Places
1019        IconRole::FileGeneric => "doc",
1020        IconRole::FolderClosed => "folder",
1021        // FolderOpen: no SF Symbol equivalent
1022        IconRole::FolderOpen => return None,
1023        IconRole::TrashEmpty => "trash",
1024        IconRole::TrashFull => "trash.fill",
1025
1026        // Status
1027        // StatusBusy: no static SF Symbol (no static busy equivalent)
1028        IconRole::StatusBusy => return None,
1029        IconRole::StatusCheck => "checkmark",
1030        IconRole::StatusError => "xmark.circle.fill",
1031
1032        // System
1033        IconRole::UserAccount => "person.fill",
1034        IconRole::Notification => "bell.fill",
1035        IconRole::Help => "questionmark.circle",
1036        IconRole::Lock => "lock.fill",
1037
1038        _ => return None,
1039    })
1040}
1041
1042#[allow(unreachable_patterns)]
1043fn segoe_name(role: IconRole) -> Option<&'static str> {
1044    Some(match role {
1045        // Dialog / Alert (SHSTOCKICONID constants)
1046        IconRole::DialogWarning => "SIID_WARNING",
1047        IconRole::DialogError => "SIID_ERROR",
1048        IconRole::DialogInfo => "SIID_INFO",
1049        IconRole::DialogQuestion => "IDI_QUESTION",
1050        IconRole::DialogSuccess => "CheckMark",
1051        IconRole::Shield => "SIID_SHIELD",
1052
1053        // Window Controls (Segoe Fluent Icons glyphs)
1054        IconRole::WindowClose => "ChromeClose",
1055        IconRole::WindowMinimize => "ChromeMinimize",
1056        IconRole::WindowMaximize => "ChromeMaximize",
1057        IconRole::WindowRestore => "ChromeRestore",
1058
1059        // Common Actions (Segoe Fluent Icons glyphs)
1060        IconRole::ActionSave => "Save",
1061        IconRole::ActionDelete => "Delete",
1062        IconRole::ActionCopy => "Copy",
1063        IconRole::ActionPaste => "Paste",
1064        IconRole::ActionCut => "Cut",
1065        IconRole::ActionUndo => "Undo",
1066        IconRole::ActionRedo => "Redo",
1067        IconRole::ActionSearch => "Search",
1068        IconRole::ActionSettings => "Settings",
1069        IconRole::ActionEdit => "Edit",
1070        IconRole::ActionAdd => "Add",
1071        IconRole::ActionRemove => "Remove",
1072        IconRole::ActionRefresh => "Refresh",
1073        IconRole::ActionPrint => "Print",
1074
1075        // Navigation (Segoe Fluent Icons)
1076        IconRole::NavBack => "Back",
1077        IconRole::NavForward => "Forward",
1078        IconRole::NavUp => "Up",
1079        IconRole::NavDown => "Down",
1080        IconRole::NavHome => "Home",
1081        IconRole::NavMenu => "GlobalNavigationButton",
1082
1083        // Files / Places (SHSTOCKICONID)
1084        IconRole::FileGeneric => "SIID_DOCNOASSOC",
1085        IconRole::FolderClosed => "SIID_FOLDER",
1086        IconRole::FolderOpen => "SIID_FOLDEROPEN",
1087        IconRole::TrashEmpty => "SIID_RECYCLER",
1088        IconRole::TrashFull => "SIID_RECYCLERFULL",
1089
1090        // Status
1091        // StatusBusy: no static Windows icon (no static busy equivalent)
1092        IconRole::StatusBusy => return None,
1093        IconRole::StatusCheck => "CheckMark",
1094        IconRole::StatusError => "SIID_ERROR",
1095
1096        // System
1097        IconRole::UserAccount => "SIID_USERS",
1098        IconRole::Notification => "Ringer",
1099        IconRole::Help => "SIID_HELP",
1100        IconRole::Lock => "SIID_LOCK",
1101
1102        _ => return None,
1103    })
1104}
1105
1106#[allow(unreachable_patterns)]
1107fn freedesktop_name(role: IconRole) -> Option<&'static str> {
1108    Some(match role {
1109        // Dialog / Alert
1110        IconRole::DialogWarning => "dialog-warning",
1111        IconRole::DialogError => "dialog-error",
1112        IconRole::DialogInfo => "dialog-information",
1113        IconRole::DialogQuestion => "dialog-question",
1114        // Icon Naming Spec 0.8.90 standard name; Adwaita + Breeze both ship
1115        // `object-select-symbolic`. "emblem-ok" (a legacy GNOME-only emblem,
1116        // not in the spec) was tried first but is absent from Adwaita.
1117        IconRole::DialogSuccess => "object-select-symbolic",
1118        IconRole::Shield => "security-high",
1119
1120        // Window Controls
1121        IconRole::WindowClose => "window-close",
1122        IconRole::WindowMinimize => "window-minimize",
1123        IconRole::WindowMaximize => "window-maximize",
1124        IconRole::WindowRestore => "window-restore",
1125
1126        // Common Actions
1127        IconRole::ActionSave => "document-save",
1128        IconRole::ActionDelete => "edit-delete",
1129        IconRole::ActionCopy => "edit-copy",
1130        IconRole::ActionPaste => "edit-paste",
1131        IconRole::ActionCut => "edit-cut",
1132        IconRole::ActionUndo => "edit-undo",
1133        IconRole::ActionRedo => "edit-redo",
1134        IconRole::ActionSearch => "edit-find",
1135        IconRole::ActionSettings => "preferences-system",
1136        IconRole::ActionEdit => "document-edit",
1137        IconRole::ActionAdd => "list-add",
1138        IconRole::ActionRemove => "list-remove",
1139        IconRole::ActionRefresh => "view-refresh",
1140        IconRole::ActionPrint => "document-print",
1141
1142        // Navigation
1143        IconRole::NavBack => "go-previous",
1144        IconRole::NavForward => "go-next",
1145        IconRole::NavUp => "go-up",
1146        IconRole::NavDown => "go-down",
1147        IconRole::NavHome => "go-home",
1148        IconRole::NavMenu => "open-menu",
1149
1150        // Files / Places
1151        IconRole::FileGeneric => "text-x-generic",
1152        IconRole::FolderClosed => "folder",
1153        IconRole::FolderOpen => "folder-open",
1154        IconRole::TrashEmpty => "user-trash",
1155        IconRole::TrashFull => "user-trash-full",
1156
1157        // Status
1158        // Three-dot ellipsis loading indicator. Adwaita + Breeze both ship
1159        // `content-loading-symbolic`; it's a strict superset of the older
1160        // `process-working` name (every theme shipping that ships this too,
1161        // plus Adwaita). The animated `process-working` spinner is a
1162        // separate code path (see `load_freedesktop_spinner`).
1163        IconRole::StatusBusy => "content-loading-symbolic",
1164        IconRole::StatusCheck => "emblem-default",
1165        IconRole::StatusError => "dialog-error",
1166
1167        // System
1168        IconRole::UserAccount => "system-users",
1169        // KDE convention (Breeze, Oxygen); GNOME themes return None from lookup
1170        IconRole::Notification => "notification-active",
1171        IconRole::Help => "help-browser",
1172        IconRole::Lock => "system-lock-screen",
1173
1174        _ => return None,
1175    })
1176}
1177
1178#[allow(unreachable_patterns)]
1179fn material_name(role: IconRole) -> Option<&'static str> {
1180    Some(match role {
1181        // Dialog / Alert
1182        IconRole::DialogWarning => "warning",
1183        IconRole::DialogError => "error",
1184        IconRole::DialogInfo => "info",
1185        IconRole::DialogQuestion => "help",
1186        IconRole::DialogSuccess => "check_circle",
1187        IconRole::Shield => "shield",
1188
1189        // Window Controls
1190        IconRole::WindowClose => "close",
1191        IconRole::WindowMinimize => "minimize",
1192        IconRole::WindowMaximize => "open_in_full",
1193        IconRole::WindowRestore => "close_fullscreen",
1194
1195        // Common Actions
1196        IconRole::ActionSave => "save",
1197        IconRole::ActionDelete => "delete",
1198        IconRole::ActionCopy => "content_copy",
1199        IconRole::ActionPaste => "content_paste",
1200        IconRole::ActionCut => "content_cut",
1201        IconRole::ActionUndo => "undo",
1202        IconRole::ActionRedo => "redo",
1203        IconRole::ActionSearch => "search",
1204        IconRole::ActionSettings => "settings",
1205        IconRole::ActionEdit => "edit",
1206        IconRole::ActionAdd => "add",
1207        IconRole::ActionRemove => "remove",
1208        IconRole::ActionRefresh => "refresh",
1209        IconRole::ActionPrint => "print",
1210
1211        // Navigation
1212        IconRole::NavBack => "arrow_back",
1213        IconRole::NavForward => "arrow_forward",
1214        IconRole::NavUp => "arrow_upward",
1215        IconRole::NavDown => "arrow_downward",
1216        IconRole::NavHome => "home",
1217        IconRole::NavMenu => "menu",
1218
1219        // Files / Places
1220        IconRole::FileGeneric => "description",
1221        IconRole::FolderClosed => "folder",
1222        IconRole::FolderOpen => "folder_open",
1223        IconRole::TrashEmpty => "delete",
1224        // same as TrashEmpty -- Material has no full-trash variant
1225        IconRole::TrashFull => "delete",
1226
1227        // Status
1228        IconRole::StatusBusy => "progress_activity",
1229        IconRole::StatusCheck => "check",
1230        IconRole::StatusError => "error",
1231
1232        // System
1233        IconRole::UserAccount => "person",
1234        IconRole::Notification => "notifications",
1235        IconRole::Help => "help",
1236        IconRole::Lock => "lock",
1237
1238        _ => return None,
1239    })
1240}
1241
1242#[allow(unreachable_patterns)]
1243fn lucide_name(role: IconRole) -> Option<&'static str> {
1244    Some(match role {
1245        // Dialog / Alert
1246        IconRole::DialogWarning => "triangle-alert",
1247        IconRole::DialogError => "circle-x",
1248        IconRole::DialogInfo => "info",
1249        IconRole::DialogQuestion => "circle-question-mark",
1250        IconRole::DialogSuccess => "circle-check",
1251        IconRole::Shield => "shield",
1252
1253        // Window Controls
1254        IconRole::WindowClose => "x",
1255        IconRole::WindowMinimize => "minimize",
1256        IconRole::WindowMaximize => "maximize",
1257        IconRole::WindowRestore => "minimize-2",
1258
1259        // Common Actions
1260        IconRole::ActionSave => "save",
1261        IconRole::ActionDelete => "trash",
1262        IconRole::ActionCopy => "copy",
1263        IconRole::ActionPaste => "clipboard-paste",
1264        IconRole::ActionCut => "scissors",
1265        IconRole::ActionUndo => "undo-2",
1266        IconRole::ActionRedo => "redo-2",
1267        IconRole::ActionSearch => "search",
1268        IconRole::ActionSettings => "settings",
1269        IconRole::ActionEdit => "pencil",
1270        IconRole::ActionAdd => "plus",
1271        IconRole::ActionRemove => "minus",
1272        IconRole::ActionRefresh => "refresh-cw",
1273        IconRole::ActionPrint => "printer",
1274
1275        // Navigation
1276        IconRole::NavBack => "chevron-left",
1277        IconRole::NavForward => "chevron-right",
1278        IconRole::NavUp => "chevron-up",
1279        IconRole::NavDown => "chevron-down",
1280        IconRole::NavHome => "house",
1281        IconRole::NavMenu => "menu",
1282
1283        // Files / Places
1284        IconRole::FileGeneric => "file",
1285        IconRole::FolderClosed => "folder-closed",
1286        IconRole::FolderOpen => "folder-open",
1287        IconRole::TrashEmpty => "trash",
1288        // same as TrashEmpty -- Lucide has no full-trash variant
1289        IconRole::TrashFull => "trash",
1290
1291        // Status
1292        IconRole::StatusBusy => "loader",
1293        IconRole::StatusCheck => "check",
1294        IconRole::StatusError => "circle-x",
1295
1296        // System
1297        IconRole::UserAccount => "user",
1298        IconRole::Notification => "bell",
1299        IconRole::Help => "circle-question-mark",
1300        IconRole::Lock => "lock",
1301
1302        _ => return None,
1303    })
1304}
1305
1306#[cfg(test)]
1307#[allow(clippy::unwrap_used, clippy::expect_used)]
1308mod tests {
1309    use super::*;
1310
1311    // === IconRole tests ===
1312
1313    #[test]
1314    fn icon_role_all_has_42_variants() {
1315        assert_eq!(IconRole::ALL.len(), 42);
1316    }
1317
1318    #[test]
1319    fn icon_role_all_contains_every_variant() {
1320        // Exhaustive match — adding a new IconRole variant without adding it
1321        // here will cause a compile error (missing match arm). This is valid
1322        // within the defining crate despite #[non_exhaustive].
1323        use std::collections::HashSet;
1324        let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1325        let check = |role: IconRole| {
1326            assert!(
1327                all_set.contains(&role),
1328                "IconRole::{role:?} missing from ALL array"
1329            );
1330        };
1331
1332        // Exhaustive match forces compiler to catch new variants:
1333        #[deny(unreachable_patterns)]
1334        match IconRole::DialogWarning {
1335            IconRole::DialogWarning
1336            | IconRole::DialogError
1337            | IconRole::DialogInfo
1338            | IconRole::DialogQuestion
1339            | IconRole::DialogSuccess
1340            | IconRole::Shield
1341            | IconRole::WindowClose
1342            | IconRole::WindowMinimize
1343            | IconRole::WindowMaximize
1344            | IconRole::WindowRestore
1345            | IconRole::ActionSave
1346            | IconRole::ActionDelete
1347            | IconRole::ActionCopy
1348            | IconRole::ActionPaste
1349            | IconRole::ActionCut
1350            | IconRole::ActionUndo
1351            | IconRole::ActionRedo
1352            | IconRole::ActionSearch
1353            | IconRole::ActionSettings
1354            | IconRole::ActionEdit
1355            | IconRole::ActionAdd
1356            | IconRole::ActionRemove
1357            | IconRole::ActionRefresh
1358            | IconRole::ActionPrint
1359            | IconRole::NavBack
1360            | IconRole::NavForward
1361            | IconRole::NavUp
1362            | IconRole::NavDown
1363            | IconRole::NavHome
1364            | IconRole::NavMenu
1365            | IconRole::FileGeneric
1366            | IconRole::FolderClosed
1367            | IconRole::FolderOpen
1368            | IconRole::TrashEmpty
1369            | IconRole::TrashFull
1370            | IconRole::StatusBusy
1371            | IconRole::StatusCheck
1372            | IconRole::StatusError
1373            | IconRole::UserAccount
1374            | IconRole::Notification
1375            | IconRole::Help
1376            | IconRole::Lock => {}
1377        }
1378
1379        // Verify each variant is in ALL:
1380        check(IconRole::DialogWarning);
1381        check(IconRole::DialogError);
1382        check(IconRole::DialogInfo);
1383        check(IconRole::DialogQuestion);
1384        check(IconRole::DialogSuccess);
1385        check(IconRole::Shield);
1386        check(IconRole::WindowClose);
1387        check(IconRole::WindowMinimize);
1388        check(IconRole::WindowMaximize);
1389        check(IconRole::WindowRestore);
1390        check(IconRole::ActionSave);
1391        check(IconRole::ActionDelete);
1392        check(IconRole::ActionCopy);
1393        check(IconRole::ActionPaste);
1394        check(IconRole::ActionCut);
1395        check(IconRole::ActionUndo);
1396        check(IconRole::ActionRedo);
1397        check(IconRole::ActionSearch);
1398        check(IconRole::ActionSettings);
1399        check(IconRole::ActionEdit);
1400        check(IconRole::ActionAdd);
1401        check(IconRole::ActionRemove);
1402        check(IconRole::ActionRefresh);
1403        check(IconRole::ActionPrint);
1404        check(IconRole::NavBack);
1405        check(IconRole::NavForward);
1406        check(IconRole::NavUp);
1407        check(IconRole::NavDown);
1408        check(IconRole::NavHome);
1409        check(IconRole::NavMenu);
1410        check(IconRole::FileGeneric);
1411        check(IconRole::FolderClosed);
1412        check(IconRole::FolderOpen);
1413        check(IconRole::TrashEmpty);
1414        check(IconRole::TrashFull);
1415        check(IconRole::StatusBusy);
1416        check(IconRole::StatusCheck);
1417        check(IconRole::StatusError);
1418        check(IconRole::UserAccount);
1419        check(IconRole::Notification);
1420        check(IconRole::Help);
1421        check(IconRole::Lock);
1422    }
1423
1424    #[test]
1425    fn icon_role_all_no_duplicates() {
1426        let all = &IconRole::ALL;
1427        for (i, role) in all.iter().enumerate() {
1428            for (j, other) in all.iter().enumerate() {
1429                if i != j {
1430                    assert_ne!(role, other, "Duplicate at index {i} and {j}");
1431                }
1432            }
1433        }
1434    }
1435
1436    #[test]
1437    fn icon_role_derives_copy_clone() {
1438        let role = IconRole::ActionCopy;
1439        let copied1 = role;
1440        let copied2 = role;
1441        assert_eq!(role, copied1);
1442        assert_eq!(role, copied2);
1443    }
1444
1445    #[test]
1446    fn icon_role_derives_debug() {
1447        let s = format!("{:?}", IconRole::DialogWarning);
1448        assert!(s.contains("DialogWarning"));
1449    }
1450
1451    #[test]
1452    fn icon_role_derives_hash() {
1453        use std::collections::HashSet;
1454        let mut set = HashSet::new();
1455        set.insert(IconRole::ActionSave);
1456        set.insert(IconRole::ActionDelete);
1457        assert_eq!(set.len(), 2);
1458        assert!(set.contains(&IconRole::ActionSave));
1459    }
1460
1461    // === IconData tests ===
1462
1463    #[test]
1464    fn icon_data_svg_construct_and_match() {
1465        let data = IconData::Svg(Cow::Borrowed(b"<svg></svg>"));
1466        assert_eq!(data.bytes(), b"<svg></svg>");
1467        match data {
1468            IconData::Svg(ref cow) => assert_eq!(cow.as_ref(), b"<svg></svg>"),
1469            _ => panic!("Expected Svg variant"),
1470        }
1471    }
1472
1473    #[test]
1474    fn icon_data_rgba_construct_and_match() {
1475        let pixels = vec![255, 0, 0, 255]; // 1 red pixel
1476        let data = IconData::Rgba {
1477            width: 1,
1478            height: 1,
1479            data: pixels.clone(),
1480        };
1481        match data {
1482            IconData::Rgba {
1483                width,
1484                height,
1485                data,
1486            } => {
1487                assert_eq!(width, 1);
1488                assert_eq!(height, 1);
1489                assert_eq!(data, pixels);
1490            }
1491            _ => panic!("Expected Rgba variant"),
1492        }
1493    }
1494
1495    #[test]
1496    fn icon_data_derives_debug() {
1497        let data = IconData::Svg(Cow::Borrowed(b""));
1498        let s = format!("{:?}", data);
1499        assert!(s.contains("Svg"));
1500    }
1501
1502    #[test]
1503    fn icon_data_derives_clone() {
1504        let data = IconData::Rgba {
1505            width: 16,
1506            height: 16,
1507            data: vec![0; 16 * 16 * 4],
1508        };
1509        let cloned = data.clone();
1510        assert_eq!(data, cloned);
1511    }
1512
1513    #[test]
1514    fn icon_data_derives_eq() {
1515        let a = IconData::Svg(Cow::Borrowed(b"<svg/>"));
1516        let b = IconData::Svg(Cow::Owned(b"<svg/>".to_vec()));
1517        assert_eq!(a, b);
1518
1519        let c = IconData::Svg(Cow::Borrowed(b"<other/>"));
1520        assert_ne!(a, c);
1521    }
1522
1523    // === IconSet tests ===
1524
1525    #[test]
1526    fn icon_set_from_name_sf_symbols() {
1527        assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1528    }
1529
1530    #[test]
1531    fn icon_set_from_name_segoe_fluent() {
1532        assert_eq!(
1533            IconSet::from_name("segoe-fluent"),
1534            Some(IconSet::SegoeIcons)
1535        );
1536    }
1537
1538    #[test]
1539    fn icon_set_from_name_freedesktop() {
1540        assert_eq!(
1541            IconSet::from_name("freedesktop"),
1542            Some(IconSet::Freedesktop)
1543        );
1544    }
1545
1546    #[test]
1547    fn icon_set_from_name_material() {
1548        assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1549    }
1550
1551    #[test]
1552    fn icon_set_from_name_lucide() {
1553        assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1554    }
1555
1556    #[test]
1557    fn icon_set_from_name_unknown() {
1558        assert_eq!(IconSet::from_name("unknown"), None);
1559    }
1560
1561    #[test]
1562    fn icon_set_name_sf_symbols() {
1563        assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1564    }
1565
1566    #[test]
1567    fn icon_set_name_segoe_fluent() {
1568        assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1569    }
1570
1571    #[test]
1572    fn icon_set_name_freedesktop() {
1573        assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1574    }
1575
1576    #[test]
1577    fn icon_set_name_material() {
1578        assert_eq!(IconSet::Material.name(), "material");
1579    }
1580
1581    #[test]
1582    fn icon_set_name_lucide() {
1583        assert_eq!(IconSet::Lucide.name(), "lucide");
1584    }
1585
1586    #[test]
1587    fn icon_set_from_name_name_round_trip() {
1588        let sets = [
1589            IconSet::SfSymbols,
1590            IconSet::SegoeIcons,
1591            IconSet::Freedesktop,
1592            IconSet::Material,
1593            IconSet::Lucide,
1594        ];
1595        for set in &sets {
1596            let name = set.name();
1597            let parsed = IconSet::from_name(name);
1598            assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1599        }
1600    }
1601
1602    #[test]
1603    fn icon_set_derives_copy_clone() {
1604        let set = IconSet::Material;
1605        let copied1 = set;
1606        let copied2 = set;
1607        assert_eq!(set, copied1);
1608        assert_eq!(set, copied2);
1609    }
1610
1611    #[test]
1612    fn icon_set_derives_hash() {
1613        use std::collections::HashSet;
1614        let mut map = HashSet::new();
1615        map.insert(IconSet::SfSymbols);
1616        map.insert(IconSet::Lucide);
1617        assert_eq!(map.len(), 2);
1618    }
1619
1620    #[test]
1621    fn icon_set_derives_debug() {
1622        let s = format!("{:?}", IconSet::Freedesktop);
1623        assert!(s.contains("Freedesktop"));
1624    }
1625
1626    #[test]
1627    fn icon_set_serde_round_trip() {
1628        let set = IconSet::SfSymbols;
1629        let json = serde_json::to_string(&set).unwrap();
1630        let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1631        assert_eq!(set, deserialized);
1632    }
1633
1634    // === icon_name() tests ===
1635
1636    #[test]
1637    fn icon_name_sf_symbols_action_copy() {
1638        assert_eq!(
1639            icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1640            Some("doc.on.doc")
1641        );
1642    }
1643
1644    #[test]
1645    fn icon_name_segoe_action_copy() {
1646        assert_eq!(
1647            icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1648            Some("Copy")
1649        );
1650    }
1651
1652    #[test]
1653    fn icon_name_freedesktop_action_copy() {
1654        assert_eq!(
1655            icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1656            Some("edit-copy")
1657        );
1658    }
1659
1660    #[test]
1661    fn icon_name_material_action_copy() {
1662        assert_eq!(
1663            icon_name(IconRole::ActionCopy, IconSet::Material),
1664            Some("content_copy")
1665        );
1666    }
1667
1668    #[test]
1669    fn icon_name_lucide_action_copy() {
1670        assert_eq!(
1671            icon_name(IconRole::ActionCopy, IconSet::Lucide),
1672            Some("copy")
1673        );
1674    }
1675
1676    #[test]
1677    fn icon_name_sf_symbols_dialog_warning() {
1678        assert_eq!(
1679            icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1680            Some("exclamationmark.triangle.fill")
1681        );
1682    }
1683
1684    // None cases for known gaps
1685    #[test]
1686    fn icon_name_sf_symbols_folder_open_is_none() {
1687        assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1688    }
1689
1690    #[test]
1691    fn icon_name_sf_symbols_trash_full() {
1692        assert_eq!(
1693            icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1694            Some("trash.fill")
1695        );
1696    }
1697
1698    #[test]
1699    fn icon_name_sf_symbols_status_busy_is_none() {
1700        assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1701    }
1702
1703    #[test]
1704    fn icon_name_sf_symbols_window_restore() {
1705        assert_eq!(
1706            icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1707            Some("arrow.down.right.and.arrow.up.left")
1708        );
1709    }
1710
1711    #[test]
1712    fn icon_name_segoe_dialog_success() {
1713        assert_eq!(
1714            icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1715            Some("CheckMark")
1716        );
1717    }
1718
1719    #[test]
1720    fn icon_name_segoe_status_busy_is_none() {
1721        assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1722    }
1723
1724    #[test]
1725    fn icon_name_freedesktop_notification() {
1726        assert_eq!(
1727            icon_name(IconRole::Notification, IconSet::Freedesktop),
1728            Some("notification-active")
1729        );
1730    }
1731
1732    #[test]
1733    fn icon_name_freedesktop_dialog_success() {
1734        // Must be the Icon Naming Spec 0.8.90 standard name "object-select"
1735        // (Adwaita ships it; "emblem-ok" is a legacy GNOME emblem that
1736        // Adwaita does not ship and is not in the spec).
1737        assert_eq!(
1738            icon_name(IconRole::DialogSuccess, IconSet::Freedesktop),
1739            Some("object-select-symbolic")
1740        );
1741    }
1742
1743    #[test]
1744    fn icon_name_freedesktop_status_busy() {
1745        // Must resolve to "content-loading-symbolic" (three-dot ellipsis),
1746        // which Adwaita and Breeze both ship. `process-working` was tried
1747        // first but is absent from Adwaita.
1748        assert_eq!(
1749            icon_name(IconRole::StatusBusy, IconSet::Freedesktop),
1750            Some("content-loading-symbolic")
1751        );
1752    }
1753
1754    #[test]
1755    fn icon_name_material_trash_full() {
1756        assert_eq!(
1757            icon_name(IconRole::TrashFull, IconSet::Material),
1758            Some("delete")
1759        );
1760    }
1761
1762    #[test]
1763    fn icon_name_lucide_trash_full() {
1764        assert_eq!(
1765            icon_name(IconRole::TrashFull, IconSet::Lucide),
1766            Some("trash")
1767        );
1768    }
1769
1770    // Spot-check across all 5 icon sets for multiple roles
1771    #[test]
1772    fn icon_name_spot_check_dialog_error() {
1773        assert_eq!(
1774            icon_name(IconRole::DialogError, IconSet::SfSymbols),
1775            Some("xmark.circle.fill")
1776        );
1777        assert_eq!(
1778            icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1779            Some("SIID_ERROR")
1780        );
1781        assert_eq!(
1782            icon_name(IconRole::DialogError, IconSet::Freedesktop),
1783            Some("dialog-error")
1784        );
1785        assert_eq!(
1786            icon_name(IconRole::DialogError, IconSet::Material),
1787            Some("error")
1788        );
1789        assert_eq!(
1790            icon_name(IconRole::DialogError, IconSet::Lucide),
1791            Some("circle-x")
1792        );
1793    }
1794
1795    #[test]
1796    fn icon_name_spot_check_nav_home() {
1797        assert_eq!(
1798            icon_name(IconRole::NavHome, IconSet::SfSymbols),
1799            Some("house")
1800        );
1801        assert_eq!(
1802            icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1803            Some("Home")
1804        );
1805        assert_eq!(
1806            icon_name(IconRole::NavHome, IconSet::Freedesktop),
1807            Some("go-home")
1808        );
1809        assert_eq!(
1810            icon_name(IconRole::NavHome, IconSet::Material),
1811            Some("home")
1812        );
1813        assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1814    }
1815
1816    // Count test: verify expected Some/None count for each icon set
1817    #[test]
1818    fn icon_name_sf_symbols_expected_count() {
1819        // SF Symbols: 42 - 2 None (FolderOpen, StatusBusy) = 40 Some
1820        let some_count = IconRole::ALL
1821            .iter()
1822            .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1823            .count();
1824        assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1825    }
1826
1827    #[test]
1828    fn icon_name_segoe_expected_count() {
1829        // Segoe: 42 - 1 None (StatusBusy) = 41 Some
1830        let some_count = IconRole::ALL
1831            .iter()
1832            .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1833            .count();
1834        assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1835    }
1836
1837    #[test]
1838    fn icon_name_freedesktop_expected_count() {
1839        // Freedesktop: all 42 roles mapped
1840        let some_count = IconRole::ALL
1841            .iter()
1842            .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1843            .count();
1844        assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1845    }
1846
1847    #[test]
1848    fn icon_name_material_expected_count() {
1849        // Material: all 42 roles mapped
1850        let some_count = IconRole::ALL
1851            .iter()
1852            .filter(|r| icon_name(**r, IconSet::Material).is_some())
1853            .count();
1854        assert_eq!(some_count, 42, "Material should have 42 mappings");
1855    }
1856
1857    #[test]
1858    fn icon_name_lucide_expected_count() {
1859        // Lucide: all 42 roles mapped
1860        let some_count = IconRole::ALL
1861            .iter()
1862            .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1863            .count();
1864        assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1865    }
1866
1867    // === system_icon_set() tests ===
1868
1869    #[test]
1870    #[cfg(target_os = "linux")]
1871    fn system_icon_set_returns_freedesktop_on_linux() {
1872        assert_eq!(system_icon_set(), IconSet::Freedesktop);
1873    }
1874
1875    // === IconProvider trait tests ===
1876
1877    #[test]
1878    fn icon_provider_is_object_safe() {
1879        // Box<dyn IconProvider> must compile and be usable
1880        let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1881        let debug_str = format!("{:?}", provider);
1882        assert!(
1883            debug_str.contains("ActionCopy"),
1884            "Debug should print variant name"
1885        );
1886    }
1887
1888    #[test]
1889    fn icon_role_provider_icon_name() {
1890        // IconRole::ActionCopy should return "content_copy" for Material via IconProvider
1891        let role = IconRole::ActionCopy;
1892        let name = IconProvider::icon_name(&role, IconSet::Material);
1893        assert_eq!(name, Some("content_copy"));
1894    }
1895
1896    #[test]
1897    fn icon_role_provider_icon_name_sf_symbols() {
1898        let role = IconRole::ActionCopy;
1899        let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1900        assert_eq!(name, Some("doc.on.doc"));
1901    }
1902
1903    #[test]
1904    #[cfg(feature = "material-icons")]
1905    fn icon_role_provider_icon_svg_material() {
1906        let role = IconRole::ActionCopy;
1907        let svg = IconProvider::icon_svg(&role, IconSet::Material);
1908        assert!(svg.is_some(), "Material SVG should be Some");
1909        let cow = svg.unwrap();
1910        let content = std::str::from_utf8(&cow).expect("valid UTF-8");
1911        assert!(content.contains("<svg"), "should contain <svg tag");
1912    }
1913
1914    #[test]
1915    fn icon_role_provider_icon_svg_non_bundled() {
1916        // SfSymbols is not a bundled set, so icon_svg should return None
1917        let role = IconRole::ActionCopy;
1918        let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1919        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1920    }
1921
1922    #[test]
1923    fn icon_role_provider_all_roles() {
1924        // All 42 IconRole variants implement IconProvider -- iterate and call icon_name
1925        for role in IconRole::ALL {
1926            // All 42 roles are mapped for Material
1927            let _name = IconProvider::icon_name(&role, IconSet::Material);
1928            // Just verifying it doesn't panic
1929        }
1930    }
1931
1932    #[test]
1933    fn icon_provider_dyn_dispatch() {
1934        // Call icon_name and icon_svg through &dyn IconProvider
1935        let role = IconRole::ActionCopy;
1936        let provider: &dyn IconProvider = &role;
1937        let name = provider.icon_name(IconSet::Material);
1938        assert_eq!(name, Some("content_copy"));
1939        let svg = provider.icon_svg(IconSet::SfSymbols);
1940        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1941    }
1942
1943    // === Coverage tests ===
1944
1945    fn known_gaps() -> &'static [(IconSet, IconRole)] {
1946        &[
1947            (IconSet::SfSymbols, IconRole::FolderOpen),
1948            (IconSet::SfSymbols, IconRole::StatusBusy),
1949            (IconSet::SegoeIcons, IconRole::StatusBusy),
1950        ]
1951    }
1952
1953    #[test]
1954    fn no_unexpected_icon_gaps() {
1955        let gaps = known_gaps();
1956        let system_sets = [
1957            IconSet::SfSymbols,
1958            IconSet::SegoeIcons,
1959            IconSet::Freedesktop,
1960        ];
1961        for &set in &system_sets {
1962            for role in IconRole::ALL {
1963                let is_known_gap = gaps.contains(&(set, role));
1964                let is_mapped = icon_name(role, set).is_some();
1965                if !is_known_gap {
1966                    assert!(
1967                        is_mapped,
1968                        "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1969                    );
1970                }
1971            }
1972        }
1973    }
1974
1975    #[test]
1976    #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1977    fn all_roles_have_bundled_svg() {
1978        use crate::bundled_icon_svg;
1979        for set in [IconSet::Material, IconSet::Lucide] {
1980            for role in IconRole::ALL {
1981                assert!(
1982                    bundled_icon_svg(role, set).is_some(),
1983                    "{role:?} has no bundled SVG for {set:?}"
1984                );
1985            }
1986        }
1987    }
1988
1989    /// Exhaustive icon_name() coverage: Material, Lucide, and Freedesktop map ALL 42,
1990    /// Segoe maps at least 41, SF Symbols maps at least 40.
1991    #[test]
1992    fn icon_name_exhaustive_all_sets() {
1993        // Material: all 42
1994        for role in IconRole::ALL {
1995            assert!(
1996                icon_name(role, IconSet::Material).is_some(),
1997                "Material must map {role:?} to Some"
1998            );
1999        }
2000        // Lucide: all 42
2001        for role in IconRole::ALL {
2002            assert!(
2003                icon_name(role, IconSet::Lucide).is_some(),
2004                "Lucide must map {role:?} to Some"
2005            );
2006        }
2007        // Freedesktop: all 42
2008        for role in IconRole::ALL {
2009            assert!(
2010                icon_name(role, IconSet::Freedesktop).is_some(),
2011                "Freedesktop must map {role:?} to Some"
2012            );
2013        }
2014        // Segoe: at least 41 (StatusBusy is None)
2015        let segoe_count = IconRole::ALL
2016            .iter()
2017            .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
2018            .count();
2019        assert!(
2020            segoe_count >= 41,
2021            "Segoe should map at least 41 roles, got {segoe_count}"
2022        );
2023        // SF Symbols: at least 40 (FolderOpen and StatusBusy are None)
2024        let sf_count = IconRole::ALL
2025            .iter()
2026            .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
2027            .count();
2028        assert!(
2029            sf_count >= 40,
2030            "SfSymbols should map at least 40 roles, got {sf_count}"
2031        );
2032    }
2033
2034    /// Verify icon_name returns non-empty strings for all mapped roles.
2035    #[test]
2036    fn icon_name_returns_nonempty_strings() {
2037        let all_sets = [
2038            IconSet::SfSymbols,
2039            IconSet::SegoeIcons,
2040            IconSet::Freedesktop,
2041            IconSet::Material,
2042            IconSet::Lucide,
2043        ];
2044        for set in all_sets {
2045            for role in IconRole::ALL {
2046                if let Some(name) = icon_name(role, set) {
2047                    assert!(
2048                        !name.is_empty(),
2049                        "icon_name({role:?}, {set:?}) returned empty string"
2050                    );
2051                }
2052            }
2053        }
2054    }
2055
2056    // === IconSet drift-guard test (ICON-06) ===
2057
2058    #[test]
2059    fn icon_set_name_round_trip() {
2060        // Drift-guard: if a new IconSet variant is added without updating
2061        // from_name(), this test fails.
2062        let all_sets = [
2063            IconSet::SfSymbols,
2064            IconSet::SegoeIcons,
2065            IconSet::Freedesktop,
2066            IconSet::Material,
2067            IconSet::Lucide,
2068        ];
2069        for set in all_sets {
2070            assert_eq!(
2071                IconSet::from_name(set.name()),
2072                Some(set),
2073                "IconSet::{:?}.name() = {:?} did not round-trip through from_name()",
2074                set,
2075                set.name()
2076            );
2077        }
2078    }
2079
2080    // === IconRole::name() tests (ICON-07) ===
2081
2082    #[test]
2083    fn icon_role_name_returns_kebab_case() {
2084        assert_eq!(IconRole::ActionSave.name(), "action-save");
2085        assert_eq!(IconRole::DialogWarning.name(), "dialog-warning");
2086        assert_eq!(IconRole::WindowClose.name(), "window-close");
2087        assert_eq!(IconRole::NavBack.name(), "nav-back");
2088        assert_eq!(IconRole::FileGeneric.name(), "file-generic");
2089        assert_eq!(IconRole::StatusBusy.name(), "status-busy");
2090        assert_eq!(IconRole::UserAccount.name(), "user-account");
2091        assert_eq!(IconRole::Shield.name(), "shield");
2092        assert_eq!(IconRole::Notification.name(), "notification");
2093        assert_eq!(IconRole::Help.name(), "help");
2094        assert_eq!(IconRole::Lock.name(), "lock");
2095    }
2096
2097    #[test]
2098    fn icon_role_display_delegates_to_name() {
2099        for role in IconRole::ALL {
2100            assert_eq!(
2101                format!("{role}"),
2102                role.name(),
2103                "Display for IconRole::{:?} should delegate to name()",
2104                role
2105            );
2106        }
2107    }
2108
2109    // === IconSet serde-vs-name cross-check (GAP-5) ===
2110
2111    #[test]
2112    fn icon_set_serde_matches_name() {
2113        let all_sets = [
2114            IconSet::SfSymbols,
2115            IconSet::SegoeIcons,
2116            IconSet::Freedesktop,
2117            IconSet::Material,
2118            IconSet::Lucide,
2119        ];
2120        for set in all_sets {
2121            let serialized = serde_json::to_string(&set)
2122                .unwrap_or_else(|e| panic!("failed to serialize {set:?}: {e}"));
2123            let trimmed = serialized.trim_matches('"');
2124            assert_eq!(
2125                trimmed,
2126                set.name(),
2127                "serde serialization of {set:?} ({trimmed:?}) does not match name() ({:?})",
2128                set.name()
2129            );
2130        }
2131    }
2132
2133    // === IconData::bytes() accessor test ===
2134
2135    #[test]
2136    fn icon_data_bytes_accessor() {
2137        let svg = IconData::Svg(Cow::Borrowed(b"<svg/>"));
2138        assert_eq!(svg.bytes(), b"<svg/>");
2139
2140        let rgba = IconData::Rgba {
2141            width: 2,
2142            height: 2,
2143            data: vec![1, 2, 3, 4],
2144        };
2145        assert_eq!(rgba.bytes(), &[1, 2, 3, 4]);
2146    }
2147}
2148
2149#[cfg(test)]
2150#[cfg(target_os = "linux")]
2151#[allow(clippy::unwrap_used, clippy::expect_used)]
2152mod icon_theme_detection_tests {
2153    use super::*;
2154    use std::os::unix::process::ExitStatusExt;
2155    use std::path::{Path, PathBuf};
2156    use std::process::{ExitStatus, Output};
2157
2158    fn missing(_: &Path) -> std::io::Result<String> {
2159        Err(std::io::Error::from(std::io::ErrorKind::NotFound))
2160    }
2161
2162    fn printed(stdout: &str) -> std::io::Result<Output> {
2163        Ok(Output {
2164            status: ExitStatus::from_raw(0),
2165            stdout: stdout.as_bytes().to_vec(),
2166            stderr: Vec::new(),
2167        })
2168    }
2169
2170    fn exited_1(stderr: &str) -> std::io::Result<Output> {
2171        Ok(Output {
2172            status: ExitStatus::from_raw(1 << 8),
2173            stdout: Vec::new(),
2174            stderr: stderr.as_bytes().to_vec(),
2175        })
2176    }
2177
2178    fn not_found() -> std::io::Result<Output> {
2179        Err(std::io::Error::from(std::io::ErrorKind::NotFound))
2180    }
2181
2182    // === KDE: kdeglobals ===
2183
2184    #[test]
2185    fn kde_without_a_config_dir_names_kdeglobals_and_the_config_dir() {
2186        let failure = kde_icon_theme(None, missing).unwrap_err().to_string();
2187        assert!(failure.contains("kdeglobals"), "got: {failure}");
2188        assert!(failure.contains("config directory"), "got: {failure}");
2189        assert!(failure.contains("XDG_CONFIG_HOME"), "got: {failure}");
2190    }
2191
2192    #[test]
2193    fn kde_with_no_readable_file_names_both_files() {
2194        let dir = PathBuf::from("/cfg");
2195        let failure = kde_icon_theme(Some(&dir), missing).unwrap_err().to_string();
2196        assert!(failure.contains("/cfg/kdeglobals"), "got: {failure}");
2197        assert!(
2198            failure.contains("/cfg/kdedefaults/kdeglobals"),
2199            "got: {failure}"
2200        );
2201        assert!(failure.contains("unreadable"), "got: {failure}");
2202    }
2203
2204    #[test]
2205    fn kde_with_no_icons_theme_key_says_the_key_is_absent() {
2206        let dir = PathBuf::from("/cfg");
2207        let failure = kde_icon_theme(Some(&dir), |_: &Path| {
2208            Ok("[General]\nTheme=not-this-one\n".to_string())
2209        })
2210        .unwrap_err()
2211        .to_string();
2212        assert!(failure.contains("kdeglobals"), "got: {failure}");
2213        assert!(
2214            failure.contains("`[Icons] Theme` is absent or empty"),
2215            "got: {failure}"
2216        );
2217    }
2218
2219    #[test]
2220    fn kde_takes_the_defaults_file_where_kdeglobals_lacks_the_key() {
2221        let dir = PathBuf::from("/cfg");
2222        let theme = kde_icon_theme(Some(&dir), |path: &Path| {
2223            if path.ends_with("kdedefaults/kdeglobals") {
2224                Ok("[Icons]\nTheme=breeze-dark\n".to_string())
2225            } else {
2226                Ok("[General]\n".to_string())
2227            }
2228        });
2229        assert_eq!(theme, Ok("breeze-dark".to_string()));
2230    }
2231
2232    // === gsettings ===
2233
2234    #[test]
2235    fn gsettings_not_found_names_the_command() {
2236        let failure = gsettings_icon_theme_from("org.gnome.desktop.interface", not_found())
2237            .unwrap_err()
2238            .to_string();
2239        assert!(
2240            failure.contains("gsettings get org.gnome.desktop.interface icon-theme"),
2241            "got: {failure}"
2242        );
2243        assert!(failure.contains("command not found"), "got: {failure}");
2244    }
2245
2246    #[test]
2247    fn gsettings_nonzero_exit_gives_the_status_and_stderr() {
2248        let failure = gsettings_icon_theme_from(
2249            "org.mate.interface",
2250            exited_1("No such schema “org.mate.interface”\n"),
2251        )
2252        .unwrap_err()
2253        .to_string();
2254        assert!(
2255            failure.contains("gsettings get org.mate.interface icon-theme"),
2256            "got: {failure}"
2257        );
2258        assert!(
2259            failure.contains("unsuccessful (exit status: 1)"),
2260            "got: {failure}"
2261        );
2262        assert!(failure.contains("No such schema"), "got: {failure}");
2263    }
2264
2265    #[test]
2266    fn gsettings_empty_output_says_it_printed_no_name() {
2267        let failure = gsettings_icon_theme_from("org.cinnamon.desktop.interface", printed("''\n"))
2268            .unwrap_err()
2269            .to_string();
2270        assert!(
2271            failure.contains("gsettings get org.cinnamon.desktop.interface icon-theme"),
2272            "got: {failure}"
2273        );
2274        assert!(failure.contains("printed no theme name"), "got: {failure}");
2275    }
2276
2277    #[test]
2278    fn gsettings_output_is_unquoted() {
2279        assert_eq!(
2280            gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'\n")),
2281            Ok("Adwaita".to_string())
2282        );
2283    }
2284
2285    // === XFCE: xfconf-query ===
2286
2287    #[test]
2288    fn xfconf_not_found_names_the_command() {
2289        let failure = xfce_icon_theme_from(not_found()).unwrap_err().to_string();
2290        assert!(
2291            failure.contains("xfconf-query -c xsettings -p /Net/IconThemeName"),
2292            "got: {failure}"
2293        );
2294        assert!(failure.contains("command not found"), "got: {failure}");
2295    }
2296
2297    #[test]
2298    fn xfconf_nonzero_exit_gives_the_status() {
2299        let failure = xfce_icon_theme_from(exited_1("")).unwrap_err().to_string();
2300        assert!(failure.contains("xfconf-query"), "got: {failure}");
2301        assert!(
2302            failure.contains("unsuccessful (exit status: 1)"),
2303            "got: {failure}"
2304        );
2305    }
2306
2307    #[test]
2308    fn xfconf_empty_output_says_it_printed_no_name() {
2309        let failure = xfce_icon_theme_from(printed("\n")).unwrap_err().to_string();
2310        assert!(failure.contains("xfconf-query"), "got: {failure}");
2311        assert!(failure.contains("printed no theme name"), "got: {failure}");
2312    }
2313
2314    #[test]
2315    fn xfconf_output_is_trimmed() {
2316        assert_eq!(
2317            xfce_icon_theme_from(printed("elementary\n")),
2318            Ok("elementary".to_string())
2319        );
2320    }
2321
2322    // === LXQt: lxqt.conf ===
2323
2324    #[test]
2325    fn lxqt_without_a_config_dir_names_lxqt_conf_and_the_config_dir() {
2326        let failure = lxqt_icon_theme(None, missing).unwrap_err().to_string();
2327        assert!(failure.contains("lxqt.conf"), "got: {failure}");
2328        assert!(failure.contains("config directory"), "got: {failure}");
2329    }
2330
2331    #[test]
2332    fn lxqt_unreadable_file_names_the_file() {
2333        let dir = PathBuf::from("/cfg");
2334        let failure = lxqt_icon_theme(Some(&dir), missing)
2335            .unwrap_err()
2336            .to_string();
2337        assert!(failure.contains("/cfg/lxqt/lxqt.conf"), "got: {failure}");
2338        assert!(failure.contains("unreadable"), "got: {failure}");
2339    }
2340
2341    #[test]
2342    fn lxqt_without_the_key_says_the_key_is_absent() {
2343        let dir = PathBuf::from("/cfg");
2344        let failure = lxqt_icon_theme(Some(&dir), |_: &Path| Ok("theme=frost\n".to_string()))
2345            .unwrap_err()
2346            .to_string();
2347        assert!(failure.contains("/cfg/lxqt/lxqt.conf"), "got: {failure}");
2348        assert!(
2349            failure.contains("`icon_theme` is absent or empty"),
2350            "got: {failure}"
2351        );
2352    }
2353
2354    #[test]
2355    fn lxqt_reads_the_icon_theme_key() {
2356        let dir = PathBuf::from("/cfg");
2357        assert_eq!(
2358            lxqt_icon_theme(Some(&dir), |_: &Path| Ok(
2359                "[General]\nicon_theme=Papirus\n".to_string()
2360            )),
2361            Ok("Papirus".to_string())
2362        );
2363    }
2364
2365    // === Unknown desktop: KDE, then GNOME ===
2366
2367    #[test]
2368    fn unknown_desktop_error_names_both_attempts() {
2369        let error = unknown_desktop_icon_theme(
2370            || kde_icon_theme(None, missing),
2371            || gsettings_icon_theme_from("org.gnome.desktop.interface", not_found()),
2372        )
2373        .unwrap_err()
2374        .to_error();
2375        let message = error.to_string();
2376        assert!(message.contains("kdeglobals"), "got: {message}");
2377        assert!(
2378            message.contains("gsettings get org.gnome.desktop.interface icon-theme"),
2379            "got: {message}"
2380        );
2381        assert!(
2382            matches!(
2383                error,
2384                crate::Error::ReaderFailed {
2385                    reader: "icon-theme",
2386                    ..
2387                }
2388            ),
2389            "got: {error:?}"
2390        );
2391    }
2392
2393    #[test]
2394    fn unknown_desktop_takes_kde_first() {
2395        let dir = PathBuf::from("/cfg");
2396        let theme = unknown_desktop_icon_theme(
2397            || {
2398                kde_icon_theme(Some(&dir), |_: &Path| {
2399                    Ok("[Icons]\nTheme=breeze\n".to_string())
2400                })
2401            },
2402            || gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'")),
2403        );
2404        assert_eq!(theme, Ok("breeze".to_string()));
2405    }
2406
2407    #[test]
2408    fn unknown_desktop_takes_gnome_where_kde_fails() {
2409        let theme = unknown_desktop_icon_theme(
2410            || kde_icon_theme(None, missing),
2411            || gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'")),
2412        );
2413        assert_eq!(theme, Ok("Adwaita".to_string()));
2414    }
2415
2416    // === The public result ===
2417
2418    #[test]
2419    fn system_icon_theme_is_a_name_or_the_reason_for_none() {
2420        match system_icon_theme() {
2421            Ok(theme) => assert!(!theme.is_empty(), "detection named an empty theme"),
2422            Err(e) => assert!(!e.to_string().is_empty(), "a failure gave no reason"),
2423        }
2424    }
2425}