1use chrono::{DateTime, Utc};
2use clap::ValueEnum;
3use colored::*;
4use prettytable::{row, Table};
5use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::time::Duration;
8
9pub fn expand_guide_anchor(details: &str) -> String {
20 const KNOWN_SLUGS: &[&str] = &[
21 "handlers-before-connect",
22 "do-not-pass-tools",
23 "csp-external-resources",
24 "vite-singlefile",
25 "common-failures-claude",
26 ];
27 const URL_PREFIX: &str =
28 "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#";
29 let mut out = details.to_string();
30 for slug in KNOWN_SLUGS {
31 let token = format!("[guide:{slug}]");
32 let url = format!("{URL_PREFIX}{slug}");
33 out = out.replace(&token, &url);
34 }
35 out
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, ValueEnum, Serialize, Deserialize)]
39pub enum OutputFormat {
40 Pretty,
41 Json,
42 Minimal,
43 Verbose,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub enum TestStatus {
48 Passed,
49 Failed,
50 Warning,
51 Skipped,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub enum TestCategory {
56 Core,
57 Transport,
63 Protocol,
64 Tools,
65 Resources,
66 Prompts,
67 Performance,
68 Compatibility,
69 Apps,
70 Tasks,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TestResult {
75 pub name: String,
76 pub category: TestCategory,
77 pub status: TestStatus,
78 pub duration: Duration,
79 pub error: Option<String>,
80 pub details: Option<String>,
81}
82
83impl TestResult {
84 pub fn passed(
86 name: impl Into<String>,
87 category: TestCategory,
88 duration: Duration,
89 details: impl Into<String>,
90 ) -> Self {
91 Self {
92 name: name.into(),
93 category,
94 status: TestStatus::Passed,
95 duration,
96 error: None,
97 details: Some(details.into()),
98 }
99 }
100
101 pub fn failed(
103 name: impl Into<String>,
104 category: TestCategory,
105 duration: Duration,
106 error: impl Into<String>,
107 ) -> Self {
108 Self {
109 name: name.into(),
110 category,
111 status: TestStatus::Failed,
112 duration,
113 error: Some(error.into()),
114 details: None,
115 }
116 }
117
118 pub fn warning(
120 name: impl Into<String>,
121 category: TestCategory,
122 duration: Duration,
123 details: impl Into<String>,
124 ) -> Self {
125 Self {
126 name: name.into(),
127 category,
128 status: TestStatus::Warning,
129 duration,
130 error: None,
131 details: Some(details.into()),
132 }
133 }
134
135 pub fn skipped(
137 name: impl Into<String>,
138 category: TestCategory,
139 details: impl Into<String>,
140 ) -> Self {
141 Self {
142 name: name.into(),
143 category,
144 status: TestStatus::Skipped,
145 duration: Duration::from_secs(0),
146 error: None,
147 details: Some(details.into()),
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TestReport {
154 pub tests: Vec<TestResult>,
155 pub duration: Duration,
156 pub timestamp: DateTime<Utc>,
157 pub summary: TestSummary,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct TestSummary {
162 pub total: usize,
163 pub passed: usize,
164 pub failed: usize,
165 pub warnings: usize,
166 pub skipped: usize,
167}
168
169impl Default for TestReport {
170 fn default() -> Self {
171 Self {
172 tests: Vec::new(),
173 duration: Duration::from_secs(0),
174 timestamp: Utc::now(),
175 summary: TestSummary {
176 total: 0,
177 passed: 0,
178 failed: 0,
179 warnings: 0,
180 skipped: 0,
181 },
182 }
183 }
184}
185
186impl TestReport {
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 pub fn from_error(error: anyhow::Error) -> Self {
192 let mut report = Self::new();
193 report.add_test(TestResult {
194 name: "Error".to_string(),
195 category: TestCategory::Core,
196 status: TestStatus::Failed,
197 duration: Duration::from_secs(0),
198 error: Some(error.to_string()),
199 details: None,
200 });
201 report
202 }
203
204 pub fn add_test(&mut self, test: TestResult) {
205 match test.status {
206 TestStatus::Passed => self.summary.passed += 1,
207 TestStatus::Failed => self.summary.failed += 1,
208 TestStatus::Warning => self.summary.warnings += 1,
209 TestStatus::Skipped => self.summary.skipped += 1,
210 }
211 self.summary.total += 1;
212 self.tests.push(test);
213 }
214
215 pub fn has_failures(&self) -> bool {
216 self.summary.failed > 0
217 }
218
219 pub fn apply_strict_mode(&mut self) {
220 for test in &mut self.tests {
222 if test.status == TestStatus::Warning {
223 test.status = TestStatus::Failed;
224 self.summary.warnings -= 1;
225 self.summary.failed += 1;
226 }
227 }
228 }
229
230 pub fn print(&self, format: OutputFormat) {
231 let mut stdout = std::io::stdout();
232 let _ = self.print_to_writer(format, &mut stdout);
236 }
237
238 pub fn print_to_writer<W: Write>(
245 &self,
246 format: OutputFormat,
247 w: &mut W,
248 ) -> std::io::Result<()> {
249 match format {
250 OutputFormat::Pretty => self.print_pretty(w),
251 OutputFormat::Json => self.print_json(w),
252 OutputFormat::Minimal => self.print_minimal(w),
253 OutputFormat::Verbose => self.print_verbose(w),
254 }
255 }
256
257 fn print_pretty<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
258 writeln!(w)?;
259 writeln!(w, "{}", "TEST RESULTS".cyan().bold())?;
260 writeln!(w, "{}", "═".repeat(60).cyan())?;
261 writeln!(w)?;
262
263 let mut by_category: std::collections::HashMap<String, Vec<&TestResult>> =
265 std::collections::HashMap::new();
266
267 for test in &self.tests {
268 let category = format!("{:?}", test.category);
269 by_category.entry(category).or_default().push(test);
270 }
271
272 for (category, tests) in by_category {
274 writeln!(w, "{}", format!("{}:", category).yellow().bold())?;
275 writeln!(w)?;
276
277 for test in tests {
278 self.print_test_result_pretty(w, test)?;
279 }
280 writeln!(w)?;
281 }
282
283 self.print_summary_pretty(w)?;
285
286 if self.has_failures() {
288 self.print_recommendations(w)?;
289 }
290 Ok(())
291 }
292
293 fn print_test_result_pretty<W: Write>(
294 &self,
295 w: &mut W,
296 test: &TestResult,
297 ) -> std::io::Result<()> {
298 let status_symbol = match test.status {
299 TestStatus::Passed => "✓".green().bold(),
300 TestStatus::Failed => "✗".red().bold(),
301 TestStatus::Warning => "⚠".yellow().bold(),
302 TestStatus::Skipped => "○".dimmed(),
303 };
304
305 let name = if test.name.len() > 40 {
306 format!("{}...", &test.name[..37])
307 } else {
308 test.name.clone()
309 };
310
311 write!(w, " {} {:<40}", status_symbol, name)?;
312
313 if test.duration.as_millis() > 100 {
315 write!(w, " {:>6}ms", test.duration.as_millis())?;
316 } else {
317 write!(w, " ")?;
318 }
319
320 if let Some(error) = &test.error {
324 writeln!(w, " {}", error.red())?;
325 } else if let Some(details) = &test.details {
326 let expanded = expand_guide_anchor(details);
327 if test.status == TestStatus::Warning {
328 writeln!(w, " {}", expanded.yellow())?;
329 } else {
330 writeln!(w, " {}", expanded.dimmed())?;
331 }
332 } else {
333 writeln!(w)?;
334 }
335 Ok(())
336 }
337
338 fn print_summary_pretty<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
339 writeln!(w, "{}", "═".repeat(60).cyan())?;
340 writeln!(w, "{}", "SUMMARY".cyan().bold())?;
341 writeln!(w, "{}", "═".repeat(60).cyan())?;
342 writeln!(w)?;
343
344 let mut table = Table::new();
345 table.add_row(row!["Total Tests", self.summary.total.to_string().bold()]);
346 table.add_row(row![
347 "Passed",
348 self.summary.passed.to_string().green().bold()
349 ]);
350
351 if self.summary.failed > 0 {
352 table.add_row(row!["Failed", self.summary.failed.to_string().red().bold()]);
353 }
354
355 if self.summary.warnings > 0 {
356 table.add_row(row![
357 "Warnings",
358 self.summary.warnings.to_string().yellow().bold()
359 ]);
360 }
361
362 if self.summary.skipped > 0 {
363 table.add_row(row!["Skipped", self.summary.skipped.to_string().dimmed()]);
364 }
365
366 table.add_row(row![
367 "Duration",
368 format!("{:.2}s", self.duration.as_secs_f64())
369 ]);
370
371 table.print(w)?;
372 writeln!(w)?;
373
374 let overall = if self.summary.failed > 0 {
376 "FAILED".red().bold()
377 } else if self.summary.warnings > 0 {
378 "PASSED WITH WARNINGS".yellow().bold()
379 } else {
380 "PASSED".green().bold()
381 };
382
383 writeln!(w, "Overall Status: {}", overall)?;
384 Ok(())
385 }
386
387 fn print_recommendations<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
388 writeln!(w)?;
389 writeln!(w, "{}", "RECOMMENDATIONS".yellow().bold())?;
390 writeln!(w, "{}", "═".repeat(60).yellow())?;
391 writeln!(w)?;
392
393 let failed_tests: Vec<_> = self
394 .tests
395 .iter()
396 .filter(|t| t.status == TestStatus::Failed)
397 .collect();
398
399 if failed_tests.is_empty() {
400 return Ok(());
401 }
402
403 let mut protocol_failures = 0;
405 let mut tool_failures = 0;
406 let mut core_failures = 0;
407 let mut task_failures = 0;
408
409 for test in &failed_tests {
410 match test.category {
411 TestCategory::Protocol => protocol_failures += 1,
412 TestCategory::Tools => tool_failures += 1,
413 TestCategory::Core => core_failures += 1,
414 TestCategory::Tasks => task_failures += 1,
415 _ => {},
416 }
417 }
418
419 if core_failures > 0 {
420 writeln!(w, " • Fix core connectivity issues first")?;
421 writeln!(w, " - Verify server is running and accessible")?;
422 writeln!(w, " - Check network configuration and firewall rules")?;
423 }
424
425 if protocol_failures > 0 {
426 writeln!(w, " • Review MCP protocol implementation")?;
427 writeln!(w, " - Ensure JSON-RPC 2.0 compliance")?;
428 writeln!(w, " - Verify protocol version compatibility")?;
429 writeln!(w, " - Check required method implementations")?;
430 }
431
432 if tool_failures > 0 {
433 writeln!(w, " • Debug tool implementations")?;
434 writeln!(w, " - Verify tool registration and handlers")?;
435 writeln!(w, " - Check input validation and error handling")?;
436 writeln!(w, " - Review tool response formats")?;
437 }
438
439 if task_failures > 0 {
440 writeln!(w, " - Debug task implementations")?;
441 writeln!(
442 w,
443 " - Verify task capability is advertised in ServerCapabilities"
444 )?;
445 writeln!(
446 w,
447 " - Check task lifecycle state machine (working -> completed/failed)"
448 )?;
449 writeln!(
450 w,
451 " - Ensure tasks/get and tasks/list return valid Task structures"
452 )?;
453 }
454
455 writeln!(w)?;
456 writeln!(w, "Run with --verbose for detailed error information")?;
457 Ok(())
458 }
459
460 fn print_json<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
461 let json = serde_json::to_string_pretty(self).unwrap();
462 writeln!(w, "{}", json)
463 }
464
465 fn print_minimal<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
466 let status = if self.summary.failed > 0 {
467 "FAIL"
468 } else {
469 "PASS"
470 };
471
472 writeln!(
473 w,
474 "{}: {} passed, {} failed, {} warnings in {:.2}s",
475 status,
476 self.summary.passed,
477 self.summary.failed,
478 self.summary.warnings,
479 self.duration.as_secs_f64()
480 )
481 }
482
483 fn print_verbose<W: Write>(&self, w: &mut W) -> std::io::Result<()> {
484 self.print_pretty(w)?;
485
486 writeln!(w)?;
487 writeln!(w, "{}", "DETAILED TEST INFORMATION".cyan().bold())?;
488 writeln!(w, "{}", "═".repeat(60).cyan())?;
489 writeln!(w)?;
490
491 for test in &self.tests {
492 writeln!(w, "Test: {}", test.name.bold())?;
493 writeln!(w, " Category: {:?}", test.category)?;
494 writeln!(w, " Status: {:?}", test.status)?;
495 writeln!(w, " Duration: {:?}", test.duration)?;
496
497 if let Some(error) = &test.error {
498 writeln!(w, " Error: {}", error.red())?;
499 }
500
501 if let Some(details) = &test.details {
502 let expanded = expand_guide_anchor(details);
504 writeln!(w, " Details: {}", expanded)?;
505 }
506
507 writeln!(w)?;
508 }
509 Ok(())
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
519 fn transport_category_serde_roundtrip() {
520 let original = TestCategory::Transport;
521 let json = serde_json::to_string(&original).expect("serialize");
522 assert_eq!(json, "\"Transport\"");
523 let parsed: TestCategory = serde_json::from_str(&json).expect("deserialize");
524 assert_eq!(parsed, TestCategory::Transport);
525 }
526
527 #[test]
529 fn transport_category_traits() {
530 let a = TestCategory::Transport;
531 let b = a.clone();
532 assert_eq!(a, b);
533 assert_ne!(a, TestCategory::Core);
534 assert_eq!(format!("{:?}", a), "Transport");
536 }
537
538 #[test]
540 fn transport_results_aggregate_in_summary() {
541 let mut report = TestReport::new();
542 report.add_test(TestResult::passed(
543 "Transport: GET /mcp",
544 TestCategory::Transport,
545 Duration::from_millis(10),
546 "ok",
547 ));
548 report.add_test(TestResult::failed(
549 "Transport: OPTIONS /mcp",
550 TestCategory::Transport,
551 Duration::from_millis(10),
552 "boom",
553 ));
554 report.add_test(TestResult::warning(
555 "Transport: DELETE /mcp",
556 TestCategory::Transport,
557 Duration::from_millis(10),
558 "warn",
559 ));
560 assert_eq!(report.summary.total, 3);
561 assert_eq!(report.summary.passed, 1);
562 assert_eq!(report.summary.failed, 1);
563 assert_eq!(report.summary.warnings, 1);
564 assert!(report.has_failures());
565 }
566
567 #[test]
573 fn expand_guide_anchor_handlers_before_connect() {
574 let out = expand_guide_anchor("Missing handler [guide:handlers-before-connect]");
575 assert_eq!(
576 out,
577 "Missing handler https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
578 );
579 }
580
581 #[test]
583 fn expand_guide_anchor_no_token() {
584 let out = expand_guide_anchor("plain text");
585 assert_eq!(out, "plain text");
586 }
587
588 #[test]
590 fn expand_guide_anchor_unknown_slug() {
591 let input = "see [guide:not-a-real-slug] for details";
592 let out = expand_guide_anchor(input);
593 assert_eq!(out, input, "unknown slugs must be left in place");
594 }
595
596 #[test]
598 fn expand_guide_anchor_multiple_tokens() {
599 let input = "First [guide:handlers-before-connect], then [guide:common-failures-claude].";
600 let out = expand_guide_anchor(input);
601 assert!(
602 out.contains(
603 "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
604 ),
605 "first token must expand; got: {}",
606 out
607 );
608 assert!(
609 out.contains(
610 "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#common-failures-claude"
611 ),
612 "second token must expand; got: {}",
613 out
614 );
615 assert!(
616 !out.contains("[guide:"),
617 "no [guide:...] tokens must remain; got: {}",
618 out
619 );
620 }
621
622 #[test]
624 fn expand_guide_anchor_common_failures() {
625 let out = expand_guide_anchor("[guide:common-failures-claude]");
626 assert!(
627 out.ends_with("#common-failures-claude"),
628 "expected URL ending with #common-failures-claude; got: {}",
629 out
630 );
631 assert!(
632 out.starts_with("https://"),
633 "expected absolute URL; got: {}",
634 out
635 );
636 }
637
638 #[test]
642 fn pretty_output_includes_expanded_url() {
643 colored::control::set_override(false);
646
647 let mut report = TestReport::new();
648 report.add_test(TestResult {
649 name: "[example] handler: onteardown".to_string(),
650 category: TestCategory::Apps,
651 status: TestStatus::Failed,
652 duration: Duration::from_secs(0),
653 error: None,
654 details: Some(
655 "Widget does not register onteardown. [guide:handlers-before-connect]".to_string(),
656 ),
657 });
658 let mut buf: Vec<u8> = Vec::new();
659 report
660 .print_to_writer(OutputFormat::Pretty, &mut buf)
661 .expect("write to Vec<u8> should not fail");
662 let captured = String::from_utf8_lossy(&buf);
663 assert!(
664 captured.contains(
665 "https://github.com/paiml/rust-mcp-sdk/blob/main/src/server/mcp_apps/GUIDE.md#handlers-before-connect"
666 ),
667 "pretty output must contain the expanded URL; got:\n{}",
668 captured
669 );
670 assert!(
671 !captured.contains("[guide:handlers-before-connect]"),
672 "pretty output must not contain the unexpanded token; got:\n{}",
673 captured
674 );
675
676 colored::control::unset_override();
678 }
679}