use haz_domain::task_id::TaskId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
const FNV1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV1A_PRIME: u64 = 0x0000_0100_0000_01b3;
#[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
}
#[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());
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();
#[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
}
const TASK_SATURATION: f32 = 0.70;
const TASK_LIGHTNESS: f32 = 0.62;
#[must_use]
pub fn task_color(task: &TaskId) -> Rgb {
let display = format!("{task}");
let hash = fnv1a_64(display.as_bytes());
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)
}
pub mod ansi {
use super::Rgb;
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const FG_GREEN: &str = "\x1b[32m";
pub const FG_YELLOW: &str = "\x1b[33m";
pub const FG_RED: &str = "\x1b[31m";
#[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() {
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() {
assert_eq!(hsl_to_rgb(0.0, 1.0, 0.5), Rgb::new(255, 0, 0));
assert_eq!(hsl_to_rgb(120.0, 1.0, 0.5), Rgb::new(0, 255, 0));
assert_eq!(hsl_to_rgb(240.0, 1.0, 0.5), Rgb::new(0, 0, 255));
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");
}
}