Skip to main content

mermaid_cli/utils/
logger.rs

1use std::collections::VecDeque;
2use std::fs::{File, OpenOptions};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock};
6use tracing::{debug, error, info, warn};
7use tracing_subscriber::fmt::MakeWriter;
8use tracing_subscriber::{
9    EnvFilter, Layer,
10    layer::{Context as LayerContext, SubscriberExt},
11    util::SubscriberInitExt,
12};
13
14/// Rotate the log file when it reaches this size. Bounded: at most two
15/// log files (`mermaid.log` current + `mermaid.log.old` previous), so
16/// worst-case disk use is ~2x this value between restarts.
17const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
18
19/// Events retained by the in-memory TRACE ring (`mermaid feedback`).
20const RING_CAPACITY: usize = 2000;
21/// Per-event byte clamp so one giant payload can't hog the ring
22/// (~1 MiB worst-case total with [`RING_CAPACITY`]).
23const RING_MAX_EVENT_BYTES: usize = 512;
24
25/// Get the log file path (~/.mermaid/mermaid.log)
26fn get_log_file_path() -> Option<PathBuf> {
27    // Fall back to USERPROFILE on Windows where HOME is not conventionally
28    // set; mirrors the pattern used in app::config::get_config_dir.
29    std::env::var("HOME")
30        .or_else(|_| std::env::var("USERPROFILE"))
31        .ok()
32        .map(|home| PathBuf::from(home).join(".mermaid").join("mermaid.log"))
33}
34
35/// The log file path, if resolvable — `mermaid feedback` tails it.
36pub fn log_file_path() -> Option<PathBuf> {
37    get_log_file_path()
38}
39
40/// Always-on in-memory ring of recent trace events. Captures at TRACE for the
41/// mermaid crates regardless of `RUST_LOG` (deps capped at INFO), so a bug
42/// report carries the last ~2000 events without asking the user to reproduce
43/// under elevated logging. Secrets are redacted AT CAPTURE, not at read time.
44#[derive(Clone)]
45pub struct TraceRing {
46    inner: Arc<Mutex<VecDeque<String>>>,
47}
48
49impl TraceRing {
50    fn new() -> Self {
51        Self {
52            inner: Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAPACITY))),
53        }
54    }
55
56    /// Append one formatted event, evicting the oldest past capacity.
57    fn push(&self, line: String) {
58        let Ok(mut ring) = self.inner.lock() else {
59            return;
60        };
61        if ring.len() == RING_CAPACITY {
62            ring.pop_front();
63        }
64        ring.push_back(line);
65    }
66
67    /// Copy of the ring contents, oldest first.
68    pub fn snapshot(&self) -> Vec<String> {
69        self.inner
70            .lock()
71            .map(|ring| ring.iter().cloned().collect())
72            .unwrap_or_default()
73    }
74}
75
76/// Process-global ring, installed by [`init_logger`]. `None` before init
77/// (unit tests, library embedding).
78static TRACE_RING: OnceLock<TraceRing> = OnceLock::new();
79
80/// The process-global trace ring, if logging was initialized.
81pub fn trace_ring() -> Option<&'static TraceRing> {
82    TRACE_RING.get()
83}
84
85/// `tracing` layer that mirrors every (filter-passing) event into a
86/// [`TraceRing`] as one compact line: `ts LEVEL target: message k=v`.
87struct RingLayer {
88    ring: TraceRing,
89}
90
91/// Field visitor for [`RingLayer`]: the `message` field becomes the line body,
92/// every other field is appended as ` k=v`.
93struct RingVisitor {
94    message: String,
95    fields: String,
96}
97
98impl tracing::field::Visit for RingVisitor {
99    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
100        use std::fmt::Write;
101        if field.name() == "message" {
102            self.message.push_str(value);
103        } else {
104            let _ = write!(self.fields, " {}={}", field.name(), value);
105        }
106    }
107
108    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
109        use std::fmt::Write;
110        if field.name() == "message" {
111            let _ = write!(self.message, "{value:?}");
112        } else {
113            let _ = write!(self.fields, " {}={:?}", field.name(), value);
114        }
115    }
116}
117
118impl<S> Layer<S> for RingLayer
119where
120    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
121{
122    // NOTE: never log from inside on_event — a tracing call here would
123    // re-enter the subscriber.
124    fn on_event(&self, event: &tracing::Event<'_>, _ctx: LayerContext<'_, S>) {
125        let mut visitor = RingVisitor {
126            message: String::new(),
127            fields: String::new(),
128        };
129        event.record(&mut visitor);
130        let meta = event.metadata();
131        let mut line = format!(
132            "{} {} {}: {}{}",
133            chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"),
134            meta.level(),
135            meta.target(),
136            visitor.message,
137            visitor.fields
138        );
139        if line.len() > RING_MAX_EVENT_BYTES {
140            line.truncate(line.floor_char_boundary(RING_MAX_EVENT_BYTES));
141            line.push_str("...");
142        }
143        // Redact ON CAPTURE: the ring is read back by `mermaid feedback`, so a
144        // key must never sit in memory waiting to be exported.
145        self.ring.push(crate::utils::redact_secrets(&line));
146    }
147}
148
149/// The ring's fixed filter: our crates at TRACE, dependencies capped at INFO
150/// (hyper/h2 TRACE floods stay disabled via per-callsite interest caching).
151/// Independent of `RUST_LOG`, which scopes only the file layer. `Targets`
152/// (not `EnvFilter`) deliberately: `EnvFilter` is documented as unsuitable
153/// for per-layer use alongside another `EnvFilter` — its callsite-interest
154/// caching made the ring silently drop everything next to the file layer.
155fn ring_filter() -> tracing_subscriber::filter::Targets {
156    use tracing::level_filters::LevelFilter;
157    tracing_subscriber::filter::Targets::new()
158        .with_default(LevelFilter::INFO)
159        .with_target("mermaid_cli", LevelFilter::TRACE)
160        .with_target("mermaid_runtime", LevelFilter::TRACE)
161        .with_target("mermaidd", LevelFilter::TRACE)
162}
163
164/// The filtered ring layer, generic over the subscriber stack it joins —
165/// `Filtered<…, S>` is stack-specific, so each `init_logger` branch builds
166/// its own instance (both share the ONE process-global ring).
167fn build_ring_layer<S>()
168-> tracing_subscriber::filter::Filtered<RingLayer, tracing_subscriber::filter::Targets, S>
169where
170    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
171{
172    RingLayer {
173        ring: TRACE_RING.get_or_init(TraceRing::new).clone(),
174    }
175    .with_filter(ring_filter())
176}
177
178/// If the log file exceeds MAX_LOG_SIZE, rename it to `.log.old`
179/// (overwriting any prior `.log.old`). Best-effort — rotation failures
180/// are silent because logging is non-critical. Runs once per startup.
181fn rotate_if_large(path: &Path) {
182    let Ok(meta) = std::fs::metadata(path) else {
183        return;
184    };
185    if meta.len() >= MAX_LOG_SIZE {
186        let rotated = path.with_extension("log.old");
187        let _ = std::fs::rename(path, rotated);
188    }
189}
190
191/// Initialize the logging system with tracing.
192///
193/// Two layers, each with its OWN filter:
194/// - the file layer (`~/.mermaid/mermaid.log`), scoped by `RUST_LOG` /
195///   `--verbose` exactly as before;
196/// - the always-on [`TraceRing`] with a fixed mermaid-at-TRACE filter, so
197///   `mermaid feedback` can export recent events without a reproduce-under-
198///   RUST_LOG round trip.
199pub fn init_logger(verbose: bool) {
200    // If --verbose flag is set, override to debug level
201    // Otherwise use RUST_LOG environment variable, default to warn level
202    // (quieter). This filter scopes ONLY the file layer.
203    let filter = if verbose {
204        EnvFilter::new("debug,mermaid=debug")
205    } else {
206        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,mermaid=info"))
207    };
208
209    // Try to write logs to a file to avoid corrupting the TUI
210    // Falls back to no logging if file creation fails (TUI takes priority)
211    if let Some(log_path) = get_log_file_path() {
212        // Ensure parent directory exists
213        if let Some(parent) = log_path.parent() {
214            let _ = std::fs::create_dir_all(parent);
215        }
216
217        // Rotate at startup if the previous session left a large file.
218        rotate_if_large(&log_path);
219
220        // Open the log for appending, owner-only (0o600): it can incidentally
221        // capture secrets the model surfaced (a `read_file` of `.env`, an API
222        // error echoing a key), so a shared temp/cwd must not expose it. Mirrors
223        // recorder.rs; RedactingWriter additionally scrubs credential shapes.
224        let mut opts = OpenOptions::new();
225        opts.create(true).append(true);
226        #[cfg(unix)]
227        {
228            use std::os::unix::fs::OpenOptionsExt;
229            opts.mode(0o600);
230        }
231        if let Ok(file) = opts.open(&log_path) {
232            // `mode` only applies on create; tighten an existing log too.
233            #[cfg(unix)]
234            {
235                use std::os::unix::fs::PermissionsExt;
236                let _ = std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o600));
237            }
238            let fmt_layer = tracing_subscriber::fmt::layer()
239                .with_writer(RedactingWriter::new(file))
240                .with_target(false)
241                .with_thread_ids(false)
242                .with_thread_names(false)
243                .with_ansi(false) // No ANSI colors in file
244                .compact()
245                .with_filter(filter);
246
247            tracing_subscriber::registry()
248                .with(fmt_layer)
249                .with(build_ring_layer())
250                .init();
251            return;
252        }
253    }
254
255    // Fallback: no file logging if creation fails (don't corrupt the TUI) —
256    // but the trace ring still captures, so `mermaid feedback` keeps working.
257    tracing_subscriber::registry()
258        .with(build_ring_layer())
259        .init();
260}
261
262/// A `MakeWriter` that scrubs credential-shaped strings out of every formatted
263/// log event before it reaches disk. The log can incidentally capture secrets
264/// the model surfaced (a `read_file` of `.env`, an API error echoing a key);
265/// [`redact_secrets`](crate::utils::redact_secrets) removes the common shapes at
266/// this single sink so no `tracing::warn!`/`error!` payload persists a key.
267#[derive(Clone)]
268struct RedactingWriter {
269    file: Arc<Mutex<File>>,
270}
271
272impl RedactingWriter {
273    /// Wrap an open log file; the handle is shared across concurrently-logging threads.
274    fn new(file: File) -> Self {
275        Self {
276            file: Arc::new(Mutex::new(file)),
277        }
278    }
279}
280
281impl<'a> MakeWriter<'a> for RedactingWriter {
282    type Writer = RedactingEvent;
283
284    fn make_writer(&'a self) -> Self::Writer {
285        RedactingEvent {
286            buf: Vec::new(),
287            file: Arc::clone(&self.file),
288        }
289    }
290}
291
292/// One event's write buffer. The fmt layer formats a whole event and writes it
293/// here; we accumulate the bytes and redact the *complete* text on drop (so a
294/// secret split across writes can't slip through unredacted), then append the
295/// scrubbed line to the shared file.
296struct RedactingEvent {
297    buf: Vec<u8>,
298    file: Arc<Mutex<File>>,
299}
300
301impl Write for RedactingEvent {
302    fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
303        self.buf.extend_from_slice(data);
304        Ok(data.len())
305    }
306
307    fn flush(&mut self) -> std::io::Result<()> {
308        Ok(())
309    }
310}
311
312impl Drop for RedactingEvent {
313    fn drop(&mut self) {
314        if self.buf.is_empty() {
315            return;
316        }
317        let text = String::from_utf8_lossy(&self.buf);
318        let redacted = crate::utils::redact_secrets(&text);
319        if let Ok(mut file) = self.file.lock() {
320            let _ = file.write_all(redacted.as_bytes());
321        }
322    }
323}
324
325/// Log an info message with category prefix (backward compatible)
326pub fn log_info(category: &str, message: impl std::fmt::Display) {
327    info!(category = %category, "{}", message);
328}
329
330/// Log a warning message with category prefix (backward compatible)
331pub fn log_warn(category: &str, message: impl std::fmt::Display) {
332    warn!(category = %category, "{}", message);
333}
334
335/// Log an error message with category prefix (backward compatible)
336pub fn log_error(category: &str, message: impl std::fmt::Display) {
337    error!(category = %category, "{}", message);
338}
339
340/// Log a debug message (backward compatible)
341pub fn log_debug(message: impl std::fmt::Display) {
342    debug!("{}", message);
343}
344
345/// Progress indicator for startup sequence
346pub fn log_progress(step: usize, total: usize, message: impl std::fmt::Display) {
347    info!(step = step, total = total, "{}", message);
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn rotate_small_file_is_noop() {
356        let tmp = std::env::temp_dir().join("mermaid_logger_small.log");
357        let _ = std::fs::remove_file(&tmp);
358        let _ = std::fs::remove_file(tmp.with_extension("log.old"));
359        std::fs::write(&tmp, b"hello world").unwrap();
360
361        rotate_if_large(&tmp);
362
363        assert!(tmp.exists(), "small file should NOT be rotated");
364        assert!(
365            !tmp.with_extension("log.old").exists(),
366            "no .log.old should be created for small files"
367        );
368
369        let _ = std::fs::remove_file(&tmp);
370    }
371
372    #[test]
373    fn rotate_large_file_renames_to_old() {
374        let tmp = std::env::temp_dir().join("mermaid_logger_large.log");
375        let _ = std::fs::remove_file(&tmp);
376        let old = tmp.with_extension("log.old");
377        let _ = std::fs::remove_file(&old);
378
379        let file = std::fs::File::create(&tmp).unwrap();
380        file.set_len(MAX_LOG_SIZE + 1).unwrap();
381        drop(file);
382
383        rotate_if_large(&tmp);
384
385        assert!(!tmp.exists(), "oversized file should be rotated away");
386        assert!(old.exists(), ".log.old should now exist");
387
388        let _ = std::fs::remove_file(&old);
389    }
390
391    #[test]
392    fn rotate_overwrites_prior_old() {
393        let tmp = std::env::temp_dir().join("mermaid_logger_overwrite.log");
394        let _ = std::fs::remove_file(&tmp);
395        let old = tmp.with_extension("log.old");
396        std::fs::write(&old, b"stale previous rotation").unwrap();
397
398        let file = std::fs::File::create(&tmp).unwrap();
399        file.set_len(MAX_LOG_SIZE + 1).unwrap();
400        drop(file);
401
402        rotate_if_large(&tmp);
403
404        // Previous .old should have been replaced by the freshly rotated file.
405        let rotated_size = std::fs::metadata(&old).unwrap().len();
406        assert!(
407            rotated_size >= MAX_LOG_SIZE,
408            "the rotated file should be the large one, not the stale old"
409        );
410
411        let _ = std::fs::remove_file(&old);
412    }
413
414    /// Local (non-global) subscriber for ring tests — never touches the
415    /// `TRACE_RING` OnceLock, so tests can't interfere with each other.
416    fn with_ring_subscriber(ring: TraceRing, f: impl FnOnce()) {
417        let subscriber =
418            tracing_subscriber::registry().with(RingLayer { ring }.with_filter(ring_filter()));
419        tracing::subscriber::with_default(subscriber, f);
420    }
421
422    #[test]
423    fn ring_captures_trace_events_from_mermaid_targets() {
424        let ring = TraceRing::new();
425        with_ring_subscriber(ring.clone(), || {
426            tracing::trace!(target: "mermaid_cli::probe", step = 3, "ring probe fired");
427        });
428        let lines = ring.snapshot();
429        assert_eq!(lines.len(), 1, "TRACE from our crates must be captured");
430        assert!(lines[0].contains("TRACE"));
431        assert!(lines[0].contains("mermaid_cli::probe"));
432        assert!(lines[0].contains("ring probe fired"));
433        assert!(lines[0].contains("step=3"));
434    }
435
436    #[test]
437    fn ring_caps_dependencies_at_info() {
438        let ring = TraceRing::new();
439        with_ring_subscriber(ring.clone(), || {
440            tracing::trace!(target: "hyper::client", "dep noise");
441            tracing::info!(target: "hyper::client", "dep signal");
442        });
443        let lines = ring.snapshot();
444        assert_eq!(lines.len(), 1, "dep TRACE dropped, dep INFO kept");
445        assert!(lines[0].contains("dep signal"));
446    }
447
448    #[test]
449    fn ring_evicts_oldest_past_capacity() {
450        let ring = TraceRing::new();
451        for i in 0..(RING_CAPACITY + 10) {
452            ring.push(format!("event {i}"));
453        }
454        let lines = ring.snapshot();
455        assert_eq!(lines.len(), RING_CAPACITY);
456        assert_eq!(lines[0], "event 10", "oldest evicted first");
457        assert_eq!(
458            lines[RING_CAPACITY - 1],
459            format!("event {}", RING_CAPACITY + 9)
460        );
461    }
462
463    #[test]
464    fn ring_redacts_secrets_on_capture() {
465        let ring = TraceRing::new();
466        with_ring_subscriber(ring.clone(), || {
467            tracing::warn!(target: "mermaid_cli::auth", "key OPENAI_API_KEY=sk-abcdefghijklmnop1234 seen");
468        });
469        let lines = ring.snapshot();
470        assert_eq!(lines.len(), 1);
471        assert!(lines[0].contains("[REDACTED]"), "got: {}", lines[0]);
472        assert!(!lines[0].contains("sk-abcdefghijklmnop1234"));
473    }
474
475    #[test]
476    fn ring_truncates_oversized_events() {
477        let ring = TraceRing::new();
478        let huge = "x".repeat(4 * RING_MAX_EVENT_BYTES);
479        with_ring_subscriber(ring.clone(), || {
480            tracing::info!(target: "mermaid_cli::big", "{huge}");
481        });
482        let lines = ring.snapshot();
483        assert_eq!(lines.len(), 1);
484        assert!(
485            lines[0].len() <= RING_MAX_EVENT_BYTES + 8,
486            "event must be clamped, got {} bytes",
487            lines[0].len()
488        );
489        assert!(lines[0].ends_with("..."));
490    }
491
492    #[test]
493    fn log_writer_redacts_secrets_per_event() {
494        let tmp =
495            std::env::temp_dir().join(format!("mermaid_log_redact_{}.log", std::process::id()));
496        let _ = std::fs::remove_file(&tmp);
497        let file = std::fs::File::create(&tmp).unwrap();
498        let mw = RedactingWriter::new(file);
499        {
500            let mut w = mw.make_writer();
501            writeln!(w, "startup OPENAI_API_KEY=sk-abcdefghijklmnop1234 ready").unwrap();
502        } // drop flushes + redacts the complete event
503        let contents = std::fs::read_to_string(&tmp).unwrap();
504        assert!(
505            contents.contains("[REDACTED]"),
506            "secret must be redacted in the log: {contents}"
507        );
508        assert!(
509            !contents.contains("sk-abcdefghijklmnop1234"),
510            "raw key must not reach disk: {contents}"
511        );
512        let _ = std::fs::remove_file(&tmp);
513    }
514}