1use 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
43pub 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
50pub 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#[derive(Debug, Clone)]
59pub struct TraceRetentionConfig {
60 pub max_files: Option<usize>,
62 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), }
72 }
73}
74
75pub 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 entries.sort_by_key(|(_, time)| *time);
118
119 let mut removed = 0;
120 let now = SystemTime::now();
121
122 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 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
153static CORRELATION_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
155
156fn new_correlation_id() -> String {
158 let id = CORRELATION_ID_COUNTER.fetch_add(1, Ordering::SeqCst);
159 format!("trace-{id:08x}")
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(tag = "type")]
165pub enum TraceEntry {
166 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 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 SpanStart {
188 span_id: String,
189 parent_span_id: Option<String>,
190 name: String,
191 timestamp: String,
192 },
193 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 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 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#[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#[derive(Debug)]
246pub struct Span {
247 pub id: String,
249 pub parent_id: Option<String>,
251 pub name: String,
253 pub start: Instant,
255 pub start_timestamp: String,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct TestTraceOutput {
262 pub name: String,
264 pub started_at: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub ended_at: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub duration_ms: Option<f64>,
272 pub entries: Vec<TraceEntry>,
274 pub summary: TraceSummary,
276 #[serde(skip_serializing_if = "HashMap::is_empty")]
278 pub metadata: HashMap<String, serde_json::Value>,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct TraceSummary {
284 pub request_count: usize,
286 pub response_count: usize,
288 pub success_count: usize,
290 pub error_count: usize,
292 pub span_count: usize,
294 pub method_counts: HashMap<String, usize>,
296 pub method_timings: HashMap<String, MethodTiming>,
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct MethodTiming {
303 pub count: usize,
305 pub total_ms: f64,
307 pub min_ms: Option<f64>,
309 pub max_ms: Option<f64>,
311 pub mean_ms: Option<f64>,
313}
314
315pub 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 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 #[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 #[must_use]
356 pub fn with_console_output(mut self, enabled: bool) -> Self {
357 self.console_output = enabled;
358 self
359 }
360
361 #[must_use]
363 pub fn with_min_level(mut self, level: TraceLevel) -> Self {
364 self.min_level = level;
365 self
366 }
367
368 #[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#[derive(Debug)]
390pub struct TestTrace {
391 name: String,
393 started_at: String,
395 start_instant: Instant,
397 entries: Vec<TraceEntry>,
399 metadata: HashMap<String, serde_json::Value>,
401 console_output: bool,
403 min_level: TraceLevel,
405 active_spans: Vec<Span>,
407 pending_requests: HashMap<String, (String, Instant)>,
409}
410
411impl TestTrace {
412 #[must_use]
420 pub fn new(name: impl Into<String>) -> Self {
421 TestTraceBuilder::new(name).build()
422 }
423
424 #[must_use]
426 pub fn builder(name: impl Into<String>) -> TestTraceBuilder {
427 TestTraceBuilder::new(name)
428 }
429
430 #[must_use]
432 pub fn name(&self) -> &str {
433 &self.name
434 }
435
436 #[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 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 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 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 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 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 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 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 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 pub fn debug(&mut self, message: impl Into<String>) {
691 self.log(TraceLevel::Debug, message);
692 }
693
694 pub fn info(&mut self, message: impl Into<String>) {
696 self.log(TraceLevel::Info, message);
697 }
698
699 pub fn warn(&mut self, message: impl Into<String>) {
701 self.log(TraceLevel::Warn, message);
702 }
703
704 pub fn error(&mut self, message: impl Into<String>) {
706 self.log(TraceLevel::Error, message);
707 }
708
709 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 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 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 #[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 pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
805 let path = path.as_ref();
806
807 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 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 fs::create_dir_all(&dir)?;
862
863 if let Some(config) = cleanup {
865 let _ = cleanup_old_traces(&dir, config);
866 }
867
868 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 self.save(&path)?;
876
877 Ok(path)
878 }
879
880 fn generate_file_timestamp(&self) -> String {
882 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 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 pub fn to_json(&self) -> serde_json::Result<String> {
912 serde_json::to_string_pretty(&self.build_output())
913 }
914
915 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 #[must_use]
955 pub fn entries(&self) -> &[TraceEntry] {
956 &self.entries
957 }
958
959 #[must_use]
961 pub fn entry_count(&self) -> usize {
962 self.entries.len()
963 }
964}
965
966fn 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 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
980fn epoch_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
982 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 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
1023fn 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(¶ms));
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); }
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"); trace.info("info message");
1096 trace.warn("warn message");
1097 trace.error("error message");
1098
1099 assert_eq!(trace.entry_count(), 3); }
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 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 assert!(!is_trace_enabled());
1155 }
1156
1157 #[test]
1158 fn test_timestamp_format() {
1159 let ts = current_timestamp();
1160 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 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 let dir = get_trace_dir();
1182 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 assert_eq!(ts.len(), 19);
1214 assert!(ts.contains('_'));
1215 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 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 assert!(path.exists());
1239
1240 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 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 let _ = fs::remove_dir_all(&test_dir);
1258 fs::create_dir_all(&test_dir).unwrap();
1259
1260 for i in 0..5 {
1262 let path = test_dir.join(format!("trace_{i}.json"));
1263 fs::write(&path, "{}").unwrap();
1264 }
1265
1266 let count_before: usize = fs::read_dir(&test_dir).unwrap().count();
1268 assert_eq!(count_before, 5);
1269
1270 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 assert_eq!(removed, 2);
1279 let count_after: usize = fs::read_dir(&test_dir).unwrap().count();
1280 assert_eq!(count_after, 3);
1281
1282 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 #[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 trace.end_span_by_id(&outer, None);
1358
1359 assert!(trace.current_span_id().is_some());
1361
1362 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)); assert!(!is_leap_year(1900)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
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 #[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 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 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 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 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}