Skip to main content

fastmcp_rust/testing/
trace.rs

1//! E2E test logging framework with structured trace output.
2//!
3//! Provides comprehensive logging for E2E tests with:
4//! - Structured JSON output
5//! - Request/response correlation IDs
6//! - Timing information (start, end, duration)
7//! - Nested operation tracking (spans)
8//! - Log level filtering
9//! - File and console output options
10//!
11//! # Example
12//!
13//! ```ignore
14//! use fastmcp_rust::testing::prelude::*;
15//!
16//! let mut trace = TestTrace::new("client-server-flow");
17//!
18//! // Log request/response pairs
19//! trace.log_request("tools/list", &request);
20//! // ... operation ...
21//! trace.log_response("tools/list", &response, duration);
22//!
23//! // Save to file
24//! trace.save("test_traces/client_server.json")?;
25//!
26//! // Or print summary
27//! trace.print_summary();
28//! ```
29//!
30//! # Environment Variable
31//!
32//! Set `FASTMCP_TEST_TRACE=1` to enable trace output during tests.
33
34use std::collections::HashMap;
35use std::fs::{self, File};
36use std::io::{BufWriter, Write};
37use std::path::Path;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
40
41use serde::{Deserialize, Serialize};
42
43/// Check if test tracing is enabled via environment variable.
44pub fn is_trace_enabled() -> bool {
45    std::env::var("FASTMCP_TEST_TRACE")
46        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
47        .unwrap_or(false)
48}
49
50/// Returns the trace output directory from `FASTMCP_TEST_TRACE_DIR` or defaults to "test_traces".
51pub fn get_trace_dir() -> std::path::PathBuf {
52    std::env::var("FASTMCP_TEST_TRACE_DIR")
53        .map(std::path::PathBuf::from)
54        .unwrap_or_else(|_| std::path::PathBuf::from("test_traces"))
55}
56
57/// Configuration for trace file cleanup.
58#[derive(Debug, Clone)]
59pub struct TraceRetentionConfig {
60    /// Maximum number of trace files to keep.
61    pub max_files: Option<usize>,
62    /// Maximum age of trace files in seconds.
63    pub max_age_secs: Option<u64>,
64}
65
66impl Default for TraceRetentionConfig {
67    fn default() -> Self {
68        Self {
69            max_files: Some(100),
70            max_age_secs: Some(7 * 24 * 60 * 60), // 7 days
71        }
72    }
73}
74
75/// Cleans up old trace files based on retention configuration.
76///
77/// Removes trace files that exceed either the max file count or max age.
78/// Files are sorted by modification time (oldest first) when enforcing max_files.
79///
80/// # Arguments
81///
82/// * `dir` - The directory containing trace files
83/// * `config` - Retention configuration
84///
85/// # Returns
86///
87/// The number of files removed.
88///
89/// # Errors
90///
91/// Returns an error if the directory cannot be read.
92pub fn cleanup_old_traces(
93    dir: impl AsRef<Path>,
94    config: &TraceRetentionConfig,
95) -> std::io::Result<usize> {
96    let dir = dir.as_ref();
97    if !dir.exists() {
98        return Ok(0);
99    }
100
101    let mut entries: Vec<_> = fs::read_dir(dir)?
102        .filter_map(|e| e.ok())
103        .filter(|e| {
104            e.path()
105                .extension()
106                .map(|ext| ext == "json")
107                .unwrap_or(false)
108        })
109        .filter_map(|e| {
110            let metadata = e.metadata().ok()?;
111            let modified = metadata.modified().ok()?;
112            Some((e.path(), modified))
113        })
114        .collect();
115
116    // Sort by modification time (oldest first)
117    entries.sort_by_key(|(_, time)| *time);
118
119    let mut removed = 0;
120    let now = SystemTime::now();
121
122    // Remove files older than max_age_secs
123    if let Some(max_age) = config.max_age_secs {
124        let max_age_duration = std::time::Duration::from_secs(max_age);
125        entries.retain(|(path, modified)| {
126            if let Ok(age) = now.duration_since(*modified) {
127                if age > max_age_duration {
128                    if fs::remove_file(path).is_ok() {
129                        removed += 1;
130                    }
131                    return false;
132                }
133            }
134            true
135        });
136    }
137
138    // Remove excess files (oldest first)
139    if let Some(max_files) = config.max_files {
140        while entries.len() > max_files {
141            if let Some((path, _)) = entries.first() {
142                if fs::remove_file(path).is_ok() {
143                    removed += 1;
144                }
145            }
146            entries.remove(0);
147        }
148    }
149
150    Ok(removed)
151}
152
153/// Global correlation ID counter.
154static CORRELATION_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
155
156/// Generates a new unique correlation ID.
157fn new_correlation_id() -> String {
158    let id = CORRELATION_ID_COUNTER.fetch_add(1, Ordering::SeqCst);
159    format!("trace-{id:08x}")
160}
161
162/// Entry type in a trace.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(tag = "type")]
165pub enum TraceEntry {
166    /// A request was sent.
167    Request {
168        correlation_id: String,
169        timestamp: String,
170        method: String,
171        params: Option<serde_json::Value>,
172        #[serde(skip_serializing_if = "Option::is_none")]
173        span_id: Option<String>,
174    },
175    /// A response was received.
176    Response {
177        correlation_id: String,
178        timestamp: String,
179        method: String,
180        duration_ms: f64,
181        result: Option<serde_json::Value>,
182        error: Option<serde_json::Value>,
183        #[serde(skip_serializing_if = "Option::is_none")]
184        span_id: Option<String>,
185    },
186    /// A span was started.
187    SpanStart {
188        span_id: String,
189        parent_span_id: Option<String>,
190        name: String,
191        timestamp: String,
192    },
193    /// A span was ended.
194    SpanEnd {
195        span_id: String,
196        timestamp: String,
197        duration_ms: f64,
198        #[serde(skip_serializing_if = "Option::is_none")]
199        error: Option<String>,
200    },
201    /// A log message.
202    Log {
203        timestamp: String,
204        level: TraceLevel,
205        message: String,
206        #[serde(skip_serializing_if = "Option::is_none")]
207        span_id: Option<String>,
208        #[serde(skip_serializing_if = "Option::is_none")]
209        data: Option<serde_json::Value>,
210    },
211    /// A metric measurement.
212    Metric {
213        timestamp: String,
214        name: String,
215        value: f64,
216        #[serde(skip_serializing_if = "Option::is_none")]
217        unit: Option<String>,
218        #[serde(skip_serializing_if = "Option::is_none")]
219        span_id: Option<String>,
220    },
221}
222
223/// Log level for trace entries.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "lowercase")]
226pub enum TraceLevel {
227    Debug,
228    Info,
229    Warn,
230    Error,
231}
232
233impl std::fmt::Display for TraceLevel {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            TraceLevel::Debug => write!(f, "DEBUG"),
237            TraceLevel::Info => write!(f, "INFO"),
238            TraceLevel::Warn => write!(f, "WARN"),
239            TraceLevel::Error => write!(f, "ERROR"),
240        }
241    }
242}
243
244/// A span for tracking nested operations.
245#[derive(Debug)]
246pub struct Span {
247    /// Unique span ID.
248    pub id: String,
249    /// Parent span ID, if any.
250    pub parent_id: Option<String>,
251    /// Span name/description.
252    pub name: String,
253    /// When the span started.
254    pub start: Instant,
255    /// Start timestamp (ISO 8601).
256    pub start_timestamp: String,
257}
258
259/// Complete trace of a test execution.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct TestTraceOutput {
262    /// Name of the test/trace.
263    pub name: String,
264    /// When the trace started.
265    pub started_at: String,
266    /// When the trace ended.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub ended_at: Option<String>,
269    /// Total duration in milliseconds.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub duration_ms: Option<f64>,
272    /// All trace entries.
273    pub entries: Vec<TraceEntry>,
274    /// Summary statistics.
275    pub summary: TraceSummary,
276    /// Custom metadata.
277    #[serde(skip_serializing_if = "HashMap::is_empty")]
278    pub metadata: HashMap<String, serde_json::Value>,
279}
280
281/// Summary statistics for a trace.
282#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct TraceSummary {
284    /// Total number of requests.
285    pub request_count: usize,
286    /// Total number of responses.
287    pub response_count: usize,
288    /// Number of successful responses.
289    pub success_count: usize,
290    /// Number of error responses.
291    pub error_count: usize,
292    /// Total number of spans.
293    pub span_count: usize,
294    /// Methods called with their counts.
295    pub method_counts: HashMap<String, usize>,
296    /// Request/response timing by method.
297    pub method_timings: HashMap<String, MethodTiming>,
298}
299
300/// Timing statistics for a method.
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct MethodTiming {
303    /// Number of calls.
304    pub count: usize,
305    /// Total duration in milliseconds.
306    pub total_ms: f64,
307    /// Minimum duration.
308    pub min_ms: Option<f64>,
309    /// Maximum duration.
310    pub max_ms: Option<f64>,
311    /// Mean duration.
312    pub mean_ms: Option<f64>,
313}
314
315/// Builder for creating test traces.
316///
317/// # Example
318///
319/// ```ignore
320/// let trace = TestTrace::builder("my-test")
321///     .with_metadata("env", "test")
322///     .with_console_output(true)
323///     .build();
324/// ```
325pub struct TestTraceBuilder {
326    name: String,
327    metadata: HashMap<String, serde_json::Value>,
328    console_output: bool,
329    min_level: TraceLevel,
330}
331
332impl TestTraceBuilder {
333    /// Creates a new trace builder.
334    pub fn new(name: impl Into<String>) -> Self {
335        Self {
336            name: name.into(),
337            metadata: HashMap::new(),
338            console_output: is_trace_enabled(),
339            min_level: TraceLevel::Debug,
340        }
341    }
342
343    /// Adds custom metadata to the trace.
344    #[must_use]
345    pub fn with_metadata(
346        mut self,
347        key: impl Into<String>,
348        value: impl Into<serde_json::Value>,
349    ) -> Self {
350        self.metadata.insert(key.into(), value.into());
351        self
352    }
353
354    /// Enables or disables console output.
355    #[must_use]
356    pub fn with_console_output(mut self, enabled: bool) -> Self {
357        self.console_output = enabled;
358        self
359    }
360
361    /// Sets the minimum log level.
362    #[must_use]
363    pub fn with_min_level(mut self, level: TraceLevel) -> Self {
364        self.min_level = level;
365        self
366    }
367
368    /// Builds the trace.
369    #[must_use]
370    pub fn build(self) -> TestTrace {
371        TestTrace {
372            name: self.name,
373            started_at: current_timestamp(),
374            start_instant: Instant::now(),
375            entries: Vec::new(),
376            metadata: self.metadata,
377            console_output: self.console_output,
378            min_level: self.min_level,
379            active_spans: Vec::new(),
380            pending_requests: HashMap::new(),
381        }
382    }
383}
384
385/// Main test trace collector.
386///
387/// Collects trace entries during test execution and can output
388/// them as structured JSON or console summary.
389#[derive(Debug)]
390pub struct TestTrace {
391    /// Name of the test/trace.
392    name: String,
393    /// When the trace started.
394    started_at: String,
395    /// Start instant for duration calculation.
396    start_instant: Instant,
397    /// All collected entries.
398    entries: Vec<TraceEntry>,
399    /// Custom metadata.
400    metadata: HashMap<String, serde_json::Value>,
401    /// Whether to output to console.
402    console_output: bool,
403    /// Minimum log level.
404    min_level: TraceLevel,
405    /// Stack of active spans.
406    active_spans: Vec<Span>,
407    /// Pending requests awaiting responses (correlation_id -> (method, start_time)).
408    pending_requests: HashMap<String, (String, Instant)>,
409}
410
411impl TestTrace {
412    /// Creates a new test trace with the given name.
413    ///
414    /// # Example
415    ///
416    /// ```ignore
417    /// let mut trace = TestTrace::new("client-server-flow");
418    /// ```
419    #[must_use]
420    pub fn new(name: impl Into<String>) -> Self {
421        TestTraceBuilder::new(name).build()
422    }
423
424    /// Creates a trace builder for advanced configuration.
425    #[must_use]
426    pub fn builder(name: impl Into<String>) -> TestTraceBuilder {
427        TestTraceBuilder::new(name)
428    }
429
430    /// Returns the trace name.
431    #[must_use]
432    pub fn name(&self) -> &str {
433        &self.name
434    }
435
436    /// Returns the current span ID, if any.
437    #[must_use]
438    pub fn current_span_id(&self) -> Option<&str> {
439        self.active_spans.last().map(|s| s.id.as_str())
440    }
441
442    /// Logs a request.
443    ///
444    /// Returns a correlation ID that can be used to match with the response.
445    pub fn log_request(
446        &mut self,
447        method: impl Into<String>,
448        params: Option<&impl Serialize>,
449    ) -> String {
450        let correlation_id = new_correlation_id();
451        let method = method.into();
452        let timestamp = current_timestamp();
453        let span_id = self.current_span_id().map(String::from);
454
455        let params_value = params.and_then(|p| serde_json::to_value(p).ok());
456
457        self.pending_requests
458            .insert(correlation_id.clone(), (method.clone(), Instant::now()));
459
460        let entry = TraceEntry::Request {
461            correlation_id: correlation_id.clone(),
462            timestamp: timestamp.clone(),
463            method: method.clone(),
464            params: params_value.clone(),
465            span_id: span_id.clone(),
466        };
467
468        self.entries.push(entry);
469
470        if self.console_output {
471            let params_str = params_value
472                .as_ref()
473                .map(|v| format!(" {v}"))
474                .unwrap_or_default();
475            eprintln!("[{timestamp}] -> {method}{params_str}");
476        }
477
478        correlation_id
479    }
480
481    /// Logs a response.
482    ///
483    /// Uses the correlation ID from the matching request to calculate duration.
484    pub fn log_response(
485        &mut self,
486        correlation_id: &str,
487        result: Option<&impl Serialize>,
488        error: Option<&impl Serialize>,
489    ) {
490        let timestamp = current_timestamp();
491        let span_id = self.current_span_id().map(String::from);
492
493        let (method, duration_ms) =
494            if let Some((method, start)) = self.pending_requests.remove(correlation_id) {
495                let duration = start.elapsed();
496                (method, duration.as_secs_f64() * 1000.0)
497            } else {
498                ("unknown".to_string(), 0.0)
499            };
500
501        let result_value = result.and_then(|r| serde_json::to_value(r).ok());
502        let error_value = error.and_then(|e| serde_json::to_value(e).ok());
503
504        let entry = TraceEntry::Response {
505            correlation_id: correlation_id.to_string(),
506            timestamp: timestamp.clone(),
507            method: method.clone(),
508            duration_ms,
509            result: result_value.clone(),
510            error: error_value.clone(),
511            span_id,
512        };
513
514        self.entries.push(entry);
515
516        if self.console_output {
517            let status = if error_value.is_some() { "ERROR" } else { "OK" };
518            eprintln!("[{timestamp}] <- {method} ({duration_ms:.2}ms) {status}");
519        }
520    }
521
522    /// Logs a response with explicit duration.
523    pub fn log_response_with_duration(
524        &mut self,
525        method: impl Into<String>,
526        duration: Duration,
527        result: Option<&impl Serialize>,
528        error: Option<&impl Serialize>,
529    ) {
530        let correlation_id = new_correlation_id();
531        let method = method.into();
532        let timestamp = current_timestamp();
533        let span_id = self.current_span_id().map(String::from);
534        let duration_ms = duration.as_secs_f64() * 1000.0;
535
536        let result_value = result.and_then(|r| serde_json::to_value(r).ok());
537        let error_value = error.and_then(|e| serde_json::to_value(e).ok());
538
539        let entry = TraceEntry::Response {
540            correlation_id,
541            timestamp: timestamp.clone(),
542            method: method.clone(),
543            duration_ms,
544            result: result_value,
545            error: error_value.clone(),
546            span_id,
547        };
548
549        self.entries.push(entry);
550
551        if self.console_output {
552            let status = if error_value.is_some() { "ERROR" } else { "OK" };
553            eprintln!("[{timestamp}] <- {method} ({duration_ms:.2}ms) {status}");
554        }
555    }
556
557    /// Starts a new span for tracking a nested operation.
558    ///
559    /// Returns the span ID.
560    pub fn start_span(&mut self, name: impl Into<String>) -> String {
561        let span_id = format!(
562            "span-{:08x}",
563            CORRELATION_ID_COUNTER.fetch_add(1, Ordering::SeqCst)
564        );
565        let parent_id = self.current_span_id().map(String::from);
566        let name = name.into();
567        let timestamp = current_timestamp();
568
569        let span = Span {
570            id: span_id.clone(),
571            parent_id: parent_id.clone(),
572            name: name.clone(),
573            start: Instant::now(),
574            start_timestamp: timestamp.clone(),
575        };
576
577        self.entries.push(TraceEntry::SpanStart {
578            span_id: span_id.clone(),
579            parent_span_id: parent_id,
580            name: name.clone(),
581            timestamp: timestamp.clone(),
582        });
583
584        if self.console_output {
585            let indent = "  ".repeat(self.active_spans.len());
586            eprintln!("[{timestamp}] {indent}┌─ {name}");
587        }
588
589        self.active_spans.push(span);
590        span_id
591    }
592
593    /// Ends the current span.
594    ///
595    /// If `error` is provided, marks the span as failed.
596    pub fn end_span(&mut self, error: Option<&str>) {
597        if let Some(span) = self.active_spans.pop() {
598            let timestamp = current_timestamp();
599            let duration_ms = span.start.elapsed().as_secs_f64() * 1000.0;
600
601            self.entries.push(TraceEntry::SpanEnd {
602                span_id: span.id.clone(),
603                timestamp: timestamp.clone(),
604                duration_ms,
605                error: error.map(String::from),
606            });
607
608            if self.console_output {
609                let indent = "  ".repeat(self.active_spans.len());
610                let status = if error.is_some() { " FAILED" } else { "" };
611                eprintln!(
612                    "[{timestamp}] {indent}└─ {} ({duration_ms:.2}ms){status}",
613                    span.name
614                );
615            }
616        }
617    }
618
619    /// Ends a specific span by ID.
620    pub fn end_span_by_id(&mut self, span_id: &str, error: Option<&str>) {
621        if let Some(pos) = self.active_spans.iter().position(|s| s.id == span_id) {
622            let span = self.active_spans.remove(pos);
623            let timestamp = current_timestamp();
624            let duration_ms = span.start.elapsed().as_secs_f64() * 1000.0;
625
626            self.entries.push(TraceEntry::SpanEnd {
627                span_id: span.id.clone(),
628                timestamp,
629                duration_ms,
630                error: error.map(String::from),
631            });
632        }
633    }
634
635    /// Logs a message at the specified level.
636    pub fn log(&mut self, level: TraceLevel, message: impl Into<String>) {
637        if (level as u8) < (self.min_level as u8) {
638            return;
639        }
640
641        let timestamp = current_timestamp();
642        let message = message.into();
643        let span_id = self.current_span_id().map(String::from);
644
645        self.entries.push(TraceEntry::Log {
646            timestamp: timestamp.clone(),
647            level,
648            message: message.clone(),
649            span_id,
650            data: None,
651        });
652
653        if self.console_output {
654            let indent = "  ".repeat(self.active_spans.len());
655            eprintln!("[{timestamp}] {indent}[{level}] {message}");
656        }
657    }
658
659    /// Logs a message with additional data.
660    pub fn log_with_data(
661        &mut self,
662        level: TraceLevel,
663        message: impl Into<String>,
664        data: impl Serialize,
665    ) {
666        if (level as u8) < (self.min_level as u8) {
667            return;
668        }
669
670        let timestamp = current_timestamp();
671        let message = message.into();
672        let span_id = self.current_span_id().map(String::from);
673        let data_value = serde_json::to_value(data).ok();
674
675        self.entries.push(TraceEntry::Log {
676            timestamp: timestamp.clone(),
677            level,
678            message: message.clone(),
679            span_id,
680            data: data_value,
681        });
682
683        if self.console_output {
684            let indent = "  ".repeat(self.active_spans.len());
685            eprintln!("[{timestamp}] {indent}[{level}] {message}");
686        }
687    }
688
689    /// Logs a debug message.
690    pub fn debug(&mut self, message: impl Into<String>) {
691        self.log(TraceLevel::Debug, message);
692    }
693
694    /// Logs an info message.
695    pub fn info(&mut self, message: impl Into<String>) {
696        self.log(TraceLevel::Info, message);
697    }
698
699    /// Logs a warning message.
700    pub fn warn(&mut self, message: impl Into<String>) {
701        self.log(TraceLevel::Warn, message);
702    }
703
704    /// Logs an error message.
705    pub fn error(&mut self, message: impl Into<String>) {
706        self.log(TraceLevel::Error, message);
707    }
708
709    /// Records a metric value.
710    pub fn metric(&mut self, name: impl Into<String>, value: f64, unit: Option<&str>) {
711        let timestamp = current_timestamp();
712        let span_id = self.current_span_id().map(String::from);
713
714        self.entries.push(TraceEntry::Metric {
715            timestamp,
716            name: name.into(),
717            value,
718            unit: unit.map(String::from),
719            span_id,
720        });
721    }
722
723    /// Adds custom metadata to the trace.
724    pub fn add_metadata(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
725        self.metadata.insert(key.into(), value.into());
726    }
727
728    /// Computes summary statistics from the entries.
729    fn compute_summary(&self) -> TraceSummary {
730        let mut summary = TraceSummary::default();
731
732        for entry in &self.entries {
733            match entry {
734                TraceEntry::Request { method, .. } => {
735                    summary.request_count += 1;
736                    *summary.method_counts.entry(method.clone()).or_insert(0) += 1;
737                }
738                TraceEntry::Response {
739                    method,
740                    duration_ms,
741                    error,
742                    ..
743                } => {
744                    summary.response_count += 1;
745                    if error.is_some() {
746                        summary.error_count += 1;
747                    } else {
748                        summary.success_count += 1;
749                    }
750
751                    let timing = summary
752                        .method_timings
753                        .entry(method.clone())
754                        .or_insert_with(MethodTiming::default);
755                    timing.count += 1;
756                    timing.total_ms += duration_ms;
757                    timing.min_ms = Some(
758                        timing
759                            .min_ms
760                            .map(|m| m.min(*duration_ms))
761                            .unwrap_or(*duration_ms),
762                    );
763                    timing.max_ms = Some(
764                        timing
765                            .max_ms
766                            .map(|m| m.max(*duration_ms))
767                            .unwrap_or(*duration_ms),
768                    );
769                    timing.mean_ms = Some(timing.total_ms / timing.count as f64);
770                }
771                TraceEntry::SpanStart { .. } => {
772                    summary.span_count += 1;
773                }
774                _ => {}
775            }
776        }
777
778        summary
779    }
780
781    /// Builds the complete trace output.
782    #[must_use]
783    pub fn build_output(&self) -> TestTraceOutput {
784        let duration_ms = self.start_instant.elapsed().as_secs_f64() * 1000.0;
785
786        TestTraceOutput {
787            name: self.name.clone(),
788            started_at: self.started_at.clone(),
789            ended_at: Some(current_timestamp()),
790            duration_ms: Some(duration_ms),
791            entries: self.entries.clone(),
792            summary: self.compute_summary(),
793            metadata: self.metadata.clone(),
794        }
795    }
796
797    /// Saves the trace to a JSON file.
798    ///
799    /// Creates parent directories if they don't exist.
800    ///
801    /// # Errors
802    ///
803    /// Returns an error if the file cannot be written.
804    pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
805        let path = path.as_ref();
806
807        // Create parent directories
808        if let Some(parent) = path.parent() {
809            fs::create_dir_all(parent)?;
810        }
811
812        let file = File::create(path)?;
813        let mut writer = BufWriter::new(file);
814
815        let output = self.build_output();
816        serde_json::to_writer_pretty(&mut writer, &output)?;
817        writer.flush()?;
818
819        Ok(())
820    }
821
822    /// Saves the trace with automatic file naming to the trace directory.
823    ///
824    /// The file is saved to `FASTMCP_TEST_TRACE_DIR/{name}_{timestamp}.json`.
825    /// Creates the directory if it doesn't exist.
826    ///
827    /// Optionally runs cleanup to remove old trace files.
828    ///
829    /// # Arguments
830    ///
831    /// * `cleanup` - If Some, runs cleanup with the given retention config
832    ///
833    /// # Returns
834    ///
835    /// The path to the saved file.
836    ///
837    /// # Errors
838    ///
839    /// Returns an error if the file cannot be written.
840    ///
841    /// # Example
842    ///
843    /// ```ignore
844    /// let trace = TestTrace::new("my-test");
845    /// // ... record trace entries ...
846    ///
847    /// // Save with default cleanup
848    /// let path = trace.auto_save(Some(&TraceRetentionConfig::default()))?;
849    /// println!("Trace saved to: {}", path.display());
850    ///
851    /// // Save without cleanup
852    /// let path = trace.auto_save(None)?;
853    /// ```
854    pub fn auto_save(
855        &self,
856        cleanup: Option<&TraceRetentionConfig>,
857    ) -> std::io::Result<std::path::PathBuf> {
858        let dir = get_trace_dir();
859
860        // Ensure directory exists
861        fs::create_dir_all(&dir)?;
862
863        // Run cleanup if configured
864        if let Some(config) = cleanup {
865            let _ = cleanup_old_traces(&dir, config);
866        }
867
868        // Generate filename: {name}_{timestamp}.json
869        let timestamp = self.generate_file_timestamp();
870        let safe_name = self.sanitize_filename(&self.name);
871        let filename = format!("{safe_name}_{timestamp}.json");
872        let path = dir.join(filename);
873
874        // Save the trace
875        self.save(&path)?;
876
877        Ok(path)
878    }
879
880    /// Generates a timestamp suitable for filenames (no colons).
881    fn generate_file_timestamp(&self) -> String {
882        // Use the trace's start timestamp but make it filename-safe
883        // Format: YYYYMMDD_HHMMSS_mmm
884        let now = SystemTime::now()
885            .duration_since(UNIX_EPOCH)
886            .unwrap_or_default();
887
888        let secs = now.as_secs();
889        let millis = now.subsec_millis();
890        let (year, month, day, hour, min, sec) = epoch_to_datetime(secs);
891
892        format!("{year:04}{month:02}{day:02}_{hour:02}{min:02}{sec:02}_{millis:03}")
893    }
894
895    /// Sanitizes a string for use in filenames.
896    fn sanitize_filename(&self, name: &str) -> String {
897        name.chars()
898            .map(|c| match c {
899                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
900                ' ' | '/' | '\\' | ':' => '_',
901                _ => '_',
902            })
903            .collect()
904    }
905
906    /// Returns the trace as a JSON string.
907    ///
908    /// # Errors
909    ///
910    /// Returns an error if serialization fails.
911    pub fn to_json(&self) -> serde_json::Result<String> {
912        serde_json::to_string_pretty(&self.build_output())
913    }
914
915    /// Prints a summary to stderr.
916    pub fn print_summary(&self) {
917        let output = self.build_output();
918        let duration_str = output
919            .duration_ms
920            .map(|d| format!("{d:.2}ms"))
921            .unwrap_or_else(|| "N/A".to_string());
922
923        eprintln!("\n=== Test Trace Summary: {} ===", self.name);
924        eprintln!("Duration: {duration_str}");
925        eprintln!(
926            "Requests: {}, Responses: {} ({} success, {} error)",
927            output.summary.request_count,
928            output.summary.response_count,
929            output.summary.success_count,
930            output.summary.error_count
931        );
932        eprintln!("Spans: {}", output.summary.span_count);
933
934        if !output.summary.method_timings.is_empty() {
935            eprintln!("\nMethod Timings:");
936            for (method, timing) in &output.summary.method_timings {
937                let mean = timing
938                    .mean_ms
939                    .map(|m| format!("{m:.2}"))
940                    .unwrap_or_default();
941                let min = timing.min_ms.map(|m| format!("{m:.2}")).unwrap_or_default();
942                let max = timing.max_ms.map(|m| format!("{m:.2}")).unwrap_or_default();
943                eprintln!(
944                    "  {method}: {count}x (mean: {mean}ms, min: {min}ms, max: {max}ms)",
945                    count = timing.count
946                );
947            }
948        }
949
950        eprintln!("===\n");
951    }
952
953    /// Returns all entries.
954    #[must_use]
955    pub fn entries(&self) -> &[TraceEntry] {
956        &self.entries
957    }
958
959    /// Returns the number of entries.
960    #[must_use]
961    pub fn entry_count(&self) -> usize {
962        self.entries.len()
963    }
964}
965
966/// Returns the current timestamp in ISO 8601 format.
967fn current_timestamp() -> String {
968    let now = SystemTime::now()
969        .duration_since(UNIX_EPOCH)
970        .unwrap_or_default();
971
972    let secs = now.as_secs();
973    let millis = now.subsec_millis();
974
975    // Simple ISO 8601 format (UTC)
976    let (year, month, day, hour, min, sec) = epoch_to_datetime(secs);
977    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{millis:03}Z")
978}
979
980/// Converts epoch seconds to date-time components.
981fn epoch_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
982    // Simple calculation (doesn't account for leap seconds but good enough for logging)
983    let days = secs / 86400;
984    let time_of_day = secs % 86400;
985
986    let hour = (time_of_day / 3600) as u32;
987    let min = ((time_of_day % 3600) / 60) as u32;
988    let sec = (time_of_day % 60) as u32;
989
990    // Calculate year, month, day from days since epoch
991    let mut year = 1970u32;
992    let mut remaining_days = days as i64;
993
994    loop {
995        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
996        if remaining_days < days_in_year {
997            break;
998        }
999        remaining_days -= days_in_year;
1000        year += 1;
1001    }
1002
1003    let days_in_months: [i64; 12] = if is_leap_year(year) {
1004        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1005    } else {
1006        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1007    };
1008
1009    let mut month = 1u32;
1010    for days_in_month in days_in_months {
1011        if remaining_days < days_in_month {
1012            break;
1013        }
1014        remaining_days -= days_in_month;
1015        month += 1;
1016    }
1017
1018    let day = remaining_days as u32 + 1;
1019
1020    (year, month, day, hour, min, sec)
1021}
1022
1023/// Checks if a year is a leap year.
1024fn is_leap_year(year: u32) -> bool {
1025    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    #[test]
1033    fn test_trace_creation() {
1034        let trace = TestTrace::new("test-trace");
1035        assert_eq!(trace.name(), "test-trace");
1036        assert_eq!(trace.entry_count(), 0);
1037    }
1038
1039    #[test]
1040    fn test_trace_builder() {
1041        let trace = TestTrace::builder("my-trace")
1042            .with_metadata("env", "test")
1043            .with_console_output(false)
1044            .with_min_level(TraceLevel::Info)
1045            .build();
1046
1047        assert_eq!(trace.name(), "my-trace");
1048        assert!(trace.metadata.contains_key("env"));
1049    }
1050
1051    #[test]
1052    fn test_log_request_response() {
1053        let mut trace = TestTrace::builder("req-resp")
1054            .with_console_output(false)
1055            .build();
1056
1057        let params = serde_json::json!({"name": "test"});
1058        let correlation_id = trace.log_request("tools/call", Some(&params));
1059
1060        let result = serde_json::json!({"content": []});
1061        trace.log_response(&correlation_id, Some(&result), None::<&()>);
1062
1063        assert_eq!(trace.entry_count(), 2);
1064    }
1065
1066    #[test]
1067    fn test_spans() {
1068        let mut trace = TestTrace::builder("spans")
1069            .with_console_output(false)
1070            .build();
1071
1072        let span_id = trace.start_span("outer");
1073        assert_eq!(trace.current_span_id(), Some(span_id.as_str()));
1074
1075        let inner_span = trace.start_span("inner");
1076        assert_eq!(trace.current_span_id(), Some(inner_span.as_str()));
1077
1078        trace.end_span(None);
1079        assert_eq!(trace.current_span_id(), Some(span_id.as_str()));
1080
1081        trace.end_span(Some("test error"));
1082
1083        assert!(trace.current_span_id().is_none());
1084        assert_eq!(trace.entry_count(), 4); // 2 starts + 2 ends
1085    }
1086
1087    #[test]
1088    fn test_log_levels() {
1089        let mut trace = TestTrace::builder("levels")
1090            .with_console_output(false)
1091            .with_min_level(TraceLevel::Info)
1092            .build();
1093
1094        trace.debug("debug message"); // Should be filtered
1095        trace.info("info message");
1096        trace.warn("warn message");
1097        trace.error("error message");
1098
1099        assert_eq!(trace.entry_count(), 3); // debug filtered out
1100    }
1101
1102    #[test]
1103    fn test_metrics() {
1104        let mut trace = TestTrace::builder("metrics")
1105            .with_console_output(false)
1106            .build();
1107
1108        trace.metric("response_time", 45.5, Some("ms"));
1109        trace.metric("memory_usage", 1024.0, Some("KB"));
1110
1111        assert_eq!(trace.entry_count(), 2);
1112    }
1113
1114    #[test]
1115    fn test_summary_computation() {
1116        let mut trace = TestTrace::builder("summary")
1117            .with_console_output(false)
1118            .build();
1119
1120        // Simulate some requests/responses
1121        let id1 = trace.log_request("tools/list", None::<&()>);
1122        let id2 = trace.log_request("tools/call", None::<&()>);
1123
1124        trace.log_response(&id1, Some(&serde_json::json!({"tools": []})), None::<&()>);
1125        trace.log_response(
1126            &id2,
1127            None::<&()>,
1128            Some(&serde_json::json!({"error": "test"})),
1129        );
1130
1131        let output = trace.build_output();
1132        assert_eq!(output.summary.request_count, 2);
1133        assert_eq!(output.summary.response_count, 2);
1134        assert_eq!(output.summary.success_count, 1);
1135        assert_eq!(output.summary.error_count, 1);
1136    }
1137
1138    #[test]
1139    fn test_to_json() {
1140        let mut trace = TestTrace::builder("json")
1141            .with_console_output(false)
1142            .build();
1143
1144        trace.info("test message");
1145
1146        let json = trace.to_json().unwrap();
1147        assert!(json.contains("json"));
1148        assert!(json.contains("test message"));
1149    }
1150
1151    #[test]
1152    fn test_is_trace_enabled() {
1153        // By default should be false
1154        assert!(!is_trace_enabled());
1155    }
1156
1157    #[test]
1158    fn test_timestamp_format() {
1159        let ts = current_timestamp();
1160        // Should match ISO 8601 format: YYYY-MM-DDTHH:MM:SS.mmmZ
1161        assert!(ts.contains('T'));
1162        assert!(ts.ends_with('Z'));
1163        assert_eq!(ts.len(), 24);
1164    }
1165
1166    #[test]
1167    fn test_epoch_to_datetime() {
1168        // 2020-01-01 00:00:00 UTC = 1577836800
1169        let (year, month, day, hour, min, sec) = epoch_to_datetime(1577836800);
1170        assert_eq!(year, 2020);
1171        assert_eq!(month, 1);
1172        assert_eq!(day, 1);
1173        assert_eq!(hour, 0);
1174        assert_eq!(min, 0);
1175        assert_eq!(sec, 0);
1176    }
1177
1178    #[test]
1179    fn test_get_trace_dir_returns_path() {
1180        // Just verify get_trace_dir returns a PathBuf (default or from env)
1181        let dir = get_trace_dir();
1182        // Should return some valid path
1183        assert!(!dir.as_os_str().is_empty());
1184    }
1185
1186    #[test]
1187    fn test_trace_retention_config_default() {
1188        let config = TraceRetentionConfig::default();
1189        assert_eq!(config.max_files, Some(100));
1190        assert_eq!(config.max_age_secs, Some(7 * 24 * 60 * 60));
1191    }
1192
1193    #[test]
1194    fn test_sanitize_filename() {
1195        let trace = TestTrace::builder("test/with:special\\chars")
1196            .with_console_output(false)
1197            .build();
1198        let sanitized = trace.sanitize_filename(&trace.name);
1199        assert!(!sanitized.contains('/'));
1200        assert!(!sanitized.contains(':'));
1201        assert!(!sanitized.contains('\\'));
1202        assert!(sanitized.contains('_'));
1203    }
1204
1205    #[test]
1206    fn test_generate_file_timestamp() {
1207        let trace = TestTrace::builder("timestamp-test")
1208            .with_console_output(false)
1209            .build();
1210        let ts = trace.generate_file_timestamp();
1211        // Should be format: YYYYMMDD_HHMMSS_mmm (e.g., 20260129_013800_123)
1212        // Length is 19: 8 + 1 + 6 + 1 + 3 = 19
1213        assert_eq!(ts.len(), 19);
1214        assert!(ts.contains('_'));
1215        // Should not contain colons or dashes
1216        assert!(!ts.contains(':'));
1217        assert!(!ts.contains('-'));
1218    }
1219
1220    #[test]
1221    fn test_save_to_explicit_path() {
1222        use std::path::PathBuf;
1223
1224        // Use explicit path instead of auto_save to avoid env var issues
1225        let test_dir = PathBuf::from("target/test_traces_explicit");
1226        let _ = fs::remove_dir_all(&test_dir);
1227
1228        let mut trace = TestTrace::builder("explicit-save-test")
1229            .with_console_output(false)
1230            .build();
1231
1232        trace.info("test message");
1233
1234        let path = test_dir.join("test_trace.json");
1235        trace.save(&path).unwrap();
1236
1237        // Verify file was created
1238        assert!(path.exists());
1239
1240        // Verify content is valid JSON
1241        let content = fs::read_to_string(&path).unwrap();
1242        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1243        assert_eq!(json["name"], "explicit-save-test");
1244        assert!(json["entries"].is_array());
1245
1246        // Cleanup
1247        let _ = fs::remove_dir_all(&test_dir);
1248    }
1249
1250    #[test]
1251    fn test_cleanup_old_traces() {
1252        use std::path::PathBuf;
1253
1254        let test_dir = PathBuf::from("target/test_traces_cleanup");
1255
1256        // Clean up and create directory
1257        let _ = fs::remove_dir_all(&test_dir);
1258        fs::create_dir_all(&test_dir).unwrap();
1259
1260        // Create some test files
1261        for i in 0..5 {
1262            let path = test_dir.join(format!("trace_{i}.json"));
1263            fs::write(&path, "{}").unwrap();
1264        }
1265
1266        // Verify files exist
1267        let count_before: usize = fs::read_dir(&test_dir).unwrap().count();
1268        assert_eq!(count_before, 5);
1269
1270        // Run cleanup with max_files=3
1271        let config = TraceRetentionConfig {
1272            max_files: Some(3),
1273            max_age_secs: None,
1274        };
1275        let removed = cleanup_old_traces(&test_dir, &config).unwrap();
1276
1277        // Verify cleanup worked
1278        assert_eq!(removed, 2);
1279        let count_after: usize = fs::read_dir(&test_dir).unwrap().count();
1280        assert_eq!(count_after, 3);
1281
1282        // Cleanup
1283        let _ = fs::remove_dir_all(&test_dir);
1284    }
1285
1286    #[test]
1287    fn test_cleanup_nonexistent_dir() {
1288        let config = TraceRetentionConfig::default();
1289        let result = cleanup_old_traces("/nonexistent/path/123456", &config);
1290        assert!(result.is_ok());
1291        assert_eq!(result.unwrap(), 0);
1292    }
1293
1294    // =========================================================================
1295    // Additional coverage tests (bd-3cyi)
1296    // =========================================================================
1297
1298    #[test]
1299    fn trace_level_display() {
1300        assert_eq!(TraceLevel::Debug.to_string(), "DEBUG");
1301        assert_eq!(TraceLevel::Info.to_string(), "INFO");
1302        assert_eq!(TraceLevel::Warn.to_string(), "WARN");
1303        assert_eq!(TraceLevel::Error.to_string(), "ERROR");
1304    }
1305
1306    #[test]
1307    fn trace_level_equality_and_clone() {
1308        let level = TraceLevel::Warn;
1309        let cloned = level;
1310        assert_eq!(level, cloned);
1311        assert_ne!(TraceLevel::Debug, TraceLevel::Error);
1312    }
1313
1314    #[test]
1315    fn log_with_data_records_entry() {
1316        let mut trace = TestTrace::builder("data-log")
1317            .with_console_output(false)
1318            .build();
1319
1320        trace.log_with_data(
1321            TraceLevel::Info,
1322            "with data",
1323            serde_json::json!({"key": "val"}),
1324        );
1325
1326        assert_eq!(trace.entry_count(), 1);
1327        match &trace.entries()[0] {
1328            TraceEntry::Log { data, message, .. } => {
1329                assert_eq!(message, "with data");
1330                assert!(data.is_some());
1331            }
1332            other => panic!("expected Log, got {other:?}"),
1333        }
1334    }
1335
1336    #[test]
1337    fn log_with_data_filtered_by_min_level() {
1338        let mut trace = TestTrace::builder("filter-data")
1339            .with_console_output(false)
1340            .with_min_level(TraceLevel::Error)
1341            .build();
1342
1343        trace.log_with_data(TraceLevel::Info, "filtered", serde_json::json!({}));
1344        assert_eq!(trace.entry_count(), 0);
1345    }
1346
1347    #[test]
1348    fn end_span_by_id_removes_correct_span() {
1349        let mut trace = TestTrace::builder("end-by-id")
1350            .with_console_output(false)
1351            .build();
1352
1353        let outer = trace.start_span("outer");
1354        let _inner = trace.start_span("inner");
1355
1356        // End the outer span by ID (out of order)
1357        trace.end_span_by_id(&outer, None);
1358
1359        // Inner should still be current
1360        assert!(trace.current_span_id().is_some());
1361
1362        // End remaining span
1363        trace.end_span(None);
1364        assert!(trace.current_span_id().is_none());
1365    }
1366
1367    #[test]
1368    fn end_span_by_id_nonexistent_is_noop() {
1369        let mut trace = TestTrace::builder("noop-end")
1370            .with_console_output(false)
1371            .build();
1372
1373        trace.end_span_by_id("nonexistent-span-id", None);
1374        assert_eq!(trace.entry_count(), 0);
1375    }
1376
1377    #[test]
1378    fn log_response_with_duration_records_entry() {
1379        let mut trace = TestTrace::builder("duration")
1380            .with_console_output(false)
1381            .build();
1382
1383        trace.log_response_with_duration(
1384            "tools/call",
1385            Duration::from_millis(42),
1386            Some(&serde_json::json!({"ok": true})),
1387            None::<&()>,
1388        );
1389
1390        assert_eq!(trace.entry_count(), 1);
1391        match &trace.entries()[0] {
1392            TraceEntry::Response {
1393                method,
1394                duration_ms,
1395                ..
1396            } => {
1397                assert_eq!(method, "tools/call");
1398                assert!((duration_ms - 42.0).abs() < 0.01);
1399            }
1400            other => panic!("expected Response, got {other:?}"),
1401        }
1402    }
1403
1404    #[test]
1405    fn log_response_unknown_correlation_uses_unknown_method() {
1406        let mut trace = TestTrace::builder("unknown-corr")
1407            .with_console_output(false)
1408            .build();
1409
1410        trace.log_response("nonexistent-id", Some(&serde_json::json!({})), None::<&()>);
1411
1412        assert_eq!(trace.entry_count(), 1);
1413        match &trace.entries()[0] {
1414            TraceEntry::Response { method, .. } => {
1415                assert_eq!(method, "unknown");
1416            }
1417            other => panic!("expected Response, got {other:?}"),
1418        }
1419    }
1420
1421    #[test]
1422    fn add_metadata_stores_value() {
1423        let mut trace = TestTrace::builder("meta")
1424            .with_console_output(false)
1425            .build();
1426
1427        trace.add_metadata("version", serde_json::json!("1.0"));
1428        let output = trace.build_output();
1429        assert_eq!(output.metadata["version"], "1.0");
1430    }
1431
1432    #[test]
1433    fn is_leap_year_correct() {
1434        assert!(is_leap_year(2000)); // divisible by 400
1435        assert!(!is_leap_year(1900)); // divisible by 100 but not 400
1436        assert!(is_leap_year(2024)); // divisible by 4 but not 100
1437        assert!(!is_leap_year(2023)); // not divisible by 4
1438    }
1439
1440    #[test]
1441    fn trace_retention_config_debug_and_clone() {
1442        let config = TraceRetentionConfig::default();
1443        let debug = format!("{config:?}");
1444        assert!(debug.contains("TraceRetentionConfig"));
1445        assert!(debug.contains("100"));
1446
1447        let cloned = config.clone();
1448        assert_eq!(cloned.max_files, Some(100));
1449    }
1450
1451    #[test]
1452    fn correlation_ids_are_unique() {
1453        let id1 = new_correlation_id();
1454        let id2 = new_correlation_id();
1455        let id3 = new_correlation_id();
1456        assert_ne!(id1, id2);
1457        assert_ne!(id2, id3);
1458        assert!(id1.starts_with("trace-"));
1459    }
1460
1461    // =========================================================================
1462    // Additional coverage tests (bd-m32k)
1463    // =========================================================================
1464
1465    #[test]
1466    fn epoch_to_datetime_unix_epoch() {
1467        let (year, month, day, hour, min, sec) = epoch_to_datetime(0);
1468        assert_eq!((year, month, day, hour, min, sec), (1970, 1, 1, 0, 0, 0));
1469    }
1470
1471    #[test]
1472    fn epoch_to_datetime_leap_year_feb29() {
1473        // 2000-02-29 00:00:00 UTC = 951782400
1474        let (year, month, day, hour, min, sec) = epoch_to_datetime(951_782_400);
1475        assert_eq!(year, 2000);
1476        assert_eq!(month, 2);
1477        assert_eq!(day, 29);
1478        assert_eq!((hour, min, sec), (0, 0, 0));
1479    }
1480
1481    #[test]
1482    fn epoch_to_datetime_year_end() {
1483        // 2023-12-31 23:59:59 UTC = 1704067199
1484        let (year, month, day, hour, min, sec) = epoch_to_datetime(1_704_067_199);
1485        assert_eq!(year, 2023);
1486        assert_eq!(month, 12);
1487        assert_eq!(day, 31);
1488        assert_eq!((hour, min, sec), (23, 59, 59));
1489    }
1490
1491    #[test]
1492    fn epoch_to_datetime_mid_year() {
1493        // 2024-06-15 12:30:45 UTC = 1718451045
1494        let (year, month, day, hour, min, sec) = epoch_to_datetime(1_718_451_045);
1495        assert_eq!(year, 2024);
1496        assert_eq!(month, 6);
1497        assert_eq!(day, 15);
1498        assert_eq!((hour, min, sec), (11, 30, 45));
1499    }
1500
1501    #[test]
1502    fn trace_entry_serde_roundtrip() {
1503        let entries = vec![
1504            TraceEntry::Request {
1505                correlation_id: "c1".into(),
1506                timestamp: "ts".into(),
1507                method: "tools/list".into(),
1508                params: Some(serde_json::json!({"x": 1})),
1509                span_id: Some("s1".into()),
1510            },
1511            TraceEntry::Response {
1512                correlation_id: "c1".into(),
1513                timestamp: "ts".into(),
1514                method: "tools/list".into(),
1515                duration_ms: 42.5,
1516                result: Some(serde_json::json!([])),
1517                error: None,
1518                span_id: None,
1519            },
1520            TraceEntry::SpanStart {
1521                span_id: "s1".into(),
1522                parent_span_id: None,
1523                name: "test-span".into(),
1524                timestamp: "ts".into(),
1525            },
1526            TraceEntry::SpanEnd {
1527                span_id: "s1".into(),
1528                timestamp: "ts".into(),
1529                duration_ms: 10.0,
1530                error: Some("failed".into()),
1531            },
1532            TraceEntry::Log {
1533                timestamp: "ts".into(),
1534                level: TraceLevel::Warn,
1535                message: "warning".into(),
1536                span_id: None,
1537                data: None,
1538            },
1539            TraceEntry::Metric {
1540                timestamp: "ts".into(),
1541                name: "latency".into(),
1542                value: 99.9,
1543                unit: Some("ms".into()),
1544                span_id: None,
1545            },
1546        ];
1547
1548        for entry in &entries {
1549            let json = serde_json::to_string(entry).expect("serialize");
1550            let back: TraceEntry = serde_json::from_str(&json).expect("deserialize");
1551            let json2 = serde_json::to_string(&back).expect("re-serialize");
1552            assert_eq!(json, json2);
1553        }
1554    }
1555
1556    #[test]
1557    fn trace_summary_default_is_zeroed() {
1558        let s = TraceSummary::default();
1559        assert_eq!(s.request_count, 0);
1560        assert_eq!(s.response_count, 0);
1561        assert_eq!(s.success_count, 0);
1562        assert_eq!(s.error_count, 0);
1563        assert_eq!(s.span_count, 0);
1564        assert!(s.method_counts.is_empty());
1565        assert!(s.method_timings.is_empty());
1566    }
1567
1568    #[test]
1569    fn method_timing_default_values() {
1570        let t = MethodTiming::default();
1571        assert_eq!(t.count, 0);
1572        assert!((t.total_ms).abs() < f64::EPSILON);
1573        assert!(t.min_ms.is_none());
1574        assert!(t.max_ms.is_none());
1575        assert!(t.mean_ms.is_none());
1576    }
1577
1578    #[test]
1579    fn request_within_span_gets_span_id() {
1580        let mut trace = TestTrace::builder("span-req")
1581            .with_console_output(false)
1582            .build();
1583
1584        let span_id = trace.start_span("my-span");
1585        let corr = trace.log_request("tools/call", Some(&serde_json::json!({})));
1586        trace.log_response(&corr, Some(&serde_json::json!({})), None::<&()>);
1587        trace.end_span(None);
1588
1589        // Request and response should have span_id set
1590        match &trace.entries()[1] {
1591            TraceEntry::Request {
1592                span_id: sid,
1593                method,
1594                ..
1595            } => {
1596                assert_eq!(method, "tools/call");
1597                assert_eq!(sid.as_deref(), Some(span_id.as_str()));
1598            }
1599            other => panic!("expected Request, got {other:?}"),
1600        }
1601        match &trace.entries()[2] {
1602            TraceEntry::Response {
1603                span_id: sid,
1604                method,
1605                ..
1606            } => {
1607                assert_eq!(method, "tools/call");
1608                assert_eq!(sid.as_deref(), Some(span_id.as_str()));
1609            }
1610            other => panic!("expected Response, got {other:?}"),
1611        }
1612    }
1613
1614    #[test]
1615    fn metric_within_span_gets_span_id() {
1616        let mut trace = TestTrace::builder("span-metric")
1617            .with_console_output(false)
1618            .build();
1619
1620        let span_id = trace.start_span("measurement");
1621        trace.metric("latency", 42.0, Some("ms"));
1622        trace.end_span(None);
1623
1624        match &trace.entries()[1] {
1625            TraceEntry::Metric {
1626                span_id: sid,
1627                name,
1628                value,
1629                unit,
1630                ..
1631            } => {
1632                assert_eq!(name, "latency");
1633                assert!((value - 42.0).abs() < f64::EPSILON);
1634                assert_eq!(unit.as_deref(), Some("ms"));
1635                assert_eq!(sid.as_deref(), Some(span_id.as_str()));
1636            }
1637            other => panic!("expected Metric, got {other:?}"),
1638        }
1639    }
1640
1641    #[test]
1642    fn build_output_empty_trace() {
1643        let trace = TestTrace::builder("empty")
1644            .with_console_output(false)
1645            .build();
1646
1647        let output = trace.build_output();
1648        assert_eq!(output.name, "empty");
1649        assert!(output.entries.is_empty());
1650        assert_eq!(output.summary.request_count, 0);
1651        assert!(output.ended_at.is_some());
1652        assert!(output.duration_ms.is_some());
1653    }
1654
1655    #[test]
1656    fn sanitize_filename_various_chars() {
1657        let trace = TestTrace::builder("test")
1658            .with_console_output(false)
1659            .build();
1660
1661        assert_eq!(trace.sanitize_filename("hello world"), "hello_world");
1662        assert_eq!(trace.sanitize_filename("a/b\\c:d"), "a_b_c_d");
1663        assert_eq!(trace.sanitize_filename("dots.and.more"), "dots_and_more");
1664        assert_eq!(
1665            trace.sanitize_filename("keep-dash_underscore"),
1666            "keep-dash_underscore"
1667        );
1668        assert_eq!(trace.sanitize_filename("UPPER123"), "UPPER123");
1669    }
1670
1671    #[test]
1672    fn test_trace_output_serde_roundtrip() {
1673        let mut trace = TestTrace::builder("roundtrip")
1674            .with_console_output(false)
1675            .with_metadata("ver", "1.0")
1676            .build();
1677
1678        trace.info("hello");
1679        let id = trace.log_request("tools/list", None::<&()>);
1680        trace.log_response(&id, Some(&serde_json::json!([])), None::<&()>);
1681
1682        let output = trace.build_output();
1683        let json = serde_json::to_string(&output).expect("serialize");
1684        let back: TestTraceOutput = serde_json::from_str(&json).expect("deserialize");
1685
1686        assert_eq!(back.name, "roundtrip");
1687        assert_eq!(back.entries.len(), output.entries.len());
1688        assert_eq!(back.summary.request_count, 1);
1689        assert_eq!(back.summary.response_count, 1);
1690        assert_eq!(back.metadata["ver"], "1.0");
1691    }
1692
1693    #[test]
1694    fn trace_level_serde_roundtrip() {
1695        for level in [
1696            TraceLevel::Debug,
1697            TraceLevel::Info,
1698            TraceLevel::Warn,
1699            TraceLevel::Error,
1700        ] {
1701            let json = serde_json::to_string(&level).expect("serialize");
1702            let back: TraceLevel = serde_json::from_str(&json).expect("deserialize");
1703            assert_eq!(level, back);
1704        }
1705    }
1706}