use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::terminal::cell::NamedColor;
pub const DEFAULT_SCROLLBACK: usize = 10_000;
pub const CONFIG_FILE: &str = "tui-test.toml";
pub const DEFAULT_PROFILE: &str = "default";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Rgb { r, g, b }
}
pub fn to_hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
pub fn parse(s: &str) -> Result<Self, String> {
let trimmed = s.trim();
let hex = trimmed.strip_prefix('#').unwrap_or(trimmed);
let digit = |byte: u8| -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
};
let digits = hex
.bytes()
.map(digit)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| format!("invalid hex color {s:?}"))?;
match digits.as_slice() {
[r, g, b] => Ok(Rgb::new(r * 17, g * 17, b * 17)),
[r1, r2, g1, g2, b1, b2] => Ok(Rgb::new(r1 * 16 + r2, g1 * 16 + g2, b1 * 16 + b2)),
_ => Err(format!("color must be #rgb or #rrggbb (got {s:?})")),
}
}
}
impl Serialize for Rgb {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for Rgb {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Rgb::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Colors {
pub foreground: Rgb,
pub background: Rgb,
pub cursor: Rgb,
pub black: Rgb,
pub red: Rgb,
pub green: Rgb,
pub yellow: Rgb,
pub blue: Rgb,
pub magenta: Rgb,
pub cyan: Rgb,
pub white: Rgb,
pub bright_black: Rgb,
pub bright_red: Rgb,
pub bright_green: Rgb,
pub bright_yellow: Rgb,
pub bright_blue: Rgb,
pub bright_magenta: Rgb,
pub bright_cyan: Rgb,
pub bright_white: Rgb,
}
impl Default for Colors {
fn default() -> Self {
Colors {
foreground: Rgb::new(192, 192, 192),
background: Rgb::new(0, 0, 0),
cursor: Rgb::new(192, 192, 192),
black: Rgb::new(0, 0, 0),
red: Rgb::new(128, 0, 0),
green: Rgb::new(0, 128, 0),
yellow: Rgb::new(128, 128, 0),
blue: Rgb::new(0, 0, 128),
magenta: Rgb::new(128, 0, 128),
cyan: Rgb::new(0, 128, 128),
white: Rgb::new(192, 192, 192),
bright_black: Rgb::new(128, 128, 128),
bright_red: Rgb::new(255, 0, 0),
bright_green: Rgb::new(0, 255, 0),
bright_yellow: Rgb::new(255, 255, 0),
bright_blue: Rgb::new(0, 0, 255),
bright_magenta: Rgb::new(255, 0, 255),
bright_cyan: Rgb::new(0, 255, 255),
bright_white: Rgb::new(255, 255, 255),
}
}
}
impl Colors {
pub fn ansi(&self) -> [Rgb; 16] {
[
self.black,
self.red,
self.green,
self.yellow,
self.blue,
self.magenta,
self.cyan,
self.white,
self.bright_black,
self.bright_red,
self.bright_green,
self.bright_yellow,
self.bright_blue,
self.bright_magenta,
self.bright_cyan,
self.bright_white,
]
}
pub fn slot_name(index: u8) -> Option<&'static str> {
Some(match NamedColor::from_index(index)? {
NamedColor::Black => "black",
NamedColor::Red => "red",
NamedColor::Green => "green",
NamedColor::Yellow => "yellow",
NamedColor::Blue => "blue",
NamedColor::Magenta => "magenta",
NamedColor::Cyan => "cyan",
NamedColor::White => "white",
NamedColor::BrightBlack => "bright_black",
NamedColor::BrightRed => "bright_red",
NamedColor::BrightGreen => "bright_green",
NamedColor::BrightYellow => "bright_yellow",
NamedColor::BrightBlue => "bright_blue",
NamedColor::BrightMagenta => "bright_magenta",
NamedColor::BrightCyan => "bright_cyan",
NamedColor::BrightWhite => "bright_white",
})
}
pub fn set_named(&mut self, name: &str, value: Rgb) -> bool {
let target = match name {
"foreground" => &mut self.foreground,
"background" => &mut self.background,
"cursor" => &mut self.cursor,
"black" => &mut self.black,
"red" => &mut self.red,
"green" => &mut self.green,
"yellow" => &mut self.yellow,
"blue" => &mut self.blue,
"magenta" => &mut self.magenta,
"cyan" => &mut self.cyan,
"white" => &mut self.white,
"bright_black" => &mut self.bright_black,
"bright_red" => &mut self.bright_red,
"bright_green" => &mut self.bright_green,
"bright_yellow" => &mut self.bright_yellow,
"bright_blue" => &mut self.bright_blue,
"bright_magenta" => &mut self.bright_magenta,
"bright_cyan" => &mut self.bright_cyan,
"bright_white" => &mut self.bright_white,
_ => return false,
};
*target = value;
true
}
pub fn rgb(&self, index: u8) -> Rgb {
match index {
0..=15 => self.ansi()[index as usize],
_ => xterm_color(index),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSlot {
Indexed(u8),
Foreground,
Background,
Cursor,
}
static XTERM_256: [Rgb; 256] = build_xterm_256();
const fn build_xterm_256() -> [Rgb; 256] {
let mut table = [Rgb::new(0, 0, 0); 256];
let vga = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
let mut i = 0;
while i < 16 {
table[i] = Rgb::new(vga[i].0, vga[i].1, vga[i].2);
i += 1;
}
let levels = [0u8, 95, 135, 175, 215, 255];
while i < 232 {
let n = i - 16;
table[i] = Rgb::new(levels[(n / 36) % 6], levels[(n / 6) % 6], levels[n % 6]);
i += 1;
}
while i < 256 {
let v = (i - 232) as u8 * 10 + 8;
table[i] = Rgb::new(v, v, v);
i += 1;
}
table
}
pub fn xterm_color(index: u8) -> Rgb {
XTERM_256[index as usize]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
pub scrollback: usize,
pub colors: Colors,
}
impl Default for Profile {
fn default() -> Self {
Profile {
scrollback: DEFAULT_SCROLLBACK,
colors: Colors::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConfigProfile {
pub scrollback: usize,
pub colors: Colors,
pub timeouts: crate::api::Timeouts,
}
impl Default for ConfigProfile {
fn default() -> Self {
Self {
scrollback: DEFAULT_SCROLLBACK,
colors: Colors::default(),
timeouts: crate::api::Timeouts::default(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Settings {
pub profile: Profile,
pub timeouts: crate::api::Timeouts,
}
impl From<ConfigProfile> for Settings {
fn from(value: ConfigProfile) -> Self {
Self {
profile: Profile {
scrollback: value.scrollback,
colors: value.colors,
},
timeouts: value.timeouts,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ConfigFile {
pub profiles: BTreeMap<String, ConfigProfile>,
}
impl ConfigFile {
pub fn parse(toml_text: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(toml_text)?)
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?;
Self::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
}
pub fn profile(&self, name: Option<&str>) -> anyhow::Result<Profile> {
Ok(self.settings(name)?.profile)
}
pub fn settings(&self, name: Option<&str>) -> anyhow::Result<Settings> {
let profile = match name {
Some(name) => self.profiles.get(name).copied().ok_or_else(|| {
let known: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
if known.is_empty() {
anyhow::anyhow!("no profile {name:?}; the config file defines none")
} else {
anyhow::anyhow!("no profile {name:?}; found: {}", known.join(", "))
}
}),
None => Ok(self
.profiles
.get(DEFAULT_PROFILE)
.copied()
.unwrap_or_default()),
}?;
Ok(profile.into())
}
}
pub fn search_paths(cwd: &Path) -> Vec<PathBuf> {
if let Some(explicit) = std::env::var_os("TUI_TEST_CONFIG") {
return vec![PathBuf::from(explicit)];
}
default_search_paths(cwd)
}
fn default_search_paths(cwd: &Path) -> Vec<PathBuf> {
let config_home = if std::env::var_os("TUI_TEST_HOME").is_none() {
dirs::config_dir()
} else {
None
};
config_search_paths(cwd, config_home.as_deref(), &crate::config::home_dir())
}
fn config_search_paths(
cwd: &Path,
platform_config_home: Option<&Path>,
tui_test_home: &Path,
) -> Vec<PathBuf> {
let mut paths = vec![cwd.join(CONFIG_FILE)];
if let Some(config_home) = platform_config_home {
paths.push(config_home.join("tui-test").join(CONFIG_FILE));
}
let home_config = tui_test_home.join(CONFIG_FILE);
if !paths.contains(&home_config) {
paths.push(home_config);
}
paths
}
pub fn resolve(
explicit_config: Option<&Path>,
profile_name: Option<&str>,
cwd: &Path,
) -> anyhow::Result<Profile> {
Ok(resolve_settings(explicit_config, profile_name, cwd)?.profile)
}
pub fn resolve_settings(
explicit_config: Option<&Path>,
profile_name: Option<&str>,
cwd: &Path,
) -> anyhow::Result<Settings> {
if let Some(path) = explicit_config {
return ConfigFile::load(path)?.settings(profile_name);
}
if let Some(path) = std::env::var_os("TUI_TEST_CONFIG").map(PathBuf::from) {
return ConfigFile::load(&path)?.settings(profile_name);
}
for path in default_search_paths(cwd) {
if path.is_file() {
return ConfigFile::load(&path)?.settings(profile_name);
}
}
match profile_name {
Some(name) => anyhow::bail!("no profile {name:?}: no config file found"),
None => Ok(Settings::default()),
}
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn hex_colors_round_trip() {
for raw in ["#000000", "#ffffff", "#800000", "#c0c0c0"] {
assert_eq!(Rgb::parse(raw).unwrap().to_hex(), raw);
}
assert_eq!(Rgb::parse("#f00").unwrap(), Rgb::new(255, 0, 0));
assert_eq!(Rgb::parse("800000").unwrap(), Rgb::new(128, 0, 0));
}
#[test]
fn a_bad_color_says_what_it_wanted() {
for raw in ["", "#12", "#1234567", "nope", "#gggggg", "éa", "##fff"] {
let err = Rgb::parse(raw).unwrap_err();
assert!(
err.contains("color") || err.contains("hex"),
"{raw:?}: {err}"
);
}
}
#[test]
fn an_empty_config_yields_the_defaults() {
let cfg = ConfigFile::parse("").unwrap();
assert_eq!(cfg.profile(None).unwrap(), Profile::default());
assert_eq!(Profile::default().scrollback, 10_000);
}
#[test]
fn a_partial_profile_keeps_the_other_defaults() {
let cfg = ConfigFile::parse(
r##"
[profiles.ci]
scrollback = 50
[profiles.ci.colors]
red = "#ff0000"
"##,
)
.unwrap();
let p = cfg.profile(Some("ci")).unwrap();
assert_eq!(p.scrollback, 50);
assert_eq!(p.colors.red, Rgb::new(255, 0, 0), "the override applies");
assert_eq!(
p.colors.green,
Colors::default().green,
"an unset slot keeps its default"
);
assert_eq!(
p.colors.background,
Colors::default().background,
"an unset default color is untouched"
);
}
#[test]
fn profile_timeouts_are_loaded_and_accept_cli_overrides() {
let cfg = ConfigFile::parse(
r#"
[profiles.ci.timeouts]
text = 1000
command = 30000
"#,
)
.unwrap();
let settings = cfg.settings(Some("ci")).unwrap();
assert_eq!(settings.timeouts.text, Some(1_000));
assert_eq!(settings.timeouts.command, Some(30_000));
assert_eq!(settings.timeouts.ready, None);
let merged = settings.timeouts.with_overrides(crate::api::Timeouts {
text: Some(2_000),
ready: Some(5_000),
..Default::default()
});
assert_eq!(merged.text, Some(2_000));
assert_eq!(merged.command, Some(30_000));
assert_eq!(merged.ready, Some(5_000));
}
#[test]
fn an_unknown_timeout_class_is_rejected() {
let err = ConfigFile::parse("[profiles.ci.timeouts]\ncommands = 10\n")
.unwrap_err()
.to_string();
assert!(err.contains("commands"), "{err}");
}
#[test]
fn an_unknown_profile_names_the_ones_that_exist() {
let cfg = ConfigFile::parse("[profiles.ci]\n[profiles.demo]\n").unwrap();
let err = cfg.profile(Some("nope")).unwrap_err().to_string();
assert!(err.contains("ci") && err.contains("demo"), "{err}");
}
#[test]
fn an_unknown_key_is_rejected() {
let err = ConfigFile::parse("[profiles.ci]\nscrollbacks = 10\n")
.unwrap_err()
.to_string();
assert!(err.contains("scrollbacks"), "{err}");
}
#[test]
fn the_color_cube_ignores_the_profile() {
let recolored = Colors {
red: Rgb::new(1, 2, 3),
..Default::default()
};
for n in 16u8..=255 {
assert_eq!(recolored.rgb(n), Colors::default().rgb(n), "index {n}");
}
assert_eq!(Colors::default().rgb(196), Rgb::new(255, 0, 0));
assert_eq!(Colors::default().rgb(232), Rgb::new(8, 8, 8));
assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "but slot 1 follows it");
}
#[test]
fn every_ansi_slot_has_a_config_key() {
for i in 0u8..16 {
let name = Colors::slot_name(i).unwrap_or_else(|| panic!("slot {i} unnamed"));
let toml = format!("[profiles.p.colors]\n{name} = \"#010203\"\n");
let p = ConfigFile::parse(&toml)
.unwrap()
.profile(Some("p"))
.unwrap();
assert_eq!(
p.colors.rgb(i),
Rgb::new(1, 2, 3),
"setting {name:?} must move slot {i}"
);
}
assert_eq!(Colors::slot_name(16), None, "only 0-15 are configurable");
}
#[test]
fn every_binding_color_name_is_settable() {
let mut colors = Colors::default();
let replacement = Rgb::new(1, 2, 3);
for name in [
"foreground",
"background",
"cursor",
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
] {
assert!(colors.set_named(name, replacement), "{name}");
}
assert!(!colors.set_named("chartreuse", replacement));
assert!([
colors.foreground,
colors.background,
colors.cursor,
colors.black,
colors.red,
colors.green,
colors.yellow,
colors.blue,
colors.magenta,
colors.cyan,
colors.white,
colors.bright_black,
colors.bright_red,
colors.bright_green,
colors.bright_yellow,
colors.bright_blue,
colors.bright_magenta,
colors.bright_cyan,
colors.bright_white,
]
.into_iter()
.all(|color| color == replacement));
}
#[test]
fn only_the_ansi_slots_follow_the_profile() {
let recolored = Colors {
red: Rgb::new(1, 2, 3),
..Default::default()
};
assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "slot 1 follows it");
for index in 16u8..=255 {
assert_eq!(
recolored.rgb(index),
xterm_color(index),
"slot {index} is fixed by the specification"
);
}
}
#[test]
fn the_xterm_table_matches_the_specification() {
assert_eq!(xterm_color(0), Rgb::new(0, 0, 0), "VGA black");
assert_eq!(xterm_color(1), Rgb::new(128, 0, 0), "VGA red");
assert_eq!(xterm_color(15), Rgb::new(255, 255, 255), "VGA bright white");
assert_eq!(
xterm_color(16),
Rgb::new(0, 0, 0),
"the cube starts at black"
);
assert_eq!(xterm_color(196), Rgb::new(255, 0, 0), "cube red");
assert_eq!(
xterm_color(231),
Rgb::new(255, 255, 255),
"the cube ends white"
);
assert_eq!(xterm_color(232), Rgb::new(8, 8, 8), "the ramp starts at 8");
assert_eq!(
xterm_color(255),
Rgb::new(238, 238, 238),
"the ramp ends at 238"
);
}
#[test]
fn the_search_order_puts_the_project_first() {
let _guard = ENV_LOCK.lock().unwrap();
let old = std::env::var_os("TUI_TEST_CONFIG");
std::env::remove_var("TUI_TEST_CONFIG");
let cwd = std::env::temp_dir().join("some-project");
let pinned_path = std::env::temp_dir().join("pinned.toml");
let result = std::panic::catch_unwind(|| {
let paths = search_paths(&cwd);
assert!(paths.len() >= 2);
assert_eq!(paths[0], cwd.join(CONFIG_FILE), "the project file is first");
assert!(
paths[1..].iter().all(|path| path.ends_with(CONFIG_FILE)),
"every user candidate names the config file: {paths:?}"
);
std::env::set_var("TUI_TEST_CONFIG", &pinned_path);
let pinned = search_paths(&cwd);
assert_eq!(
pinned,
vec![pinned_path.clone()],
"an explicit config replaces the search entirely"
);
});
std::env::remove_var("TUI_TEST_CONFIG");
if let Some(value) = old {
std::env::set_var("TUI_TEST_CONFIG", value);
}
result.unwrap();
}
#[test]
fn the_platform_config_directory_precedes_tui_test_home() {
let cwd = Path::new("project");
let config_home = Path::new("xdg-config");
let tui_test_home = Path::new("tui-test-home");
assert_eq!(
config_search_paths(cwd, Some(config_home), tui_test_home),
vec![
cwd.join(CONFIG_FILE),
config_home.join("tui-test").join(CONFIG_FILE),
tui_test_home.join(CONFIG_FILE),
]
);
}
#[test]
fn a_missing_environment_override_is_an_error() {
let _guard = ENV_LOCK.lock().unwrap();
let old = std::env::var_os("TUI_TEST_CONFIG");
let dir = std::env::temp_dir().join(format!("su-profile-env-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let missing = dir.join("missing.toml");
std::env::set_var("TUI_TEST_CONFIG", &missing);
let result = std::panic::catch_unwind(|| {
let err = resolve(None, None, &dir).unwrap_err().to_string();
assert!(
err.contains("missing.toml"),
"the explicit missing path is named: {err}"
);
});
std::env::remove_var("TUI_TEST_CONFIG");
if let Some(value) = old {
std::env::set_var("TUI_TEST_CONFIG", value);
}
std::fs::remove_dir_all(&dir).ok();
result.unwrap();
}
#[test]
fn a_missing_config_defaults_but_a_broken_one_fails() {
let dir = std::env::temp_dir().join(format!("su-profile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let missing = dir.join("absent.toml");
assert!(
resolve(Some(&missing), None, &dir).is_err(),
"named-but-absent is an error"
);
let broken = dir.join("broken.toml");
std::fs::write(&broken, "[profiles.ci]\nscrollback = \"lots\"\n").unwrap();
let err = resolve(Some(&broken), None, &dir).unwrap_err().to_string();
assert!(
err.contains("broken.toml"),
"the error names the file: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
}