skiller 0.14.0

Declarative project and global skill management over the Vercel Skills CLI
use std::io::{self, IsTerminal};

use crossterm::style::{Attribute, Color, Stylize, style};

pub(crate) const ACCENT: Color = Color::Rgb {
    r: 86,
    g: 182,
    b: 194,
};
pub(crate) const SUCCESS: Color = Color::Rgb {
    r: 152,
    g: 195,
    b: 121,
};
pub(crate) const WARNING: Color = Color::Rgb {
    r: 229,
    g: 192,
    b: 123,
};
pub(crate) const ERROR: Color = Color::Rgb {
    r: 224,
    g: 108,
    b: 117,
};
pub(crate) const MUTED: Color = Color::Rgb {
    r: 128,
    g: 135,
    b: 150,
};
pub(crate) const SELECTED_BG: Color = Color::Rgb {
    r: 35,
    g: 78,
    b: 86,
};
pub(crate) const SELECTED_TEXT: Color = Color::Rgb {
    r: 238,
    g: 242,
    b: 246,
};
pub(crate) const SCOPE_PALETTE: [Color; 6] = [
    Color::Rgb {
        r: 97,
        g: 175,
        b: 239,
    },
    Color::Rgb {
        r: 198,
        g: 120,
        b: 221,
    },
    Color::Rgb {
        r: 86,
        g: 182,
        b: 194,
    },
    Color::Rgb {
        r: 209,
        g: 134,
        b: 178,
    },
    Color::Rgb {
        r: 125,
        g: 130,
        b: 230,
    },
    Color::Rgb {
        r: 70,
        g: 180,
        b: 160,
    },
];

#[derive(Clone, Copy)]
pub(crate) struct HumanOutput {
    styled: bool,
}

impl HumanOutput {
    pub(crate) fn stdout() -> Self {
        Self {
            styled: color_enabled(io::stdout().is_terminal()),
        }
    }

    pub(crate) fn stderr() -> Self {
        Self {
            styled: color_enabled(io::stderr().is_terminal()),
        }
    }

    #[cfg(test)]
    pub(crate) fn plain() -> Self {
        Self { styled: false }
    }

    #[cfg(test)]
    pub(crate) fn styled() -> Self {
        Self { styled: true }
    }

    pub(crate) fn heading(self, message: &str) -> String {
        self.paint(message, ACCENT, true)
    }

    pub(crate) fn success(self, message: &str) -> String {
        self.line("", message, SUCCESS, None)
    }

    pub(crate) fn info(self, message: &str) -> String {
        self.line("", message, ACCENT, None)
    }

    pub(crate) fn warning(self, message: &str) -> String {
        self.line("!", message, WARNING, Some("warning:"))
    }

    pub(crate) fn error(self, message: &str) -> String {
        self.line("×", message, ERROR, Some("error:"))
    }

    pub(crate) fn item(self, message: &str) -> String {
        self.line("", message, ACCENT, Some("-"))
    }

    fn line(self, icon: &str, message: &str, color: Color, plain_prefix: Option<&str>) -> String {
        if self.styled {
            format!("{} {message}", self.paint(icon, color, true))
        } else if let Some(prefix) = plain_prefix {
            format!("{prefix} {message}")
        } else {
            message.to_owned()
        }
    }

    fn paint(self, message: &str, color: Color, bold: bool) -> String {
        if !self.styled {
            return message.to_owned();
        }
        let content = style(message).with(color);
        if bold {
            format!("{}", content.attribute(Attribute::Bold))
        } else {
            content.to_string()
        }
    }
}

pub(crate) fn color_enabled(terminal: bool) -> bool {
    terminal
        && std::env::var_os("NO_COLOR").is_none()
        && std::env::var_os("TERM").is_none_or(|term| term != "dumb")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plain_output_preserves_script_friendly_prefixes() {
        let output = HumanOutput::plain();
        assert_eq!(output.success("done"), "done");
        assert_eq!(output.info("working"), "working");
        assert_eq!(output.warning("careful"), "warning: careful");
        assert_eq!(output.error("blocked"), "error: blocked");
        assert_eq!(output.item("skill"), "- skill");
    }

    #[test]
    fn styled_output_uses_semantic_icons_and_ansi_roles() {
        let output = HumanOutput::styled();
        assert!(output.success("done").contains(''));
        assert!(output.success("done").contains("done"));
        assert!(output.warning("careful").contains('!'));
        assert!(output.warning("careful").contains("careful"));
        assert!(output.error("blocked").contains('×'));
        assert!(output.error("blocked").contains("blocked"));
        assert!(output.heading("Updates").contains("Updates"));
        assert!(output.success("done").contains("\u{1b}["));
    }
}