use std::env;
use std::path::{Path, PathBuf};
use ratatui::style::{Color, Modifier, Style};
use serde::Deserialize;
use crate::cli::TopGlyphs;
#[derive(Debug, Clone)]
pub struct Skin {
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub cool: Color,
pub warm: Color,
pub hot: Color,
pub muted: Color,
#[allow(dead_code)]
pub categorical: Vec<Color>,
}
impl Skin {
pub fn default_dark() -> Self {
Self {
primary: Color::Rgb(232, 232, 240),
secondary: Color::Rgb(180, 184, 192),
accent: Color::Rgb(86, 192, 240),
cool: Color::Rgb(57, 173, 152),
warm: Color::Rgb(245, 191, 79),
hot: Color::Rgb(232, 84, 90),
muted: Color::Rgb(96, 100, 112),
categorical: OKABE_ITO_PLUS.to_vec(),
}
}
pub fn resolve(name: Option<&str>) -> (Self, Option<String>) {
let env_choice = env::var("SOZU_TOP_SKIN").ok();
let effective = env_choice.as_deref().or(name);
let choice = match effective {
Some("") | Some("default") | Some("none") | None => {
return (Self::default_dark(), None);
}
Some(other) => other,
};
let default_with = |msg: String| (Self::default_dark(), Some(msg));
match Self::lookup_paths(choice).into_iter().find(|p| p.is_file()) {
Some(path) => {
let Ok(resolved) = path.canonicalize() else {
return default_with(format!(
"skin `{choice}` canonicalize failed; using default"
));
};
let Some(anchor) = Self::skins_anchor(&path) else {
return default_with(format!(
"skin `{choice}` anchor resolve failed; using default"
));
};
if !resolved.starts_with(&anchor) {
return default_with(format!(
"skin `{choice}` resolved outside skins dir; using default"
));
}
match Self::from_open_file(&resolved) {
Ok(skin) => (skin, None),
Err(e) => {
default_with(format!("skin `{choice}` parse error: {e}; using default"))
}
}
}
None => default_with(format!("skin `{choice}` not found; using default")),
}
}
fn skins_anchor(candidate: &Path) -> Option<PathBuf> {
candidate.parent()?.canonicalize().ok()
}
pub fn from_open_file(path: &Path) -> Result<Self, SkinError> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(SkinError::Io)?;
let mut body = String::new();
file.read_to_string(&mut body).map_err(SkinError::Io)?;
let raw: RawSkin = toml::from_str(&body).map_err(|e| SkinError::Parse(e.to_string()))?;
raw.into_skin().map_err(SkinError::Validate)
}
fn lookup_paths(name: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
if name.contains('/') || name.contains('\\') || name.contains("..") {
return paths;
}
let xdg = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
if let Some(base) = xdg {
paths.push(base.join("sozu").join("skins").join(format!("{name}.toml")));
}
paths.push(PathBuf::from("/etc/sozu/skins").join(format!("{name}.toml")));
paths
}
pub fn tab_focused(&self) -> Style {
Style::default()
.fg(self.primary)
.bg(self.accent)
.add_modifier(Modifier::BOLD)
}
pub fn tab_unfocused(&self) -> Style {
Style::default().fg(self.muted)
}
pub fn spark_color(&self, pos: f32) -> Color {
if pos < 0.5 {
self.cool
} else if pos < 0.85 {
self.warm
} else {
self.hot
}
}
pub fn row_critical(&self) -> Style {
Style::default().fg(self.hot).add_modifier(Modifier::BOLD)
}
pub fn pulse_hot(&self) -> Style {
Style::default()
.fg(self.hot)
.bg(self.muted)
.add_modifier(Modifier::BOLD)
}
pub fn pulse_cool(&self) -> Style {
Style::default()
.fg(self.cool)
.bg(self.muted)
.add_modifier(Modifier::BOLD)
}
pub fn fkey_label(&self) -> Style {
Style::default()
.fg(self.primary)
.bg(self.muted)
.add_modifier(Modifier::BOLD)
}
pub fn fkey_action(&self) -> Style {
Style::default().fg(self.secondary)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SkinError {
#[error("read skin: {0}")]
Io(std::io::Error),
#[error("parse skin: {0}")]
Parse(String),
#[error("validate skin: {0}")]
Validate(String),
}
#[derive(Debug, Deserialize)]
struct RawSkin {
primary: String,
secondary: String,
accent: String,
cool: String,
warm: String,
hot: String,
muted: String,
#[serde(default)]
categorical: Vec<String>,
}
impl RawSkin {
fn into_skin(self) -> Result<Skin, String> {
let primary = parse_hex(&self.primary, "primary")?;
let secondary = parse_hex(&self.secondary, "secondary")?;
let accent = parse_hex(&self.accent, "accent")?;
let cool = parse_hex(&self.cool, "cool")?;
let warm = parse_hex(&self.warm, "warm")?;
let hot = parse_hex(&self.hot, "hot")?;
let muted = parse_hex(&self.muted, "muted")?;
let categorical: Vec<Color> = if self.categorical.is_empty() {
OKABE_ITO_PLUS.to_vec()
} else {
self.categorical
.iter()
.enumerate()
.map(|(i, s)| parse_hex(s, &format!("categorical[{i}]")))
.collect::<Result<Vec<_>, _>>()?
};
Ok(Skin {
primary,
secondary,
accent,
cool,
warm,
hot,
muted,
categorical,
})
}
}
fn parse_hex(s: &str, field: &str) -> Result<Color, String> {
let raw = s.trim_start_matches('#');
if raw.len() != 6 {
return Err(format!(
"field `{field}`: expected #RRGGBB hex colour, got `{s}`"
));
}
let bytes = match u32::from_str_radix(raw, 16) {
Ok(n) => n,
Err(_) => return Err(format!("field `{field}`: `{s}` is not hex")),
};
let r = ((bytes >> 16) & 0xff) as u8;
let g = ((bytes >> 8) & 0xff) as u8;
let b = (bytes & 0xff) as u8;
Ok(Color::Rgb(r, g, b))
}
const OKABE_ITO_PLUS: &[Color] = &[
Color::Rgb(0, 158, 115), Color::Rgb(86, 180, 233), Color::Rgb(213, 94, 0), Color::Rgb(204, 121, 167), Color::Rgb(240, 228, 66), Color::Rgb(0, 114, 178), Color::Rgb(230, 159, 0), Color::Rgb(247, 209, 60),
Color::Rgb(94, 201, 97),
];
#[derive(Debug, Clone, Copy)]
pub enum GlyphMode {
Braille,
Block,
Tty,
}
impl GlyphMode {
pub fn sparkline_set(self) -> ratatui::symbols::bar::Set<'static> {
use ratatui::symbols::bar::{NINE_LEVELS, Set};
match self {
Self::Block => NINE_LEVELS,
Self::Braille => Set {
full: "⣿",
seven_eighths: "⣷",
three_quarters: "⣧",
five_eighths: "⣇",
half: "⡇",
three_eighths: "⡆",
one_quarter: "⡄",
one_eighth: "⡀",
empty: " ",
},
Self::Tty => Set {
full: "#",
seven_eighths: "#",
three_quarters: "+",
five_eighths: "+",
half: "=",
three_eighths: "-",
one_quarter: "-",
one_eighth: ".",
empty: " ",
},
}
}
pub fn trend_alphabet(self) -> &'static [char] {
match self {
Self::Block => &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'],
Self::Braille => &['⡀', '⡄', '⡆', '⡇', '⣇', '⣧', '⣷', '⣿'],
Self::Tty => &['.', ',', '-', '=', '+', '*', 'o', '#'],
}
}
pub fn cycle(self) -> Self {
match self {
Self::Block => Self::Braille,
Self::Braille => Self::Tty,
Self::Tty => Self::Block,
}
}
pub fn resolve(override_: Option<TopGlyphs>) -> Self {
if let Some(forced) = override_ {
return match forced {
TopGlyphs::Braille => Self::Braille,
TopGlyphs::Block => Self::Block,
TopGlyphs::Tty => Self::Tty,
};
}
Self::autodetect()
}
fn autodetect() -> Self {
let term = std::env::var("TERM").unwrap_or_default();
let term_lower = term.to_ascii_lowercase();
if term_lower.is_empty()
|| term_lower == "dumb"
|| term_lower == "linux"
|| term_lower == "xterm-old"
|| term_lower.ends_with("-mono")
|| term_lower.contains("-mono-")
{
return Self::Tty;
}
let locale = std::env::var("LC_ALL")
.or_else(|_| std::env::var("LC_CTYPE"))
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default();
let locale_upper = locale.to_ascii_uppercase();
let is_c_locale =
locale_upper == "C" || locale_upper == "POSIX" || locale_upper.starts_with("C.");
let is_utf8 = locale_upper.contains("UTF-8") || locale_upper.contains("UTF8");
if is_utf8 && !is_c_locale {
Self::Braille
} else {
Self::Block
}
}
}
pub const GLYPH_RISING: &str = "▲";
pub const GLYPH_FALLING: &str = "▼";
pub const GLYPH_STEADY: &str = "●";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hex_accepts_hash_prefix_and_bare() {
assert_eq!(
parse_hex("#56c0f0", "x").unwrap(),
Color::Rgb(0x56, 0xc0, 0xf0)
);
assert_eq!(
parse_hex("56c0f0", "x").unwrap(),
Color::Rgb(0x56, 0xc0, 0xf0)
);
}
#[test]
fn parse_hex_rejects_wrong_length() {
assert!(parse_hex("#abc", "x").is_err());
assert!(parse_hex("#abcdefgg", "x").is_err());
}
#[test]
fn skin_from_toml_round_trip() {
let toml = r##"
primary = "#e8e8f0"
secondary = "#b4b8c0"
accent = "#56c0f0"
cool = "#39ad98"
warm = "#f5bf4f"
hot = "#e8545a"
muted = "#606470"
categorical = ["#009e73", "#56b4e9"]
"##;
let raw: RawSkin = toml::from_str(toml).expect("parse");
let skin = raw.into_skin().expect("validate");
assert_eq!(skin.hot, Color::Rgb(0xe8, 0x54, 0x5a));
assert_eq!(skin.categorical.len(), 2);
}
#[test]
fn skin_from_toml_empty_categorical_uses_default() {
let toml = r##"
primary = "#e8e8f0"
secondary = "#b4b8c0"
accent = "#56c0f0"
cool = "#39ad98"
warm = "#f5bf4f"
hot = "#e8545a"
muted = "#606470"
"##;
let raw: RawSkin = toml::from_str(toml).unwrap();
let skin = raw.into_skin().unwrap();
assert_eq!(skin.categorical.len(), OKABE_ITO_PLUS.len());
}
#[test]
fn skin_lookup_rejects_traversal() {
assert!(Skin::lookup_paths("../etc/passwd").is_empty());
assert!(Skin::lookup_paths("foo/bar").is_empty());
}
#[test]
fn glyph_mode_explicit_override_wins() {
assert!(matches!(
GlyphMode::resolve(Some(TopGlyphs::Tty)),
GlyphMode::Tty
));
assert!(matches!(
GlyphMode::resolve(Some(TopGlyphs::Braille)),
GlyphMode::Braille
));
assert!(matches!(
GlyphMode::resolve(Some(TopGlyphs::Block)),
GlyphMode::Block
));
}
#[test]
fn from_open_file_refuses_leaf_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().expect("create temp skins dir");
let link = tmp.path().join("evil.toml");
let target = Path::new("/etc/hostname");
if !target.exists() {
return;
}
symlink(target, &link).expect("plant symlink");
let err = Skin::from_open_file(&link).expect_err("must refuse symlink leaf");
match err {
SkinError::Io(io) => {
let _ = io;
}
other => panic!("expected Io(ELOOP) error, got {other:?}"),
}
}
}