Skip to main content

git_paw/mcp/
logging.rs

1//! Minimal stderr logger for the MCP server (design D5).
2//!
3//! stdio MCP servers MUST keep **stdout** reserved for JSON-RPC frames, so all
4//! diagnostics go to **stderr** (and, optionally, a `--log-file`). Verbosity is
5//! controlled by the `RUST_LOG` environment variable (a recognised level
6//! keyword anywhere in the value), defaulting to `warn` — quiet by default.
7//!
8//! This is intentionally a tiny homegrown facility rather than a new
9//! `tracing-subscriber` dependency: the project minimises its dependency
10//! surface (cf. the homegrown `crate::dirs`), and the only audited
11//! requirements are "logging to stderr, `RUST_LOG`-controlled, default warn,
12//! optional file tee" — all satisfied here without pulling in a logging stack.
13
14use std::fs::OpenOptions;
15use std::io::Write;
16use std::path::Path;
17use std::sync::{Mutex, OnceLock};
18
19use crate::error::PawError;
20
21/// Severity levels, ordered least- to most-verbose.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Level {
24    /// Errors that prevented an operation.
25    Error,
26    /// Warnings — the default threshold.
27    Warn,
28    /// Informational lifecycle messages.
29    Info,
30    /// Debugging detail.
31    Debug,
32    /// Very verbose tracing.
33    Trace,
34}
35
36impl Level {
37    fn label(self) -> &'static str {
38        match self {
39            Self::Error => "ERROR",
40            Self::Warn => "WARN",
41            Self::Info => "INFO",
42            Self::Debug => "DEBUG",
43            Self::Trace => "TRACE",
44        }
45    }
46}
47
48/// Parses a verbosity threshold from a `RUST_LOG`-style value. Recognises a
49/// level keyword appearing anywhere in the string (case-insensitive); defaults
50/// to [`Level::Warn`] when none is found.
51#[must_use]
52pub fn parse_level(rust_log: Option<&str>) -> Level {
53    let Some(value) = rust_log else {
54        return Level::Warn;
55    };
56    let v = value.to_ascii_lowercase();
57    // Most-verbose wins if multiple appear, so check from the top down.
58    if v.contains("trace") {
59        Level::Trace
60    } else if v.contains("debug") {
61        Level::Debug
62    } else if v.contains("info") {
63        Level::Info
64    } else if v.contains("error") && !v.contains("warn") {
65        Level::Error
66    } else {
67        Level::Warn
68    }
69}
70
71struct Logger {
72    threshold: Level,
73    file: Option<Mutex<std::fs::File>>,
74}
75
76static LOGGER: OnceLock<Logger> = OnceLock::new();
77
78/// Initialises the global logger. Idempotent: a second call is a no-op (the
79/// first configuration wins), which keeps tests that spin up the server
80/// repeatedly from panicking.
81pub fn init(log_file: Option<&Path>) -> Result<(), PawError> {
82    let threshold = parse_level(std::env::var("RUST_LOG").ok().as_deref());
83    let file = match log_file {
84        Some(path) => {
85            let f = OpenOptions::new()
86                .create(true)
87                .append(true)
88                .open(path)
89                .map_err(|e| {
90                    PawError::McpError(format!("could not open --log-file {}: {e}", path.display()))
91                })?;
92            Some(Mutex::new(f))
93        }
94        None => None,
95    };
96    // First writer wins; ignore if already initialised.
97    let _ = LOGGER.set(Logger { threshold, file });
98    Ok(())
99}
100
101/// Emits a log line at `level` to stderr and the log file (when enabled),
102/// gated by the configured threshold. Before [`init`], falls back to the
103/// default `warn` threshold against stderr only.
104pub fn log(level: Level, message: &str) {
105    match LOGGER.get() {
106        Some(logger) => {
107            if level > logger.threshold {
108                return;
109            }
110            let line = format!("[git-paw mcp] {}: {message}\n", level.label());
111            // stderr is always the primary sink.
112            let _ = std::io::stderr().write_all(line.as_bytes());
113            if let Some(file) = logger.file.as_ref()
114                && let Ok(mut f) = file.lock()
115            {
116                let _ = f.write_all(line.as_bytes());
117            }
118        }
119        None => {
120            if level <= Level::Warn {
121                let _ = std::io::stderr()
122                    .write_all(format!("[git-paw mcp] {}: {message}\n", level.label()).as_bytes());
123            }
124        }
125    }
126}
127
128/// Logs at [`Level::Info`].
129pub fn info(message: &str) {
130    log(Level::Info, message);
131}
132
133/// Logs at [`Level::Warn`].
134pub fn warn(message: &str) {
135    log(Level::Warn, message);
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn parse_level_defaults_to_warn() {
144        assert_eq!(parse_level(None), Level::Warn);
145        assert_eq!(parse_level(Some("")), Level::Warn);
146    }
147
148    #[test]
149    fn parse_level_recognises_keywords() {
150        assert_eq!(parse_level(Some("debug")), Level::Debug);
151        assert_eq!(parse_level(Some("info")), Level::Info);
152        assert_eq!(parse_level(Some("trace")), Level::Trace);
153        assert_eq!(parse_level(Some("git_paw=debug,hyper=warn")), Level::Debug);
154    }
155
156    #[test]
157    fn level_ordering_is_least_to_most_verbose() {
158        assert!(Level::Error < Level::Warn);
159        assert!(Level::Warn < Level::Debug);
160        assert!(Level::Debug < Level::Trace);
161    }
162}