haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Deterministic per-task colour assignment plus the small ANSI
//! escape-code helpers the presenter writes.
//!
//! Each task identity (`<project>:<task>` byte string) maps to a
//! 24-bit RGB triple via:
//!
//! 1. An FNV-1a 64-bit hash of the identity string. Stable across
//!    runs and across processes (no per-process randomisation, no
//!    transient `std::hash::RandomState`).
//! 2. The hash, taken modulo 360, becomes the HSL hue in degrees.
//!    Fixed saturation (0.70) and lightness (0.62) keep every
//!    rendered colour bright enough to read on a dark terminal
//!    background and dim enough to read on a light one.
//! 3. The HSL triple converts to RGB via the textbook
//!    chroma-and-match formula.
//!
//! The presenter writes raw ANSI escape sequences directly: the
//! truecolor SGR (`ESC [ 38 ; 2 ; r ; g ; b m`) for foreground
//! colour, plus the reset (`ESC [ 0 m`). No external crate. The
//! file's public surface is the [`task_color`] entry point plus
//! the [`Rgb`] colour value and the [`ansi`] sub-module that
//! produces the escape bytes.

use haz_domain::task_id::TaskId;

/// 24-bit colour value the presenter writes into the truecolor
/// SGR sequence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
    /// Red channel, 0..=255.
    pub r: u8,
    /// Green channel, 0..=255.
    pub g: u8,
    /// Blue channel, 0..=255.
    pub b: u8,
}

impl Rgb {
    /// Construct an [`Rgb`] from the three channels.
    #[must_use]
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }
}

/// FNV-1a offset basis for the 64-bit variant.
const FNV1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a prime for the 64-bit variant.
const FNV1A_PRIME: u64 = 0x0000_0100_0000_01b3;

/// FNV-1a hash of `bytes`. Stable across runs and processes; no
/// per-process randomisation.
#[must_use]
fn fnv1a_64(bytes: &[u8]) -> u64 {
    let mut hash = FNV1A_OFFSET_BASIS;
    for byte in bytes {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV1A_PRIME);
    }
    hash
}

/// Convert an HSL triple to an RGB triple.
///
/// `hue_degrees` is wrapped into `[0, 360)`. `saturation` and
/// `lightness` are clamped into `[0, 1]` because callers in this
/// module pin them at compile time, but the clamps mean a stray
/// out-of-range constant cannot panic.
#[must_use]
fn hsl_to_rgb(hue_degrees: f32, saturation: f32, lightness: f32) -> Rgb {
    let saturation = saturation.clamp(0.0, 1.0);
    let lightness = lightness.clamp(0.0, 1.0);
    let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
    let hue_sector = hue_degrees.rem_euclid(360.0) / 60.0;
    let secondary = chroma * (1.0 - (hue_sector.rem_euclid(2.0) - 1.0).abs());
    // `hue_sector` is in [0, 6) by construction. The six branches
    // map each half-open unit interval to its primary-channel
    // assignment per the standard HSL-to-RGB derivation. Branching
    // on a float (rather than casting the floor to an integer)
    // sidesteps the truncation lint without changing the result.
    let (r1, g1, b1) = if hue_sector < 1.0 {
        (chroma, secondary, 0.0)
    } else if hue_sector < 2.0 {
        (secondary, chroma, 0.0)
    } else if hue_sector < 3.0 {
        (0.0, chroma, secondary)
    } else if hue_sector < 4.0 {
        (0.0, secondary, chroma)
    } else if hue_sector < 5.0 {
        (secondary, 0.0, chroma)
    } else {
        (chroma, 0.0, secondary)
    };
    let match_value = lightness - chroma / 2.0;
    Rgb::new(
        channel_to_byte(r1 + match_value),
        channel_to_byte(g1 + match_value),
        channel_to_byte(b1 + match_value),
    )
}

fn channel_to_byte(value: f32) -> u8 {
    let scaled = (value.clamp(0.0, 1.0) * 255.0).round();
    // `scaled` is in [0.0, 255.0] by construction (clamp + scale +
    // round). The saturating `f32 as u8` cast added in Rust 1.45
    // additionally maps any NaN / out-of-range input to a safe
    // byte, so the conversion is total.
    #[expect(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "scaled is bounded by the preceding clamp + scale + round"
    )]
    let byte = scaled as u8;
    byte
}

/// Saturation pinned for every task colour. Chosen so colours are
/// vivid on both light and dark terminal backgrounds without
/// overpowering the surrounding text.
const TASK_SATURATION: f32 = 0.70;
/// Lightness pinned for every task colour. Same rationale as
/// `TASK_SATURATION`: bright enough on a dark terminal, dim enough
/// on a light one.
const TASK_LIGHTNESS: f32 = 0.62;

/// Return the deterministic RGB colour for `task`.
///
/// The mapping is a pure function of the task's `<project>:<task>`
/// display form: identical workspaces produce identical colours
/// across runs and across machines. Distinct task identities give
/// well-distributed hues; collisions exist (two distinct identities
/// MAY hash to the same hue degree) but are rare in practice and
/// never cause a correctness issue, only a presentation one.
#[must_use]
pub fn task_color(task: &TaskId) -> Rgb {
    let display = format!("{task}");
    let hash = fnv1a_64(display.as_bytes());
    // `hash % 360` fits in 9 bits; the widening through `u16` is
    // lossless and `f32::from(u16)` is precision-preserving for
    // every value below 2^24, well above 360.
    let hue_bucket = u16::try_from(hash % 360).unwrap_or(0);
    let hue = f32::from(hue_bucket);
    hsl_to_rgb(hue, TASK_SATURATION, TASK_LIGHTNESS)
}

/// ANSI escape-sequence helpers the presenter writes when colour
/// is enabled.
///
/// Truecolor (24-bit) foreground / background SGRs are widely
/// supported across modern terminals (xterm, alacritty, kitty,
/// wezterm, iTerm2, Windows Terminal, GNOME Terminal). A terminal
/// that lacks truecolor support either ignores the SGR and renders
/// the text uncoloured or falls back to an approximate indexed
/// colour; both degraded modes are acceptable. Older limited-
/// colour terminals are not the target.
pub mod ansi {
    use super::Rgb;

    /// ANSI escape: reset all SGR attributes.
    pub const RESET: &str = "\x1b[0m";
    /// ANSI escape: bold (intensity) on.
    pub const BOLD: &str = "\x1b[1m";
    /// ANSI escape: dim (low intensity) on.
    pub const DIM: &str = "\x1b[2m";

    /// 8-colour palette indices that map to a status-bearing
    /// meaning the presenter reuses across the summary line and
    /// the run-level epilogue.
    pub const FG_GREEN: &str = "\x1b[32m";
    pub const FG_YELLOW: &str = "\x1b[33m";
    pub const FG_RED: &str = "\x1b[31m";

    /// Truecolor foreground SGR for `rgb`. Wraps the channels into
    /// `\x1b[38;2;R;G;Bm`.
    #[must_use]
    pub fn fg(rgb: Rgb) -> String {
        format!("\x1b[38;2;{};{};{}m", rgb.r, rgb.g, rgb.b)
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use haz_domain::name::{ProjectName, TaskName};

    use super::*;

    fn task_id_for(project: &str, task: &str) -> TaskId {
        TaskId {
            project: ProjectName::from_str(project).unwrap(),
            task: TaskName::from_str(task).unwrap(),
        }
    }

    #[test]
    fn fnv1a_is_deterministic() {
        assert_eq!(fnv1a_64(b"abc"), fnv1a_64(b"abc"));
        assert_ne!(fnv1a_64(b"abc"), fnv1a_64(b"abd"));
    }

    #[test]
    fn task_color_is_stable_across_calls() {
        let task = task_id_for("lib", "build");
        assert_eq!(task_color(&task), task_color(&task));
    }

    #[test]
    fn task_color_differs_for_distinct_tasks() {
        // A handful of identities; at least one pair MUST differ.
        // The chance of all four hashing to the same hue degree is
        // negligible; the assertion catches a hash function that
        // collapses everything to zero.
        let a = task_color(&task_id_for("lib", "build"));
        let b = task_color(&task_id_for("lib", "test"));
        let c = task_color(&task_id_for("web", "bundle"));
        let d = task_color(&task_id_for("api", "check"));
        assert!(a != b || a != c || a != d);
    }

    #[test]
    fn hsl_to_rgb_known_points() {
        // Hue 0 + lightness 0.5 + saturation 1.0 = pure red.
        assert_eq!(hsl_to_rgb(0.0, 1.0, 0.5), Rgb::new(255, 0, 0));
        // Hue 120 = pure green.
        assert_eq!(hsl_to_rgb(120.0, 1.0, 0.5), Rgb::new(0, 255, 0));
        // Hue 240 = pure blue.
        assert_eq!(hsl_to_rgb(240.0, 1.0, 0.5), Rgb::new(0, 0, 255));
        // Saturation 0 = greyscale.
        let grey = hsl_to_rgb(0.0, 0.0, 0.5);
        assert_eq!(grey.r, grey.g);
        assert_eq!(grey.g, grey.b);
    }

    #[test]
    fn ansi_fg_renders_truecolor_sgr() {
        let escape = ansi::fg(Rgb::new(10, 20, 30));
        assert_eq!(escape, "\x1b[38;2;10;20;30m");
    }
}