fundaia 0.36.2

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};

/// 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,
    pub trace: 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,
            trace: 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,
            trace: false,
        }
    }

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

    pub fn showing_traces(self, trace: bool) -> Self {
        Self { trace, ..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 {
    // One buffer for the whole line: this runs once per line of a build, and a
    // build is tens of thousands of them. Writing to a `String` cannot fail.
    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));

    /*
     * The trace, abbreviated, and only for the lines that carry one.
     *
     * No padding for the lines that do not, unlike the columns above: a log is
     * the application's lines interleaved with the platform's, and only the
     * application traces anything — so reserving the width would indent every
     * line the platform wrote to align with a column it can never fill.
     */
    if format.trace {
        if let Some(id) = line.trace_id.as_deref() {
            let _ = write!(out, "{} ", short_trace(id).style(theme::muted()));
        }
    }

    // Colour survives; anything else a terminal would obey does not. The
    // container printed this, and a container prints what it was sent.
    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
}

/// 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}')
}

/// The first characters of a trace, which is all anybody reads off a screen.
///
/// Eight, the same as the web draws, so an id recognised in one is recognised in
/// the other — and it is enough to tell two traces apart in one deployment while
/// being short enough to sit in a gutter. The whole id is in `--json`, and it is
/// what `--trace` wants.
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()),
    }
}

/// Colour for output that arrived with none, or `None` to leave it as it is.
///
/// 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) -> 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
}

/// Whether `text` contains the lower-case ASCII `word`, in any case.
///
/// The answer lower-casing the whole line gave for every word above, without a
/// copy of every line: the only characters outside ASCII that lower-case into
/// ASCII are the Kelvin sign, into a `k` none of the words has, and `İ`, into an
/// `i` that a combining dot follows.
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"));
    }

    /// The whole id is for `--json` and for `--trace`; a gutter gets the prefix.
    #[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"));
    }

    /// A line with no trace is drawn identically whether traces are on or off:
    /// the column is not reserved, so the platform's own lines are not indented
    /// to align with one only the application can fill.
    #[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);
    }

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

    /// It was `Style::new().blue()` applied directly, the one stream `--no-color`
    /// left painted.
    #[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"));
    }
}