mecha10-cli 4.2.15

Mecha10 CLI tool
Documentation
//! Small color helpers for the CLI's non-TUI command output.
//!
//! Built on the existing `console::style` API (already a direct dependency, already used for
//! interactive-prompt theming in [`super::prompts`] and ad hoc in a couple of handlers). `console`
//! auto-detects whether stdout is a tty and respects `NO_COLOR`, so these helpers don't need to
//! do any manual `atty`/env-var checks themselves -- styling is simply a no-op when output isn't
//! a color-capable terminal.
//!
//! The color choices here follow what was already in use ad hoc in
//! `handlers/topology.rs`/`handlers/model.rs`: cyan for headings/section titles, green for
//! success, red for errors, yellow for warnings, dim for secondary/muted text.

use console::{style, StyledObject};

/// Green -- successful operations (pairs well with a `✅`/`✓` marker).
pub fn success(msg: impl std::fmt::Display) -> StyledObject<String> {
    style(msg.to_string()).green()
}

/// Bold red -- errors/failures (pairs well with a `❌`/`✗` marker).
pub fn error(msg: impl std::fmt::Display) -> StyledObject<String> {
    style(msg.to_string()).red().bold()
}

/// Yellow -- warnings (pairs well with a `⚠️` marker).
pub fn warn(msg: impl std::fmt::Display) -> StyledObject<String> {
    style(msg.to_string()).yellow()
}

/// Bold cyan -- section headings/titles.
pub fn heading(msg: impl std::fmt::Display) -> StyledObject<String> {
    style(msg.to_string()).cyan().bold()
}

/// Dim -- secondary/supporting text.
pub fn muted(msg: impl std::fmt::Display) -> StyledObject<String> {
    style(msg.to_string()).dim()
}

/// A dimmed `━`-repeated separator line of the given width, matching the `heading`/`muted`
/// palette used around it.
pub fn separator(width: usize) -> StyledObject<String> {
    muted("━".repeat(width))
}

/// Step-progress narration ("Checking X...", "Fetching Y...", "Uploading Z...") gated on the
/// CLI's `--log-level`. Routes through `tracing::debug!`, so `tracing_subscriber`'s level
/// filtering (already wired up in `main.rs` from `--log-level`, default `info`) does the gating
/// for free: silent by default, visible with `--log-level debug`, and -- for the `dev` command --
/// still captured in full by the rolling file log layer regardless of terminal verbosity.
///
/// Models `handlers/dev/ops/spawn.rs`'s existing `tracing::debug!`/`tracing::info!` usage: call
/// this (or `tracing::debug!`/`tracing::info!` directly, whichever reads better at the call site)
/// instead of a bare `println!` for this kind of narration.
///
/// IMPORTANT: this is for success-path step/status narration ONLY. Failures/errors must always
/// print to the terminal by default, in full detail -- never route them through this helper (or
/// gate them behind `--log-level debug`/`-v`), since a user should never have to dig through a
/// piped log file to see why something failed. Use [`error`] (plus `eprintln!`/`tracing::error!`)
/// for those instead.
pub fn step(msg: impl std::fmt::Display) {
    tracing::debug!("{msg}");
}

/// Fixed, deterministic color palette cycled by index -- used to give each log source (or any
/// other small, repeating set of labels) a stable, visually distinct color without randomness.
const PALETTE: [Palette; 5] = [
    Palette::Cyan,
    Palette::Magenta,
    Palette::Green,
    Palette::Yellow,
    Palette::Blue,
];

#[derive(Clone, Copy)]
enum Palette {
    Cyan,
    Magenta,
    Green,
    Yellow,
    Blue,
}

/// Style `msg` with the palette color at `index`, wrapping around for `index >= PALETTE.len()`.
pub fn palette(index: usize, msg: impl std::fmt::Display) -> StyledObject<String> {
    let styled = style(msg.to_string());
    match PALETTE[index % PALETTE.len()] {
        Palette::Cyan => styled.cyan(),
        Palette::Magenta => styled.magenta(),
        Palette::Green => styled.green(),
        Palette::Yellow => styled.yellow(),
        Palette::Blue => styled.blue(),
    }
}

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

    #[test]
    fn palette_wraps_around() {
        assert_eq!(format!("{}", palette(0, "x")), format!("{}", palette(5, "x")));
    }

    /// Mirrors the real `--log-level` gating from `main.rs`: a `tracing_subscriber` fmt layer
    /// filtered at `INFO` (the CLI's default) must swallow `step()`, while one filtered at
    /// `DEBUG` (i.e. `--log-level debug`) must let it through.
    #[test]
    fn step_is_gated_on_log_level() {
        use std::io;
        use std::sync::{Arc, Mutex};
        use tracing_subscriber::prelude::*;

        #[derive(Clone)]
        struct SharedBuf(Arc<Mutex<Vec<u8>>>);

        impl io::Write for SharedBuf {
            fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
                self.0.lock().unwrap().extend_from_slice(buf);
                Ok(buf.len())
            }

            fn flush(&mut self) -> io::Result<()> {
                Ok(())
            }
        }

        let buf = Arc::new(Mutex::new(Vec::new()));
        let make_writer = {
            let buf = buf.clone();
            move || SharedBuf(buf.clone())
        };

        let info_subscriber = tracing_subscriber::registry().with(
            tracing_subscriber::fmt::layer()
                .with_writer(make_writer.clone())
                .with_filter(tracing_subscriber::filter::LevelFilter::INFO),
        );
        tracing::subscriber::with_default(info_subscriber, || {
            step("hidden at the default info level");
        });
        assert!(
            buf.lock().unwrap().is_empty(),
            "step() must stay silent under the default --log-level info filter"
        );

        let debug_subscriber = tracing_subscriber::registry().with(
            tracing_subscriber::fmt::layer()
                .with_writer(make_writer)
                .with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
        );
        tracing::subscriber::with_default(debug_subscriber, || {
            step("visible under --log-level debug");
        });
        assert!(
            !buf.lock().unwrap().is_empty(),
            "step() must print once --log-level is debug or below"
        );
    }
}