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
35pub fn log_file_path() -> Option<PathBuf> {
37 get_log_file_path()
38}
39
40#[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 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 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
76static TRACE_RING: OnceLock<TraceRing> = OnceLock::new();
79
80pub fn trace_ring() -> Option<&'static TraceRing> {
82 TRACE_RING.get()
83}
84
85struct RingLayer {
88 ring: TraceRing,
89}
90
91struct 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 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 self.ring.push(crate::utils::redact_secrets(&line));
146 }
147}
148
149fn 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
164fn 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
178fn 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
191pub fn init_logger(verbose: bool) {
200 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 if let Some(log_path) = get_log_file_path() {
212 if let Some(parent) = log_path.parent() {
214 let _ = std::fs::create_dir_all(parent);
215 }
216
217 rotate_if_large(&log_path);
219
220 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 #[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) .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 tracing_subscriber::registry()
258 .with(build_ring_layer())
259 .init();
260}
261
262#[derive(Clone)]
268struct RedactingWriter {
269 file: Arc<Mutex<File>>,
270}
271
272impl RedactingWriter {
273 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
292struct 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
325pub fn log_info(category: &str, message: impl std::fmt::Display) {
327 info!(category = %category, "{}", message);
328}
329
330pub fn log_warn(category: &str, message: impl std::fmt::Display) {
332 warn!(category = %category, "{}", message);
333}
334
335pub fn log_error(category: &str, message: impl std::fmt::Display) {
337 error!(category = %category, "{}", message);
338}
339
340pub fn log_debug(message: impl std::fmt::Display) {
342 debug!("{}", message);
343}
344
345pub 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 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 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 } 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}