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
5#[cfg(target_os = "linux")]
6use std::sync::RwLock;
7
8#[cfg(target_os = "linux")]
9static CACHED_ICON_THEME: RwLock<Option<&'static str>> = RwLock::new(None);
10
11use serde::{Deserialize, Serialize};
12
13/// Semantic icon roles for cross-platform icon resolution.
14///
15/// Each variant represents a conceptual icon role (not a specific icon image).
16/// Platform-specific icon identifiers are resolved via
17/// [`icon_name()`](crate::icon_name) using an [`IconSet`].
18///
19/// # Categories
20///
21/// Variants are grouped by prefix into 7 categories:
22/// - **Dialog** (6): Alerts and dialog indicators
23/// - **Window** (4): Window control buttons
24/// - **Action** (14): Common user actions
25/// - **Navigation** (6): Directional and structural navigation
26/// - **Files** (5): File and folder representations
27/// - **Status** (3): State indicators
28/// - **System** (4): System-level UI elements
29///
30/// # Examples
31///
32/// ```
33/// use native_theme::IconRole;
34///
35/// let role = IconRole::ActionSave;
36/// match role {
37///     IconRole::ActionSave => println!("save icon"),
38///     _ => println!("other icon"),
39/// }
40///
41/// // Iterate all roles
42/// assert_eq!(IconRole::ALL.len(), 42);
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum IconRole {
47    // Dialog / Alert (6)
48    /// Warning indicator for dialogs
49    DialogWarning,
50    /// Error indicator for dialogs
51    DialogError,
52    /// Informational indicator for dialogs
53    DialogInfo,
54    /// Question indicator for dialogs
55    DialogQuestion,
56    /// Success/confirmation indicator for dialogs
57    DialogSuccess,
58    /// Security/shield indicator
59    Shield,
60
61    // Window Controls (4)
62    /// Close window button
63    WindowClose,
64    /// Minimize window button
65    WindowMinimize,
66    /// Maximize window button
67    WindowMaximize,
68    /// Restore window button (from maximized state)
69    WindowRestore,
70
71    // Common Actions (14)
72    /// Save action
73    ActionSave,
74    /// Delete action
75    ActionDelete,
76    /// Copy to clipboard
77    ActionCopy,
78    /// Paste from clipboard
79    ActionPaste,
80    /// Cut to clipboard
81    ActionCut,
82    /// Undo last action
83    ActionUndo,
84    /// Redo last undone action
85    ActionRedo,
86    /// Search / find
87    ActionSearch,
88    /// Settings / preferences
89    ActionSettings,
90    /// Edit / modify
91    ActionEdit,
92    /// Add / create new item
93    ActionAdd,
94    /// Remove item
95    ActionRemove,
96    /// Refresh / reload
97    ActionRefresh,
98    /// Print
99    ActionPrint,
100
101    // Navigation (6)
102    /// Navigate backward
103    NavBack,
104    /// Navigate forward
105    NavForward,
106    /// Navigate up in hierarchy
107    NavUp,
108    /// Navigate down in hierarchy
109    NavDown,
110    /// Navigate to home / root
111    NavHome,
112    /// Open menu / hamburger
113    NavMenu,
114
115    // Files / Places (5)
116    /// Generic file icon
117    FileGeneric,
118    /// Closed folder
119    FolderClosed,
120    /// Open folder
121    FolderOpen,
122    /// Empty trash / recycle bin
123    TrashEmpty,
124    /// Full trash / recycle bin
125    TrashFull,
126
127    // Status (3)
128    /// Busy / working state indicator
129    StatusBusy,
130    /// Check / success indicator
131    StatusCheck,
132    /// Error state indicator
133    StatusError,
134
135    // System (4)
136    /// User account / profile
137    UserAccount,
138    /// Notification / bell
139    Notification,
140    /// Help / question mark
141    Help,
142    /// Lock / security
143    Lock,
144}
145
146impl std::fmt::Display for IconRole {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        std::fmt::Debug::fmt(self, f)
149    }
150}
151
152impl IconRole {
153    /// All icon role variants, useful for iteration and exhaustive testing.
154    ///
155    /// Contains exactly 42 variants, one for each role, in declaration order.
156    pub const ALL: [IconRole; 42] = [
157        // Dialog (6)
158        Self::DialogWarning,
159        Self::DialogError,
160        Self::DialogInfo,
161        Self::DialogQuestion,
162        Self::DialogSuccess,
163        Self::Shield,
164        // Window (4)
165        Self::WindowClose,
166        Self::WindowMinimize,
167        Self::WindowMaximize,
168        Self::WindowRestore,
169        // Action (14)
170        Self::ActionSave,
171        Self::ActionDelete,
172        Self::ActionCopy,
173        Self::ActionPaste,
174        Self::ActionCut,
175        Self::ActionUndo,
176        Self::ActionRedo,
177        Self::ActionSearch,
178        Self::ActionSettings,
179        Self::ActionEdit,
180        Self::ActionAdd,
181        Self::ActionRemove,
182        Self::ActionRefresh,
183        Self::ActionPrint,
184        // Navigation (6)
185        Self::NavBack,
186        Self::NavForward,
187        Self::NavUp,
188        Self::NavDown,
189        Self::NavHome,
190        Self::NavMenu,
191        // Files (5)
192        Self::FileGeneric,
193        Self::FolderClosed,
194        Self::FolderOpen,
195        Self::TrashEmpty,
196        Self::TrashFull,
197        // Status (3)
198        Self::StatusBusy,
199        Self::StatusCheck,
200        Self::StatusError,
201        // System (4)
202        Self::UserAccount,
203        Self::Notification,
204        Self::Help,
205        Self::Lock,
206    ];
207}
208
209/// Icon data returned by loading functions.
210///
211/// Represents the actual pixel or vector data for an icon. This type is
212/// produced by platform icon loaders and bundled icon accessors.
213///
214/// # Examples
215///
216/// ```
217/// use native_theme::IconData;
218///
219/// let svg = IconData::Svg(b"<svg></svg>".to_vec());
220/// match svg {
221///     IconData::Svg(bytes) => assert!(!bytes.is_empty()),
222///     _ => unreachable!(),
223/// }
224///
225/// let rgba = IconData::Rgba { width: 16, height: 16, data: vec![0; 16*16*4] };
226/// match rgba {
227///     IconData::Rgba { width, height, .. } => {
228///         assert_eq!(width, 16);
229///         assert_eq!(height, 16);
230///     }
231///     _ => unreachable!(),
232/// }
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[non_exhaustive]
236#[must_use = "loading icon data without using it is likely a bug"]
237pub enum IconData {
238    /// SVG content as raw bytes (from freedesktop themes, bundled icon sets).
239    Svg(Vec<u8>),
240
241    /// Rasterized RGBA pixels (from macOS/Windows system APIs).
242    Rgba {
243        /// Image width in pixels.
244        width: u32,
245        /// Image height in pixels.
246        height: u32,
247        /// Raw RGBA pixel data (4 bytes per pixel, row-major).
248        data: Vec<u8>,
249    },
250}
251
252/// Known icon sets that provide platform-specific icon identifiers.
253///
254/// Each variant corresponds to a well-known icon naming system.
255/// Use [`from_name`](IconSet::from_name) to parse from TOML strings
256/// and [`name`](IconSet::name) to serialize back to kebab-case.
257///
258/// # Examples
259///
260/// ```
261/// use native_theme::IconSet;
262///
263/// let set = IconSet::from_name("sf-symbols").unwrap();
264/// assert_eq!(set, IconSet::SfSymbols);
265/// assert_eq!(set.name(), "sf-symbols");
266///
267/// // Round-trip
268/// let name = IconSet::Material.name();
269/// assert_eq!(IconSet::from_name(name), Some(IconSet::Material));
270///
271/// // Unknown names return None
272/// assert_eq!(IconSet::from_name("unknown"), None);
273/// ```
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
275#[serde(rename_all = "kebab-case")]
276#[non_exhaustive]
277pub enum IconSet {
278    /// Apple SF Symbols (macOS, iOS).
279    SfSymbols,
280    /// Microsoft Segoe Fluent Icons (Windows).
281    #[serde(rename = "segoe-fluent")]
282    SegoeIcons,
283    /// freedesktop Icon Naming Specification (Linux).
284    ///
285    /// This is the `#[default]` variant, so `IconSet::default()` returns
286    /// `Freedesktop`. This serves as a serialization-friendly fallback, not
287    /// a platform-correct value. The `resolve()` pipeline handles
288    /// platform-correct icon set selection.
289    #[default]
290    Freedesktop,
291    /// Google Material Symbols.
292    Material,
293    /// Lucide Icons (fork of Feather).
294    Lucide,
295}
296
297impl std::fmt::Display for IconSet {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.write_str(self.name())
300    }
301}
302
303impl IconSet {
304    /// Parse an icon set from its kebab-case string identifier.
305    ///
306    /// Accepts the names used in TOML configuration:
307    /// `"sf-symbols"`, `"segoe-fluent"`, `"freedesktop"`, `"material"`, `"lucide"`.
308    ///
309    /// Returns `None` for unrecognized names.
310    #[must_use]
311    pub fn from_name(name: &str) -> Option<Self> {
312        match name {
313            "sf-symbols" => Some(Self::SfSymbols),
314            "segoe-fluent" => Some(Self::SegoeIcons),
315            "freedesktop" => Some(Self::Freedesktop),
316            "material" => Some(Self::Material),
317            "lucide" => Some(Self::Lucide),
318            _ => None,
319        }
320    }
321
322    /// The kebab-case string identifier for this icon set, as used in TOML.
323    #[must_use]
324    pub fn name(&self) -> &'static str {
325        match self {
326            Self::SfSymbols => "sf-symbols",
327            Self::SegoeIcons => "segoe-fluent",
328            Self::Freedesktop => "freedesktop",
329            Self::Material => "material",
330            Self::Lucide => "lucide",
331        }
332    }
333}
334
335/// Trait for types that map icon identifiers to platform-specific names and SVG data.
336///
337/// Implement this trait on an enum to make its variants loadable via
338/// [`load_custom_icon()`](crate::load_custom_icon). The typical pattern is
339/// for each enum variant to represent an icon role, with `icon_name()` returning
340/// the platform-specific identifier and `icon_svg()` returning embedded SVG bytes.
341///
342/// The `native-theme-build` crate can auto-generate implementations from TOML
343/// definitions at build time, so manual implementation is only needed for
344/// special cases.
345///
346/// [`IconRole`] implements this trait, delegating to the built-in icon mappings.
347///
348/// # Object Safety
349///
350/// This trait is object-safe (only requires [`Debug`] as a supertrait).
351/// `Box<dyn IconProvider>` works for dynamic dispatch.
352///
353/// # Examples
354///
355/// ```
356/// use native_theme::{IconProvider, IconSet};
357///
358/// #[derive(Debug)]
359/// enum MyIcon { Play, Pause }
360///
361/// impl IconProvider for MyIcon {
362///     fn icon_name(&self, set: IconSet) -> Option<&str> {
363///         match (self, set) {
364///             (MyIcon::Play, IconSet::SfSymbols) => Some("play.fill"),
365///             (MyIcon::Play, IconSet::Material) => Some("play_arrow"),
366///             (MyIcon::Pause, IconSet::SfSymbols) => Some("pause.fill"),
367///             (MyIcon::Pause, IconSet::Material) => Some("pause"),
368///             _ => None,
369///         }
370///     }
371///     fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
372///         None // No bundled SVGs in this example
373///     }
374/// }
375/// ```
376pub trait IconProvider: std::fmt::Debug {
377    /// Return the platform/theme-specific icon name for this icon in the given set.
378    fn icon_name(&self, set: IconSet) -> Option<&str>;
379
380    /// Return bundled SVG bytes for this icon in the given set.
381    fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]>;
382}
383
384impl IconProvider for IconRole {
385    fn icon_name(&self, set: IconSet) -> Option<&str> {
386        icon_name(*self, set)
387    }
388
389    fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]> {
390        crate::model::bundled::bundled_icon_svg(*self, set)
391    }
392}
393
394/// Look up the platform-specific icon identifier for a given icon set and role.
395///
396/// Returns `Some(name)` if the icon set has a standard icon for the role,
397/// or `None` if no standard icon exists (e.g., SF Symbols has no open-folder
398/// variant).
399///
400/// # Examples
401///
402/// ```
403/// use native_theme::{IconSet, IconRole, icon_name};
404///
405/// assert_eq!(icon_name(IconRole::ActionCopy, IconSet::SfSymbols), Some("doc.on.doc"));
406/// assert_eq!(icon_name(IconRole::ActionCopy, IconSet::Freedesktop), Some("edit-copy"));
407/// assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
408/// ```
409#[must_use]
410#[allow(unreachable_patterns)] // wildcard arm kept for #[non_exhaustive] forward compat
411pub fn icon_name(role: IconRole, set: IconSet) -> Option<&'static str> {
412    match set {
413        IconSet::SfSymbols => sf_symbols_name(role),
414        IconSet::SegoeIcons => segoe_name(role),
415        IconSet::Freedesktop => freedesktop_name(role),
416        IconSet::Material => material_name(role),
417        IconSet::Lucide => lucide_name(role),
418        _ => None,
419    }
420}
421
422/// Detect the native icon set for the current operating system.
423///
424/// Returns the platform-appropriate icon set at runtime using `cfg!()` macros:
425/// - macOS / iOS: [`IconSet::SfSymbols`]
426/// - Windows: [`IconSet::SegoeIcons`]
427/// - Linux: [`IconSet::Freedesktop`]
428/// - Other: [`IconSet::Material`] (safe cross-platform fallback)
429///
430/// # Examples
431///
432/// ```
433/// use native_theme::{IconSet, system_icon_set};
434///
435/// let set = system_icon_set();
436/// // On Linux, this returns Freedesktop
437/// ```
438#[must_use = "this returns the current icon set for the platform"]
439pub fn system_icon_set() -> IconSet {
440    if cfg!(any(target_os = "macos", target_os = "ios")) {
441        IconSet::SfSymbols
442    } else if cfg!(target_os = "windows") {
443        IconSet::SegoeIcons
444    } else if cfg!(target_os = "linux") {
445        IconSet::Freedesktop
446    } else {
447        IconSet::Material
448    }
449}
450
451/// Detect the icon theme name for the current platform.
452///
453/// Returns the name of the icon theme that provides the actual icon files:
454/// - **macOS / iOS:** `"sf-symbols"` (no user-configurable icon theme)
455/// - **Windows:** `"segoe-fluent"` (no user-configurable icon theme)
456/// - **Linux:** DE-specific detection (e.g., `"breeze-dark"`, `"Adwaita"`)
457/// - **Other:** `"material"` (bundled fallback)
458///
459/// On Linux, the detection method depends on the desktop environment:
460/// - KDE: reads `[Icons] Theme` from `kdeglobals`
461/// - GNOME/Budgie: `gsettings get org.gnome.desktop.interface icon-theme`
462/// - Cinnamon: `gsettings get org.cinnamon.desktop.interface icon-theme`
463/// - XFCE: `xfconf-query -c xsettings -p /Net/IconThemeName`
464/// - MATE: `gsettings get org.mate.interface icon-theme`
465/// - LXQt: reads `icon_theme` from `~/.config/lxqt/lxqt.conf`
466/// - Unknown: tries KDE, then GNOME gsettings, then `"hicolor"`
467///
468/// # Examples
469///
470/// ```
471/// use native_theme::system_icon_theme;
472///
473/// let theme = system_icon_theme();
474/// // On a KDE system with Breeze Dark: "breeze-dark"
475/// // On macOS: "sf-symbols"
476/// ```
477#[must_use = "this returns the current icon theme name"]
478pub fn system_icon_theme() -> &'static str {
479    // Cached per-process. Call `invalidate_caches()` to force re-detection.
480    // Use `detect_icon_theme()` for a fresh uncached reading.
481
482    #[cfg(any(target_os = "macos", target_os = "ios"))]
483    {
484        return "sf-symbols";
485    }
486
487    #[cfg(target_os = "windows")]
488    {
489        return "segoe-fluent";
490    }
491
492    #[cfg(target_os = "linux")]
493    {
494        if let Ok(guard) = CACHED_ICON_THEME.read()
495            && let Some(v) = *guard
496        {
497            return v;
498        }
499        let value = detect_linux_icon_theme();
500        // Leak the string to produce a &'static str. This is intentional:
501        // invalidation is rare and the leaked theme name is ~20 bytes.
502        let leaked: &'static str = Box::leak(value.into_boxed_str());
503        if let Ok(mut guard) = CACHED_ICON_THEME.write() {
504            *guard = Some(leaked);
505        }
506        leaked
507    }
508
509    #[cfg(not(any(
510        target_os = "linux",
511        target_os = "windows",
512        target_os = "macos",
513        target_os = "ios"
514    )))]
515    {
516        "material"
517    }
518}
519
520/// Clear the cached icon theme so the next [`system_icon_theme()`] call
521/// re-queries the OS. Called by [`crate::invalidate_caches()`].
522#[cfg(target_os = "linux")]
523pub(crate) fn invalidate_icon_theme_cache() {
524    if let Ok(mut g) = CACHED_ICON_THEME.write() {
525        *g = None;
526    }
527}
528
529/// No-op on non-Linux (icon theme is a compile-time constant).
530#[cfg(not(target_os = "linux"))]
531pub(crate) fn invalidate_icon_theme_cache() {}
532
533/// Detect the icon theme name for the current platform without caching.
534///
535/// Unlike [`system_icon_theme()`], this function queries the OS every time it
536/// is called and never caches the result. Use this when polling for icon theme
537/// changes (e.g., the user switches from Breeze to Breeze Dark in system
538/// settings).
539///
540/// See [`system_icon_theme()`] for platform behavior details.
541#[must_use = "this returns the current icon theme name"]
542#[allow(unreachable_code)]
543pub fn detect_icon_theme() -> String {
544    #[cfg(any(target_os = "macos", target_os = "ios"))]
545    {
546        return "sf-symbols".to_string();
547    }
548
549    #[cfg(target_os = "windows")]
550    {
551        return "segoe-fluent".to_string();
552    }
553
554    #[cfg(target_os = "linux")]
555    {
556        detect_linux_icon_theme()
557    }
558
559    #[cfg(not(any(
560        target_os = "linux",
561        target_os = "windows",
562        target_os = "macos",
563        target_os = "ios"
564    )))]
565    {
566        "material".to_string()
567    }
568}
569
570/// Linux icon theme detection, dispatched by desktop environment.
571#[cfg(target_os = "linux")]
572fn detect_linux_icon_theme() -> String {
573    let de = crate::detect::detect_linux_de(&crate::detect::xdg_current_desktop());
574
575    match de {
576        crate::detect::LinuxDesktop::Kde => detect_kde_icon_theme(),
577        crate::detect::LinuxDesktop::Gnome | crate::detect::LinuxDesktop::Budgie => {
578            gsettings_icon_theme("org.gnome.desktop.interface")
579        }
580        crate::detect::LinuxDesktop::Cinnamon => {
581            gsettings_icon_theme("org.cinnamon.desktop.interface")
582        }
583        crate::detect::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
584        crate::detect::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
585        crate::detect::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
586        crate::detect::LinuxDesktop::Unknown => {
587            let kde = detect_kde_icon_theme();
588            if kde != "hicolor" {
589                return kde;
590            }
591            let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
592            if gnome != "hicolor" {
593                return gnome;
594            }
595            "hicolor".to_string()
596        }
597    }
598}
599
600/// Read icon theme from KDE's kdeglobals INI file.
601///
602/// Checks `~/.config/kdeglobals` first, then `~/.config/kdedefaults/kdeglobals`
603/// (Plasma 6 stores distro defaults there, including the icon theme).
604///
605/// Uses simple line parsing — no `configparser` dependency required — so this
606/// works without the `kde` feature enabled.
607#[cfg(target_os = "linux")]
608fn detect_kde_icon_theme() -> String {
609    let Some(config_dir) = xdg_config_dir() else {
610        return "hicolor".to_string();
611    };
612    let paths = [
613        config_dir.join("kdeglobals"),
614        config_dir.join("kdedefaults").join("kdeglobals"),
615    ];
616
617    for path in &paths {
618        if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
619            return theme;
620        }
621    }
622    "hicolor".to_string()
623}
624
625/// Query gsettings for icon-theme with the given schema.
626#[cfg(target_os = "linux")]
627fn gsettings_icon_theme(schema: &str) -> String {
628    std::process::Command::new("gsettings")
629        .args(["get", schema, "icon-theme"])
630        .output()
631        .ok()
632        .filter(|o| o.status.success())
633        .and_then(|o| String::from_utf8(o.stdout).ok())
634        .map(|s| s.trim().trim_matches('\'').to_string())
635        .filter(|s| !s.is_empty())
636        .unwrap_or_else(|| "hicolor".to_string())
637}
638
639/// Read icon theme from XFCE's xfconf-query.
640#[cfg(target_os = "linux")]
641fn detect_xfce_icon_theme() -> String {
642    std::process::Command::new("xfconf-query")
643        .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
644        .output()
645        .ok()
646        .filter(|o| o.status.success())
647        .and_then(|o| String::from_utf8(o.stdout).ok())
648        .map(|s| s.trim().to_string())
649        .filter(|s| !s.is_empty())
650        .unwrap_or_else(|| "hicolor".to_string())
651}
652
653/// Read icon theme from LXQt's config file.
654///
655/// LXQt uses a flat `key=value` format (no section headers for the icon_theme
656/// key), so we scan for the bare `icon_theme=` prefix.
657#[cfg(target_os = "linux")]
658fn detect_lxqt_icon_theme() -> String {
659    let Some(config_dir) = xdg_config_dir() else {
660        return "hicolor".to_string();
661    };
662    let path = config_dir.join("lxqt").join("lxqt.conf");
663
664    if let Ok(content) = std::fs::read_to_string(&path) {
665        for line in content.lines() {
666            let trimmed = line.trim();
667            if let Some(value) = trimmed.strip_prefix("icon_theme=") {
668                let value = value.trim();
669                if !value.is_empty() {
670                    return value.to_string();
671                }
672            }
673        }
674    }
675    "hicolor".to_string()
676}
677
678/// Resolve `$XDG_CONFIG_HOME`, falling back to `$HOME/.config`.
679///
680/// Returns `None` when both `$XDG_CONFIG_HOME` and `$HOME` are unset,
681/// avoiding a bogus `/tmp/.config` fallback.
682#[cfg(target_os = "linux")]
683fn xdg_config_dir() -> Option<std::path::PathBuf> {
684    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
685        && !config_home.is_empty()
686    {
687        return Some(std::path::PathBuf::from(config_home));
688    }
689    std::env::var("HOME")
690        .ok()
691        .filter(|h| !h.is_empty())
692        .map(|h| std::path::PathBuf::from(h).join(".config"))
693}
694
695/// Read a value from an INI file by section and key.
696///
697/// Simple line-based parser — no external crate needed. Handles `[Section]`
698/// headers and `Key=Value` lines. Returns `None` if the file doesn't exist,
699/// the section/key is missing, or the value is empty.
700#[cfg(target_os = "linux")]
701fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
702    let content = std::fs::read_to_string(path).ok()?;
703    let target_section = format!("[{}]", section);
704    let mut in_section = false;
705
706    for line in content.lines() {
707        let trimmed = line.trim();
708        if trimmed.starts_with('[') {
709            in_section = trimmed == target_section;
710            continue;
711        }
712        if in_section && let Some(value) = trimmed.strip_prefix(key) {
713            let value = value.trim_start();
714            if let Some(value) = value.strip_prefix('=') {
715                let value = value.trim();
716                if !value.is_empty() {
717                    return Some(value.to_string());
718                }
719            }
720        }
721    }
722    None
723}
724
725// --- Private mapping functions ---
726
727#[allow(unreachable_patterns)]
728fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
729    Some(match role {
730        // Dialog / Alert
731        IconRole::DialogWarning => "exclamationmark.triangle.fill",
732        IconRole::DialogError => "xmark.circle.fill",
733        IconRole::DialogInfo => "info.circle.fill",
734        IconRole::DialogQuestion => "questionmark.circle.fill",
735        IconRole::DialogSuccess => "checkmark.circle.fill",
736        IconRole::Shield => "shield.fill",
737
738        // Window Controls
739        IconRole::WindowClose => "xmark",
740        IconRole::WindowMinimize => "minus",
741        IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
742        IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
743
744        // Common Actions
745        IconRole::ActionSave => "square.and.arrow.down",
746        IconRole::ActionDelete => "trash",
747        IconRole::ActionCopy => "doc.on.doc",
748        IconRole::ActionPaste => "doc.on.clipboard",
749        IconRole::ActionCut => "scissors",
750        IconRole::ActionUndo => "arrow.uturn.backward",
751        IconRole::ActionRedo => "arrow.uturn.forward",
752        IconRole::ActionSearch => "magnifyingglass",
753        IconRole::ActionSettings => "gearshape",
754        IconRole::ActionEdit => "pencil",
755        IconRole::ActionAdd => "plus",
756        IconRole::ActionRemove => "minus",
757        IconRole::ActionRefresh => "arrow.clockwise",
758        IconRole::ActionPrint => "printer",
759
760        // Navigation
761        IconRole::NavBack => "chevron.backward",
762        IconRole::NavForward => "chevron.forward",
763        IconRole::NavUp => "chevron.up",
764        IconRole::NavDown => "chevron.down",
765        IconRole::NavHome => "house",
766        IconRole::NavMenu => "line.horizontal.3",
767
768        // Files / Places
769        IconRole::FileGeneric => "doc",
770        IconRole::FolderClosed => "folder",
771        // FolderOpen: no SF Symbol equivalent
772        IconRole::FolderOpen => return None,
773        IconRole::TrashEmpty => "trash",
774        IconRole::TrashFull => "trash.fill",
775
776        // Status
777        // StatusBusy: no static SF Symbol (no static busy equivalent)
778        IconRole::StatusBusy => return None,
779        IconRole::StatusCheck => "checkmark",
780        IconRole::StatusError => "xmark.circle.fill",
781
782        // System
783        IconRole::UserAccount => "person.fill",
784        IconRole::Notification => "bell.fill",
785        IconRole::Help => "questionmark.circle",
786        IconRole::Lock => "lock.fill",
787
788        _ => return None,
789    })
790}
791
792#[allow(unreachable_patterns)]
793fn segoe_name(role: IconRole) -> Option<&'static str> {
794    Some(match role {
795        // Dialog / Alert (SHSTOCKICONID constants)
796        IconRole::DialogWarning => "SIID_WARNING",
797        IconRole::DialogError => "SIID_ERROR",
798        IconRole::DialogInfo => "SIID_INFO",
799        IconRole::DialogQuestion => "IDI_QUESTION",
800        IconRole::DialogSuccess => "CheckMark",
801        IconRole::Shield => "SIID_SHIELD",
802
803        // Window Controls (Segoe Fluent Icons glyphs)
804        IconRole::WindowClose => "ChromeClose",
805        IconRole::WindowMinimize => "ChromeMinimize",
806        IconRole::WindowMaximize => "ChromeMaximize",
807        IconRole::WindowRestore => "ChromeRestore",
808
809        // Common Actions (mix of SHSTOCKICONID and Segoe Fluent)
810        IconRole::ActionSave => "Save",
811        IconRole::ActionDelete => "SIID_DELETE",
812        IconRole::ActionCopy => "Copy",
813        IconRole::ActionPaste => "Paste",
814        IconRole::ActionCut => "Cut",
815        IconRole::ActionUndo => "Undo",
816        IconRole::ActionRedo => "Redo",
817        IconRole::ActionSearch => "SIID_FIND",
818        IconRole::ActionSettings => "SIID_SETTINGS",
819        IconRole::ActionEdit => "Edit",
820        IconRole::ActionAdd => "Add",
821        IconRole::ActionRemove => "Remove",
822        IconRole::ActionRefresh => "Refresh",
823        IconRole::ActionPrint => "SIID_PRINTER",
824
825        // Navigation (Segoe Fluent Icons)
826        IconRole::NavBack => "Back",
827        IconRole::NavForward => "Forward",
828        IconRole::NavUp => "Up",
829        IconRole::NavDown => "Down",
830        IconRole::NavHome => "Home",
831        IconRole::NavMenu => "GlobalNavigationButton",
832
833        // Files / Places (SHSTOCKICONID)
834        IconRole::FileGeneric => "SIID_DOCNOASSOC",
835        IconRole::FolderClosed => "SIID_FOLDER",
836        IconRole::FolderOpen => "SIID_FOLDEROPEN",
837        IconRole::TrashEmpty => "SIID_RECYCLER",
838        IconRole::TrashFull => "SIID_RECYCLERFULL",
839
840        // Status
841        // StatusBusy: no static Windows icon (no static busy equivalent)
842        IconRole::StatusBusy => return None,
843        IconRole::StatusCheck => "CheckMark",
844        IconRole::StatusError => "SIID_ERROR",
845
846        // System
847        IconRole::UserAccount => "SIID_USERS",
848        IconRole::Notification => "Ringer",
849        IconRole::Help => "SIID_HELP",
850        IconRole::Lock => "SIID_LOCK",
851
852        _ => return None,
853    })
854}
855
856#[allow(unreachable_patterns)]
857fn freedesktop_name(role: IconRole) -> Option<&'static str> {
858    Some(match role {
859        // Dialog / Alert
860        IconRole::DialogWarning => "dialog-warning",
861        IconRole::DialogError => "dialog-error",
862        IconRole::DialogInfo => "dialog-information",
863        IconRole::DialogQuestion => "dialog-question",
864        IconRole::DialogSuccess => "emblem-ok-symbolic",
865        IconRole::Shield => "security-high",
866
867        // Window Controls
868        IconRole::WindowClose => "window-close",
869        IconRole::WindowMinimize => "window-minimize",
870        IconRole::WindowMaximize => "window-maximize",
871        IconRole::WindowRestore => "window-restore",
872
873        // Common Actions
874        IconRole::ActionSave => "document-save",
875        IconRole::ActionDelete => "edit-delete",
876        IconRole::ActionCopy => "edit-copy",
877        IconRole::ActionPaste => "edit-paste",
878        IconRole::ActionCut => "edit-cut",
879        IconRole::ActionUndo => "edit-undo",
880        IconRole::ActionRedo => "edit-redo",
881        IconRole::ActionSearch => "edit-find",
882        IconRole::ActionSettings => "preferences-system",
883        IconRole::ActionEdit => "document-edit",
884        IconRole::ActionAdd => "list-add",
885        IconRole::ActionRemove => "list-remove",
886        IconRole::ActionRefresh => "view-refresh",
887        IconRole::ActionPrint => "document-print",
888
889        // Navigation
890        IconRole::NavBack => "go-previous",
891        IconRole::NavForward => "go-next",
892        IconRole::NavUp => "go-up",
893        IconRole::NavDown => "go-down",
894        IconRole::NavHome => "go-home",
895        IconRole::NavMenu => "open-menu",
896
897        // Files / Places
898        IconRole::FileGeneric => "text-x-generic",
899        IconRole::FolderClosed => "folder",
900        IconRole::FolderOpen => "folder-open",
901        IconRole::TrashEmpty => "user-trash",
902        IconRole::TrashFull => "user-trash-full",
903
904        // Status
905        IconRole::StatusBusy => "process-working",
906        IconRole::StatusCheck => "emblem-default",
907        IconRole::StatusError => "dialog-error",
908
909        // System
910        IconRole::UserAccount => "system-users",
911        // KDE convention (Breeze, Oxygen); GNOME themes return None from lookup
912        IconRole::Notification => "notification-active",
913        IconRole::Help => "help-browser",
914        IconRole::Lock => "system-lock-screen",
915
916        _ => return None,
917    })
918}
919
920#[allow(unreachable_patterns)]
921fn material_name(role: IconRole) -> Option<&'static str> {
922    Some(match role {
923        // Dialog / Alert
924        IconRole::DialogWarning => "warning",
925        IconRole::DialogError => "error",
926        IconRole::DialogInfo => "info",
927        IconRole::DialogQuestion => "help",
928        IconRole::DialogSuccess => "check_circle",
929        IconRole::Shield => "shield",
930
931        // Window Controls
932        IconRole::WindowClose => "close",
933        IconRole::WindowMinimize => "minimize",
934        IconRole::WindowMaximize => "open_in_full",
935        IconRole::WindowRestore => "close_fullscreen",
936
937        // Common Actions
938        IconRole::ActionSave => "save",
939        IconRole::ActionDelete => "delete",
940        IconRole::ActionCopy => "content_copy",
941        IconRole::ActionPaste => "content_paste",
942        IconRole::ActionCut => "content_cut",
943        IconRole::ActionUndo => "undo",
944        IconRole::ActionRedo => "redo",
945        IconRole::ActionSearch => "search",
946        IconRole::ActionSettings => "settings",
947        IconRole::ActionEdit => "edit",
948        IconRole::ActionAdd => "add",
949        IconRole::ActionRemove => "remove",
950        IconRole::ActionRefresh => "refresh",
951        IconRole::ActionPrint => "print",
952
953        // Navigation
954        IconRole::NavBack => "arrow_back",
955        IconRole::NavForward => "arrow_forward",
956        IconRole::NavUp => "arrow_upward",
957        IconRole::NavDown => "arrow_downward",
958        IconRole::NavHome => "home",
959        IconRole::NavMenu => "menu",
960
961        // Files / Places
962        IconRole::FileGeneric => "description",
963        IconRole::FolderClosed => "folder",
964        IconRole::FolderOpen => "folder_open",
965        IconRole::TrashEmpty => "delete",
966        // same as TrashEmpty -- Material has no full-trash variant
967        IconRole::TrashFull => "delete",
968
969        // Status
970        IconRole::StatusBusy => "progress_activity",
971        IconRole::StatusCheck => "check",
972        IconRole::StatusError => "error",
973
974        // System
975        IconRole::UserAccount => "person",
976        IconRole::Notification => "notifications",
977        IconRole::Help => "help",
978        IconRole::Lock => "lock",
979
980        _ => return None,
981    })
982}
983
984#[allow(unreachable_patterns)]
985fn lucide_name(role: IconRole) -> Option<&'static str> {
986    Some(match role {
987        // Dialog / Alert
988        IconRole::DialogWarning => "triangle-alert",
989        IconRole::DialogError => "circle-x",
990        IconRole::DialogInfo => "info",
991        IconRole::DialogQuestion => "circle-question-mark",
992        IconRole::DialogSuccess => "circle-check",
993        IconRole::Shield => "shield",
994
995        // Window Controls
996        IconRole::WindowClose => "x",
997        IconRole::WindowMinimize => "minimize",
998        IconRole::WindowMaximize => "maximize",
999        IconRole::WindowRestore => "minimize-2",
1000
1001        // Common Actions
1002        IconRole::ActionSave => "save",
1003        IconRole::ActionDelete => "trash-2",
1004        IconRole::ActionCopy => "copy",
1005        IconRole::ActionPaste => "clipboard-paste",
1006        IconRole::ActionCut => "scissors",
1007        IconRole::ActionUndo => "undo-2",
1008        IconRole::ActionRedo => "redo-2",
1009        IconRole::ActionSearch => "search",
1010        IconRole::ActionSettings => "settings",
1011        IconRole::ActionEdit => "pencil",
1012        IconRole::ActionAdd => "plus",
1013        IconRole::ActionRemove => "minus",
1014        IconRole::ActionRefresh => "refresh-cw",
1015        IconRole::ActionPrint => "printer",
1016
1017        // Navigation
1018        IconRole::NavBack => "chevron-left",
1019        IconRole::NavForward => "chevron-right",
1020        IconRole::NavUp => "chevron-up",
1021        IconRole::NavDown => "chevron-down",
1022        IconRole::NavHome => "house",
1023        IconRole::NavMenu => "menu",
1024
1025        // Files / Places
1026        IconRole::FileGeneric => "file",
1027        IconRole::FolderClosed => "folder-closed",
1028        IconRole::FolderOpen => "folder-open",
1029        IconRole::TrashEmpty => "trash-2",
1030        // same as TrashEmpty -- Lucide has no full-trash variant
1031        IconRole::TrashFull => "trash-2",
1032
1033        // Status
1034        IconRole::StatusBusy => "loader",
1035        IconRole::StatusCheck => "check",
1036        IconRole::StatusError => "circle-x",
1037
1038        // System
1039        IconRole::UserAccount => "user",
1040        IconRole::Notification => "bell",
1041        IconRole::Help => "circle-question-mark",
1042        IconRole::Lock => "lock",
1043
1044        _ => return None,
1045    })
1046}
1047
1048#[cfg(test)]
1049#[allow(clippy::unwrap_used, clippy::expect_used)]
1050mod tests {
1051    use super::*;
1052
1053    // === IconRole tests ===
1054
1055    #[test]
1056    fn icon_role_all_has_42_variants() {
1057        assert_eq!(IconRole::ALL.len(), 42);
1058    }
1059
1060    #[test]
1061    fn icon_role_all_contains_every_variant() {
1062        // Exhaustive match — adding a new IconRole variant without adding it
1063        // here will cause a compile error (missing match arm). This is valid
1064        // within the defining crate despite #[non_exhaustive].
1065        use std::collections::HashSet;
1066        let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1067        let check = |role: IconRole| {
1068            assert!(
1069                all_set.contains(&role),
1070                "IconRole::{role:?} missing from ALL array"
1071            );
1072        };
1073
1074        // Exhaustive match forces compiler to catch new variants:
1075        #[deny(unreachable_patterns)]
1076        match IconRole::DialogWarning {
1077            IconRole::DialogWarning
1078            | IconRole::DialogError
1079            | IconRole::DialogInfo
1080            | IconRole::DialogQuestion
1081            | IconRole::DialogSuccess
1082            | IconRole::Shield
1083            | IconRole::WindowClose
1084            | IconRole::WindowMinimize
1085            | IconRole::WindowMaximize
1086            | IconRole::WindowRestore
1087            | IconRole::ActionSave
1088            | IconRole::ActionDelete
1089            | IconRole::ActionCopy
1090            | IconRole::ActionPaste
1091            | IconRole::ActionCut
1092            | IconRole::ActionUndo
1093            | IconRole::ActionRedo
1094            | IconRole::ActionSearch
1095            | IconRole::ActionSettings
1096            | IconRole::ActionEdit
1097            | IconRole::ActionAdd
1098            | IconRole::ActionRemove
1099            | IconRole::ActionRefresh
1100            | IconRole::ActionPrint
1101            | IconRole::NavBack
1102            | IconRole::NavForward
1103            | IconRole::NavUp
1104            | IconRole::NavDown
1105            | IconRole::NavHome
1106            | IconRole::NavMenu
1107            | IconRole::FileGeneric
1108            | IconRole::FolderClosed
1109            | IconRole::FolderOpen
1110            | IconRole::TrashEmpty
1111            | IconRole::TrashFull
1112            | IconRole::StatusBusy
1113            | IconRole::StatusCheck
1114            | IconRole::StatusError
1115            | IconRole::UserAccount
1116            | IconRole::Notification
1117            | IconRole::Help
1118            | IconRole::Lock => {}
1119        }
1120
1121        // Verify each variant is in ALL:
1122        check(IconRole::DialogWarning);
1123        check(IconRole::DialogError);
1124        check(IconRole::DialogInfo);
1125        check(IconRole::DialogQuestion);
1126        check(IconRole::DialogSuccess);
1127        check(IconRole::Shield);
1128        check(IconRole::WindowClose);
1129        check(IconRole::WindowMinimize);
1130        check(IconRole::WindowMaximize);
1131        check(IconRole::WindowRestore);
1132        check(IconRole::ActionSave);
1133        check(IconRole::ActionDelete);
1134        check(IconRole::ActionCopy);
1135        check(IconRole::ActionPaste);
1136        check(IconRole::ActionCut);
1137        check(IconRole::ActionUndo);
1138        check(IconRole::ActionRedo);
1139        check(IconRole::ActionSearch);
1140        check(IconRole::ActionSettings);
1141        check(IconRole::ActionEdit);
1142        check(IconRole::ActionAdd);
1143        check(IconRole::ActionRemove);
1144        check(IconRole::ActionRefresh);
1145        check(IconRole::ActionPrint);
1146        check(IconRole::NavBack);
1147        check(IconRole::NavForward);
1148        check(IconRole::NavUp);
1149        check(IconRole::NavDown);
1150        check(IconRole::NavHome);
1151        check(IconRole::NavMenu);
1152        check(IconRole::FileGeneric);
1153        check(IconRole::FolderClosed);
1154        check(IconRole::FolderOpen);
1155        check(IconRole::TrashEmpty);
1156        check(IconRole::TrashFull);
1157        check(IconRole::StatusBusy);
1158        check(IconRole::StatusCheck);
1159        check(IconRole::StatusError);
1160        check(IconRole::UserAccount);
1161        check(IconRole::Notification);
1162        check(IconRole::Help);
1163        check(IconRole::Lock);
1164    }
1165
1166    #[test]
1167    fn icon_role_all_no_duplicates() {
1168        let all = &IconRole::ALL;
1169        for (i, role) in all.iter().enumerate() {
1170            for (j, other) in all.iter().enumerate() {
1171                if i != j {
1172                    assert_ne!(role, other, "Duplicate at index {i} and {j}");
1173                }
1174            }
1175        }
1176    }
1177
1178    #[test]
1179    fn icon_role_derives_copy_clone() {
1180        let role = IconRole::ActionCopy;
1181        let copied1 = role;
1182        let copied2 = role;
1183        assert_eq!(role, copied1);
1184        assert_eq!(role, copied2);
1185    }
1186
1187    #[test]
1188    fn icon_role_derives_debug() {
1189        let s = format!("{:?}", IconRole::DialogWarning);
1190        assert!(s.contains("DialogWarning"));
1191    }
1192
1193    #[test]
1194    fn icon_role_derives_hash() {
1195        use std::collections::HashSet;
1196        let mut set = HashSet::new();
1197        set.insert(IconRole::ActionSave);
1198        set.insert(IconRole::ActionDelete);
1199        assert_eq!(set.len(), 2);
1200        assert!(set.contains(&IconRole::ActionSave));
1201    }
1202
1203    // === IconData tests ===
1204
1205    #[test]
1206    fn icon_data_svg_construct_and_match() {
1207        let svg_bytes = b"<svg></svg>".to_vec();
1208        let data = IconData::Svg(svg_bytes.clone());
1209        match data {
1210            IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1211            _ => panic!("Expected Svg variant"),
1212        }
1213    }
1214
1215    #[test]
1216    fn icon_data_rgba_construct_and_match() {
1217        let pixels = vec![255, 0, 0, 255]; // 1 red pixel
1218        let data = IconData::Rgba {
1219            width: 1,
1220            height: 1,
1221            data: pixels.clone(),
1222        };
1223        match data {
1224            IconData::Rgba {
1225                width,
1226                height,
1227                data,
1228            } => {
1229                assert_eq!(width, 1);
1230                assert_eq!(height, 1);
1231                assert_eq!(data, pixels);
1232            }
1233            _ => panic!("Expected Rgba variant"),
1234        }
1235    }
1236
1237    #[test]
1238    fn icon_data_derives_debug() {
1239        let data = IconData::Svg(vec![]);
1240        let s = format!("{:?}", data);
1241        assert!(s.contains("Svg"));
1242    }
1243
1244    #[test]
1245    fn icon_data_derives_clone() {
1246        let data = IconData::Rgba {
1247            width: 16,
1248            height: 16,
1249            data: vec![0; 16 * 16 * 4],
1250        };
1251        let cloned = data.clone();
1252        assert_eq!(data, cloned);
1253    }
1254
1255    #[test]
1256    fn icon_data_derives_eq() {
1257        let a = IconData::Svg(b"<svg/>".to_vec());
1258        let b = IconData::Svg(b"<svg/>".to_vec());
1259        assert_eq!(a, b);
1260
1261        let c = IconData::Svg(b"<other/>".to_vec());
1262        assert_ne!(a, c);
1263    }
1264
1265    // === IconSet tests ===
1266
1267    #[test]
1268    fn icon_set_from_name_sf_symbols() {
1269        assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1270    }
1271
1272    #[test]
1273    fn icon_set_from_name_segoe_fluent() {
1274        assert_eq!(
1275            IconSet::from_name("segoe-fluent"),
1276            Some(IconSet::SegoeIcons)
1277        );
1278    }
1279
1280    #[test]
1281    fn icon_set_from_name_freedesktop() {
1282        assert_eq!(
1283            IconSet::from_name("freedesktop"),
1284            Some(IconSet::Freedesktop)
1285        );
1286    }
1287
1288    #[test]
1289    fn icon_set_from_name_material() {
1290        assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1291    }
1292
1293    #[test]
1294    fn icon_set_from_name_lucide() {
1295        assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1296    }
1297
1298    #[test]
1299    fn icon_set_from_name_unknown() {
1300        assert_eq!(IconSet::from_name("unknown"), None);
1301    }
1302
1303    #[test]
1304    fn icon_set_name_sf_symbols() {
1305        assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1306    }
1307
1308    #[test]
1309    fn icon_set_name_segoe_fluent() {
1310        assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1311    }
1312
1313    #[test]
1314    fn icon_set_name_freedesktop() {
1315        assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1316    }
1317
1318    #[test]
1319    fn icon_set_name_material() {
1320        assert_eq!(IconSet::Material.name(), "material");
1321    }
1322
1323    #[test]
1324    fn icon_set_name_lucide() {
1325        assert_eq!(IconSet::Lucide.name(), "lucide");
1326    }
1327
1328    #[test]
1329    fn icon_set_from_name_name_round_trip() {
1330        let sets = [
1331            IconSet::SfSymbols,
1332            IconSet::SegoeIcons,
1333            IconSet::Freedesktop,
1334            IconSet::Material,
1335            IconSet::Lucide,
1336        ];
1337        for set in &sets {
1338            let name = set.name();
1339            let parsed = IconSet::from_name(name);
1340            assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1341        }
1342    }
1343
1344    #[test]
1345    fn icon_set_derives_copy_clone() {
1346        let set = IconSet::Material;
1347        let copied1 = set;
1348        let copied2 = set;
1349        assert_eq!(set, copied1);
1350        assert_eq!(set, copied2);
1351    }
1352
1353    #[test]
1354    fn icon_set_derives_hash() {
1355        use std::collections::HashSet;
1356        let mut map = HashSet::new();
1357        map.insert(IconSet::SfSymbols);
1358        map.insert(IconSet::Lucide);
1359        assert_eq!(map.len(), 2);
1360    }
1361
1362    #[test]
1363    fn icon_set_derives_debug() {
1364        let s = format!("{:?}", IconSet::Freedesktop);
1365        assert!(s.contains("Freedesktop"));
1366    }
1367
1368    #[test]
1369    fn icon_set_serde_round_trip() {
1370        let set = IconSet::SfSymbols;
1371        let json = serde_json::to_string(&set).unwrap();
1372        let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1373        assert_eq!(set, deserialized);
1374    }
1375
1376    // === icon_name() tests ===
1377
1378    #[test]
1379    fn icon_name_sf_symbols_action_copy() {
1380        assert_eq!(
1381            icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1382            Some("doc.on.doc")
1383        );
1384    }
1385
1386    #[test]
1387    fn icon_name_segoe_action_copy() {
1388        assert_eq!(
1389            icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1390            Some("Copy")
1391        );
1392    }
1393
1394    #[test]
1395    fn icon_name_freedesktop_action_copy() {
1396        assert_eq!(
1397            icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1398            Some("edit-copy")
1399        );
1400    }
1401
1402    #[test]
1403    fn icon_name_material_action_copy() {
1404        assert_eq!(
1405            icon_name(IconRole::ActionCopy, IconSet::Material),
1406            Some("content_copy")
1407        );
1408    }
1409
1410    #[test]
1411    fn icon_name_lucide_action_copy() {
1412        assert_eq!(
1413            icon_name(IconRole::ActionCopy, IconSet::Lucide),
1414            Some("copy")
1415        );
1416    }
1417
1418    #[test]
1419    fn icon_name_sf_symbols_dialog_warning() {
1420        assert_eq!(
1421            icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1422            Some("exclamationmark.triangle.fill")
1423        );
1424    }
1425
1426    // None cases for known gaps
1427    #[test]
1428    fn icon_name_sf_symbols_folder_open_is_none() {
1429        assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1430    }
1431
1432    #[test]
1433    fn icon_name_sf_symbols_trash_full() {
1434        assert_eq!(
1435            icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1436            Some("trash.fill")
1437        );
1438    }
1439
1440    #[test]
1441    fn icon_name_sf_symbols_status_busy_is_none() {
1442        assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1443    }
1444
1445    #[test]
1446    fn icon_name_sf_symbols_window_restore() {
1447        assert_eq!(
1448            icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1449            Some("arrow.down.right.and.arrow.up.left")
1450        );
1451    }
1452
1453    #[test]
1454    fn icon_name_segoe_dialog_success() {
1455        assert_eq!(
1456            icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1457            Some("CheckMark")
1458        );
1459    }
1460
1461    #[test]
1462    fn icon_name_segoe_status_busy_is_none() {
1463        assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1464    }
1465
1466    #[test]
1467    fn icon_name_freedesktop_notification() {
1468        assert_eq!(
1469            icon_name(IconRole::Notification, IconSet::Freedesktop),
1470            Some("notification-active")
1471        );
1472    }
1473
1474    #[test]
1475    fn icon_name_material_trash_full() {
1476        assert_eq!(
1477            icon_name(IconRole::TrashFull, IconSet::Material),
1478            Some("delete")
1479        );
1480    }
1481
1482    #[test]
1483    fn icon_name_lucide_trash_full() {
1484        assert_eq!(
1485            icon_name(IconRole::TrashFull, IconSet::Lucide),
1486            Some("trash-2")
1487        );
1488    }
1489
1490    // Spot-check across all 5 icon sets for multiple roles
1491    #[test]
1492    fn icon_name_spot_check_dialog_error() {
1493        assert_eq!(
1494            icon_name(IconRole::DialogError, IconSet::SfSymbols),
1495            Some("xmark.circle.fill")
1496        );
1497        assert_eq!(
1498            icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1499            Some("SIID_ERROR")
1500        );
1501        assert_eq!(
1502            icon_name(IconRole::DialogError, IconSet::Freedesktop),
1503            Some("dialog-error")
1504        );
1505        assert_eq!(
1506            icon_name(IconRole::DialogError, IconSet::Material),
1507            Some("error")
1508        );
1509        assert_eq!(
1510            icon_name(IconRole::DialogError, IconSet::Lucide),
1511            Some("circle-x")
1512        );
1513    }
1514
1515    #[test]
1516    fn icon_name_spot_check_nav_home() {
1517        assert_eq!(
1518            icon_name(IconRole::NavHome, IconSet::SfSymbols),
1519            Some("house")
1520        );
1521        assert_eq!(
1522            icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1523            Some("Home")
1524        );
1525        assert_eq!(
1526            icon_name(IconRole::NavHome, IconSet::Freedesktop),
1527            Some("go-home")
1528        );
1529        assert_eq!(
1530            icon_name(IconRole::NavHome, IconSet::Material),
1531            Some("home")
1532        );
1533        assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1534    }
1535
1536    // Count test: verify expected Some/None count for each icon set
1537    #[test]
1538    fn icon_name_sf_symbols_expected_count() {
1539        // SF Symbols: 42 - 2 None (FolderOpen, StatusBusy) = 40 Some
1540        let some_count = IconRole::ALL
1541            .iter()
1542            .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1543            .count();
1544        assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1545    }
1546
1547    #[test]
1548    fn icon_name_segoe_expected_count() {
1549        // Segoe: 42 - 1 None (StatusBusy) = 41 Some
1550        let some_count = IconRole::ALL
1551            .iter()
1552            .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1553            .count();
1554        assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1555    }
1556
1557    #[test]
1558    fn icon_name_freedesktop_expected_count() {
1559        // Freedesktop: all 42 roles mapped
1560        let some_count = IconRole::ALL
1561            .iter()
1562            .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1563            .count();
1564        assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1565    }
1566
1567    #[test]
1568    fn icon_name_material_expected_count() {
1569        // Material: all 42 roles mapped
1570        let some_count = IconRole::ALL
1571            .iter()
1572            .filter(|r| icon_name(**r, IconSet::Material).is_some())
1573            .count();
1574        assert_eq!(some_count, 42, "Material should have 42 mappings");
1575    }
1576
1577    #[test]
1578    fn icon_name_lucide_expected_count() {
1579        // Lucide: all 42 roles mapped
1580        let some_count = IconRole::ALL
1581            .iter()
1582            .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1583            .count();
1584        assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1585    }
1586
1587    // === system_icon_set() tests ===
1588
1589    #[test]
1590    #[cfg(target_os = "linux")]
1591    fn system_icon_set_returns_freedesktop_on_linux() {
1592        assert_eq!(system_icon_set(), IconSet::Freedesktop);
1593    }
1594
1595    #[test]
1596    fn system_icon_theme_returns_non_empty() {
1597        let theme = system_icon_theme();
1598        assert!(
1599            !theme.is_empty(),
1600            "system_icon_theme() should return a non-empty string"
1601        );
1602    }
1603
1604    // === IconProvider trait tests ===
1605
1606    #[test]
1607    fn icon_provider_is_object_safe() {
1608        // Box<dyn IconProvider> must compile and be usable
1609        let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1610        let debug_str = format!("{:?}", provider);
1611        assert!(
1612            debug_str.contains("ActionCopy"),
1613            "Debug should print variant name"
1614        );
1615    }
1616
1617    #[test]
1618    fn icon_role_provider_icon_name() {
1619        // IconRole::ActionCopy should return "content_copy" for Material via IconProvider
1620        let role = IconRole::ActionCopy;
1621        let name = IconProvider::icon_name(&role, IconSet::Material);
1622        assert_eq!(name, Some("content_copy"));
1623    }
1624
1625    #[test]
1626    fn icon_role_provider_icon_name_sf_symbols() {
1627        let role = IconRole::ActionCopy;
1628        let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1629        assert_eq!(name, Some("doc.on.doc"));
1630    }
1631
1632    #[test]
1633    #[cfg(feature = "material-icons")]
1634    fn icon_role_provider_icon_svg_material() {
1635        let role = IconRole::ActionCopy;
1636        let svg = IconProvider::icon_svg(&role, IconSet::Material);
1637        assert!(svg.is_some(), "Material SVG should be Some");
1638        let content = std::str::from_utf8(svg.unwrap()).expect("valid UTF-8");
1639        assert!(content.contains("<svg"), "should contain <svg tag");
1640    }
1641
1642    #[test]
1643    fn icon_role_provider_icon_svg_non_bundled() {
1644        // SfSymbols is not a bundled set, so icon_svg should return None
1645        let role = IconRole::ActionCopy;
1646        let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1647        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1648    }
1649
1650    #[test]
1651    fn icon_role_provider_all_roles() {
1652        // All 42 IconRole variants implement IconProvider -- iterate and call icon_name
1653        for role in IconRole::ALL {
1654            // All 42 roles are mapped for Material
1655            let _name = IconProvider::icon_name(&role, IconSet::Material);
1656            // Just verifying it doesn't panic
1657        }
1658    }
1659
1660    #[test]
1661    fn icon_provider_dyn_dispatch() {
1662        // Call icon_name and icon_svg through &dyn IconProvider
1663        let role = IconRole::ActionCopy;
1664        let provider: &dyn IconProvider = &role;
1665        let name = provider.icon_name(IconSet::Material);
1666        assert_eq!(name, Some("content_copy"));
1667        let svg = provider.icon_svg(IconSet::SfSymbols);
1668        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1669    }
1670
1671    // === Coverage tests ===
1672
1673    fn known_gaps() -> &'static [(IconSet, IconRole)] {
1674        &[
1675            (IconSet::SfSymbols, IconRole::FolderOpen),
1676            (IconSet::SfSymbols, IconRole::StatusBusy),
1677            (IconSet::SegoeIcons, IconRole::StatusBusy),
1678        ]
1679    }
1680
1681    #[test]
1682    fn no_unexpected_icon_gaps() {
1683        let gaps = known_gaps();
1684        let system_sets = [
1685            IconSet::SfSymbols,
1686            IconSet::SegoeIcons,
1687            IconSet::Freedesktop,
1688        ];
1689        for &set in &system_sets {
1690            for role in IconRole::ALL {
1691                let is_known_gap = gaps.contains(&(set, role));
1692                let is_mapped = icon_name(role, set).is_some();
1693                if !is_known_gap {
1694                    assert!(
1695                        is_mapped,
1696                        "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1697                    );
1698                }
1699            }
1700        }
1701    }
1702
1703    #[test]
1704    #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1705    fn all_roles_have_bundled_svg() {
1706        use crate::bundled_icon_svg;
1707        for set in [IconSet::Material, IconSet::Lucide] {
1708            for role in IconRole::ALL {
1709                assert!(
1710                    bundled_icon_svg(role, set).is_some(),
1711                    "{role:?} has no bundled SVG for {set:?}"
1712                );
1713            }
1714        }
1715    }
1716
1717    /// Exhaustive icon_name() coverage: Material, Lucide, and Freedesktop map ALL 42,
1718    /// Segoe maps at least 41, SF Symbols maps at least 40.
1719    #[test]
1720    fn icon_name_exhaustive_all_sets() {
1721        // Material: all 42
1722        for role in IconRole::ALL {
1723            assert!(
1724                icon_name(role, IconSet::Material).is_some(),
1725                "Material must map {role:?} to Some"
1726            );
1727        }
1728        // Lucide: all 42
1729        for role in IconRole::ALL {
1730            assert!(
1731                icon_name(role, IconSet::Lucide).is_some(),
1732                "Lucide must map {role:?} to Some"
1733            );
1734        }
1735        // Freedesktop: all 42
1736        for role in IconRole::ALL {
1737            assert!(
1738                icon_name(role, IconSet::Freedesktop).is_some(),
1739                "Freedesktop must map {role:?} to Some"
1740            );
1741        }
1742        // Segoe: at least 41 (StatusBusy is None)
1743        let segoe_count = IconRole::ALL
1744            .iter()
1745            .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1746            .count();
1747        assert!(
1748            segoe_count >= 41,
1749            "Segoe should map at least 41 roles, got {segoe_count}"
1750        );
1751        // SF Symbols: at least 40 (FolderOpen and StatusBusy are None)
1752        let sf_count = IconRole::ALL
1753            .iter()
1754            .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1755            .count();
1756        assert!(
1757            sf_count >= 40,
1758            "SfSymbols should map at least 40 roles, got {sf_count}"
1759        );
1760    }
1761
1762    /// Verify icon_name returns non-empty strings for all mapped roles.
1763    #[test]
1764    fn icon_name_returns_nonempty_strings() {
1765        let all_sets = [
1766            IconSet::SfSymbols,
1767            IconSet::SegoeIcons,
1768            IconSet::Freedesktop,
1769            IconSet::Material,
1770            IconSet::Lucide,
1771        ];
1772        for set in all_sets {
1773            for role in IconRole::ALL {
1774                if let Some(name) = icon_name(role, set) {
1775                    assert!(
1776                        !name.is_empty(),
1777                        "icon_name({role:?}, {set:?}) returned empty string"
1778                    );
1779                }
1780            }
1781        }
1782    }
1783}