fundaia 0.55.3

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use std::fmt::Write;

use owo_colors::{OwoColorize, Style};

use crate::api::models::LogLine;
use crate::render::{sanitise, theme};

#[derive(Clone, Copy)]
pub struct LineFormat {
    pub stream: bool,
    pub time: bool,
    pub trace: bool,
}

impl LineFormat {
    pub fn with_stream() -> Self {
        Self {
            stream: true,
            time: false,
            trace: false,
        }
    }

    pub fn plain() -> Self {
        Self {
            stream: false,
            time: false,
            trace: false,
        }
    }

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

    pub fn showing_traces(self, trace: bool) -> Self {
        Self { trace, ..self }
    }
}

pub fn render_line(line: &LogLine, format: LineFormat) -> String {
    let mut out = String::with_capacity(line.message.len() + 48);

    if format.time {
        let _ = write!(
            out,
            "{} ",
            clock_of(line.at.as_deref()).style(theme::muted())
        );
    }

    if format.stream {
        let _ = write!(
            out,
            "{} ",
            pad_stream(&line.stream).style(stream_style(&line.stream))
        );
    }

    let (mark, style) = level_mark(&line.level);
    let _ = write!(out, "{} ", mark.style(style));

    if format.trace {
        if let Some(id) = line.trace_id.as_deref() {
            let _ = write!(out, "{} ", short_trace(id).style(theme::muted()));
        }
    }

    let message = sanitise::with_colour(&line.message);
    let highlighted = if has_escapes(&message) {
        None
    } else {
        highlight(&message, &line.level)
    };
    match highlighted {
        Some(style) => {
            let _ = write!(out, "{}", message.style(style));
        }
        None => out.push_str(&message),
    }

    out
}

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 short_trace(id: &str) -> String {
    let shown: String = id.chars().take(8).collect();
    format!("[{shown}]")
}

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" => theme::info(),
        "runtime" => theme::success(),
        _ => theme::muted(),
    }
}

fn level_mark(level: &str) -> (&'static str, Style) {
    match level {
        "error" => ("✖", theme::danger()),
        "warn" => ("!", theme::warning()),
        "debug" => ("·", theme::muted()),
        _ => ("│", theme::muted()),
    }
}

fn highlight(message: &str, level: &str) -> Option<Style> {
    match level {
        "error" => return Some(theme::danger()),
        "warn" => return Some(theme::warning()),
        _ => {}
    }

    let mentions = |words: &[&str]| {
        words
            .iter()
            .any(|word| contains_ignoring_case(message, word))
    };
    if mentions(&["error", "failed", "exception"]) {
        return Some(theme::danger());
    }
    if mentions(&["warn", "deprecated"]) {
        return Some(theme::warning());
    }

    None
}

fn contains_ignoring_case(text: &str, word: &str) -> bool {
    text.as_bytes()
        .windows(word.len())
        .any(|window| window.eq_ignore_ascii_case(word.as_bytes()))
}

#[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(),
            stage: Some("build".into()),
            trace_id: None,
        }
    }

    fn traced(message: &str) -> LogLine {
        LogLine {
            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".into()),
            ..line(message, "info")
        }
    }

    #[test]
    fn it_should_draw_the_trace_a_line_carries_when_asked() {
        let drawn = render_line(
            &traced("saved"),
            LineFormat::with_stream().showing_traces(true),
        );

        assert!(drawn.contains("4bf92f35"));
    }

    #[test]
    fn it_should_abbreviate_the_trace_it_draws() {
        let drawn = render_line(
            &traced("saved"),
            LineFormat::with_stream().showing_traces(true),
        );

        assert!(!drawn.contains("4bf92f3577b34da6a3ce929d0e0e4736"));
    }

    #[test]
    fn it_should_leave_no_trace_column_on_a_line_that_carries_none() {
        let untraced = line("compiling", "info");

        assert_eq!(
            render_line(&untraced, LineFormat::with_stream().showing_traces(true)),
            render_line(&untraced, LineFormat::with_stream())
        );
    }

    #[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);
    }

    #[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()
        );
    }

    #[test]
    fn it_should_leave_the_deploy_stream_bare_when_colour_is_off() {
        let drawn = theme::silenced(|| {
            render_line(
                &LogLine {
                    stream: "deploy".into(),
                    ..line("starting", "info")
                },
                LineFormat::with_stream(),
            )
        });

        assert!(!drawn.contains('\u{1b}'));
    }

    #[test]
    fn it_should_notice_a_failure_written_in_capitals() {
        assert!(contains_ignoring_case("Build FAILED after 3s", "failed"));
    }

    #[test]
    fn it_should_not_notice_a_word_that_is_not_there() {
        assert!(!contains_ignoring_case("compiled in 3s", "error"));
    }

    #[test]
    fn it_should_not_notice_a_word_longer_than_the_line() {
        assert!(!contains_ignoring_case("warn", "deprecated"));
    }
}