Skip to main content

running_process/window_icon/
mod.rs

1//! Public policy for setting the host console/terminal window icon (#577).
2//!
3//! # Capability-reported, never assumed
4//!
5//! Most terminals do not let a running program change their window icon, and
6//! they do not say so — the API call succeeds and nothing happens. Windows
7//! Terminal is the case that matters most: `GetConsoleWindow` returns a real
8//! handle to a hidden pseudo-console window, so `WM_SETICON` succeeds against
9//! a window nobody can see.
10//!
11//! A function that returns `Ok(())` there would be worse than one that fails:
12//! the caller would ship a feature that silently does nothing on the default
13//! terminal of every recent Windows install. So support is *probed* and
14//! reported, and [`set_host_icon`] refuses rather than pretending.
15//!
16//! # What is supported
17//!
18//! Classic Windows console (`conhost.exe`) only, for now. Everything else
19//! reports [`IconSupport::Unsupported`] with a reason. Linux/X11 is a
20//! plausible later addition; macOS Terminal.app and iTerm2, Windows Terminal,
21//! Wayland compositors, and most modern emulators deliberately reserve the
22//! window decoration to themselves, and no in-process API changes that.
23
24use std::path::PathBuf;
25
26use running_process_platform_internal::platform::window_icon as platform_icon;
27
28pub mod ico {
29    pub use running_process_platform_internal::platform::window_icon::ico::*;
30}
31mod osc;
32
33/// Where an icon comes from.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum IconSource {
36    /// Icon file on disk: `.ico` on Windows.
37    Path(PathBuf),
38    /// Raw `.ico` bytes, typically embedded in the binary with
39    /// `include_bytes!` so an application ships its own icon without needing
40    /// a file to exist at runtime.
41    Bytes(Vec<u8>),
42    /// A stock icon the OS already ships, named symbolically.
43    ///
44    /// Nothing to bundle and nothing to decode, which suits the cases these
45    /// exist for — marking a console as a warning or an error surface.
46    /// See [`StockIcon`] for the names.
47    Stock(StockIcon),
48}
49
50/// A stock icon provided by the operating system.
51///
52/// A closed set rather than a free-form string. A name the OS does not know
53/// can only fail at runtime, and a caller has no way to discover which names
54/// are valid; an enum makes the answer a compile error instead. The variants
55/// are the ones with a direct equivalent on every platform this could grow
56/// to, so the set stays meaningful rather than becoming Windows constants
57/// wearing generic names.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum StockIcon {
60    /// The application's own default icon.
61    Application,
62    /// Warning: a hazard the user should notice.
63    Warning,
64    /// Error: something has already gone wrong.
65    Error,
66    /// Information: neutral notice.
67    Information,
68    /// Shield: an elevation or security prompt.
69    Shield,
70}
71
72impl StockIcon {
73    /// The symbolic name sent by the OSC 1 fallback.
74    ///
75    /// freedesktop icon-naming-spec names rather than this crate's variant
76    /// spelling: a terminal or window manager that does anything at all with
77    /// OSC 1 looks the name up in the desktop icon theme, so a bespoke name
78    /// would resolve to nothing on every host.
79    pub fn osc_name(self) -> &'static str {
80        match self {
81            Self::Application => "application-x-executable",
82            Self::Warning => "dialog-warning",
83            Self::Error => "dialog-error",
84            Self::Information => "dialog-information",
85            Self::Shield => "security-high",
86        }
87    }
88}
89
90/// Whether this process can set its host window's icon.
91///
92/// # Capability matrix
93///
94/// | Host | Verdict | Backend |
95/// |---|---|---|
96/// | Windows conhost | `Available` | `WM_SETICON` |
97/// | Windows Terminal | `Degraded` | OSC 1 name only; set the profile's `icon` field for a real image |
98/// | Other Windows emulators | `Degraded` | OSC 1 name only |
99/// | Linux X11 | `Degraded` | OSC 1 name only (`_NET_WM_ICON` not yet implemented) |
100/// | Linux Wayland | `Unsupported` | compositors do not let a client set another window's icon |
101/// | macOS | `Unsupported` | the window belongs to Terminal.app / iTerm2, not to this process |
102/// | No terminal | `Unsupported` | nothing to set an icon on |
103///
104/// An out-of-date row here is a documentation regression: callers decide
105/// whether to ship an icon at all based on this table.
106#[derive(Clone, Debug, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum IconSupport {
109    /// The host window accepts an icon.
110    Available,
111    /// The host accepts only a symbolic *name*, not an image.
112    ///
113    /// Reported rather than folded into `Available` because the difference is
114    /// visible to the user: an OSC 1 name may be shown, ignored, or applied
115    /// to something other than the window icon, and a caller told "yes" that
116    /// then sees nothing change cannot tell a failure from a terminal that
117    /// simply does not do icons.
118    Degraded {
119        /// What will actually happen, and why it is less than asked for.
120        reason: &'static str,
121    },
122    /// It does not, and this is why.
123    ///
124    /// The reason is carried so a caller can log something an operator can
125    /// act on, rather than a bare boolean that invites retrying forever.
126    Unsupported {
127        /// Human-readable explanation.
128        reason: &'static str,
129    },
130}
131
132impl IconSupport {
133    /// Whether a real image icon can be set.
134    ///
135    /// False for [`IconSupport::Degraded`]: a caller choosing whether to embed
136    /// and ship an icon file wants to know whether the file will be used, and
137    /// on a degraded host it will not be.
138    pub fn is_available(&self) -> bool {
139        matches!(self, Self::Available)
140    }
141
142    /// Whether an attempt will do *something*, image or not.
143    ///
144    /// True for both [`IconSupport::Available`] and
145    /// [`IconSupport::Degraded`] — the distinction a caller wants when
146    /// deciding whether to bother calling at all, as opposed to whether to
147    /// ship an image.
148    pub fn is_attemptable(&self) -> bool {
149        !matches!(self, Self::Unsupported { .. })
150    }
151
152    /// The reason support is absent or reduced, if it is.
153    pub fn reason(&self) -> Option<&'static str> {
154        match self {
155            Self::Available => None,
156            Self::Degraded { reason } | Self::Unsupported { reason } => Some(reason),
157        }
158    }
159}
160
161/// Why setting an icon failed.
162#[derive(Debug, thiserror::Error)]
163pub enum IconError {
164    /// The host cannot accept an icon at all.
165    ///
166    /// Distinct from an I/O failure: retrying or supplying a different file
167    /// will not help, and the caller should stop asking.
168    #[error("this host cannot accept a window icon: {reason}")]
169    Unsupported {
170        /// Why the host is unsupported.
171        reason: &'static str,
172    },
173    /// The icon source could not be loaded.
174    #[error("cannot load icon from {path}: {source}")]
175    Load {
176        /// Path that failed to load.
177        path: PathBuf,
178        /// Underlying OS error.
179        #[source]
180        source: std::io::Error,
181    },
182    /// The OS refused to build an icon from otherwise well-formed data.
183    ///
184    /// Removed in #720 because nothing constructed it; reinstated here
185    /// because `CreateIconFromResourceEx` can fail on data this crate has
186    /// already validated the shape of — the image itself may still be
187    /// something the OS will not decode.
188    #[error("the system refused the icon data: {0}")]
189    Apply(#[source] std::io::Error),
190    /// The host accepts only a symbolic name, and this source is not one.
191    ///
192    /// Distinct from [`IconError::Unsupported`]: the host *would* accept a
193    /// stock icon, so the remedy is to pass one rather than to give up.
194    #[error("this host accepts only a stock icon name, not an image file or bytes: {reason}")]
195    DegradedSourceUnsupported {
196        /// What the host will and will not accept.
197        reason: &'static str,
198    },
199    /// The supplied bytes are not a usable icon.
200    ///
201    /// Separate from [`IconError::Load`] because the remedy differs: a bad
202    /// path is fixed by pointing somewhere else, malformed bytes by fixing
203    /// what was embedded.
204    #[error("supplied icon data is unusable: {0}")]
205    Decode(ico::IcoError),
206}
207
208/// Which window an icon operation targets.
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub enum IconScope {
211    /// This process's own host console window.
212    Host,
213    /// A child process's console window.
214    ///
215    /// Only meaningful when the child was given its own console
216    /// (`CREATE_NEW_CONSOLE` on Windows). A child that inherited ours shares
217    /// the same window, so targeting it changes this process's icon too —
218    /// that is inherent to sharing a console, not a failure, and
219    /// [`icon_support`] reports it as available because the icon really does
220    /// change.
221    Child {
222        /// Process id of the child.
223        pid: u32,
224    },
225}
226
227fn platform_scope(scope: IconScope) -> platform_icon::IconScope {
228    match scope {
229        IconScope::Host => platform_icon::IconScope::Host,
230        IconScope::Child { pid } => platform_icon::IconScope::Child { pid },
231    }
232}
233
234fn platform_stock(stock: StockIcon) -> platform_icon::StockIcon {
235    match stock {
236        StockIcon::Application => platform_icon::StockIcon::Application,
237        StockIcon::Warning => platform_icon::StockIcon::Warning,
238        StockIcon::Error => platform_icon::StockIcon::Error,
239        StockIcon::Information => platform_icon::StockIcon::Information,
240        StockIcon::Shield => platform_icon::StockIcon::Shield,
241    }
242}
243
244fn platform_source(source: &IconSource) -> platform_icon::IconSource {
245    match source {
246        IconSource::Path(path) => platform_icon::IconSource::Path(path.clone()),
247        IconSource::Bytes(bytes) => platform_icon::IconSource::Bytes(bytes.clone()),
248        IconSource::Stock(stock) => platform_icon::IconSource::Stock(platform_stock(*stock)),
249    }
250}
251
252fn degraded_reason(reason: platform_icon::IconDegradedReason) -> &'static str {
253    match reason {
254        platform_icon::IconDegradedReason::WindowsTerminal => {
255            "Windows Terminal owns its window decoration and ignores WM_SETICON. Set the `icon` field on the WT profile for a real image; a stock name can still be sent via OSC 1"
256        }
257        platform_icon::IconDegradedReason::NonClassicWindowsHost => {
258            "the host is not the classic console (conhost). Modern emulators own their window decoration and ignore WM_SETICON; a stock name can still be sent via OSC 1"
259        }
260        platform_icon::IconDegradedReason::LinuxNameOnly => {
261            "WINDOWID is not set, so the terminal's X window cannot be identified; a stock name can still be sent via OSC 1"
262        }
263    }
264}
265
266fn unsupported_reason(reason: platform_icon::IconUnsupportedReason) -> &'static str {
267    match reason {
268        platform_icon::IconUnsupportedReason::ChildHasNoConsole => {
269            "that process has no console window of its own (it may share this one, have been created without a window, or have exited)"
270        }
271        platform_icon::IconUnsupportedReason::NoConsole => {
272            "this process has no console window (detached, or output is redirected from a windowless host)"
273        }
274        platform_icon::IconUnsupportedReason::MacTerminalOwnsWindow => {
275            "on macOS the window belongs to Terminal.app or iTerm2, not to this process; set the icon on the terminal application's own bundle"
276        }
277        platform_icon::IconUnsupportedReason::Wayland => {
278            "Wayland compositors do not let a client change another window's icon; set it in the terminal emulator's .desktop file"
279        }
280        platform_icon::IconUnsupportedReason::NoBackend => {
281            "no window-icon backend exists for this platform"
282        }
283        platform_icon::IconUnsupportedReason::LinuxChildScope => {
284            "X11 cannot identify another process's terminal window; WINDOWID names only this process's own host"
285        }
286        platform_icon::IconUnsupportedReason::LinuxNoDisplay => {
287            "no display server is attached (no DISPLAY or WAYLAND_DISPLAY), so there is no window to set an icon on"
288        }
289        platform_icon::IconUnsupportedReason::TargetDisappeared => {
290            "the target window disappeared between the support probe and the call"
291        }
292        platform_icon::IconUnsupportedReason::UnknownImageFormat => {
293            "the X11 backend accepts PNG data (or a .ico whose largest image is a PNG)"
294        }
295        platform_icon::IconUnsupportedReason::StockNeedsPixels => {
296            "stock icons are theme names, not images; X11 needs pixels. Pass a PNG, or let the OSC 1 fallback send the name"
297        }
298        platform_icon::IconUnsupportedReason::OversizedIcon => {
299            "icon is larger than 512x512; window managers scale down from far smaller"
300        }
301        platform_icon::IconUnsupportedReason::UnsupportedPngColorType => {
302            "the X11 backend needs an RGB or RGBA PNG; convert palette or grayscale images first"
303        }
304        platform_icon::IconUnsupportedReason::UnsupportedPngBitDepth => {
305            "the X11 backend needs an 8-bit PNG"
306        }
307        platform_icon::IconUnsupportedReason::UnsupportedX11VisualDepth => {
308            "the X11 visual depth cannot represent the requested icon"
309        }
310    }
311}
312
313fn map_platform_error(error: platform_icon::IconError) -> IconError {
314    match error {
315        platform_icon::IconError::Unsupported(reason) => IconError::Unsupported {
316            reason: unsupported_reason(reason),
317        },
318        platform_icon::IconError::Load { path, source } => IconError::Load { path, source },
319        platform_icon::IconError::Apply(source) => IconError::Apply(source),
320        platform_icon::IconError::Decode(source) => IconError::Decode(source),
321    }
322}
323
324/// Whether a window can accept an icon.
325///
326/// Cheap, and safe to call before deciding whether to ship an icon at all.
327pub fn icon_support(scope: IconScope) -> IconSupport {
328    match platform_icon::icon_support(platform_scope(scope)) {
329        platform_icon::IconSupport::Available => IconSupport::Available,
330        platform_icon::IconSupport::Degraded(reason) => IconSupport::Degraded {
331            reason: degraded_reason(reason),
332        },
333        platform_icon::IconSupport::Unsupported(reason) => IconSupport::Unsupported {
334            reason: unsupported_reason(reason),
335        },
336    }
337}
338
339/// Whether this process's host window can accept an icon.
340pub fn host_icon_support() -> IconSupport {
341    icon_support(IconScope::Host)
342}
343
344/// Set the icon on this process's host console window.
345///
346/// Returns [`IconError::Unsupported`] when the host does not accept icons,
347/// rather than succeeding without effect.
348pub fn set_host_icon(source: &IconSource) -> Result<(), IconError> {
349    set_icon(IconScope::Host, source)
350}
351
352/// Set the icon on the window named by `scope`.
353///
354/// Returns [`IconError::Unsupported`] when that window does not accept icons,
355/// rather than succeeding without effect.
356pub fn set_icon(scope: IconScope, source: &IconSource) -> Result<(), IconError> {
357    set_icon_given(icon_support(scope), scope, source)
358}
359
360/// [`set_host_icon`] with the support verdict supplied.
361///
362/// Split out so the refusal path is testable on every platform without
363/// depending on whether the machine running the tests happens to have a
364/// console window. A test that only exercises the refusal when the ambient
365/// host is unsupported silently checks nothing everywhere else.
366#[cfg(test)]
367fn set_host_icon_given(support: IconSupport, source: &IconSource) -> Result<(), IconError> {
368    set_icon_given(support, IconScope::Host, source)
369}
370
371fn set_icon_given(
372    support: IconSupport,
373    scope: IconScope,
374    source: &IconSource,
375) -> Result<(), IconError> {
376    match support {
377        IconSupport::Available => {
378            platform_icon::set_icon(platform_scope(scope), &platform_source(source))
379                .map_err(map_platform_error)
380        }
381        // Only a stock name has anything to send. A file or a byte blob would
382        // mean inventing a name the caller never chose, and OSC 1 carries a
383        // name rather than an image.
384        IconSupport::Degraded { reason } => match source {
385            IconSource::Stock(icon) => osc::emit(icon.osc_name()).map_err(IconError::Apply),
386            _ => Err(IconError::DegradedSourceUnsupported { reason }),
387        },
388        IconSupport::Unsupported { reason } => Err(IconError::Unsupported { reason }),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    /// The probe must answer without panicking wherever it runs — including
397    /// CI, which has no console window at all.
398    #[test]
399    fn support_is_reportable_everywhere() {
400        let support = host_icon_support();
401        // Whichever answer, it must be self-describing: an unsupported result
402        // without a reason would leave a caller with nothing to log.
403        match &support {
404            IconSupport::Available => assert_eq!(support.reason(), None),
405            IconSupport::Degraded { reason } | IconSupport::Unsupported { reason } => {
406                assert!(!reason.is_empty(), "a reduced verdict must explain itself");
407                assert_eq!(support.reason(), Some(*reason));
408            }
409        }
410    }
411
412    #[test]
413    fn a_degraded_host_is_attemptable_but_not_available() {
414        // The distinction a caller acts on: `is_available` decides whether
415        // to embed and ship an icon file, `is_attemptable` decides whether
416        // to bother calling at all.
417        let degraded = IconSupport::Degraded {
418            reason: "name only",
419        };
420        assert!(!degraded.is_available());
421        assert!(degraded.is_attemptable());
422        assert_eq!(degraded.reason(), Some("name only"));
423
424        assert!(IconSupport::Available.is_attemptable());
425        assert!(!IconSupport::Unsupported { reason: "no" }.is_attemptable());
426    }
427
428    #[test]
429    fn a_degraded_host_accepts_a_stock_name_and_refuses_an_image() {
430        // OSC 1 carries a name, not an image. Accepting a file here would
431        // mean inventing a name the caller never chose.
432        let degraded = IconSupport::Degraded {
433            reason: "name only",
434        };
435        let refused = set_host_icon_given(
436            degraded.clone(),
437            &IconSource::Path(PathBuf::from("some.ico")),
438        )
439        .expect_err("an image must be refused on a name-only host");
440        match refused {
441            IconError::DegradedSourceUnsupported { reason } => {
442                assert_eq!(reason, "name only");
443            }
444            other => panic!("expected DegradedSourceUnsupported, got {other:?}"),
445        }
446
447        // And it is distinct from Unsupported, because the remedy differs:
448        // pass a stock icon rather than give up.
449        let unsupported = set_host_icon_given(
450            IconSupport::Unsupported {
451                reason: "none at all",
452            },
453            &IconSource::Stock(StockIcon::Shield),
454        )
455        .expect_err("an unsupported host refuses everything");
456        assert!(matches!(unsupported, IconError::Unsupported { .. }));
457    }
458
459    #[test]
460    fn every_stock_icon_maps_to_a_freedesktop_name() {
461        // A bespoke name would resolve to nothing in any desktop icon
462        // theme, which is the only place an OSC 1 name gets looked up.
463        for icon in [
464            StockIcon::Application,
465            StockIcon::Warning,
466            StockIcon::Error,
467            StockIcon::Information,
468            StockIcon::Shield,
469        ] {
470            let name = icon.osc_name();
471            assert!(!name.is_empty(), "{icon:?} has no OSC name");
472            assert!(
473                name.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
474                "{icon:?} -> {name:?} is not a freedesktop-style name"
475            );
476        }
477    }
478
479    #[test]
480    fn stock_names_are_distinct() {
481        // Two icons sharing a name would silently show the wrong one.
482        let names: std::collections::BTreeSet<&str> = [
483            StockIcon::Application,
484            StockIcon::Warning,
485            StockIcon::Error,
486            StockIcon::Information,
487            StockIcon::Shield,
488        ]
489        .into_iter()
490        .map(StockIcon::osc_name)
491        .collect();
492        assert_eq!(names.len(), 5);
493    }
494    #[test]
495    fn availability_and_reason_are_consistent() {
496        assert!(IconSupport::Available.is_available());
497        assert!(IconSupport::Available.reason().is_none());
498
499        let no = IconSupport::Unsupported { reason: "because" };
500        assert!(!no.is_available());
501        assert_eq!(no.reason(), Some("because"));
502    }
503
504    /// An unsupported host must refuse rather than report success.
505    ///
506    /// This is the whole point of the module: a caller that gets `Ok(())`
507    /// would ship a feature that silently does nothing on the default
508    /// terminal of every recent Windows install.
509    ///
510    /// The verdict is injected rather than probed, so this runs the refusal
511    /// on every platform. Probing would make the test a no-op wherever the
512    /// ambient host happens to be supported.
513    #[test]
514    fn an_unsupported_host_refuses_instead_of_pretending() {
515        let error = set_host_icon_given(
516            IconSupport::Unsupported {
517                reason: "test verdict",
518            },
519            &IconSource::Path("anything.ico".into()),
520        )
521        .expect_err("an unsupported host must not report success");
522
523        match error {
524            IconError::Unsupported { reason } => assert_eq!(reason, "test verdict"),
525            other => panic!("expected Unsupported, got {other}"),
526        }
527    }
528
529    /// The refusal must not depend on the icon existing: an unsupported host
530    /// is unsupported whatever it is handed.
531    #[test]
532    fn refusal_precedes_loading_the_icon() {
533        let error = set_host_icon_given(
534            IconSupport::Unsupported { reason: "nope" },
535            &IconSource::Path("definitely-does-not-exist.ico".into()),
536        )
537        .expect_err("must refuse");
538        assert!(
539            matches!(error, IconError::Unsupported { .. }),
540            "a missing file must not mask the unsupported verdict; got {error}"
541        );
542    }
543
544    /// A pid that owns no console window must be refused, with a reason.
545    ///
546    /// Platform-neutral: off Windows the whole feature is unavailable and
547    /// says so, which is a different sentence but the same contract. An
548    /// earlier version asserted the Windows wording here and failed on the
549    /// musl and coverage lanes — the assertion was Windows-specific while the
550    /// test was not.
551    #[test]
552    fn a_process_with_no_console_window_is_unsupported() {
553        // pid 0 is the system idle process and never owns a console window,
554        // so this is stable across machines and needs no fixture.
555        let support = icon_support(IconScope::Child { pid: 0 });
556        assert!(!support.is_available());
557        assert!(
558            !support.reason().expect("must explain itself").is_empty(),
559            "an unsupported result must carry a usable reason"
560        );
561    }
562
563    /// Looking up our OWN pid must find the same window the host scope does.
564    ///
565    /// This is the deterministic test of the pid lookup: no spawning, no
566    /// waiting, no session-wide state. Whenever this process has a console
567    /// window, `Child { pid: self }` names that very window, so the two
568    /// scopes must agree — and a broken `console_window_of_pid` makes them
569    /// disagree immediately.
570    ///
571    /// Where there is no console window both are unsupported, which is also
572    /// agreement, so the assertion holds on every machine.
573    #[test]
574    fn own_pid_resolves_to_the_host_console_window() {
575        let host = icon_support(IconScope::Host);
576        let own = icon_support(IconScope::Child {
577            pid: std::process::id(),
578        });
579        assert_eq!(
580            host.is_available(),
581            own.is_available(),
582            "host scope says {host:?} but our own pid says {own:?}; the pid lookup              disagrees with the direct console-window lookup"
583        );
584    }
585
586    /// And the setter refuses rather than silently doing nothing.
587    #[test]
588    fn setting_a_childless_pid_is_an_error() {
589        let error = set_icon(
590            IconScope::Child { pid: 0 },
591            &IconSource::Stock(StockIcon::Warning),
592        )
593        .expect_err("a pid with no console cannot take an icon");
594        assert!(
595            matches!(error, IconError::Unsupported { .. }),
596            "expected Unsupported, got {error}"
597        );
598    }
599
600    /// An exited process cannot be targeted either — same answer, so a caller
601    /// does not have to distinguish "never had one" from "gone".
602    #[test]
603    fn an_implausible_pid_is_unsupported() {
604        let support = icon_support(IconScope::Child { pid: u32::MAX });
605        assert!(!support.is_available());
606    }
607
608    /// Host scope must keep answering exactly as before: the scope-aware
609    /// entry point is a generalisation, not a behaviour change.
610    #[test]
611    fn host_scope_agrees_with_the_host_specific_helper() {
612        assert_eq!(icon_support(IconScope::Host), host_icon_support());
613    }
614
615    #[test]
616    fn scopes_are_distinguishable() {
617        assert_ne!(IconScope::Host, IconScope::Child { pid: 1 });
618        assert_ne!(IconScope::Child { pid: 1 }, IconScope::Child { pid: 2 });
619        assert_eq!(IconScope::Child { pid: 7 }, IconScope::Child { pid: 7 });
620    }
621
622    /// A stock icon needs no data, so the only thing that can go wrong is
623    /// the host — never a decode.
624    ///
625    /// Runs everywhere by forcing the verdict, so the enum-to-OS mapping is
626    /// exercised on platforms with no console window at all.
627    #[test]
628    fn every_stock_icon_is_requestable() {
629        for stock in [
630            StockIcon::Application,
631            StockIcon::Warning,
632            StockIcon::Error,
633            StockIcon::Information,
634            StockIcon::Shield,
635        ] {
636            let result = set_host_icon_given(IconSupport::Available, &IconSource::Stock(stock));
637            match result {
638                // On a host with a real console window the icon is set.
639                Ok(()) => {}
640                // Without one, the refusal comes from the window lookup — not
641                // from the icon, which is the point: a stock icon is never a
642                // decode failure.
643                Err(IconError::Unsupported { .. }) => {}
644                Err(other) => panic!("{stock:?} failed for a reason other than the host: {other}"),
645            }
646        }
647    }
648
649    /// A stock request must never be reported as bad data.
650    #[test]
651    fn a_stock_icon_is_never_a_decode_error() {
652        let result = set_host_icon_given(
653            IconSupport::Available,
654            &IconSource::Stock(StockIcon::Warning),
655        );
656        if let Err(error) = result {
657            assert!(
658                !matches!(error, IconError::Decode(_)),
659                "a stock icon carries no data to decode, got {error}"
660            );
661        }
662    }
663
664    /// Distinct variants must not collapse onto one another.
665    #[test]
666    fn stock_variants_are_distinguishable() {
667        assert_ne!(StockIcon::Warning, StockIcon::Error);
668        assert_ne!(StockIcon::Application, StockIcon::Shield);
669        assert_eq!(StockIcon::Information, StockIcon::Information);
670    }
671
672    /// Malformed bytes must be refused before the OS sees them.
673    ///
674    /// Runs everywhere by forcing the verdict, because the decode happens
675    /// before any window is touched — so this covers the validation on
676    /// platforms that have no console window at all.
677    #[test]
678    fn malformed_icon_bytes_are_refused() {
679        let result =
680            set_host_icon_given(IconSupport::Available, &IconSource::Bytes(vec![0xFF; 64]));
681        let error = result.expect_err("garbage is not an icon");
682        assert!(
683            matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
684            "expected a refusal before the OS was handed anything, got {error}"
685        );
686    }
687
688    #[test]
689    fn empty_icon_bytes_are_refused() {
690        let error = set_host_icon_given(IconSupport::Available, &IconSource::Bytes(Vec::new()))
691            .expect_err("empty data is not an icon");
692        assert!(
693            matches!(error, IconError::Decode(_) | IconError::Unsupported { .. }),
694            "got {error}"
695        );
696    }
697
698    /// A missing file must be a load error, not a silent success.
699    ///
700    /// Reaching the load path needs a real conhost window, which a CI runner
701    /// does not have. Rather than skip invisibly, the verdict is forced to
702    /// `Available` so the load path runs everywhere: with no console window
703    /// `imp::set_host_icon` returns `Unsupported`, and with one it returns
704    /// `Load`. Both are refusals — what must never happen is `Ok`.
705    #[test]
706    fn a_missing_icon_file_never_reports_success() {
707        let result = set_host_icon_given(
708            IconSupport::Available,
709            &IconSource::Path("no-such-icon-file.ico".into()),
710        );
711        let error = result.expect_err("a missing file cannot produce a set icon");
712        assert!(
713            matches!(
714                error,
715                IconError::Load { .. } | IconError::Unsupported { .. }
716            ),
717            "expected a refusal, got {error}"
718        );
719    }
720}