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
14const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024; const RING_CAPACITY: usize = 2000;
21const RING_MAX_EVENT_BYTES: usize = 512;
24
25fn get_log_file_path() -> Option<PathBuf> {
27 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#[must_use]
37pub fn log_file_path() -> Option<PathBuf> {
38 get_log_file_path()
39}
40
41#[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 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 #[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
78static TRACE_RING: OnceLock<TraceRing> = OnceLock::new();
81
82pub fn trace_ring() -> Option<&'static TraceRing> {
84 TRACE_RING.get()
85}
86
87struct RingLayer {
90 ring: TraceRing,
91}
92
93struct 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 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 self.ring.push(crate::utils::redact_secrets(&line));
148 }
149}
150
151fn 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
166fn 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
180fn 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
193pub fn init_logger(verbose: bool) {
202 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 if let Some(log_path) = get_log_file_path() {
214 if let Some(parent) = log_path.parent() {
216 let _ = std::fs::create_dir_all(parent);
217 }
218
219 rotate_if_large(&log_path);
221
222 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 #[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) .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 tracing_subscriber::registry()
260 .with(build_ring_layer())
261 .init();
262}
263
264#[derive(Clone)]
270struct RedactingWriter {
271 file: Arc<Mutex<File>>,
272}
273
274impl RedactingWriter {
275 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
294struct 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
327pub fn log_info(category: &str, message: impl std::fmt::Display) {
329 info!(category = %category, "{}", message);
330}
331
332pub fn log_warn(category: &str, message: impl std::fmt::Display) {
334 warn!(category = %category, "{}", message);
335}
336
337pub fn log_error(category: &str, message: impl std::fmt::Display) {
339 error!(category = %category, "{}", message);
340}
341
342pub fn log_debug(message: impl std::fmt::Display) {
344 debug!("{}", message);
345}
346
347pub 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 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 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 } 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}