Skip to main content

typub_log/
cli_layer.rs

1//! Custom tracing layer for CLI-formatted output.
2//!
3//! Implements [[ADR-0004]] Phase 1: CliLayer for custom CLI formatting.
4
5use owo_colors::OwoColorize;
6use std::io::{self, IsTerminal};
7use std::sync::OnceLock;
8use tracing::{Event, Level, Subscriber};
9use tracing_subscriber::Layer;
10use tracing_subscriber::layer::Context;
11use tracing_subscriber::registry::LookupSpan;
12
13// ============ Global State ============
14
15static VERBOSE: OnceLock<bool> = OnceLock::new();
16
17/// Check if verbose mode is enabled
18pub fn is_verbose() -> bool {
19    VERBOSE.get().copied().unwrap_or(false)
20}
21
22/// Check if colors should be used
23fn use_colors() -> bool {
24    io::stderr().is_terminal() && std::env::var("NO_COLOR").is_err()
25}
26
27// ============ Icons ============
28
29mod icons {
30    pub const DEBUG: &str = "[debug]";
31    pub const INFO: &str = "ℹ";
32    pub const WARN: &str = "⚠";
33    pub const ERROR: &str = "✗";
34    pub const TRACE: &str = "[trace]";
35}
36
37// ============ CliLayer ============
38
39/// A tracing layer that formats events for CLI output.
40///
41/// Features:
42/// - Icons for each log level
43/// - Colors respecting NO_COLOR and TTY detection
44/// - Debug/trace messages only shown in verbose mode
45pub struct CliLayer {
46    verbose: bool,
47}
48
49impl CliLayer {
50    /// Create a new CLI layer.
51    ///
52    /// If `verbose` is false, debug and trace events are suppressed.
53    pub fn new(verbose: bool) -> Self {
54        Self { verbose }
55    }
56}
57
58impl<S> Layer<S> for CliLayer
59where
60    S: Subscriber + for<'a> LookupSpan<'a>,
61{
62    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
63        let meta = event.metadata();
64        let level = meta.level();
65
66        // Filter debug/trace in non-verbose mode
67        if !self.verbose && (*level == Level::DEBUG || *level == Level::TRACE) {
68            return;
69        }
70
71        // Format the message
72        let mut visitor = MessageVisitor::default();
73        event.record(&mut visitor);
74        let message = visitor.message.unwrap_or_default();
75
76        // Get icon and color based on level
77        let (icon, colored_icon, colored_message) = match *level {
78            Level::ERROR => (
79                icons::ERROR,
80                format_colored(icons::ERROR, "red"),
81                format_colored(&message, "red"),
82            ),
83            Level::WARN => (
84                icons::WARN,
85                format_colored(icons::WARN, "yellow"),
86                format_colored(&message, "yellow"),
87            ),
88            Level::INFO => (
89                icons::INFO,
90                format_colored(icons::INFO, "blue"),
91                message.clone(),
92            ),
93            Level::DEBUG => (
94                icons::DEBUG,
95                format_colored(icons::DEBUG, "bright_black"),
96                message.clone(),
97            ),
98            Level::TRACE => (
99                icons::TRACE,
100                format_colored(icons::TRACE, "bright_black"),
101                format_colored(&message, "bright_black"),
102            ),
103        };
104
105        // Output to stderr (logs go to stderr per rust-cli conventions)
106        if use_colors() {
107            eprintln!("{} {}", colored_icon, colored_message);
108        } else {
109            eprintln!("{} {}", icon, message);
110        }
111    }
112}
113
114/// Format text with color if colors are enabled.
115fn format_colored(text: &str, color: &str) -> String {
116    if !use_colors() {
117        return text.to_string();
118    }
119
120    match color {
121        "red" => text.red().to_string(),
122        "yellow" => text.yellow().to_string(),
123        "blue" => text.blue().to_string(),
124        "green" => text.green().to_string(),
125        "cyan" => text.cyan().to_string(),
126        "bright_black" => text.bright_black().to_string(),
127        _ => text.to_string(),
128    }
129}
130
131// ============ Message Visitor ============
132
133/// Visitor to extract the message field from tracing events.
134#[derive(Default)]
135struct MessageVisitor {
136    message: Option<String>,
137}
138
139impl tracing::field::Visit for MessageVisitor {
140    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
141        if field.name() == "message" {
142            self.message = Some(format!("{:?}", value));
143        }
144    }
145
146    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
147        if field.name() == "message" {
148            self.message = Some(value.to_string());
149        }
150    }
151}
152
153// ============ Initialization ============
154
155/// Initialize the CLI logging subscriber.
156///
157/// This should be called once at CLI startup.
158///
159/// # Arguments
160///
161/// * `verbose` - If true, debug and trace messages are shown.
162///
163/// # Example
164///
165/// ```rust,ignore
166/// fn main() {
167///     typub_log::init(args.verbose);
168///     // ... rest of CLI
169/// }
170/// ```
171pub fn init(verbose: bool) {
172    use tracing_subscriber::EnvFilter;
173    use tracing_subscriber::layer::SubscriberExt;
174    use tracing_subscriber::util::SubscriberInitExt;
175
176    // Store verbose setting for is_verbose()
177    let _ = VERBOSE.set(verbose);
178
179    // Build filter: respect RUST_LOG, default to info (or debug if verbose)
180    let default_level = if verbose { "debug" } else { "info" };
181    let filter =
182        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
183
184    // Build and install subscriber
185    tracing_subscriber::registry()
186        .with(filter)
187        .with(CliLayer::new(verbose))
188        .init();
189}
190
191#[cfg(test)]
192mod tests {
193
194    #[test]
195    fn test_is_verbose_default_false() {
196        // Note: This test might be affected by other tests setting VERBOSE
197        // In a fresh state, is_verbose should return false
198        // We can't easily test this without resetting global state
199    }
200
201    #[test]
202    fn test_format_colored_no_color() {
203        // When NO_COLOR is set or not a TTY, colors should be stripped
204        // This is hard to test without mocking environment
205    }
206}