alba 0.2.1

Alba, the Beamfile task runner: parallel beams, caching, watch mode, and an interactive interface
//! The interleaved renderer: every line, from every beam, as it happens,
//! prefixed with the beam it came from (`build │ Compiling alba v0.1.0`).
//!
//! The default on a terminal, where seeing a long run make progress
//! matters more than reading each beam's output as one block.

use std::io;

use alba_engine::RunEvent;
use owo_colors::{AnsiColors, OwoColorize};

use super::{LineSink, Renderer, format_duration, print_summary, status_label};

/// The colours beam ids cycle through. Six is enough to tell adjacent
/// beams apart without reaching for colours that carry their own meaning
/// (plain red reads as "this failed", which a beam id must not imply).
const PALETTE: [AnsiColors; 6] = [
    AnsiColors::Cyan,
    AnsiColors::Green,
    AnsiColors::Yellow,
    AnsiColors::Blue,
    AnsiColors::Magenta,
    AnsiColors::BrightCyan,
];

/// Separates a beam id from the line it prefixes.
const SEPARATOR: &str = "\u{2502}";

pub struct InterleavedRenderer {
    color: bool,
    /// Watch mode only: whether a triggered run should clear the screen
    /// first. Set once at construction — see
    /// [`InterleavedRenderer::new`] — and never `true` outside a watch
    /// session, since a plain `alba run` has no session to clear between.
    clear_between_runs: bool,
    /// The reference the current run's targets were computed against, set
    /// from each `RunStarted` and read back at `RunFinished` — `None` for
    /// an ordinary run.
    affected_by: Option<String>,
    out: LineSink<io::Stdout>,
    err: LineSink<io::Stderr>,
}

impl InterleavedRenderer {
    /// `color` comes from [`crate::color_enabled`] — the single TTY and
    /// `NO_COLOR` gate the whole binary shares. `clear_between_runs` is the
    /// watch session's own choice, unrelated to color.
    pub fn new(color: bool, clear_between_runs: bool) -> Self {
        Self {
            color,
            clear_between_runs,
            affected_by: None,
            out: LineSink::stdout(),
            err: LineSink::stderr(),
        }
    }

    /// Prints one prefixed line on stdout.
    fn line(&mut self, id: &str, text: &str) {
        let prefix = if self.color {
            id.color(color_for(id)).to_string()
        } else {
            id.to_string()
        };
        self.out.line(&format!("{prefix} {SEPARATOR} {text}"));
    }
}

impl Renderer for InterleavedRenderer {
    fn handle(&mut self, event: &RunEvent) {
        if super::handle_watch_event(event, &mut self.out, &mut self.err, self.clear_between_runs) {
            return;
        }
        match event {
            RunEvent::BeamStarted { id } => self.line(&id.0, "started"),
            RunEvent::BeamCached { id } => self.line(&id.0, "cached — replaying last output"),
            RunEvent::BeamOutput { id, line, .. } => self.line(&id.0, &line.text),
            RunEvent::BeamFinished {
                id,
                status,
                duration,
            } => {
                // `in` rather than a second parenthesis: a status is
                // already parenthesized (`failed (exit 7)`), and
                // `failed (exit 7) (1.2s)` reads as two unrelated asides.
                // A cached beam's duration belongs to its original run, not
                // this one, so it gets its own phrasing rather than
                // `cached in 1.2s` implying the replay itself took that long.
                let text = if matches!(status, alba_engine::BeamStatus::Cached) {
                    format!("cached in {} (original run)", format_duration(*duration))
                } else {
                    format!("{} in {}", status_label(status), format_duration(*duration))
                };
                self.line(&id.0, &text);
            }
            RunEvent::RunFinished { summary } => {
                print_summary(&mut self.err, summary, self.affected_by.as_deref());
            }
            RunEvent::RunStarted { affected_by, .. } => self.affected_by = affected_by.clone(),
            RunEvent::WatchWaiting { .. } | RunEvent::WatchTriggered { .. } => {}
            // Alba commentary, not the run's output — stderr, like every
            // other diagnostic this module prints (see the module doc).
            RunEvent::ProjectBroken { diagnostic } => {
                self.err.line(diagnostic.trim_end());
            }
        }
    }
}

/// The colour a beam id is always drawn in.
///
/// Derived from the id's own bytes, so it is the same on every run of the
/// same project and does not depend on the order beams start, finish, or
/// happen to be iterated in — this project has already shipped two
/// non-determinism defects caused by exactly that kind of incidental
/// ordering. A collision between two ids is harmless (they merely share a
/// colour), which is why a plain hash is enough and no allocation-order
/// bookkeeping is needed.
fn color_for(id: &str) -> AnsiColors {
    PALETTE[(fnv1a(id) % PALETTE.len() as u64) as usize]
}

/// FNV-1a, written out rather than taken from `std`: `DefaultHasher`'s
/// output is explicitly not guaranteed stable across Rust releases, and a
/// beam's colour changing when the toolchain is upgraded is exactly the
/// instability this function exists to prevent.
fn fnv1a(text: &str) -> u64 {
    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
    const PRIME: u64 = 0x0000_0100_0000_01b3;

    text.bytes().fold(OFFSET_BASIS, |hash, byte| {
        (hash ^ u64::from(byte)).wrapping_mul(PRIME)
    })
}

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

    /// `AnsiColors` is compared through its `Debug` form: the palette
    /// entry itself is only ever handed to `owo-colors`, and naming it in
    /// a test would pin these assertions to which colour was picked rather
    /// than to the property being asserted.
    fn color_name(id: &str) -> String {
        format!("{:?}", color_for(id))
    }

    /// The property that matters: an id's colour is a function of the id
    /// alone, so it cannot vary with the order beams are encountered in.
    #[test]
    fn a_beam_id_always_gets_the_same_color() {
        assert_eq!(color_name("build"), color_name("build"));
        assert_eq!(color_name("api:test"), color_name("api:test"));
    }

    #[test]
    fn different_ids_spread_across_the_palette() {
        let ids = ["build", "test", "lint", "deploy", "docs", "bench"];
        let used: std::collections::HashSet<_> = ids.iter().map(|id| color_name(id)).collect();
        assert!(
            used.len() > 1,
            "a palette that collapses every id to one colour is useless"
        );
    }
}