fundaia 0.6.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use owo_colors::{OwoColorize, Style};

use crate::api::models::LogLine;
use crate::render::theme;

/// Which columns of the gutter to draw.
///
/// A struct rather than two booleans at the call site, where `(line, true,
/// false)` says nothing about which of the two is which.
#[derive(Clone, Copy)]
pub struct LineFormat {
    pub stream: bool,
    pub time: bool,
}

impl LineFormat {
    /// The stream named, the time left out — what a log the platform is
    /// printing live wants, since the clock is the reader's own.
    pub fn with_stream() -> Self {
        Self {
            stream: true,
            time: false,
        }
    }

    /// Neither, for a log printed underneath something else that is already
    /// saying where it came from.
    pub fn plain() -> Self {
        Self {
            stream: false,
            time: false,
        }
    }

    pub fn showing_time(self, time: bool) -> Self {
        Self { time, ..self }
    }
}

/// One log line, coloured the way the web colours it.
///
/// The gutter is the time, the stream and the level; the message is left
/// exactly as the container wrote it, escapes and all. That last part is the
/// important one: a terminal already knows how to render ANSI, so stripping and
/// re-colouring would be throwing away better information than this could
/// reconstruct. What gets added is only what the producer did *not* supply.
pub fn render_line(line: &LogLine, format: LineFormat) -> String {
    let mut out = String::new();

    if format.time {
        out.push_str(&format!(
            "{} ",
            clock_of(line.at.as_deref()).style(theme::muted())
        ));
    }

    if format.stream {
        out.push_str(&format!(
            "{} ",
            pad_stream(&line.stream).style(stream_style(&line.stream))
        ));
    }

    out.push_str(&format!("{} ", level_mark(&line.level)));

    if has_escapes(&line.message) {
        out.push_str(&line.message);
    } else {
        out.push_str(&highlight(&line.message, &line.level));
    }

    out
}

/// The wall-clock part of an RFC3339 stamp, to the second.
///
/// Sliced rather than parsed with a date library: the field is produced by
/// `Date.toISOString()`, so its layout is fixed, and the alternative is a
/// dependency to read eight characters. Anything that does not look like one is
/// padded to the same width so the column never moves.
fn clock_of(at: Option<&str>) -> String {
    let Some(stamp) = at else {
        return " ".repeat(8);
    };

    match stamp.split('T').nth(1) {
        Some(time) if time.len() >= 8 => time[..8].to_owned(),
        _ => format!("{stamp:<8.8}"),
    }
}

fn has_escapes(message: &str) -> bool {
    message.contains('\u{1b}')
}

fn pad_stream(stream: &str) -> String {
    let shown = if stream.is_empty() { "" } else { stream };
    format!("{shown:<7}")
}

fn stream_style(stream: &str) -> Style {
    match stream {
        "build" => theme::accent(),
        "deploy" => Style::new().blue(),
        "runtime" => theme::success(),
        _ => theme::muted(),
    }
}

fn level_mark(level: &str) -> String {
    match level {
        "error" => format!("{}", "".style(theme::danger())),
        "warn" => format!("{}", "!".style(theme::warning())),
        "debug" => format!("{}", "·".style(theme::muted())),
        _ => format!("{}", "".style(theme::muted())),
    }
}

/// Colour for output that arrived with none.
///
/// Whole-line rather than per token, unlike the web. A terminal reader scans
/// down the left margin for the thing that went wrong, and a line where four
/// separate words are four separate colours is harder to scan, not easier —
/// the browser can afford the detail because it has a mouse and a filter box.
fn highlight(message: &str, level: &str) -> String {
    if level == "error" {
        return format!("{}", message.style(theme::danger()));
    }
    if level == "warn" {
        return format!("{}", message.style(theme::warning()));
    }

    let lowered = message.to_lowercase();
    if lowered.contains("error") || lowered.contains("failed") || lowered.contains("exception") {
        return format!("{}", message.style(theme::danger()));
    }
    if lowered.contains("warn") || lowered.contains("deprecated") {
        return format!("{}", message.style(theme::warning()));
    }

    message.to_owned()
}

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

    fn line(message: &str, level: &str) -> LogLine {
        LogLine {
            sequence: 1,
            at: Some("2026-07-30T20:59:16.695Z".into()),
            stream: "build".into(),
            level: level.into(),
            message: message.into(),
        }
    }

    #[test]
    fn it_should_keep_the_message_text_intact() {
        assert!(render_line(&line("compiling", "info"), LineFormat::plain()).contains("compiling"));
    }

    #[test]
    fn it_should_name_the_stream_when_asked() {
        assert!(
            render_line(&line("compiling", "info"), LineFormat::with_stream()).contains("build")
        );
    }

    #[test]
    fn it_should_leave_a_message_that_already_has_escapes_untouched() {
        let coloured = "\u{1b}[32mok\u{1b}[0m";
        assert!(render_line(&line(coloured, "info"), LineFormat::plain()).contains(coloured));
    }

    #[test]
    fn it_should_show_the_wall_clock_when_asked() {
        let shown = render_line(
            &line("compiling", "info"),
            LineFormat::plain().showing_time(true),
        );
        assert!(shown.contains("20:59:16"));
    }

    #[test]
    fn it_should_leave_out_the_date_that_every_line_would_repeat() {
        let shown = render_line(
            &line("compiling", "info"),
            LineFormat::plain().showing_time(true),
        );
        assert!(!shown.contains("2026-07-30"));
    }

    #[test]
    fn it_should_hold_the_column_when_a_line_arrived_without_a_time() {
        assert_eq!(clock_of(None).len(), 8);
    }

    #[test]
    fn it_should_hold_the_column_when_the_time_is_not_a_stamp() {
        assert_eq!(clock_of(Some("soon")).len(), 8);
    }

    // Characters, not bytes: the placeholder is an em dash, which is three
    // bytes and one column, and a byte count would have this passing only for
    // ASCII stream names.
    #[test]
    fn it_should_pad_a_missing_stream_rather_than_shifting_the_column() {
        assert_eq!(pad_stream("").chars().count(), 7);
    }

    #[test]
    fn it_should_pad_every_stream_to_the_same_width() {
        assert_eq!(
            pad_stream("build").chars().count(),
            pad_stream("runtime").chars().count()
        );
    }
}