weavegraph 0.7.0

Graph-driven, concurrent agent workflow framework with versioned state, deterministic barrier merges, and rich diagnostics.
Documentation
//! Telemetry formatting: renders workflow events and errors as human-readable or machine-readable text.

use crate::channels::errors::{ErrorEvent, WeaveError};
use crate::event_bus::Event;
use std::io::IsTerminal;
use std::sync::OnceLock;

/// ANSI green — used for scope context labels.
pub const CONTEXT_COLOR: &str = "\x1b[32m";
/// ANSI magenta — used for event and error body lines.
pub const LINE_COLOR: &str = "\x1b[35m";
/// ANSI reset — clears all color attributes.
pub const RESET_COLOR: &str = "\x1b[0m";

static STDERR_IS_TERMINAL: OnceLock<bool> = OnceLock::new();

fn stderr_is_terminal() -> bool {
    *STDERR_IS_TERMINAL.get_or_init(|| std::io::stderr().is_terminal())
}

/// Color mode for telemetry output.
///
/// - [`Auto`](FormatterMode::Auto): detect TTY on first use via `stderr.is_terminal()`
/// - [`Colored`](FormatterMode::Colored): always emit ANSI codes
/// - [`Plain`](FormatterMode::Plain): never emit ANSI codes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FormatterMode {
    /// Detect TTY on first use; resolves to `Colored` or `Plain`.
    #[default]
    Auto,
    /// Always emit ANSI color codes.
    Colored,
    /// Never emit ANSI color codes.
    Plain,
}

impl FormatterMode {
    /// Resolve `Auto` against the current stderr TTY and return a concrete mode.
    pub fn auto_detect() -> Self {
        if stderr_is_terminal() {
            Self::Colored
        } else {
            Self::Plain
        }
    }

    /// Return `true` if this mode produces colored output.
    pub fn is_colored(&self) -> bool {
        match self {
            Self::Auto => stderr_is_terminal(),
            Self::Colored => true,
            Self::Plain => false,
        }
    }
}

/// Rendered output for a single telemetry item.
#[derive(Clone, Debug, Default)]
pub struct EventRender {
    /// Optional scope label shown before the event lines.
    pub context: Option<String>,
    /// Formatted output lines for this event.
    pub lines: Vec<String>,
}

impl EventRender {
    /// Concatenate all lines into a single string.
    pub fn join_lines(&self) -> String {
        self.lines.join("")
    }
}

/// Formats workflow events and errors into [`EventRender`] values.
pub trait TelemetryFormatter: Send + Sync {
    /// Render a single [`Event`].
    fn render_event(&self, event: &Event) -> EventRender;
    /// Render a slice of [`ErrorEvent`]s, one [`EventRender`] per error.
    fn render_errors(&self, errors: &[ErrorEvent]) -> Vec<EventRender>;
}

/// Plain-text formatter with optional ANSI color support.
///
/// Color output is governed by [`FormatterMode`].
pub struct PlainFormatter {
    mode: FormatterMode,
}

impl PlainFormatter {
    /// Create a formatter with auto-detected color mode.
    pub fn new() -> Self {
        Self {
            mode: FormatterMode::Auto,
        }
    }

    /// Create a formatter with an explicit color mode.
    pub fn with_mode(mode: FormatterMode) -> Self {
        Self { mode }
    }

    fn paint<'a>(&self, code: &'a str) -> &'a str {
        if self.mode.is_colored() { code } else { "" }
    }

    fn reset(&self) -> &str {
        self.paint(RESET_COLOR)
    }
}

impl Default for PlainFormatter {
    fn default() -> Self {
        Self::new()
    }
}

fn cause_chain(error: &WeaveError, depth: usize, fmt: &PlainFormatter) -> Vec<String> {
    let Some(cause) = &error.cause else {
        return Vec::new();
    };
    let indent = "  ".repeat(depth);
    let mut lines = vec![format!(
        "{}{}cause: {}{}\n",
        fmt.paint(LINE_COLOR),
        indent,
        cause.message,
        fmt.reset()
    )];
    lines.extend(cause_chain(cause, depth + 1, fmt));
    lines
}

impl TelemetryFormatter for PlainFormatter {
    fn render_event(&self, event: &Event) -> EventRender {
        let line = format!("{}{}{}\n", self.paint(LINE_COLOR), event, self.reset());
        EventRender {
            context: event.scope_label().map(str::to_owned),
            lines: vec![line],
        }
    }

    fn render_errors(&self, errors: &[ErrorEvent]) -> Vec<EventRender> {
        errors
            .iter()
            .enumerate()
            .map(|(i, e)| {
                let scope = format!("{}{:?}{}", self.paint(CONTEXT_COLOR), e.scope, self.reset());
                let mut lines = vec![format!("[{}] {} | {}\n", i, e.when, scope)];
                lines.push(format!(
                    "{}  error: {}{}\n",
                    self.paint(LINE_COLOR),
                    e.error.message,
                    self.reset()
                ));
                lines.extend(cause_chain(&e.error, 1, self));
                if !e.tags.is_empty() {
                    lines.push(format!(
                        "{}  tags: {:?}{}\n",
                        self.paint(LINE_COLOR),
                        e.tags,
                        self.reset()
                    ));
                }
                if !e.context.is_null() {
                    lines.push(format!(
                        "{}  context: {}{}\n",
                        self.paint(LINE_COLOR),
                        e.context,
                        self.reset()
                    ));
                }
                EventRender {
                    context: Some(format!("{:?}", e.scope)),
                    lines,
                }
            })
            .collect()
    }
}