openlatch-provider 0.2.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Tracing subscriber initialisation + observability helpers.
//!
//! Wires `tracing_subscriber` once at binary startup so every `info!` /
//! `warn!` / `debug!` call in the crate becomes a visible line on stderr.
//!
//! Level resolution (top wins):
//!   1. `RUST_LOG` env (standard `tracing-subscriber::EnvFilter`)
//!   2. `--debug`   → `openlatch_provider=trace,info`
//!   3. `--verbose` → `openlatch_provider=debug,info`
//!   4. default     → `info`
//!
//! Format dispatch on stderr-is-a-tty: TTY → human-readable with ANSI;
//! non-TTY → JSON (parseable by `jq` and log shippers).

use std::io::IsTerminal;

use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter};

use crate::cli::GlobalArgs;
use crate::runtime::verdict::VerdictHint;
use crate::ui::color;

/// Install the global `tracing` subscriber. Idempotent — a second call from
/// tests is silently ignored.
pub fn init(g: &GlobalArgs) {
    let filter = resolve_filter(g);
    let ansi = should_color(g.no_color);
    let is_tty = std::io::stderr().is_terminal();

    let result = if is_tty {
        tracing_subscriber::registry()
            .with(filter)
            .with(
                fmt::layer()
                    .with_writer(std::io::stderr)
                    .with_ansi(ansi)
                    .with_target(false),
            )
            .try_init()
    } else {
        tracing_subscriber::registry()
            .with(filter)
            .with(fmt::layer().json().with_writer(std::io::stderr))
            .try_init()
    };
    // `try_init` errors only when a subscriber is already installed (tests).
    let _ = result;
}

fn resolve_filter(g: &GlobalArgs) -> EnvFilter {
    if let Some(env) = std::env::var("RUST_LOG").ok().filter(|s| !s.is_empty()) {
        if let Ok(f) = EnvFilter::try_new(&env) {
            return f;
        }
    }
    let level = if g.debug {
        "openlatch_provider=trace,info"
    } else if g.verbose {
        "openlatch_provider=debug,info"
    } else {
        "info"
    };
    EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"))
}

/// Whether ANSI color codes should be emitted into log output.
///
/// Precedence: `--no-color` flag > `NO_COLOR` env > `FORCE_COLOR` env >
/// stderr-is-a-tty. Mirrors [`crate::ui::color::is_color_enabled`] but checks
/// stderr (where tracing writes) instead of stdout.
pub fn should_color(no_color_flag: bool) -> bool {
    if no_color_flag {
        return false;
    }
    if std::env::var_os("NO_COLOR").is_some() {
        return false;
    }
    if std::env::var_os("FORCE_COLOR").is_some() {
        return true;
    }
    std::io::stderr().is_terminal()
}

/// Render a verdict-hint token (`allow` / `approve` / `deny` / `flag`)
/// colorized for log output. Accepts:
///   - `None` → "unknown" (dim if `ansi`)
///   - `Some(VerdictHint)` → typed entry-point for runtime emitters
///   - `Some(&str)` → string entry-point for audit-log replay
///
/// Note: `VerdictHint` only carries `allow`/`approve`/`deny` per
/// `envelope-format.md`. The string variant additionally tolerates `"flag"`
/// in case the audit log persisted a legacy value.
pub fn verdict_display(hint: Option<VerdictHint>, ansi: bool) -> String {
    match hint {
        Some(VerdictHint::Allow) => color::green("allow", ansi),
        Some(VerdictHint::Approve) => color::yellow("approve", ansi),
        Some(VerdictHint::Deny) => color::red("deny", ansi),
        None => color::dim("unknown", ansi),
    }
}

/// String entry-point (audit-log replay). See [`verdict_display`].
pub fn verdict_display_str(token: Option<&str>, ansi: bool) -> String {
    let Some(token) = token else {
        return color::dim("", ansi);
    };
    match token {
        "allow" => color::green(token, ansi),
        "deny" => color::red(token, ansi),
        "approve" | "flag" => color::yellow(token, ansi),
        other => other.to_string(),
    }
}

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

    #[test]
    fn verdict_display_allow_is_green_when_ansi() {
        let out = verdict_display(Some(VerdictHint::Allow), true);
        assert!(out.contains("\x1b[32m"), "expected green ANSI for allow");
        assert!(out.contains("allow"));
    }

    #[test]
    fn verdict_display_deny_is_red_when_ansi() {
        let out = verdict_display(Some(VerdictHint::Deny), true);
        assert!(out.contains("\x1b[31m"), "expected red ANSI for deny");
        assert!(out.contains("deny"));
    }

    #[test]
    fn verdict_display_approve_is_yellow_when_ansi() {
        let out = verdict_display(Some(VerdictHint::Approve), true);
        assert!(out.contains("\x1b[33m"), "expected yellow ANSI for approve");
    }

    #[test]
    fn verdict_display_plain_without_ansi() {
        assert_eq!(verdict_display(Some(VerdictHint::Allow), false), "allow");
    }

    #[test]
    fn verdict_display_missing_hint_renders_unknown() {
        assert_eq!(verdict_display(None, false), "unknown");
    }

    #[test]
    fn verdict_display_str_flag_is_yellow() {
        let out = verdict_display_str(Some("flag"), true);
        assert!(out.contains("\x1b[33m"));
        assert!(out.contains("flag"));
    }

    #[test]
    fn resolve_filter_falls_back_to_info_on_default() {
        let g = GlobalArgs::default();
        let _f = resolve_filter(&g);
    }

    #[test]
    fn should_color_no_color_flag_wins() {
        assert!(!should_color(true));
    }
}