src/render.rs

//! ANSI-colored output renderer.
//!
//! Two modes are supported:
//!
//! * [`RenderMode::Stream`] writes the raw line, prefixed with the source
//!   filename in a stable color chosen by hash.
//! * [`RenderMode::Table`] pretty-prints extracted named fields as
//!   `key=value` pairs with keys dimmed.
//!
//! Color can be disabled per-instance; we also honor `NO_COLOR` by default
//! (the binary sets that through the CLI layer).

use crossterm::style::Stylize;
use tokio::io::{AsyncWrite, AsyncWriteExt};

use crate::filter::MatchOutcome;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
    Stream,
    Table,
}

#[derive(Debug)]
pub struct Renderer {
    mode: RenderMode,
    color: bool,
}

impl Renderer {
    pub fn new(mode: RenderMode, color: bool) -> Self {
        Self { mode, color }
    }

    pub async fn write<W: AsyncWrite + Unpin>(
        &self,
        w: &mut W,
        source: &str,
        line: &str,
        outcome: &MatchOutcome,
    ) -> std::io::Result<()> {
        match (self.mode, outcome) {
            (RenderMode::Table, MatchOutcome::Fields(fields)) => {
                let prefix = self.source_prefix(source);
                let body = fields
                    .iter()
                    .map(|(k, v)| self.kv(k, v))
                    .collect::<Vec<_>>()
                    .join("  ");
                w.write_all(format!("{prefix} {body}\n").as_bytes()).await
            }
            _ => {
                let prefix = self.source_prefix(source);
                w.write_all(format!("{prefix} {line}\n").as_bytes()).await
            }
        }
    }

    pub async fn notice<W: AsyncWrite + Unpin>(
        &self,
        w: &mut W,
        source: &str,
        msg: &str,
    ) -> std::io::Result<()> {
        let prefix = self.source_prefix(source);
        let marker = if self.color { "--".dark_grey().to_string() } else { "--".into() };
        w.write_all(format!("{prefix} {marker} {msg}\n").as_bytes()).await
    }

    fn source_prefix(&self, source: &str) -> String {
        if !self.color {
            return format!("[{source}]");
        }
        let palette = [
            crossterm::style::Color::Cyan,
            crossterm::style::Color::Green,
            crossterm::style::Color::Yellow,
            crossterm::style::Color::Magenta,
            crossterm::style::Color::Blue,
        ];
        let idx = (hash(source) as usize) % palette.len();
        format!("[{}]", source.with(palette[idx]))
    }

    fn kv(&self, k: &str, v: &str) -> String {
        if self.color {
            format!("{}={}", k.dark_grey(), v)
        } else {
            format!("{k}={v}")
        }
    }
}

fn hash(s: &str) -> u64 {
    let mut h: u64 = 0xcbf29ce484222325;
    for b in s.as_bytes() {
        h ^= *b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    h
}