Skip to main content

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