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}