foukoapi 0.1.2-alpha.1

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Startup banner: a colorful, human-facing summary printed straight to
//! stdout before the adapters spin up. Logs belong in tracing; this is the
//! shop window, so it uses plain println with hand-rolled ANSI colors.
//!
//! Colors honor `NO_COLOR` and switch off when stdout is not a terminal,
//! so piped output stays clean.
//!
//! ```no_run
//! use foukoapi::banner::{Banner, Tone};
//!
//! Banner::new("MyBot", env!("CARGO_PKG_VERSION"))
//!     .row("platforms", "telegram", Tone::Ok)
//!     .row("storage", "sqlite:data.db", Tone::Plain)
//!     .print();
//! ```

use std::io::IsTerminal;
use std::time::Duration;

const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const MAGENTA: &str = "\x1b[35m";
const CYAN: &str = "\x1b[36m";
const DIM_YELLOW: &str = "\x1b[2;33m";
const BOLD_CYAN: &str = "\x1b[1;36m";

/// Minimum width of the separator rules under the banner. Grows to fit
/// the longest art or table line.
const RULE_WIDTH: usize = 46;

/// Minimum label column width in the banner table.
const LABEL_PAD: usize = 11;

/// Gradient codes for the art rows: bright cyan down to magenta.
const ART_GRADIENT: [&str; 5] = ["\x1b[96m", "\x1b[36m", "\x1b[94m", "\x1b[95m", "\x1b[35m"];

/// Tiny color helper. Honors NO_COLOR (<https://no-color.org>) and turns
/// itself off when stdout is not a terminal, so piped output stays clean.
pub struct Palette {
    on: bool,
}

impl Palette {
    /// Decide once whether colors are welcome.
    pub fn detect() -> Self {
        let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
        Self {
            on: !no_color && std::io::stdout().is_terminal(),
        }
    }

    fn paint(&self, code: &str, s: &str) -> String {
        if self.on {
            format!("{code}{s}{RESET}")
        } else {
            s.to_owned()
        }
    }

    /// Bold text.
    pub fn bold(&self, s: &str) -> String {
        self.paint(BOLD, s)
    }

    /// Dimmed text, for labels and secondary detail.
    pub fn dim(&self, s: &str) -> String {
        self.paint(DIM, s)
    }

    /// Cyan text.
    pub fn cyan(&self, s: &str) -> String {
        self.paint(CYAN, s)
    }

    /// Magenta text.
    pub fn magenta(&self, s: &str) -> String {
        self.paint(MAGENTA, s)
    }

    /// Green text, for "all good" values.
    pub fn green(&self, s: &str) -> String {
        self.paint(GREEN, s)
    }

    /// Yellow text.
    pub fn yellow(&self, s: &str) -> String {
        self.paint(YELLOW, s)
    }

    /// Red text, for failures.
    pub fn red(&self, s: &str) -> String {
        self.paint(RED, s)
    }

    /// Dim yellow text, for soft warnings ("disabled - set X").
    pub fn dim_yellow(&self, s: &str) -> String {
        self.paint(DIM_YELLOW, s)
    }
}

/// How a banner row value is colored.
pub enum Tone {
    /// Green: the feature is on and healthy.
    Ok,
    /// Dim yellow: disabled or not configured, worth a look.
    Warn,
    /// Red: something is broken.
    Err,
    /// Bold, no color: neutral facts like paths and numbers.
    Plain,
}

/// A startup banner builder: optional ASCII art, a bold title and an
/// aligned "label  value" table. Call [`Banner::print`] once logging is
/// up, before any adapter starts talking.
pub struct Banner {
    name: String,
    version: String,
    art: Vec<String>,
    rows: Vec<(String, String, Tone)>,
}

impl Banner {
    /// Start a banner with the bot name and version. Without art the name
    /// itself is the headline, printed in bold cyan.
    pub fn new(name: &str, version: &str) -> Self {
        Self {
            name: name.to_owned(),
            version: version.to_owned(),
            art: Vec::new(),
            rows: Vec::new(),
        }
    }

    /// Add ASCII art above the title. Rows are painted with a
    /// cyan-to-magenta gradient, one color per row, top to bottom.
    pub fn art(mut self, lines: &[&str]) -> Self {
        self.art = lines.iter().map(|l| (*l).to_owned()).collect();
        self
    }

    /// Add one aligned "label  value" table row.
    pub fn row(mut self, label: &str, value: &str, tone: Tone) -> Self {
        self.rows.push((label.to_owned(), value.to_owned(), tone));
        self
    }

    /// Render and print the banner to stdout.
    pub fn print(&self) {
        println!("{}", self.render(&Palette::detect()));
    }

    /// Width of the separator rules: at least [`RULE_WIDTH`], stretched
    /// to the longest art or table line.
    fn rule_width(&self, pad: usize) -> usize {
        let art = self
            .art
            .iter()
            .map(|l| l.chars().count())
            .max()
            .unwrap_or(0);
        let rows = self
            .rows
            .iter()
            .map(|(_, value, _)| 1 + pad + 1 + value.chars().count())
            .max()
            .unwrap_or(0);
        RULE_WIDTH.max(art).max(rows)
    }

    /// Label column width: at least [`LABEL_PAD`], stretched to the
    /// longest label so values always line up.
    fn label_pad(&self) -> usize {
        self.rows
            .iter()
            .map(|(label, _, _)| label.chars().count())
            .max()
            .unwrap_or(0)
            .max(LABEL_PAD)
    }

    /// Render to a string. Labels are padded before coloring, since ANSI
    /// codes would break the width format.
    fn render(&self, p: &Palette) -> String {
        let pad = self.label_pad();
        let rule = p.dim(&"\u{2500}".repeat(self.rule_width(pad)));

        let mut out = String::new();
        out.push('\n');
        if !self.art.is_empty() {
            let last = self.art.len() - 1;
            for (i, line) in self.art.iter().enumerate() {
                let step = (i * (ART_GRADIENT.len() - 1))
                    .checked_div(last)
                    .unwrap_or(0);
                out.push_str("  ");
                out.push_str(&p.paint(ART_GRADIENT[step], line));
                out.push('\n');
            }
            out.push('\n');
        }
        out.push_str(&format!(
            "  {} {}\n",
            p.paint(BOLD_CYAN, &self.name),
            p.cyan(&format!("v{}", self.version))
        ));
        out.push_str(&format!("  {rule}\n"));
        for (label, value, tone) in &self.rows {
            let value = match tone {
                Tone::Ok => p.green(value),
                Tone::Warn => p.dim_yellow(value),
                Tone::Err => p.red(value),
                Tone::Plain => p.bold(value),
            };
            out.push_str(&format!(
                "   {} {value}\n",
                p.dim(&format!("{label:<pad$}"))
            ));
        }
        out.push_str(&format!("  {rule}"));
        out
    }
}

/// Print the green "we are live" line once the adapters settle.
/// `statuses` pairs each enabled platform with whether it came online.
pub fn print_ready(statuses: &[(&str, bool)], elapsed: Duration) {
    println!("{}", render_ready(statuses, elapsed, &Palette::detect()));
}

fn render_ready(statuses: &[(&str, bool)], elapsed: Duration, p: &Palette) -> String {
    let parts: Vec<String> = statuses
        .iter()
        .map(|(name, ok)| {
            if *ok {
                format!("{name} {}", p.green("ok"))
            } else {
                format!("{name} {}", p.red("down"))
            }
        })
        .collect();
    format!(
        "  {} {}  {}  {}",
        p.green("\u{25CF}"),
        p.bold(&p.green("online")),
        parts.join(" - "),
        p.dim(&format!("(startup {:.1}s)", elapsed.as_secs_f32()))
    )
}

/// Print a green check line: a fact that has been verified, like the
/// configured owner resolving to a real account.
pub fn print_check(label: &str, value: &str) {
    let p = Palette::detect();
    println!(
        "  {} {}  {}",
        p.green("\u{2714}"),
        p.dim(label),
        p.bold(value)
    );
}

/// Print a yellow "!" line: the softer sibling of [`print_check`] for a
/// fact that could not be confirmed.
pub fn print_warn(label: &str, value: &str) {
    let p = Palette::detect();
    println!("  {} {}  {}", p.yellow("!"), p.dim(label), p.bold(value));
}

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

    fn plain() -> Palette {
        Palette { on: false }
    }

    #[test]
    fn render_without_colors_pads_labels() {
        let s = Banner::new("MyBot", "1.2.3")
            .row("platforms", "telegram", Tone::Ok)
            .row("ai", "disabled", Tone::Warn)
            .render(&plain());
        assert!(!s.contains('\x1b'));
        assert!(s.contains("  MyBot v1.2.3\n"));
        assert!(s.contains("   platforms   telegram\n"));
        assert!(s.contains("   ai          disabled\n"));
    }

    #[test]
    fn rule_defaults_to_minimum_width() {
        let s = Banner::new("Tiny", "0.1").render(&plain());
        assert!(s.contains(&"\u{2500}".repeat(RULE_WIDTH)));
    }

    #[test]
    fn rule_grows_with_a_long_art_line() {
        let long = "#".repeat(60);
        let s = Banner::new("Big", "0.1")
            .art(&[&long, "short"])
            .render(&plain());
        assert!(s.contains(&long));
        assert!(s.contains(&"\u{2500}".repeat(60)));
        assert!(!s.contains(&"\u{2500}".repeat(61)));
    }

    #[test]
    fn long_labels_stretch_the_column() {
        let s = Banner::new("MyBot", "0.1")
            .row("a-very-long-label", "x", Tone::Plain)
            .row("short", "y", Tone::Plain)
            .render(&plain());
        assert!(s.contains("   a-very-long-label x\n"));
        assert!(s.contains("   short             y\n"));
    }

    #[test]
    fn ready_line_format() {
        let s = render_ready(
            &[("telegram", true), ("discord", false)],
            Duration::from_millis(1400),
            &plain(),
        );
        assert_eq!(
            s,
            "  \u{25CF} online  telegram ok - discord down  (startup 1.4s)"
        );
    }
}