newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
//! Named theme preferences, sharing the runtime role vocabulary.
use super::{parse_color, role_from_name, role_name, Role, Theme, ALL_ROLES};
use crossterm::style::{Attribute, Color};
use std::path::PathBuf;

const FLAGS: &[(&str, Attribute)] = &[
    ("bold", Attribute::Bold),
    ("dim", Attribute::Dim),
    ("italic", Attribute::Italic),
    ("underline", Attribute::Underlined),
    ("reverse", Attribute::Reverse),
    ("strike", Attribute::CrossedOut),
];

pub fn color_name(color: Color) -> String {
    match color {
        Color::Reset => "default".into(),
        Color::Black => "black".into(),
        Color::DarkRed => "red".into(),
        Color::Red => "light-red".into(),
        Color::DarkGreen => "green".into(),
        Color::Green => "light-green".into(),
        Color::DarkYellow => "yellow".into(),
        Color::Yellow => "light-yellow".into(),
        Color::DarkBlue => "blue".into(),
        Color::Blue => "light-blue".into(),
        Color::DarkMagenta => "magenta".into(),
        Color::Magenta => "light-magenta".into(),
        Color::DarkCyan => "cyan".into(),
        Color::Cyan => "light-cyan".into(),
        Color::Grey => "gray".into(),
        Color::DarkGrey => "dark-gray".into(),
        Color::White => "white".into(),
        Color::AnsiValue(n) => n.to_string(),
        Color::Rgb { r, g, b } => format!("#{r:02x}{g:02x}{b:02x}"),
    }
}

pub fn builtins() -> Vec<Theme> {
    vec![
        Theme::builtin(),
        Theme::builtin().overlaid(
            "daylight",
            &[
                (Role::Text, Color::Black),
                (Role::AgentText, Color::Black),
                (Role::HumanText, Color::DarkBlue),
                (Role::InlineCode, Color::Black),
                (Role::MarkdownStrong, Color::Black),
                (Role::MarkdownHeading, Color::DarkBlue),
                (Role::MarkdownItalic, Color::Black),
                (Role::Dim, Color::DarkGrey),
                (Role::Spill, Color::DarkGrey),
                (Role::SelectedValue, Color::DarkBlue),
                (Role::ModalTitle, Color::DarkBlue),
                (Role::Narration, Color::DarkMagenta),
            ],
        ),
        Theme::builtin().overlaid(
            "phosphor",
            &[
                (Role::AgentText, Color::Green),
                (Role::HumanText, Color::Cyan),
                (Role::Text, Color::Green),
                (Role::Accent, Color::Green),
                (Role::Narration, Color::DarkGreen),
            ],
        ),
    ]
}

fn directory() -> Result<PathBuf, String> {
    crate::settings::settings_path()
        .and_then(|p| p.parent().map(|p| p.join("themes")))
        .ok_or_else(|| "Cannot resolve user theme directory".into())
}

fn valid_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 64
        && name
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}

fn encode(theme: &Theme) -> Result<String, String> {
    let mut doc = toml::Table::new();
    doc.insert("name".into(), theme.name.clone().into());
    let mut roles = toml::Table::new();
    for role in ALL_ROLES {
        let mut row = toml::Table::new();
        row.insert("color".into(), color_name(theme.color(*role)).into());
        for (name, attr) in FLAGS {
            row.insert(
                (*name).into(),
                theme.style(*role).attributes.has(*attr).into(),
            );
        }
        roles.insert(role_name(*role).into(), row.into());
    }
    doc.insert("roles".into(), roles.into());
    toml::to_string_pretty(&doc).map_err(|e| e.to_string())
}

fn decode(text: &str) -> Result<Theme, String> {
    let doc: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
    let name = doc
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or("Theme needs a name")?;
    if !valid_name(name) {
        return Err("Theme name must use 1-64 letters, digits, - or _".into());
    }
    let mut theme = Theme::builtin().overlaid(name, &[]);
    let roles = doc
        .get("roles")
        .and_then(|v| v.as_table())
        .ok_or("Theme needs roles")?;
    for (key, value) in roles {
        let role = role_from_name(key).ok_or_else(|| format!("Unknown theme role: {key}"))?;
        let row = value.as_table().ok_or("Role must be a table")?;
        let mut style = theme.style(role);
        if let Some(color) = row.get("color") {
            style.foreground_color =
                Some(parse_color(color.as_str().ok_or("Color must be text")?)?);
        }
        for (name, attr) in FLAGS {
            if let Some(value) = row.get(*name) {
                if value.as_bool().ok_or("Style flags must be true or false")? {
                    style.attributes.set(*attr);
                } else {
                    style.attributes.unset(*attr);
                }
            }
        }
        theme.set_style(role, style);
    }
    Ok(theme)
}

pub fn list() -> Result<(Vec<Theme>, Vec<String>), String> {
    list_directory(&directory()?)
}

fn list_directory(dir: &std::path::Path) -> Result<(Vec<Theme>, Vec<String>), String> {
    let mut themes = builtins();
    let mut warnings = Vec::new();
    if !dir.exists() {
        return Ok((themes, warnings));
    }
    let mut paths = std::fs::read_dir(dir)
        .map_err(|e| e.to_string())?
        .map(|e| e.map(|e| e.path()))
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| e.to_string())?;
    paths.sort();
    for path in paths.into_iter().filter(|p| {
        p.extension().is_some_and(|s| s == "toml")
            && p.file_name().is_some_and(|s| s != "active.toml")
    }) {
        match std::fs::read_to_string(&path)
            .map_err(|e| e.to_string())
            .and_then(|text| decode(&text))
        {
            Ok(theme)
                if themes
                    .iter()
                    .any(|existing| existing.name.eq_ignore_ascii_case(&theme.name)) =>
            {
                warnings.push(format!(
                    "{}: duplicate theme name {}",
                    path.file_name().unwrap_or_default().to_string_lossy(),
                    theme.name
                ));
            }
            Ok(theme) => themes.push(theme),
            Err(error) => warnings.push(format!(
                "{}: {error}",
                path.file_name().unwrap_or_default().to_string_lossy()
            )),
        }
    }
    Ok((themes, warnings))
}

fn validate_save_name(name: &str, existing: &[Theme]) -> Result<(), String> {
    if !valid_name(name) {
        return Err("Use 1-64 letters, digits, - or _ for the name".into());
    }
    if name.eq_ignore_ascii_case("active")
        || builtins()
            .iter()
            .any(|theme| theme.name.eq_ignore_ascii_case(name))
    {
        return Err("Choose a new name to save a copy of a built-in theme".into());
    }
    if existing
        .iter()
        .any(|theme| theme.name.eq_ignore_ascii_case(name) && theme.name != name)
    {
        return Err("A theme with this name already exists with different capitalization".into());
    }
    Ok(())
}

pub fn save(theme: &Theme) -> Result<(), String> {
    let (existing, _) = list()?;
    validate_save_name(&theme.name, &existing)?;
    write(theme, &format!("{}.toml", theme.name))
}

fn write(theme: &Theme, filename: &str) -> Result<(), String> {
    let dir = directory()?;
    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
    crate::atomic_fs::atomic_write(&dir.join(filename), encode(theme)?.as_bytes())
        .map_err(|e| e.to_string())
}

pub fn select(theme: &Theme) -> Result<(), String> {
    write(theme, "active.toml")?;
    super::set_active(theme.clone());
    Ok(())
}

pub(super) fn restore() -> (Theme, Vec<String>) {
    let result =
        directory().and_then(
            |dir| match std::fs::read_to_string(dir.join("active.toml")) {
                Ok(text) => decode(&text).map(Some),
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
                Err(e) => Err(e.to_string()),
            },
        );
    match result {
        Ok(theme) => (theme.unwrap_or_else(Theme::builtin), vec![]),
        Err(error) => (Theme::builtin(), vec![format!("Theme: {error}")]),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn saved_theme_names_are_portable_across_case_sensitive_filesystems() {
        let mut existing = builtins();
        existing.push(Theme::builtin().overlaid("MyTheme", &[]));
        for reserved in [
            "active", "Active", "ACTIVE", "newt", "NEWT", "Daylight", "PHOSPHOR",
        ] {
            assert!(
                validate_save_name(reserved, &existing).is_err(),
                "accepted reserved {reserved}"
            );
        }
        assert!(validate_save_name("mytheme", &existing).is_err());
        assert!(validate_save_name("MYTHEME", &existing).is_err());
        assert!(
            validate_save_name("MyTheme", &existing).is_ok(),
            "exact existing name remains editable"
        );
        assert!(validate_save_name("AnotherTheme", &existing).is_ok());
    }

    #[test]
    fn theme_roundtrip_preserves_colors_and_disabled_bold() {
        let mut theme =
            Theme::builtin().overlaid("mine", &[(Role::InlineCode, Color::AnsiValue(231))]);
        let mut style = theme.style(Role::InlineCode);
        style.attributes.unset(Attribute::Bold);
        style.attributes.set(Attribute::Underlined);
        theme.set_style(Role::InlineCode, style);
        let copy = decode(&encode(&theme).unwrap()).unwrap();
        for role in ALL_ROLES {
            assert_eq!(theme.style(*role), copy.style(*role));
        }
    }
    #[test]
    fn saved_presets_cannot_shadow_builtins_or_case_aliases() {
        let dir = tempfile::tempdir().unwrap();
        for (file, name) in [("a.toml", "Mine"), ("b.toml", "mine"), ("c.toml", "NEWT")] {
            let theme = Theme::builtin().overlaid(name, &[]);
            std::fs::write(dir.path().join(file), encode(&theme).unwrap()).unwrap();
        }
        let (themes, warnings) = list_directory(dir.path()).unwrap();
        assert_eq!(
            themes
                .iter()
                .filter(|theme| theme.name.eq_ignore_ascii_case("mine"))
                .count(),
            1
        );
        assert_eq!(
            themes
                .iter()
                .filter(|theme| theme.name.eq_ignore_ascii_case("newt"))
                .count(),
            1
        );
        assert_eq!(warnings.len(), 2);
    }

    #[test]
    fn malformed_theme_does_not_hide_valid_saved_themes() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("bad.toml"), "broken = [").unwrap();
        let theme = Theme::builtin().overlaid("mine", &[]);
        std::fs::write(dir.path().join("mine.toml"), encode(&theme).unwrap()).unwrap();
        let (themes, warnings) = list_directory(dir.path()).unwrap();
        assert!(themes.iter().any(|t| t.name == "mine"));
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("bad.toml"));
    }

    #[test]
    fn rejects_path_names_and_unknown_roles() {
        assert!(!valid_name("../oops"));
        assert!(decode("name='mine'\n[roles.unknown]\ncolor='white'").is_err());
    }
    #[test]
    fn all_builtin_colors_roundtrip() {
        for theme in builtins() {
            for role in ALL_ROLES {
                assert_eq!(
                    parse_color(&color_name(theme.color(*role))),
                    Ok(theme.color(*role))
                );
            }
        }
    }
}