haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! Auto-detection of whether `haz run` should emit ANSI colour
//! escape sequences.
//!
//! Two signals combine into the [`ColorChoice`] decision:
//!
//! - The two writable streams the run touches (`stdout`, `stderr`)
//!   MUST both be terminals. A pipe / redirected file on either side
//!   silences colours: writing escapes into log files or into
//!   pagers that lack `-R` produces noise no consumer can read.
//! - The `NO_COLOR` environment variable
//!   (<https://no-color.org/>) MUST be unset or empty. A non-empty
//!   value (any non-empty value, per the convention) disables
//!   colours unconditionally; accessibility tools, screen readers,
//!   and colour-blind users rely on this knob.
//!
//! The detection is read-only and pure: it inspects file
//! descriptors and the process environment, never mutates either.

use std::io::IsTerminal;

/// Whether `haz run`'s presenter renders ANSI colour sequences.
///
/// The decision is binary: colour-render bytes go out, or they
/// don't. The mid-state ("auto") is resolved at construction time
/// by [`Self::detect`], which probes the live process environment
/// and the live stdout / stderr handles. Once a `ColorChoice` is
/// built it is a fixed runtime value the presenter consults on
/// every byte.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorChoice {
    /// Colour escape sequences are emitted on every styled span.
    Always,
    /// No escape sequence reaches either sink; the presenter emits
    /// plain text only.
    Never,
}

impl ColorChoice {
    /// Probe the live process for the colour decision: both
    /// streams MUST be terminals AND `NO_COLOR` MUST be unset or
    /// empty for colours to be on.
    #[must_use]
    pub fn detect() -> Self {
        if no_color_env_set() {
            return Self::Never;
        }
        if std::io::stdout().is_terminal() && std::io::stderr().is_terminal() {
            Self::Always
        } else {
            Self::Never
        }
    }

    /// Convenience predicate: `true` iff [`Self::Always`].
    #[must_use]
    pub fn is_enabled(self) -> bool {
        matches!(self, Self::Always)
    }
}

/// Read `NO_COLOR` per the spec at <https://no-color.org/>: any
/// non-empty value disables colours; absence or the empty string
/// leaves them on.
fn no_color_env_set() -> bool {
    std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
}

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

    #[test]
    fn is_enabled_matches_variant() {
        assert!(ColorChoice::Always.is_enabled());
        assert!(!ColorChoice::Never.is_enabled());
    }
}