Skip to main content

rich_ext/
capabilities.rs

1//! Terminal capability detection with provenance. No probes: every answer is
2//! derived from environment variables and tty facts supplied through an
3//! [`Environment`], so detection is deterministic under a [`MapEnvironment`].
4//!
5//! # Detection rules
6//!
7//! Each field of a [`Report`] is decided by the first rule that matches, in
8//! this order: an explicit [`Overrides`] value, a `RICH_*` override variable,
9//! then the heuristics below.
10//!
11//! * **colour** — non-empty `NO_COLOR` → none; `FORCE_COLOR` (`0`/`false` →
12//!   none, `1` → 16, `2` → 256, `3` → truecolor, other values → at least 16,
13//!   ignoring the tty check); not a terminal → none, unless a CI provider whose
14//!   log viewer renders ANSI is detected (GitHub/Gitea Actions → truecolor;
15//!   GitLab, Buildkite, CircleCI, Travis, AppVeyor, Drone, Azure Pipelines,
16//!   TeamCity → 16, after the `supports-color` package's table); `TERM=dumb` →
17//!   none; `COLORTERM=truecolor|24bit` → truecolor; `WT_SESSION` (Windows
18//!   Terminal), kitty, `TERM_PROGRAM` iTerm.app/WezTerm/vscode/ghostty or a
19//!   `TERM` naming `direct`/`truecolor` → truecolor; `TERM_PROGRAM=Apple_Terminal`
20//!   or a `TERM` containing `256` → 256; Windows → truecolor (as core does);
21//!   anything else → 16.
22//! * **unicode** — the first non-empty of `LC_ALL`, `LC_CTYPE`, `LANG`: UTF-8 →
23//!   yes, `C`/`POSIX` → yes (Python coerces the C locale to UTF-8, which is
24//!   what upstream rich then sees), any other explicit charset → no;
25//!   `WT_SESSION` → yes; otherwise yes by default, matching core's
26//!   `ascii_only(false)` default.
27//! * **hyperlinks** (OSC 8) — only for terminals known to support them and only
28//!   on a tty: iTerm2, WezTerm, kitty, Windows Terminal, VS Code, ghostty, foot,
29//!   contour, DomTerm and VTE ≥ 0.50 (`VTE_VERSION` ≥ 5000: GNOME Terminal,
30//!   Tilix, Terminator…). Inside tmux/screen, on `TERM=dumb` or anywhere else:
31//!   no. Alacritty (≥ 0.11) supports them but does not publish its version, so
32//!   it is left out on purpose — override with `RICH_HYPERLINKS=1`.
33//! * **graphics** — on a tty only: kitty (`TERM=xterm-kitty` or
34//!   `KITTY_WINDOW_ID`) → kitty; `TERM_PROGRAM` iTerm.app or WezTerm → iTerm
35//!   inline images; else the Sixel heuristic → sixel. **sixel** is reported
36//!   separately (WezTerm speaks both): `WT_SESSION`, a `TERM` containing
37//!   `sixel`/`mlterm`/`foot`, or `TERM_PROGRAM` wezterm/mintty. This is the
38//!   same heuristic `rich_art::sixel::is_probably_supported` uses, which can
39//!   later call into this module instead.
40//! * **width / height** — `RICH_WIDTH`/`RICH_HEIGHT`, then `COLUMNS`/`LINES`,
41//!   then the terminal's reported size, then 80×25.
42//! * **interactive** — stdout is a terminal.
43//! * **animation** — interactive, not CI, not `TERM=dumb`, and no
44//!   reduced-motion preference (`RICH_A11Y` containing `reduced-motion`,
45//!   `no-animation` or `screen-reader`).
46//!
47//! # Override variables
48//!
49//! `RICH_COLOR=none|16|256|truecolor`, `RICH_UNICODE=0|1`,
50//! `RICH_HYPERLINKS=0|1`, `RICH_GRAPHICS=none|sixel|kitty|iterm`,
51//! `RICH_SIXEL=0|1` (the CLI's existing variable, kept as an alias: `1` means
52//! sixel graphics, `0` means no sixel), `RICH_ANIMATION=0|1`, `RICH_WIDTH`,
53//! `RICH_HEIGHT`. Booleans also accept `true/false/yes/no/on/off`. Invalid values
54//! are ignored and listed in [`Report::warnings`].
55
56use crate::target::{CapabilityOrigin, DetectedCapabilities, TargetObservations};
57use rich::protocol::{Support, TargetCapabilities};
58use rich::{ColorSystem, Console, ConsoleOptions, Renderable, Segment, Table, Text};
59use std::collections::BTreeMap;
60use std::io::IsTerminal;
61
62/// Environment facts detection reads. Implement it to feed detection from
63/// anywhere; [`SystemEnvironment`] reads the process, [`MapEnvironment`] a map.
64pub trait Environment {
65    /// An environment variable, `None` when unset or not Unicode.
66    fn var(&self, name: &str) -> Option<String>;
67    /// Whether stdout is a terminal.
68    fn is_terminal(&self) -> bool;
69    /// The terminal size in cells `(width, height)`, when known.
70    fn size(&self) -> Option<(usize, usize)>;
71    /// Whether this is Windows (core assumes truecolor there).
72    fn is_windows(&self) -> bool {
73        false
74    }
75    /// The size of one cell in pixels `(width, height)`, when the terminal
76    /// reports its window size in pixels. Used by
77    /// [`GraphicsEnvironment`](crate::graphics::GraphicsEnvironment).
78    fn cell_pixels(&self) -> Option<(u16, u16)> {
79        None
80    }
81}
82
83/// The real process environment: `std::env`, stdout's tty status and the
84/// size core's console detects.
85#[derive(Clone, Copy, Debug, Default)]
86pub struct SystemEnvironment;
87
88impl Environment for SystemEnvironment {
89    fn var(&self, name: &str) -> Option<String> {
90        std::env::var(name).ok()
91    }
92    fn is_terminal(&self) -> bool {
93        std::io::stdout().is_terminal()
94    }
95    fn size(&self) -> Option<(usize, usize)> {
96        // Core falls back to 80×25 when the size is unknown; only a terminal
97        // has a size worth reporting.
98        if !self.is_terminal() {
99            return None;
100        }
101        let console = Console::builder().force_terminal(true).build();
102        Some((console.width(), console.height()))
103    }
104    fn is_windows(&self) -> bool {
105        cfg!(windows)
106    }
107    fn cell_pixels(&self) -> Option<(u16, u16)> {
108        if !self.is_terminal() {
109            return None;
110        }
111        let (cells, pixels) = crate::graphics::stdout_window()?;
112        crate::graphics::CellPixels::from_window(pixels, cells).map(|c| (c.width, c.height))
113    }
114}
115
116/// A deterministic environment for tests and CI.
117#[derive(Clone, Debug, Default)]
118pub struct MapEnvironment {
119    pub vars: BTreeMap<String, String>,
120    pub terminal: bool,
121    pub size: Option<(usize, usize)>,
122    pub windows: bool,
123    /// The cell size in pixels the terminal reports, if any.
124    pub cell_pixels: Option<(u16, u16)>,
125}
126
127impl MapEnvironment {
128    /// An empty, non-terminal environment.
129    pub fn new() -> Self {
130        Self::default()
131    }
132    /// An empty terminal environment.
133    pub fn tty() -> Self {
134        Self {
135            terminal: true,
136            ..Self::default()
137        }
138    }
139    /// Set a variable.
140    #[must_use]
141    pub fn var(mut self, name: &str, value: &str) -> Self {
142        self.vars.insert(name.into(), value.into());
143        self
144    }
145    /// Set the terminal flag.
146    #[must_use]
147    pub fn terminal(mut self, terminal: bool) -> Self {
148        self.terminal = terminal;
149        self
150    }
151    /// Set the reported size.
152    #[must_use]
153    pub fn size(mut self, width: usize, height: usize) -> Self {
154        self.size = Some((width, height));
155        self
156    }
157    /// Mark the environment as Windows.
158    #[must_use]
159    pub fn windows(mut self, windows: bool) -> Self {
160        self.windows = windows;
161        self
162    }
163    /// Set the cell size in pixels the terminal reports.
164    #[must_use]
165    pub fn cell_pixels(mut self, width: u16, height: u16) -> Self {
166        self.cell_pixels = Some((width, height));
167        self
168    }
169}
170
171impl Environment for MapEnvironment {
172    fn var(&self, name: &str) -> Option<String> {
173        self.vars.get(name).cloned()
174    }
175    fn is_terminal(&self) -> bool {
176        self.terminal
177    }
178    fn size(&self) -> Option<(usize, usize)> {
179        self.size
180    }
181    fn is_windows(&self) -> bool {
182        self.windows
183    }
184    fn cell_pixels(&self) -> Option<(u16, u16)> {
185        self.cell_pixels
186    }
187}
188
189/// Where a capability value came from.
190#[derive(Clone, Debug, PartialEq, Eq, Hash)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize))]
192#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
193pub enum Origin {
194    /// An explicit [`Overrides`] value.
195    Override,
196    /// An environment variable, named.
197    Environment(String),
198    /// Nothing said anything; the documented default.
199    Default,
200    /// Derived from other facts (tty status, terminal identity, platform).
201    Inferred,
202}
203
204impl std::fmt::Display for Origin {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        match self {
207            Origin::Override => f.write_str("override"),
208            Origin::Environment(name) => write!(f, "environment ({name})"),
209            Origin::Default => f.write_str("default"),
210            Origin::Inferred => f.write_str("inferred"),
211        }
212    }
213}
214
215/// A value with its provenance and a human reason (`"COLORTERM=truecolor"`).
216#[derive(Clone, Debug, PartialEq, Eq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218pub struct Field<T> {
219    pub value: T,
220    pub origin: Origin,
221    pub reason: String,
222}
223
224impl<T> Field<T> {
225    /// A value, where it came from, and why.
226    pub fn new(value: T, origin: Origin, reason: impl Into<String>) -> Self {
227        Self {
228            value,
229            origin,
230            reason: reason.into(),
231        }
232    }
233}
234
235/// Colour depth, ordered from none to truecolor.
236#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
237#[cfg_attr(feature = "serde", derive(serde::Serialize))]
238#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
239pub enum ColorDepth {
240    None,
241    Ansi16,
242    Ansi256,
243    TrueColor,
244}
245
246impl ColorDepth {
247    /// The core colour system, `None` for no colour.
248    pub fn color_system(self) -> Option<ColorSystem> {
249        match self {
250            ColorDepth::None => None,
251            ColorDepth::Ansi16 => Some(ColorSystem::Standard),
252            ColorDepth::Ansi256 => Some(ColorSystem::EightBit),
253            ColorDepth::TrueColor => Some(ColorSystem::Truecolor),
254        }
255    }
256    /// The depth of a core colour system.
257    pub fn from_color_system(system: Option<ColorSystem>) -> Self {
258        match system {
259            None => ColorDepth::None,
260            Some(ColorSystem::Standard | ColorSystem::Windows) => ColorDepth::Ansi16,
261            Some(ColorSystem::EightBit) => ColorDepth::Ansi256,
262            Some(ColorSystem::Truecolor) => ColorDepth::TrueColor,
263        }
264    }
265    /// Parse `none|16|256|truecolor` (also `0`, `24bit`, `8bit`).
266    pub fn parse(value: &str) -> Option<Self> {
267        match value.trim().to_ascii_lowercase().as_str() {
268            "none" | "0" | "no" | "off" | "false" => Some(ColorDepth::None),
269            "16" | "standard" | "ansi" => Some(ColorDepth::Ansi16),
270            "256" | "8bit" | "eightbit" => Some(ColorDepth::Ansi256),
271            "truecolor" | "24bit" | "16m" => Some(ColorDepth::TrueColor),
272            _ => None,
273        }
274    }
275    /// `none`, `16`, `256` or `truecolor`.
276    pub fn name(self) -> &'static str {
277        match self {
278            ColorDepth::None => "none",
279            ColorDepth::Ansi16 => "16",
280            ColorDepth::Ansi256 => "256",
281            ColorDepth::TrueColor => "truecolor",
282        }
283    }
284}
285
286/// An inline graphics protocol.
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
288#[cfg_attr(feature = "serde", derive(serde::Serialize))]
289#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
290pub enum Graphics {
291    None,
292    Sixel,
293    Kitty,
294    Iterm,
295}
296
297impl Graphics {
298    /// Parse `none|sixel|kitty|iterm`.
299    pub fn parse(value: &str) -> Option<Self> {
300        match value.trim().to_ascii_lowercase().as_str() {
301            "none" | "0" | "no" | "off" | "false" => Some(Graphics::None),
302            "sixel" => Some(Graphics::Sixel),
303            "kitty" => Some(Graphics::Kitty),
304            "iterm" | "iterm2" => Some(Graphics::Iterm),
305            _ => None,
306        }
307    }
308    /// `none`, `sixel`, `kitty` or `iterm`.
309    pub fn name(self) -> &'static str {
310        match self {
311            Graphics::None => "none",
312            Graphics::Sixel => "sixel",
313            Graphics::Kitty => "kitty",
314            Graphics::Iterm => "iterm",
315        }
316    }
317}
318
319/// Caller overrides, applied last with [`Origin::Override`].
320#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
321pub struct Overrides {
322    pub color: Option<ColorDepth>,
323    pub unicode: Option<bool>,
324    pub hyperlinks: Option<bool>,
325    pub graphics: Option<Graphics>,
326    pub sixel: Option<bool>,
327    pub animation: Option<bool>,
328    pub interactive: Option<bool>,
329    pub width: Option<usize>,
330    pub height: Option<usize>,
331}
332
333/// The documented override variables, in report order.
334pub const OVERRIDE_VARS: &[&str] = &[
335    "RICH_COLOR",
336    "RICH_UNICODE",
337    "RICH_HYPERLINKS",
338    "RICH_GRAPHICS",
339    "RICH_SIXEL",
340    "RICH_ANIMATION",
341    "RICH_WIDTH",
342    "RICH_HEIGHT",
343];
344
345/// Parse a boolean override (`0|1|true|false|yes|no|on|off`).
346pub fn parse_bool(value: &str) -> Option<bool> {
347    match value.trim().to_ascii_lowercase().as_str() {
348        "1" | "true" | "yes" | "on" => Some(true),
349        "0" | "false" | "no" | "off" => Some(false),
350        _ => None,
351    }
352}
353
354/// A detection result. Every field records its provenance.
355#[derive(Clone, Debug, PartialEq, Eq)]
356#[cfg_attr(feature = "serde", derive(serde::Serialize))]
357pub struct Report {
358    pub color: Field<ColorDepth>,
359    pub unicode: Field<bool>,
360    pub hyperlinks: Field<bool>,
361    pub graphics: Field<Graphics>,
362    pub sixel: Field<bool>,
363    pub width: Field<usize>,
364    pub height: Field<usize>,
365    pub interactive: Field<bool>,
366    pub animation: Field<bool>,
367    /// The identified terminal (`"iTerm.app"`, `"Windows Terminal"`…), if any.
368    pub terminal: Option<String>,
369    /// The identified CI provider, if any.
370    pub ci: Option<String>,
371    /// Override values that could not be parsed.
372    pub warnings: Vec<String>,
373}
374
375/// Capability detection. See the [module docs](self) for the rules.
376#[derive(Clone, Copy, Debug, Default)]
377pub struct Capabilities;
378
379const CI_PROVIDERS: &[(&str, &str, ColorDepth)] = &[
380    ("GITHUB_ACTIONS", "GitHub Actions", ColorDepth::TrueColor),
381    ("GITEA_ACTIONS", "Gitea Actions", ColorDepth::TrueColor),
382    ("GITLAB_CI", "GitLab CI", ColorDepth::Ansi16),
383    ("BUILDKITE", "Buildkite", ColorDepth::Ansi16),
384    ("CIRCLECI", "CircleCI", ColorDepth::Ansi16),
385    ("TRAVIS", "Travis CI", ColorDepth::Ansi16),
386    ("APPVEYOR", "AppVeyor", ColorDepth::Ansi16),
387    ("DRONE", "Drone", ColorDepth::Ansi16),
388    ("TF_BUILD", "Azure Pipelines", ColorDepth::Ansi16),
389    ("TEAMCITY_VERSION", "TeamCity", ColorDepth::Ansi16),
390    ("JENKINS_URL", "Jenkins", ColorDepth::None),
391    (
392        "BITBUCKET_BUILD_NUMBER",
393        "Bitbucket Pipelines",
394        ColorDepth::None,
395    ),
396];
397
398struct Probe<'a> {
399    env: &'a dyn Environment,
400}
401
402impl Probe<'_> {
403    fn get(&self, name: &str) -> Option<String> {
404        self.env.var(name).filter(|v| !v.is_empty())
405    }
406    fn term(&self) -> String {
407        self.get("TERM").unwrap_or_default().to_ascii_lowercase()
408    }
409    fn program(&self) -> String {
410        self.get("TERM_PROGRAM").unwrap_or_default()
411    }
412    fn kitty(&self) -> Option<String> {
413        if self.term() == "xterm-kitty" {
414            Some("TERM=xterm-kitty".into())
415        } else {
416            self.get("KITTY_WINDOW_ID")
417                .map(|_| "KITTY_WINDOW_ID".into())
418        }
419    }
420    fn ci(&self) -> Option<(&'static str, &'static str, ColorDepth)> {
421        CI_PROVIDERS
422            .iter()
423            .find(|(var, _, _)| self.get(var).is_some_and(|v| v != "false"))
424            .copied()
425            .or_else(|| {
426                self.get("CI")
427                    .filter(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false"))
428                    .map(|_| ("CI", "CI", ColorDepth::None))
429            })
430    }
431    fn terminal(&self) -> Option<String> {
432        if self.get("WT_SESSION").is_some() {
433            return Some("Windows Terminal".into());
434        }
435        if self.kitty().is_some() {
436            return Some("kitty".into());
437        }
438        if let Some(p) = self.get("TERM_PROGRAM") {
439            return Some(p);
440        }
441        self.get("VTE_VERSION").map(|v| format!("VTE {v}"))
442    }
443    fn sixel(&self) -> Option<String> {
444        if self.get("WT_SESSION").is_some() {
445            return Some("WT_SESSION".into());
446        }
447        let term = self.term();
448        if ["sixel", "mlterm", "foot"].iter().any(|t| term.contains(t)) {
449            return Some(format!("TERM={}", self.get("TERM").unwrap_or_default()));
450        }
451        let program = self.program().to_ascii_lowercase();
452        if program.contains("wezterm") || program.contains("mintty") {
453            return Some(format!("TERM_PROGRAM={}", self.program()));
454        }
455        None
456    }
457    fn reduced_motion(&self) -> Option<String> {
458        let list = self.get("RICH_A11Y")?;
459        list.split(',')
460            .map(|item| item.trim().to_ascii_lowercase())
461            .find(|item| {
462                matches!(
463                    item.as_str(),
464                    "reduced-motion" | "no-animation" | "screen-reader"
465                )
466            })
467            .map(|item| format!("RICH_A11Y={item}"))
468    }
469}
470
471fn from_var(name: &str) -> Origin {
472    Origin::Environment(name.into())
473}
474
475impl Capabilities {
476    /// Detect from `env` with no caller overrides.
477    pub fn detect(env: &dyn Environment) -> Report {
478        Self::detect_with(env, &Overrides::default())
479    }
480
481    /// Detect from the real process environment.
482    pub fn system() -> Report {
483        Self::detect(&SystemEnvironment)
484    }
485
486    /// Detect from `env`, then apply `overrides`.
487    pub fn detect_with(env: &dyn Environment, overrides: &Overrides) -> Report {
488        let p = Probe { env };
489        let mut warnings = Vec::new();
490        let tty = env.is_terminal();
491        let term = p.term();
492        let program = p.program();
493        let ci = p.ci();
494
495        // Override variables, parsed once.
496        fn parsed<T>(
497            p: &Probe<'_>,
498            name: &str,
499            parse: impl Fn(&str) -> Option<T>,
500            warnings: &mut Vec<String>,
501        ) -> Option<Field<T>> {
502            let raw = p.get(name)?;
503            match parse(&raw) {
504                Some(value) => Some(Field::new(value, from_var(name), format!("{name}={raw}"))),
505                None => {
506                    warnings.push(format!("ignored {name}={raw:?}: unrecognised value"));
507                    None
508                }
509            }
510        }
511        let size = |v: &str| v.trim().parse::<usize>().ok().filter(|v| *v > 0);
512
513        // Colour.
514        let color = parsed(&p, "RICH_COLOR", ColorDepth::parse, &mut warnings)
515            .unwrap_or_else(|| detect_color(&p, tty, &term, &program, ci, env.is_windows()));
516
517        // Unicode.
518        let unicode = parsed(&p, "RICH_UNICODE", parse_bool, &mut warnings).unwrap_or_else(|| {
519            for name in ["LC_ALL", "LC_CTYPE", "LANG"] {
520                if let Some(value) = p.get(name) {
521                    let lower = value.to_ascii_lowercase();
522                    let charset = lower.split('@').next().unwrap_or("");
523                    let reason = format!("{name}={value}");
524                    return if charset.contains("utf-8") || charset.contains("utf8") {
525                        Field::new(true, from_var(name), reason)
526                    } else if matches!(charset, "c" | "posix") {
527                        Field::new(true, from_var(name), format!("{reason} (coerced to UTF-8)"))
528                    } else {
529                        Field::new(false, from_var(name), format!("{reason} is not UTF-8"))
530                    };
531                }
532            }
533            if p.get("WT_SESSION").is_some() {
534                return Field::new(true, from_var("WT_SESSION"), "Windows Terminal");
535            }
536            Field::new(true, Origin::Default, "no locale set; UTF-8 assumed")
537        });
538
539        // Hyperlinks.
540        let hyperlinks = parsed(&p, "RICH_HYPERLINKS", parse_bool, &mut warnings)
541            .unwrap_or_else(|| detect_hyperlinks(&p, tty, &term, &program));
542
543        // Graphics and sixel.
544        let sixel_alias = parsed(&p, "RICH_SIXEL", parse_bool, &mut warnings);
545        let graphics_var = parsed(&p, "RICH_GRAPHICS", Graphics::parse, &mut warnings);
546        let sixel = if let Some(g) = &graphics_var {
547            Field::new(
548                g.value == Graphics::Sixel,
549                g.origin.clone(),
550                g.reason.clone(),
551            )
552        } else if let Some(s) = &sixel_alias {
553            s.clone()
554        } else if !tty {
555            Field::new(false, Origin::Inferred, "stdout is not a terminal")
556        } else if let Some(reason) = p.sixel() {
557            let origin = from_var(reason.split('=').next().unwrap_or(&reason));
558            Field::new(true, origin, reason)
559        } else {
560            Field::new(
561                false,
562                Origin::Default,
563                "no sixel-capable terminal identified",
564            )
565        };
566        let graphics = if let Some(g) = graphics_var {
567            g
568        } else if let Some(s) = sixel_alias {
569            let value = if s.value {
570                Graphics::Sixel
571            } else {
572                Graphics::None
573            };
574            Field::new(value, s.origin, s.reason)
575        } else if !tty {
576            Field::new(Graphics::None, Origin::Inferred, "stdout is not a terminal")
577        } else if let Some(reason) = p.kitty() {
578            let origin = from_var(reason.split('=').next().unwrap_or(&reason));
579            Field::new(Graphics::Kitty, origin, reason)
580        } else if program == "iTerm.app" || program.eq_ignore_ascii_case("wezterm") {
581            Field::new(
582                Graphics::Iterm,
583                from_var("TERM_PROGRAM"),
584                format!("TERM_PROGRAM={program}"),
585            )
586        } else if sixel.value {
587            Field::new(Graphics::Sixel, sixel.origin.clone(), sixel.reason.clone())
588        } else {
589            Field::new(
590                Graphics::None,
591                Origin::Default,
592                "no graphics protocol identified",
593            )
594        };
595
596        // Dimensions.
597        let dimension =
598            |rich: &str, var: &str, index: usize, default: usize, w: &mut Vec<String>| {
599                parsed(&p, rich, size, w)
600                    .or_else(|| parsed(&p, var, size, w))
601                    .unwrap_or_else(|| match env.size() {
602                        Some(s) => {
603                            let v = if index == 0 { s.0 } else { s.1 };
604                            Field::new(v, Origin::Inferred, "terminal size")
605                        }
606                        None => Field::new(default, Origin::Default, format!("default {default}")),
607                    })
608            };
609        let width = dimension("RICH_WIDTH", "COLUMNS", 0, 80, &mut warnings);
610        let height = dimension("RICH_HEIGHT", "LINES", 1, 25, &mut warnings);
611
612        let interactive = if tty {
613            Field::new(true, Origin::Inferred, "stdout is a terminal")
614        } else {
615            Field::new(false, Origin::Inferred, "stdout is not a terminal")
616        };
617
618        let mut report = Report {
619            color,
620            unicode,
621            hyperlinks,
622            graphics,
623            sixel,
624            width,
625            height,
626            interactive,
627            animation: Field::new(false, Origin::Default, ""),
628            terminal: p.terminal(),
629            ci: ci.map(|(_, name, _)| name.to_owned()),
630            warnings,
631        };
632        report.apply(overrides);
633        let animation_var = parsed(&p, "RICH_ANIMATION", parse_bool, &mut report.warnings);
634        report.animation = match (overrides.animation, animation_var) {
635            (Some(v), _) => Field::new(v, Origin::Override, "override"),
636            (None, Some(field)) => field,
637            (None, None) => {
638                if !report.interactive.value {
639                    Field::new(false, Origin::Inferred, "not interactive")
640                } else if let Some((var, name, _)) = ci {
641                    Field::new(false, from_var(var), format!("running in {name}"))
642                } else if term == "dumb" {
643                    Field::new(false, from_var("TERM"), "TERM=dumb")
644                } else if let Some(reason) = p.reduced_motion() {
645                    Field::new(false, from_var("RICH_A11Y"), reason)
646                } else {
647                    Field::new(true, Origin::Inferred, "interactive terminal")
648                }
649            }
650        };
651        report.escape_controls();
652        report
653    }
654}
655
656/// `text` with control and bidi characters written as escapes (`\u{1b}`),
657/// so an environment value quoted in a reason is inert wherever it is shown.
658fn escape_controls(text: &str) -> String {
659    if !text
660        .chars()
661        .any(|c| c.is_control() || crate::sanitize::is_bidi_control(c))
662    {
663        return text.to_owned();
664    }
665    let mut out = String::with_capacity(text.len() + 8);
666    for c in text.chars() {
667        if c.is_control() || crate::sanitize::is_bidi_control(c) {
668            out.push_str(&format!("\\u{{{:x}}}", c as u32));
669        } else {
670            out.push(c);
671        }
672    }
673    out
674}
675
676impl Report {
677    /// Escape the environment values that reasons and facts quote.
678    fn escape_controls(&mut self) {
679        for reason in [
680            &mut self.color.reason,
681            &mut self.unicode.reason,
682            &mut self.hyperlinks.reason,
683            &mut self.graphics.reason,
684            &mut self.sixel.reason,
685            &mut self.width.reason,
686            &mut self.height.reason,
687            &mut self.interactive.reason,
688            &mut self.animation.reason,
689        ] {
690            *reason = escape_controls(reason);
691        }
692        for warning in &mut self.warnings {
693            *warning = escape_controls(warning);
694        }
695        for value in [&mut self.terminal, &mut self.ci].into_iter().flatten() {
696            *value = escape_controls(value);
697        }
698    }
699}
700
701fn detect_color(
702    p: &Probe<'_>,
703    tty: bool,
704    term: &str,
705    program: &str,
706    ci: Option<(&str, &str, ColorDepth)>,
707    windows: bool,
708) -> Field<ColorDepth> {
709    use ColorDepth::*;
710    if let Some(v) = p.get("NO_COLOR") {
711        return Field::new(None, from_var("NO_COLOR"), format!("NO_COLOR={v}"));
712    }
713    // An empty FORCE_COLOR is ignored, as force-color.org specifies.
714    let force = p.get("FORCE_COLOR");
715    if let Some(force) = &force {
716        let reason = format!("FORCE_COLOR={force}");
717        match force.trim().to_ascii_lowercase().as_str() {
718            "0" | "false" => return Field::new(None, from_var("FORCE_COLOR"), reason),
719            "1" => return Field::new(Ansi16, from_var("FORCE_COLOR"), reason),
720            "2" => return Field::new(Ansi256, from_var("FORCE_COLOR"), reason),
721            "3" => return Field::new(TrueColor, from_var("FORCE_COLOR"), reason),
722            _ => {}
723        }
724    }
725    if !tty && force.is_none() {
726        if let Some((var, name, depth)) = ci.filter(|c| c.2 != None) {
727            return Field::new(depth, from_var(var), format!("{name} renders ANSI logs"));
728        }
729        return Field::new(None, Origin::Inferred, "stdout is not a terminal");
730    }
731    if term == "dumb" {
732        return Field::new(None, from_var("TERM"), "TERM=dumb");
733    }
734    let terminal = detect_terminal_depth(p, term, program, windows);
735    if let Some(force) = force {
736        // FORCE_COLOR with no level: at least 16 colours.
737        let depth = terminal.value.max(Ansi16);
738        let reason = if terminal.value >= Ansi16 && terminal.origin != Origin::Default {
739            terminal.reason
740        } else {
741            format!("FORCE_COLOR={force}")
742        };
743        return Field::new(depth, from_var("FORCE_COLOR"), reason);
744    }
745    terminal
746}
747
748fn detect_terminal_depth(
749    p: &Probe<'_>,
750    term: &str,
751    program: &str,
752    windows: bool,
753) -> Field<ColorDepth> {
754    use ColorDepth::*;
755    if let Some(v) = p.get("COLORTERM") {
756        let lower = v.to_ascii_lowercase();
757        if lower.contains("truecolor") || lower.contains("24bit") {
758            return Field::new(TrueColor, from_var("COLORTERM"), format!("COLORTERM={v}"));
759        }
760    }
761    if p.get("WT_SESSION").is_some() {
762        return Field::new(TrueColor, from_var("WT_SESSION"), "Windows Terminal");
763    }
764    if let Some(reason) = p.kitty() {
765        let origin = from_var(reason.split('=').next().unwrap_or(&reason));
766        return Field::new(TrueColor, origin, reason);
767    }
768    if ["iTerm.app", "WezTerm", "vscode", "ghostty"]
769        .iter()
770        .any(|t| program.eq_ignore_ascii_case(t))
771    {
772        return Field::new(
773            TrueColor,
774            from_var("TERM_PROGRAM"),
775            format!("TERM_PROGRAM={program}"),
776        );
777    }
778    let raw_term = p.get("TERM").unwrap_or_default();
779    if term.contains("direct") || term.contains("truecolor") {
780        return Field::new(TrueColor, from_var("TERM"), format!("TERM={raw_term}"));
781    }
782    if program == "Apple_Terminal" {
783        return Field::new(
784            Ansi256,
785            from_var("TERM_PROGRAM"),
786            "TERM_PROGRAM=Apple_Terminal",
787        );
788    }
789    if term.contains("256") {
790        return Field::new(Ansi256, from_var("TERM"), format!("TERM={raw_term}"));
791    }
792    if windows {
793        return Field::new(TrueColor, Origin::Inferred, "Windows console");
794    }
795    if !term.is_empty() {
796        return Field::new(Ansi16, from_var("TERM"), format!("TERM={raw_term}"));
797    }
798    Field::new(Ansi16, Origin::Default, "terminal with no TERM")
799}
800
801fn detect_hyperlinks(p: &Probe<'_>, tty: bool, term: &str, program: &str) -> Field<bool> {
802    if !tty {
803        return Field::new(false, Origin::Inferred, "stdout is not a terminal");
804    }
805    if term == "dumb" {
806        return Field::new(false, from_var("TERM"), "TERM=dumb");
807    }
808    if p.get("TMUX").is_some() || term.starts_with("screen") || term.starts_with("tmux") {
809        let origin = if p.get("TMUX").is_some() {
810            from_var("TMUX")
811        } else {
812            from_var("TERM")
813        };
814        return Field::new(false, origin, "inside a multiplexer");
815    }
816    if p.get("WT_SESSION").is_some() {
817        return Field::new(true, from_var("WT_SESSION"), "Windows Terminal");
818    }
819    if let Some(reason) = p.kitty() {
820        let origin = from_var(reason.split('=').next().unwrap_or(&reason));
821        return Field::new(true, origin, reason);
822    }
823    if [
824        "iTerm.app",
825        "WezTerm",
826        "vscode",
827        "ghostty",
828        "contour",
829        "DomTerm",
830    ]
831    .iter()
832    .any(|t| program.eq_ignore_ascii_case(t))
833    {
834        return Field::new(
835            true,
836            from_var("TERM_PROGRAM"),
837            format!("TERM_PROGRAM={program}"),
838        );
839    }
840    if let Some(v) = p.get("VTE_VERSION") {
841        let ok = v.trim().parse::<u32>().is_ok_and(|n| n >= 5000);
842        let reason = format!("VTE_VERSION={v}");
843        return Field::new(ok, from_var("VTE_VERSION"), reason);
844    }
845    if ["foot", "contour", "xterm-ghostty"]
846        .iter()
847        .any(|t| term.starts_with(t))
848    {
849        return Field::new(true, from_var("TERM"), format!("TERM={term}"));
850    }
851    if p.get("DOMTERM").is_some() {
852        return Field::new(true, from_var("DOMTERM"), "DOMTERM");
853    }
854    Field::new(
855        false,
856        Origin::Default,
857        "terminal not known to support OSC 8",
858    )
859}
860
861impl Report {
862    /// Apply caller overrides with [`Origin::Override`].
863    pub fn apply(&mut self, o: &Overrides) {
864        fn set<T>(field: &mut Field<T>, value: Option<T>) {
865            if let Some(value) = value {
866                *field = Field::new(value, Origin::Override, "override");
867            }
868        }
869        set(&mut self.color, o.color);
870        set(&mut self.unicode, o.unicode);
871        set(&mut self.hyperlinks, o.hyperlinks);
872        if let Some(g) = o.graphics {
873            set(&mut self.graphics, Some(g));
874            if o.sixel.is_none() {
875                set(&mut self.sixel, Some(g == Graphics::Sixel));
876            }
877        }
878        set(&mut self.sixel, o.sixel);
879        set(&mut self.animation, o.animation);
880        set(&mut self.interactive, o.interactive);
881        set(&mut self.width, o.width);
882        set(&mut self.height, o.height);
883    }
884
885    /// The core capabilities this report describes, for a
886    /// [`RenderTarget`](crate::target::RenderTarget). Sixel is `Confirmed` only
887    /// when the user said so (an override or a `RICH_*` variable).
888    pub fn to_target_capabilities(&self) -> TargetCapabilities {
889        TargetCapabilities {
890            width: self.width.value,
891            height: self.height.value,
892            color_system: self.color.value.color_system(),
893            interactive: self.interactive.value,
894            unicode: self.unicode.value,
895            hyperlinks: self.hyperlinks.value,
896            sixel: self.sixel_support(),
897        }
898    }
899
900    fn sixel_support(&self) -> Support {
901        match (&self.sixel.value, &self.sixel.origin) {
902            (false, _) => Support::Unsupported,
903            (true, Origin::Override) => Support::Confirmed,
904            (true, Origin::Environment(name)) if name.starts_with("RICH_") => Support::Confirmed,
905            (true, _) => Support::Inferred,
906        }
907    }
908
909    /// The observations [`resolve_capabilities`](crate::target::resolve_capabilities)
910    /// takes, so the older API can be fed from this one.
911    pub fn observations(&self) -> TargetObservations {
912        TargetObservations {
913            width: Some(self.width.value),
914            height: Some(self.height.value),
915            is_terminal: self.interactive.value,
916            color_system: self.color.value.color_system(),
917            unicode: self.unicode.value,
918            hyperlinks: self.hyperlinks.value,
919            sixel_hint: self.sixel_support(),
920        }
921    }
922
923    /// This report in the older [`DetectedCapabilities`] shape.
924    pub fn to_detected(&self) -> DetectedCapabilities {
925        fn origin(o: &Origin) -> CapabilityOrigin {
926            match o {
927                Origin::Override => CapabilityOrigin::Configured,
928                Origin::Environment(name) if name.starts_with("RICH_") => {
929                    CapabilityOrigin::Configured
930                }
931                Origin::Environment(_) => CapabilityOrigin::Detected,
932                Origin::Default => CapabilityOrigin::Default,
933                Origin::Inferred => CapabilityOrigin::Inferred,
934            }
935        }
936        let origins = [
937            ("width", &self.width.origin),
938            ("height", &self.height.origin),
939            ("color_system", &self.color.origin),
940            ("interactive", &self.interactive.origin),
941            ("unicode", &self.unicode.origin),
942            ("hyperlinks", &self.hyperlinks.origin),
943            ("sixel", &self.sixel.origin),
944        ]
945        .into_iter()
946        .map(|(name, o)| (name.to_owned(), origin(o)))
947        .collect();
948        DetectedCapabilities {
949            capabilities: self.to_target_capabilities(),
950            origins,
951        }
952    }
953
954    /// `(capability, value, origin, reason)` rows in a fixed order.
955    pub fn rows(&self) -> Vec<(&'static str, String, &Origin, &str)> {
956        let yes = |b: bool| if b { "yes" } else { "no" }.to_owned();
957        vec![
958            (
959                "color",
960                self.color.value.name().into(),
961                &self.color.origin,
962                &self.color.reason,
963            ),
964            (
965                "unicode",
966                yes(self.unicode.value),
967                &self.unicode.origin,
968                &self.unicode.reason,
969            ),
970            (
971                "hyperlinks",
972                yes(self.hyperlinks.value),
973                &self.hyperlinks.origin,
974                &self.hyperlinks.reason,
975            ),
976            (
977                "graphics",
978                self.graphics.value.name().into(),
979                &self.graphics.origin,
980                &self.graphics.reason,
981            ),
982            (
983                "sixel",
984                yes(self.sixel.value),
985                &self.sixel.origin,
986                &self.sixel.reason,
987            ),
988            (
989                "width",
990                self.width.value.to_string(),
991                &self.width.origin,
992                &self.width.reason,
993            ),
994            (
995                "height",
996                self.height.value.to_string(),
997                &self.height.origin,
998                &self.height.reason,
999            ),
1000            (
1001                "interactive",
1002                yes(self.interactive.value),
1003                &self.interactive.origin,
1004                &self.interactive.reason,
1005            ),
1006            (
1007                "animation",
1008                yes(self.animation.value),
1009                &self.animation.origin,
1010                &self.animation.reason,
1011            ),
1012        ]
1013    }
1014}
1015
1016/// A table of a [`Report`]: capability | value | source.
1017pub struct CapabilityReport<'a> {
1018    report: &'a Report,
1019}
1020
1021impl<'a> CapabilityReport<'a> {
1022    pub fn new(report: &'a Report) -> Self {
1023        Self { report }
1024    }
1025}
1026
1027impl Renderable for CapabilityReport<'_> {
1028    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1029        let mut table = Table::new();
1030        table.add_column("Capability");
1031        table.add_column("Value");
1032        table.add_column("Source");
1033        for (name, value, origin, reason) in self.report.rows() {
1034            let source = if reason.is_empty() || reason == "override" {
1035                origin.to_string()
1036            } else {
1037                format!("{origin}: {reason}")
1038            };
1039            // Values come from the environment, so they are data, not markup.
1040            table.add_row_text(vec![Text::new(name), Text::new(value), Text::new(source)]);
1041        }
1042        let facts = [("terminal", &self.report.terminal), ("ci", &self.report.ci)];
1043        for (name, value) in facts {
1044            if let Some(value) = value {
1045                table.add_row_text(vec![
1046                    Text::new(name),
1047                    Text::new(value.as_str()),
1048                    Text::new("inferred"),
1049                ]);
1050            }
1051        }
1052        for warning in &self.report.warnings {
1053            table.add_row_text(vec![
1054                Text::new("warning"),
1055                Text::new(""),
1056                Text::new(warning.as_str()),
1057            ]);
1058        }
1059        table.rich_render(console, options)
1060    }
1061}