Skip to main content

tui_test/
profile.rs

1//! Terminal profiles: the settings a session runs with.
2//!
3//! A profile is chosen when a session opens and fixed for its lifetime. It is
4//! read from a TOML file so a project can commit the terminal its tests expect,
5//! rather than depending on whatever the machine happens to default to.
6//!
7//! # Colors are resolved here, not by the emulator
8//!
9//! A terminal grid stores color *indices*, not colors: a cell painted with
10//! `SGR 31` records palette slot 1, and what that looks like is the viewer's
11//! choice. Nothing in the emulator needs a palette — xterm.js's `theme` option
12//! is inert in a headless terminal, and alacritty has no palette at all.
13//!
14//! tui-test has to make that choice twice: once to draw a screenshot, and once
15//! to answer `expect --fg "#rrggbb"`. Those answers have to agree. They used to
16//! come from two separate hardcoded tables that disagreed on all sixteen ANSI
17//! slots, so `expect --fg "#800000"` passed on a cell the screenshot painted
18//! `#e88388`. [`Colors`] is the single table both now read.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25use crate::terminal::cell::NamedColor;
26
27/// Rows of scrollback a profile retains when it does not say otherwise.
28///
29/// The emulators do not agree on their own defaults (alacritty 10,000,
30/// xterm.js 1,000), so this is always set explicitly rather than inherited.
31pub const DEFAULT_SCROLLBACK: usize = 10_000;
32
33/// The file a profile is read from, under the config directory.
34pub const CONFIG_FILE: &str = "tui-test.toml";
35
36/// The profile used when none is named.
37pub const DEFAULT_PROFILE: &str = "default";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Rgb {
41    pub r: u8,
42    pub g: u8,
43    pub b: u8,
44}
45
46impl Rgb {
47    pub const fn new(r: u8, g: u8, b: u8) -> Self {
48        Rgb { r, g, b }
49    }
50
51    pub fn to_hex(self) -> String {
52        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
53    }
54
55    /// Parse `#rgb` or `#rrggbb`. The leading `#` is optional so a TOML value
56    /// that lost it to a stray quote still reads sensibly.
57    pub fn parse(s: &str) -> Result<Self, String> {
58        let trimmed = s.trim();
59        let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
60        let digit = |byte: u8| -> Option<u8> {
61            match byte {
62                b'0'..=b'9' => Some(byte - b'0'),
63                b'a'..=b'f' => Some(byte - b'a' + 10),
64                b'A'..=b'F' => Some(byte - b'A' + 10),
65                _ => None,
66            }
67        };
68        let digits = hex
69            .bytes()
70            .map(digit)
71            .collect::<Option<Vec<_>>>()
72            .ok_or_else(|| format!("invalid hex color {s:?}"))?;
73        match digits.as_slice() {
74            [r, g, b] => Ok(Rgb::new(r * 17, g * 17, b * 17)),
75            [r1, r2, g1, g2, b1, b2] => Ok(Rgb::new(r1 * 16 + r2, g1 * 16 + g2, b1 * 16 + b2)),
76            _ => Err(format!("color must be #rgb or #rrggbb (got {s:?})")),
77        }
78    }
79}
80
81impl Serialize for Rgb {
82    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
83        s.serialize_str(&self.to_hex())
84    }
85}
86
87impl<'de> Deserialize<'de> for Rgb {
88    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
89        let raw = String::deserialize(d)?;
90        Rgb::parse(&raw).map_err(serde::de::Error::custom)
91    }
92}
93
94/// The colors a session paints with.
95///
96/// Only the sixteen ANSI slots are configurable. Indices 16-255 are the xterm
97/// color cube and gray ramp, which are defined by the spec rather than by a
98/// theme, so [`Colors::rgb`] computes them instead of storing them. A config
99/// that could override them would let two sessions disagree about what
100/// `--fg 196` means.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(default, deny_unknown_fields)]
103pub struct Colors {
104    /// The color text takes when a cell set none of its own.
105    pub foreground: Rgb,
106    /// The color an unpainted cell takes.
107    pub background: Rgb,
108    /// The color used to draw the cursor.
109    pub cursor: Rgb,
110
111    pub black: Rgb,
112    pub red: Rgb,
113    pub green: Rgb,
114    pub yellow: Rgb,
115    pub blue: Rgb,
116    pub magenta: Rgb,
117    pub cyan: Rgb,
118    pub white: Rgb,
119    pub bright_black: Rgb,
120    pub bright_red: Rgb,
121    pub bright_green: Rgb,
122    pub bright_yellow: Rgb,
123    pub bright_blue: Rgb,
124    pub bright_magenta: Rgb,
125    pub bright_cyan: Rgb,
126    pub bright_white: Rgb,
127}
128
129impl Default for Colors {
130    /// The classic VGA/xterm palette, which is what `TERM=xterm-256color`
131    /// promises and what the assertion layer already compared against.
132    fn default() -> Self {
133        Colors {
134            foreground: Rgb::new(192, 192, 192),
135            background: Rgb::new(0, 0, 0),
136            cursor: Rgb::new(192, 192, 192),
137
138            black: Rgb::new(0, 0, 0),
139            red: Rgb::new(128, 0, 0),
140            green: Rgb::new(0, 128, 0),
141            yellow: Rgb::new(128, 128, 0),
142            blue: Rgb::new(0, 0, 128),
143            magenta: Rgb::new(128, 0, 128),
144            cyan: Rgb::new(0, 128, 128),
145            white: Rgb::new(192, 192, 192),
146            bright_black: Rgb::new(128, 128, 128),
147            bright_red: Rgb::new(255, 0, 0),
148            bright_green: Rgb::new(0, 255, 0),
149            bright_yellow: Rgb::new(255, 255, 0),
150            bright_blue: Rgb::new(0, 0, 255),
151            bright_magenta: Rgb::new(255, 0, 255),
152            bright_cyan: Rgb::new(0, 255, 255),
153            bright_white: Rgb::new(255, 255, 255),
154        }
155    }
156}
157
158impl Colors {
159    /// The sixteen ANSI slots, in palette order.
160    pub fn ansi(&self) -> [Rgb; 16] {
161        [
162            self.black,
163            self.red,
164            self.green,
165            self.yellow,
166            self.blue,
167            self.magenta,
168            self.cyan,
169            self.white,
170            self.bright_black,
171            self.bright_red,
172            self.bright_green,
173            self.bright_yellow,
174            self.bright_blue,
175            self.bright_magenta,
176            self.bright_cyan,
177            self.bright_white,
178        ]
179    }
180
181    /// The name a slot goes by in the config file.
182    pub fn slot_name(index: u8) -> Option<&'static str> {
183        Some(match NamedColor::from_index(index)? {
184            NamedColor::Black => "black",
185            NamedColor::Red => "red",
186            NamedColor::Green => "green",
187            NamedColor::Yellow => "yellow",
188            NamedColor::Blue => "blue",
189            NamedColor::Magenta => "magenta",
190            NamedColor::Cyan => "cyan",
191            NamedColor::White => "white",
192            NamedColor::BrightBlack => "bright_black",
193            NamedColor::BrightRed => "bright_red",
194            NamedColor::BrightGreen => "bright_green",
195            NamedColor::BrightYellow => "bright_yellow",
196            NamedColor::BrightBlue => "bright_blue",
197            NamedColor::BrightMagenta => "bright_magenta",
198            NamedColor::BrightCyan => "bright_cyan",
199            NamedColor::BrightWhite => "bright_white",
200        })
201    }
202
203    /// Set one color by the name used in config files and language bindings.
204    pub fn set_named(&mut self, name: &str, value: Rgb) -> bool {
205        let target = match name {
206            "foreground" => &mut self.foreground,
207            "background" => &mut self.background,
208            "cursor" => &mut self.cursor,
209            "black" => &mut self.black,
210            "red" => &mut self.red,
211            "green" => &mut self.green,
212            "yellow" => &mut self.yellow,
213            "blue" => &mut self.blue,
214            "magenta" => &mut self.magenta,
215            "cyan" => &mut self.cyan,
216            "white" => &mut self.white,
217            "bright_black" => &mut self.bright_black,
218            "bright_red" => &mut self.bright_red,
219            "bright_green" => &mut self.bright_green,
220            "bright_yellow" => &mut self.bright_yellow,
221            "bright_blue" => &mut self.bright_blue,
222            "bright_magenta" => &mut self.bright_magenta,
223            "bright_cyan" => &mut self.bright_cyan,
224            "bright_white" => &mut self.bright_white,
225            _ => return false,
226        };
227        *target = value;
228        true
229    }
230
231    /// Resolve any 256-color index.
232    ///
233    /// Slots 0-15 come from the profile; everything above comes from the
234    /// xterm table, which no profile can move.
235    pub fn rgb(&self, index: u8) -> Rgb {
236        match index {
237            0..=15 => self.ansi()[index as usize],
238            _ => xterm_color(index),
239        }
240    }
241}
242
243/// A color a program can address.
244///
245/// `OSC 4` names a palette entry and `OSC 10/11/12` name the three defaults.
246/// Emulators number these however they like internally, so each backend
247/// translates its own layout and that numbering never reaches here.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum ColorSlot {
250    Indexed(u8),
251    Foreground,
252    Background,
253    Cursor,
254}
255
256/// The xterm 256-color table, which is the same in every terminal.
257///
258/// Slots 0-15 here are the classic VGA colors, and a profile overrides them.
259/// The rest is the 6x6x6 color cube and the 24-step gray ramp, which the
260/// specification fixes and no profile can move: `--fg 196` has to mean the
261/// same thing in every session.
262static XTERM_256: [Rgb; 256] = build_xterm_256();
263
264const fn build_xterm_256() -> [Rgb; 256] {
265    let mut table = [Rgb::new(0, 0, 0); 256];
266
267    // 0-15: VGA.
268    let vga = [
269        (0, 0, 0),
270        (128, 0, 0),
271        (0, 128, 0),
272        (128, 128, 0),
273        (0, 0, 128),
274        (128, 0, 128),
275        (0, 128, 128),
276        (192, 192, 192),
277        (128, 128, 128),
278        (255, 0, 0),
279        (0, 255, 0),
280        (255, 255, 0),
281        (0, 0, 255),
282        (255, 0, 255),
283        (0, 255, 255),
284        (255, 255, 255),
285    ];
286    let mut i = 0;
287    while i < 16 {
288        table[i] = Rgb::new(vga[i].0, vga[i].1, vga[i].2);
289        i += 1;
290    }
291
292    // 16-231: a 6x6x6 cube whose levels step 0, 95, 135, 175, 215, 255.
293    let levels = [0u8, 95, 135, 175, 215, 255];
294    while i < 232 {
295        let n = i - 16;
296        table[i] = Rgb::new(levels[(n / 36) % 6], levels[(n / 6) % 6], levels[n % 6]);
297        i += 1;
298    }
299
300    // 232-255: a gray ramp from 8 to 238 in steps of 10.
301    while i < 256 {
302        let v = (i - 232) as u8 * 10 + 8;
303        table[i] = Rgb::new(v, v, v);
304        i += 1;
305    }
306    table
307}
308
309/// The color a slot has when nothing has overridden it.
310pub fn xterm_color(index: u8) -> Rgb {
311    XTERM_256[index as usize]
312}
313
314/// The settings a session runs with.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(default, deny_unknown_fields)]
317pub struct Profile {
318    /// Rows retained beyond the visible screen.
319    pub scrollback: usize,
320    pub colors: Colors,
321}
322
323impl Default for Profile {
324    fn default() -> Self {
325        Profile {
326            scrollback: DEFAULT_SCROLLBACK,
327            colors: Colors::default(),
328        }
329    }
330}
331
332/// A profile as represented in `tui-test.toml`.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(default, deny_unknown_fields)]
335pub struct ConfigProfile {
336    pub scrollback: usize,
337    pub colors: Colors,
338    pub timeouts: crate::api::Timeouts,
339}
340
341impl Default for ConfigProfile {
342    fn default() -> Self {
343        Self {
344            scrollback: DEFAULT_SCROLLBACK,
345            colors: Colors::default(),
346            timeouts: crate::api::Timeouts::default(),
347        }
348    }
349}
350
351/// Concrete session settings resolved from a config profile.
352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
353pub struct Settings {
354    pub profile: Profile,
355    pub timeouts: crate::api::Timeouts,
356}
357
358impl From<ConfigProfile> for Settings {
359    fn from(value: ConfigProfile) -> Self {
360        Self {
361            profile: Profile {
362                scrollback: value.scrollback,
363                colors: value.colors,
364            },
365            timeouts: value.timeouts,
366        }
367    }
368}
369
370/// A parsed config file: named profiles, and nothing else.
371#[derive(Debug, Clone, Default, Serialize, Deserialize)]
372#[serde(default, deny_unknown_fields)]
373pub struct ConfigFile {
374    pub profiles: BTreeMap<String, ConfigProfile>,
375}
376
377impl ConfigFile {
378    pub fn parse(toml_text: &str) -> anyhow::Result<Self> {
379        Ok(toml::from_str(toml_text)?)
380    }
381
382    pub fn load(path: &Path) -> anyhow::Result<Self> {
383        let text = std::fs::read_to_string(path)
384            .map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?;
385        Self::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
386    }
387
388    /// The named profile, or the built-in defaults when nothing is named and
389    /// the file defines no `default`.
390    pub fn profile(&self, name: Option<&str>) -> anyhow::Result<Profile> {
391        Ok(self.settings(name)?.profile)
392    }
393
394    /// The named profile and its session timeout defaults.
395    pub fn settings(&self, name: Option<&str>) -> anyhow::Result<Settings> {
396        let profile = match name {
397            Some(name) => self.profiles.get(name).copied().ok_or_else(|| {
398                let known: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
399                if known.is_empty() {
400                    anyhow::anyhow!("no profile {name:?}; the config file defines none")
401                } else {
402                    anyhow::anyhow!("no profile {name:?}; found: {}", known.join(", "))
403                }
404            }),
405            None => Ok(self
406                .profiles
407                .get(DEFAULT_PROFILE)
408                .copied()
409                .unwrap_or_default()),
410        }?;
411        Ok(profile.into())
412    }
413}
414
415/// Where a config file is looked for, nearest first.
416///
417/// A project-local file wins so a repository can pin the terminal its tests
418/// expect. `TUI_TEST_CONFIG` replaces discovery, which is also how a test
419/// suite pins a config without depending on the working directory.
420pub fn search_paths(cwd: &Path) -> Vec<PathBuf> {
421    if let Some(explicit) = std::env::var_os("TUI_TEST_CONFIG") {
422        return vec![PathBuf::from(explicit)];
423    }
424    default_search_paths(cwd)
425}
426
427fn default_search_paths(cwd: &Path) -> Vec<PathBuf> {
428    let config_home = if std::env::var_os("TUI_TEST_HOME").is_none() {
429        dirs::config_dir()
430    } else {
431        None
432    };
433    config_search_paths(cwd, config_home.as_deref(), &crate::config::home_dir())
434}
435
436fn config_search_paths(
437    cwd: &Path,
438    platform_config_home: Option<&Path>,
439    tui_test_home: &Path,
440) -> Vec<PathBuf> {
441    let mut paths = vec![cwd.join(CONFIG_FILE)];
442    if let Some(config_home) = platform_config_home {
443        paths.push(config_home.join("tui-test").join(CONFIG_FILE));
444    }
445    let home_config = tui_test_home.join(CONFIG_FILE);
446    if !paths.contains(&home_config) {
447        paths.push(home_config);
448    }
449    paths
450}
451
452/// Resolve a profile: an explicit file if given, else the first file found on
453/// the search path, else the built-in defaults.
454///
455/// Missing discovered files are normal — tui-test runs without one. A path
456/// explicitly named by `--config` or `TUI_TEST_CONFIG` must exist, because
457/// silently ignoring it would run the session with settings the user did not
458/// ask for.
459pub fn resolve(
460    explicit_config: Option<&Path>,
461    profile_name: Option<&str>,
462    cwd: &Path,
463) -> anyhow::Result<Profile> {
464    Ok(resolve_settings(explicit_config, profile_name, cwd)?.profile)
465}
466
467/// Resolve terminal settings and session timeout defaults together.
468pub fn resolve_settings(
469    explicit_config: Option<&Path>,
470    profile_name: Option<&str>,
471    cwd: &Path,
472) -> anyhow::Result<Settings> {
473    if let Some(path) = explicit_config {
474        return ConfigFile::load(path)?.settings(profile_name);
475    }
476    if let Some(path) = std::env::var_os("TUI_TEST_CONFIG").map(PathBuf::from) {
477        return ConfigFile::load(&path)?.settings(profile_name);
478    }
479    for path in default_search_paths(cwd) {
480        if path.is_file() {
481            return ConfigFile::load(&path)?.settings(profile_name);
482        }
483    }
484    match profile_name {
485        Some(name) => anyhow::bail!("no profile {name:?}: no config file found"),
486        None => Ok(Settings::default()),
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
495
496    #[test]
497    fn hex_colors_round_trip() {
498        for raw in ["#000000", "#ffffff", "#800000", "#c0c0c0"] {
499            assert_eq!(Rgb::parse(raw).unwrap().to_hex(), raw);
500        }
501        assert_eq!(Rgb::parse("#f00").unwrap(), Rgb::new(255, 0, 0));
502        assert_eq!(Rgb::parse("800000").unwrap(), Rgb::new(128, 0, 0));
503    }
504
505    #[test]
506    fn a_bad_color_says_what_it_wanted() {
507        for raw in ["", "#12", "#1234567", "nope", "#gggggg", "éa", "##fff"] {
508            let err = Rgb::parse(raw).unwrap_err();
509            assert!(
510                err.contains("color") || err.contains("hex"),
511                "{raw:?}: {err}"
512            );
513        }
514    }
515
516    /// A profile that says nothing is the built-in default, so a config file is
517    /// never required.
518    #[test]
519    fn an_empty_config_yields_the_defaults() {
520        let cfg = ConfigFile::parse("").unwrap();
521        assert_eq!(cfg.profile(None).unwrap(), Profile::default());
522        assert_eq!(Profile::default().scrollback, 10_000);
523    }
524
525    /// Every field is individually optional, so a profile can set one color
526    /// without restating the palette.
527    #[test]
528    fn a_partial_profile_keeps_the_other_defaults() {
529        let cfg = ConfigFile::parse(
530            r##"
531            [profiles.ci]
532            scrollback = 50
533
534            [profiles.ci.colors]
535            red = "#ff0000"
536            "##,
537        )
538        .unwrap();
539        let p = cfg.profile(Some("ci")).unwrap();
540        assert_eq!(p.scrollback, 50);
541        assert_eq!(p.colors.red, Rgb::new(255, 0, 0), "the override applies");
542        assert_eq!(
543            p.colors.green,
544            Colors::default().green,
545            "an unset slot keeps its default"
546        );
547        assert_eq!(
548            p.colors.background,
549            Colors::default().background,
550            "an unset default color is untouched"
551        );
552    }
553
554    #[test]
555    fn profile_timeouts_are_loaded_and_accept_cli_overrides() {
556        let cfg = ConfigFile::parse(
557            r#"
558            [profiles.ci.timeouts]
559            text = 1000
560            command = 30000
561            "#,
562        )
563        .unwrap();
564        let settings = cfg.settings(Some("ci")).unwrap();
565        assert_eq!(settings.timeouts.text, Some(1_000));
566        assert_eq!(settings.timeouts.command, Some(30_000));
567        assert_eq!(settings.timeouts.ready, None);
568
569        let merged = settings.timeouts.with_overrides(crate::api::Timeouts {
570            text: Some(2_000),
571            ready: Some(5_000),
572            ..Default::default()
573        });
574        assert_eq!(merged.text, Some(2_000));
575        assert_eq!(merged.command, Some(30_000));
576        assert_eq!(merged.ready, Some(5_000));
577    }
578
579    #[test]
580    fn an_unknown_timeout_class_is_rejected() {
581        let err = ConfigFile::parse("[profiles.ci.timeouts]\ncommands = 10\n")
582            .unwrap_err()
583            .to_string();
584        assert!(err.contains("commands"), "{err}");
585    }
586
587    #[test]
588    fn an_unknown_profile_names_the_ones_that_exist() {
589        let cfg = ConfigFile::parse("[profiles.ci]\n[profiles.demo]\n").unwrap();
590        let err = cfg.profile(Some("nope")).unwrap_err().to_string();
591        assert!(err.contains("ci") && err.contains("demo"), "{err}");
592    }
593
594    /// A typo in a key is an error rather than a setting that silently does
595    /// nothing.
596    #[test]
597    fn an_unknown_key_is_rejected() {
598        let err = ConfigFile::parse("[profiles.ci]\nscrollbacks = 10\n")
599            .unwrap_err()
600            .to_string();
601        assert!(err.contains("scrollbacks"), "{err}");
602    }
603
604    /// Above the sixteen configurable slots the palette is spec, not
605    /// preference, so profiles cannot disagree about what `--fg 196` means.
606    #[test]
607    fn the_color_cube_ignores_the_profile() {
608        let recolored = Colors {
609            red: Rgb::new(1, 2, 3),
610            ..Default::default()
611        };
612        for n in 16u8..=255 {
613            assert_eq!(recolored.rgb(n), Colors::default().rgb(n), "index {n}");
614        }
615        assert_eq!(Colors::default().rgb(196), Rgb::new(255, 0, 0));
616        assert_eq!(Colors::default().rgb(232), Rgb::new(8, 8, 8));
617        assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "but slot 1 follows it");
618    }
619
620    /// Every configurable slot is reachable by the name the file uses, so the
621    /// documented key set and the resolver cannot drift apart.
622    #[test]
623    fn every_ansi_slot_has_a_config_key() {
624        for i in 0u8..16 {
625            let name = Colors::slot_name(i).unwrap_or_else(|| panic!("slot {i} unnamed"));
626            let toml = format!("[profiles.p.colors]\n{name} = \"#010203\"\n");
627            let p = ConfigFile::parse(&toml)
628                .unwrap()
629                .profile(Some("p"))
630                .unwrap();
631            assert_eq!(
632                p.colors.rgb(i),
633                Rgb::new(1, 2, 3),
634                "setting {name:?} must move slot {i}"
635            );
636        }
637        assert_eq!(Colors::slot_name(16), None, "only 0-15 are configurable");
638    }
639
640    #[test]
641    fn every_binding_color_name_is_settable() {
642        let mut colors = Colors::default();
643        let replacement = Rgb::new(1, 2, 3);
644        for name in [
645            "foreground",
646            "background",
647            "cursor",
648            "black",
649            "red",
650            "green",
651            "yellow",
652            "blue",
653            "magenta",
654            "cyan",
655            "white",
656            "bright_black",
657            "bright_red",
658            "bright_green",
659            "bright_yellow",
660            "bright_blue",
661            "bright_magenta",
662            "bright_cyan",
663            "bright_white",
664        ] {
665            assert!(colors.set_named(name, replacement), "{name}");
666        }
667        assert!(!colors.set_named("chartreuse", replacement));
668        assert!([
669            colors.foreground,
670            colors.background,
671            colors.cursor,
672            colors.black,
673            colors.red,
674            colors.green,
675            colors.yellow,
676            colors.blue,
677            colors.magenta,
678            colors.cyan,
679            colors.white,
680            colors.bright_black,
681            colors.bright_red,
682            colors.bright_green,
683            colors.bright_yellow,
684            colors.bright_blue,
685            colors.bright_magenta,
686            colors.bright_cyan,
687            colors.bright_white,
688        ]
689        .into_iter()
690        .all(|color| color == replacement));
691    }
692
693    /// The 16 configurable slots come from the profile; the rest come from the
694    /// xterm table, which is the same in every terminal.
695    #[test]
696    fn only_the_ansi_slots_follow_the_profile() {
697        let recolored = Colors {
698            red: Rgb::new(1, 2, 3),
699            ..Default::default()
700        };
701        assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "slot 1 follows it");
702        for index in 16u8..=255 {
703            assert_eq!(
704                recolored.rgb(index),
705                xterm_color(index),
706                "slot {index} is fixed by the specification"
707            );
708        }
709    }
710
711    /// Spot-check the static table against the values the specification
712    /// defines, so a typo in 256 entries cannot pass unnoticed.
713    #[test]
714    fn the_xterm_table_matches_the_specification() {
715        assert_eq!(xterm_color(0), Rgb::new(0, 0, 0), "VGA black");
716        assert_eq!(xterm_color(1), Rgb::new(128, 0, 0), "VGA red");
717        assert_eq!(xterm_color(15), Rgb::new(255, 255, 255), "VGA bright white");
718        assert_eq!(
719            xterm_color(16),
720            Rgb::new(0, 0, 0),
721            "the cube starts at black"
722        );
723        assert_eq!(xterm_color(196), Rgb::new(255, 0, 0), "cube red");
724        assert_eq!(
725            xterm_color(231),
726            Rgb::new(255, 255, 255),
727            "the cube ends white"
728        );
729        assert_eq!(xterm_color(232), Rgb::new(8, 8, 8), "the ramp starts at 8");
730        assert_eq!(
731            xterm_color(255),
732            Rgb::new(238, 238, 238),
733            "the ramp ends at 238"
734        );
735    }
736
737    /// A project-local file wins over the user's, so a repository can pin the
738    /// terminal its tests expect. `TUI_TEST_CONFIG` replaces discovery.
739    #[test]
740    fn the_search_order_puts_the_project_first() {
741        let _guard = ENV_LOCK.lock().unwrap();
742
743        let old = std::env::var_os("TUI_TEST_CONFIG");
744        std::env::remove_var("TUI_TEST_CONFIG");
745        let cwd = std::env::temp_dir().join("some-project");
746        let pinned_path = std::env::temp_dir().join("pinned.toml");
747        let result = std::panic::catch_unwind(|| {
748            let paths = search_paths(&cwd);
749            assert!(paths.len() >= 2);
750            assert_eq!(paths[0], cwd.join(CONFIG_FILE), "the project file is first");
751            assert!(
752                paths[1..].iter().all(|path| path.ends_with(CONFIG_FILE)),
753                "every user candidate names the config file: {paths:?}"
754            );
755
756            std::env::set_var("TUI_TEST_CONFIG", &pinned_path);
757            let pinned = search_paths(&cwd);
758            assert_eq!(
759                pinned,
760                vec![pinned_path.clone()],
761                "an explicit config replaces the search entirely"
762            );
763        });
764        std::env::remove_var("TUI_TEST_CONFIG");
765        if let Some(value) = old {
766            std::env::set_var("TUI_TEST_CONFIG", value);
767        }
768        result.unwrap();
769    }
770
771    #[test]
772    fn the_platform_config_directory_precedes_tui_test_home() {
773        let cwd = Path::new("project");
774        let config_home = Path::new("xdg-config");
775        let tui_test_home = Path::new("tui-test-home");
776        assert_eq!(
777            config_search_paths(cwd, Some(config_home), tui_test_home),
778            vec![
779                cwd.join(CONFIG_FILE),
780                config_home.join("tui-test").join(CONFIG_FILE),
781                tui_test_home.join(CONFIG_FILE),
782            ]
783        );
784    }
785
786    /// An environment override is an explicit request, just like `--config`,
787    /// so a typo must not silently fall back to the built-in profile.
788    #[test]
789    fn a_missing_environment_override_is_an_error() {
790        let _guard = ENV_LOCK.lock().unwrap();
791        let old = std::env::var_os("TUI_TEST_CONFIG");
792        let dir = std::env::temp_dir().join(format!("su-profile-env-{}", std::process::id()));
793        std::fs::create_dir_all(&dir).unwrap();
794        let missing = dir.join("missing.toml");
795
796        std::env::set_var("TUI_TEST_CONFIG", &missing);
797        let result = std::panic::catch_unwind(|| {
798            let err = resolve(None, None, &dir).unwrap_err().to_string();
799            assert!(
800                err.contains("missing.toml"),
801                "the explicit missing path is named: {err}"
802            );
803        });
804
805        std::env::remove_var("TUI_TEST_CONFIG");
806        if let Some(value) = old {
807            std::env::set_var("TUI_TEST_CONFIG", value);
808        }
809        std::fs::remove_dir_all(&dir).ok();
810        result.unwrap();
811    }
812
813    /// Running without a config file is normal, so a missing one is not an
814    /// error. A file that exists but does not parse is, because ignoring it
815    /// would silently run with settings nobody asked for.
816    #[test]
817    fn a_missing_config_defaults_but_a_broken_one_fails() {
818        let dir = std::env::temp_dir().join(format!("su-profile-{}", std::process::id()));
819        std::fs::create_dir_all(&dir).unwrap();
820
821        let missing = dir.join("absent.toml");
822        assert!(
823            resolve(Some(&missing), None, &dir).is_err(),
824            "named-but-absent is an error"
825        );
826
827        let broken = dir.join("broken.toml");
828        std::fs::write(&broken, "[profiles.ci]\nscrollback = \"lots\"\n").unwrap();
829        let err = resolve(Some(&broken), None, &dir).unwrap_err().to_string();
830        assert!(
831            err.contains("broken.toml"),
832            "the error names the file: {err}"
833        );
834
835        std::fs::remove_dir_all(&dir).ok();
836    }
837}