1mod handler;
2mod helpers;
3mod prompts;
4mod rules;
5mod tools;
6mod types;
7
8pub use handler::RustDoctorServer;
10pub use types::{
11 DeepAuditArgs, DiagnosticExample, DiagnosticGroup, ExplainRuleInput, HealthCheckArgs,
12 ScanInput, ScoreInput, ScoreOutput,
13};
14
15use rmcp::service::ServiceExt;
16
17#[derive(Debug, thiserror::Error)]
24pub enum McpServerError {
25 #[error("failed to create tokio runtime: {0}")]
26 RuntimeCreation(#[from] std::io::Error),
27
28 #[error("MCP server initialization failed: {0}")]
29 Initialize(#[from] Box<rmcp::service::ServerInitializeError>),
30
31 #[error("MCP server task failed: {0}")]
32 TaskJoin(#[from] tokio::task::JoinError),
33}
34
35pub fn run_mcp_server() -> Result<(), McpServerError> {
42 let rt = tokio::runtime::Runtime::new()?;
43 rt.block_on(async {
44 let server = RustDoctorServer::new();
45 let transport = rmcp::transport::io::stdio();
46 let service = server.serve(transport).await.map_err(Box::new)?;
47 service.waiting().await?;
48 Ok(())
49 })
50}
51
52#[cfg(test)]
53mod tests {
54 use super::helpers::{discover_and_resolve, format_scan_report, group_diagnostics};
55 use super::rules::{get_all_rules_listing, get_rule_explanation, rule_docs};
56 use super::types::{DeepAuditArgs, HealthCheckArgs, MAX_EXAMPLES_PER_GROUP, ScoreOutput};
57 use super::*;
58 use crate::diagnostics::Diagnostic;
59 use crate::scan;
60 use rmcp::handler::server::wrapper::Parameters;
61
62 #[test]
65 fn test_rule_docs_covers_all_custom_rules() {
66 let docs = rule_docs();
67 let expected: Vec<String> = crate::scan::custom_rule_names()
68 .into_iter()
69 .filter(|name| name != "unused-dependency") .collect();
71
72 for rule_name in &expected {
73 assert!(
74 docs.iter().any(|doc| doc.name == *rule_name),
75 "rule_docs() is missing entry for custom rule '{rule_name}'"
76 );
77 }
78 }
79
80 #[test]
81 fn test_rule_docs_has_no_duplicates() {
82 let docs = rule_docs();
83 let mut seen = std::collections::HashSet::new();
84 for doc in docs {
85 assert!(
86 seen.insert(&doc.name),
87 "rule_docs() has duplicate entry for '{}'",
88 doc.name
89 );
90 }
91 }
92
93 #[test]
94 fn test_rule_docs_fields_not_empty() {
95 let docs = rule_docs();
96 for doc in docs {
97 assert!(!doc.name.is_empty(), "Rule has empty name");
98 assert!(
99 !doc.category.is_empty(),
100 "Rule '{}' has empty category",
101 doc.name
102 );
103 assert!(
104 !doc.severity.is_empty(),
105 "Rule '{}' has empty severity",
106 doc.name
107 );
108 assert!(
109 !doc.description.is_empty(),
110 "Rule '{}' has empty description",
111 doc.name
112 );
113 assert!(!doc.fix.is_empty(), "Rule '{}' has empty fix", doc.name);
114 }
115 }
116
117 #[test]
120 fn test_explain_known_custom_rule() {
121 let explanation = get_rule_explanation("unwrap-in-production");
122 assert!(explanation.contains("## unwrap-in-production"));
123 assert!(explanation.contains("Error Handling"));
124 assert!(explanation.contains("warning"));
125 assert!(explanation.contains("Fix:"));
126 assert!(explanation.contains("Heuristic"));
128 assert!(explanation.contains("Known limitation"));
129 }
130
131 #[test]
132 fn test_explain_custom_rule_without_limitation_is_still_heuristic() {
133 let explanation = get_rule_explanation("result-unit-error");
135 assert!(explanation.contains("Heuristic"));
136 assert!(!explanation.contains("Known limitation"));
137 }
138
139 #[test]
140 fn test_explain_clippy_lint_is_type_aware() {
141 let explanation = get_rule_explanation("clippy::expect_used");
143 assert!(explanation.contains("Type-aware"));
144 assert!(!explanation.contains("Heuristic"));
145 }
146
147 #[test]
148 fn test_explain_known_clippy_lint() {
149 let explanation = get_rule_explanation("clippy::expect_used");
150 assert!(explanation.contains("clippy::expect_used"));
151 assert!(explanation.contains("Clippy lint"));
152 assert!(explanation.contains("rust-lang.github.io"));
153 }
154
155 #[test]
156 fn test_explain_clippy_lint_without_prefix() {
157 let explanation = get_rule_explanation("expect_used");
158 assert!(explanation.contains("expect_used"));
159 assert!(explanation.contains("Clippy lint"));
160 }
161
162 #[test]
163 fn test_explain_unknown_rule() {
164 let explanation = get_rule_explanation("nonexistent-rule-xyz");
165 assert!(explanation.contains("Unknown rule"));
166 assert!(explanation.contains("list_rules"));
167 }
168
169 #[test]
172 fn test_rules_listing_has_all_sections() {
173 let listing = get_all_rules_listing();
174 assert!(listing.contains("# rust-doctor Rules"));
175 assert!(listing.contains("## Custom Rules"));
176 assert!(listing.contains("## Clippy Lints"));
177 assert!(listing.contains("## External Tools"));
178 }
179
180 #[test]
181 fn test_rules_listing_marks_heuristic_and_limitations() {
182 let listing = get_all_rules_listing();
185 assert!(listing.contains("heuristic"));
186 assert!(listing.contains("type-aware"));
187 assert!(listing.contains("Known heuristic limitations"));
188 assert!(listing.contains("large-enum-variant"));
189 }
190
191 #[test]
192 fn test_rules_listing_contains_all_categories() {
193 let listing = get_all_rules_listing();
194 assert!(listing.contains("### Error Handling"));
195 assert!(listing.contains("### Performance"));
196 assert!(listing.contains("### Security"));
197 assert!(listing.contains("### Async"));
198 assert!(listing.contains("### Framework"));
199 }
200
201 #[test]
202 fn test_rules_listing_contains_all_custom_rules() {
203 let listing = get_all_rules_listing();
204 let docs = rule_docs();
205 for doc in docs {
206 assert!(
207 listing.contains(doc.name),
208 "Rules listing is missing '{}'",
209 doc.name
210 );
211 }
212 }
213
214 #[test]
217 fn test_server_info_has_instructions() {
218 let server = RustDoctorServer::new();
219 let info = <RustDoctorServer as rmcp::handler::server::ServerHandler>::get_info(&server);
220 let instructions = info.instructions.as_deref().unwrap_or("");
221 assert!(!instructions.is_empty());
222 assert!(instructions.contains("scan"));
223 assert!(instructions.contains("explain_rule"));
224 assert!(instructions.contains("list_rules"));
225 assert!(instructions.contains("score"));
226 }
227
228 #[test]
231 fn test_prompt_router_has_all_prompts() {
232 let server = RustDoctorServer::new();
233 let prompts = server.prompt_router.list_all();
234 let names: Vec<&str> = prompts.iter().map(|p| &*p.name).collect();
235 assert!(names.contains(&"deep-audit"), "Missing deep-audit prompt");
236 assert!(
237 names.contains(&"health-check"),
238 "Missing health-check prompt"
239 );
240 assert_eq!(
241 names.len(),
242 2,
243 "Expected exactly 2 prompts, got {}",
244 names.len()
245 );
246 }
247
248 #[test]
249 fn test_deep_audit_prompt_registered_with_description() {
250 let server = RustDoctorServer::new();
251 let prompts = server.prompt_router.list_all();
252 let deep_audit = prompts.iter().find(|p| p.name == "deep-audit").unwrap();
253 let desc = deep_audit.description.as_deref().unwrap_or("");
254 assert!(
255 desc.contains("audit"),
256 "deep-audit description should mention audit"
257 );
258 assert!(
259 desc.contains("best practices"),
260 "deep-audit description should mention best practices"
261 );
262 }
263
264 #[test]
265 fn test_server_info_mentions_deep_audit() {
266 let server = RustDoctorServer::new();
267 let info = <RustDoctorServer as rmcp::handler::server::ServerHandler>::get_info(&server);
268 let instructions = info.instructions.as_deref().unwrap_or("");
269 assert!(
270 instructions.contains("deep-audit"),
271 "Server instructions should mention deep-audit prompt"
272 );
273 assert!(
274 instructions.contains("health-check"),
275 "Server instructions should mention health-check prompt"
276 );
277 }
278
279 fn extract_prompt_text(content: &rmcp::model::PromptMessageContent) -> &str {
281 match content {
282 rmcp::model::PromptMessageContent::Text { text } => text,
283 _ => panic!("expected Text content in prompt message"),
284 }
285 }
286
287 #[tokio::test]
288 async fn test_deep_audit_prompt_content() {
289 let server = RustDoctorServer::new();
290 let result = server
291 .deep_audit(Parameters(DeepAuditArgs {
292 directory: "/home/user/my-project".to_string(),
293 }))
294 .await;
295 assert_eq!(result.messages.len(), 1);
296 assert!(result.description.is_some());
297 let text = extract_prompt_text(&result.messages[0].content);
298 assert!(
300 text.contains("/home/user/my-project"),
301 "directory should be interpolated into prompt"
302 );
303 for phase in 1..=6 {
305 assert!(
306 text.contains(&format!("PHASE {phase}")),
307 "Missing PHASE {phase} in prompt"
308 );
309 }
310 assert!(text.contains("Implement all fixes"));
312 assert!(text.contains("Generate a PRD"));
313 assert!(text.contains("Manual"));
314 assert!(text.contains("HARD RULES"));
316 }
317
318 #[tokio::test]
319 async fn test_health_check_prompt_content() {
320 let server = RustDoctorServer::new();
321 let result = server
322 .health_check(Parameters(HealthCheckArgs {
323 directory: "/home/user/test".to_string(),
324 }))
325 .await;
326 assert_eq!(result.messages.len(), 1);
327 let text = extract_prompt_text(&result.messages[0].content);
328 assert!(
329 text.contains("/home/user/test"),
330 "directory should be interpolated"
331 );
332 assert!(text.contains("Phase 1"));
333 assert!(text.contains("Phase 4"));
334 }
335
336 #[test]
339 fn test_tool_router_has_all_tools() {
340 let server = RustDoctorServer::new();
341 let tools = server.tool_router.list_all();
342 let names: Vec<&str> = tools.iter().map(|t| &*t.name).collect();
343 assert!(names.contains(&"scan"), "Missing scan tool");
344 assert!(names.contains(&"score"), "Missing score tool");
345 assert!(names.contains(&"explain_rule"), "Missing explain_rule tool");
346 assert!(names.contains(&"list_rules"), "Missing list_rules tool");
347 assert_eq!(
348 names.len(),
349 4,
350 "Expected exactly 4 tools, got {}",
351 names.len()
352 );
353 }
354
355 #[test]
356 fn test_scan_tool_returns_call_tool_result() {
357 let server = RustDoctorServer::new();
360 let tools = server.tool_router.list_all();
361 let scan = tools.iter().find(|t| t.name == "scan").unwrap();
362 assert!(
363 scan.output_schema.is_none(),
364 "scan uses CallToolResult, not Json<T>"
365 );
366 }
367
368 #[test]
369 fn test_score_tool_has_output_schema() {
370 let server = RustDoctorServer::new();
371 let tools = server.tool_router.list_all();
372 let score = tools.iter().find(|t| t.name == "score").unwrap();
373 assert!(
374 score.output_schema.is_some(),
375 "score tool should have outputSchema from Json<ScoreOutput>"
376 );
377 }
378
379 #[test]
382 fn test_all_tools_have_correct_annotations() {
383 let server = RustDoctorServer::new();
384 let tools = server.tool_router.list_all();
385 for tool in &tools {
386 let ann = tool
387 .annotations
388 .as_ref()
389 .unwrap_or_else(|| panic!("tool '{}' missing annotations", tool.name));
390 assert_eq!(
391 ann.read_only_hint,
392 Some(true),
393 "tool '{}' should be read-only",
394 tool.name
395 );
396 assert_eq!(
397 ann.destructive_hint,
398 Some(false),
399 "tool '{}' should not be destructive",
400 tool.name
401 );
402 assert_eq!(
403 ann.idempotent_hint,
404 Some(true),
405 "tool '{}' should be idempotent",
406 tool.name
407 );
408 assert_eq!(
409 ann.open_world_hint,
410 Some(false),
411 "tool '{}' should be closed-world",
412 tool.name
413 );
414 assert!(
415 ann.title.is_some(),
416 "tool '{}' should have a title",
417 tool.name
418 );
419 }
420 }
421
422 #[test]
425 fn test_discover_and_resolve_invalid_path() {
426 let result = discover_and_resolve("/nonexistent/path/to/project", false);
427 assert!(result.is_err());
428 let err = result.unwrap_err();
429 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
431 }
432
433 #[test]
434 fn test_discover_and_resolve_error_does_not_contain_raw_path() {
435 let result = discover_and_resolve("/nonexistent/path/to/project", false);
436 let err = result.unwrap_err();
437 let msg = err.message.to_string();
438 assert!(
440 !msg.contains("/nonexistent/path"),
441 "MCP error should not contain raw path, got: {msg}"
442 );
443 }
444
445 #[test]
446 fn test_discover_and_resolve_outside_home() {
447 if std::env::var("HOME").is_ok() {
449 let result = discover_and_resolve("/etc", false);
450 assert!(result.is_err());
451 let err = result.unwrap_err();
452 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
453 }
454 }
455
456 #[test]
459 fn test_scan_tool_on_self() {
460 let manifest_dir = env!("CARGO_MANIFEST_DIR");
461 let result = discover_and_resolve(manifest_dir, false);
462 assert!(result.is_ok(), "discover_and_resolve failed: {result:?}");
463 let (_dir, project_info, resolved) = result.unwrap();
464 let scan_result = scan::scan_project(&project_info, &resolved, true, &[], true);
465 assert!(scan_result.is_ok(), "scan_project failed: {scan_result:?}");
466 let result = scan_result.unwrap();
467 assert!(result.score <= 100);
469 assert!(result.source_file_count > 0);
470 }
471
472 fn make_diagnostic(
475 rule: &str,
476 severity: crate::diagnostics::Severity,
477 help: Option<&str>,
478 ) -> Diagnostic {
479 Diagnostic {
480 file_path: std::path::PathBuf::from("src/lib.rs"),
481 rule: rule.to_string(),
482 category: crate::diagnostics::Category::ErrorHandling,
483 severity,
484 message: format!("test finding for {rule}"),
485 help: help.map(String::from),
486 line: Some(1),
487 column: None,
488 fix: None,
489 }
490 }
491
492 #[test]
493 fn test_group_diagnostics_empty() {
494 let groups = group_diagnostics(&[]);
495 assert!(groups.is_empty());
496 }
497
498 #[test]
499 fn test_group_diagnostics_single() {
500 let diag = make_diagnostic("rule-a", crate::diagnostics::Severity::Error, None);
501 let groups = group_diagnostics(&[diag]);
502 assert_eq!(groups.len(), 1);
503 assert_eq!(groups[0].count, 1);
504 assert_eq!(groups[0].examples.len(), 1);
505 }
506
507 #[test]
508 fn test_group_diagnostics_caps_examples() {
509 let diags: Vec<_> = (0..10)
510 .map(|_| make_diagnostic("rule-a", crate::diagnostics::Severity::Warning, None))
511 .collect();
512 let groups = group_diagnostics(&diags);
513 assert_eq!(groups.len(), 1);
514 assert_eq!(groups[0].count, 10);
515 assert_eq!(groups[0].examples.len(), MAX_EXAMPLES_PER_GROUP);
516 }
517
518 #[test]
519 fn test_group_diagnostics_sorts_errors_first() {
520 let diags = vec![
521 make_diagnostic("warn-rule", crate::diagnostics::Severity::Warning, None),
522 make_diagnostic("info-rule", crate::diagnostics::Severity::Info, None),
523 make_diagnostic("err-rule", crate::diagnostics::Severity::Error, None),
524 ];
525 let groups = group_diagnostics(&diags);
526 assert_eq!(groups[0].severity, "error");
527 assert_eq!(groups[1].severity, "warning");
528 assert_eq!(groups[2].severity, "info");
529 }
530
531 #[test]
532 fn test_group_diagnostics_help_finds_first_non_none() {
533 let diags = vec![
534 make_diagnostic("rule-a", crate::diagnostics::Severity::Warning, None),
535 make_diagnostic(
536 "rule-a",
537 crate::diagnostics::Severity::Warning,
538 Some("fix it"),
539 ),
540 make_diagnostic("rule-a", crate::diagnostics::Severity::Warning, None),
541 ];
542 let groups = group_diagnostics(&diags);
543 assert_eq!(groups[0].help.as_deref(), Some("fix it"));
544 }
545
546 #[test]
549 fn test_scan_output_grouping() {
550 let manifest_dir = env!("CARGO_MANIFEST_DIR");
551 let (_dir, project_info, resolved) = discover_and_resolve(manifest_dir, false).unwrap();
552 let result = scan::scan_project(&project_info, &resolved, true, &[], true).unwrap();
553
554 let total = result.diagnostics.len();
555 let grouped = group_diagnostics(&result.diagnostics);
556 let report = format_scan_report(&result, &grouped);
557
558 assert!(
560 grouped.len() < total,
561 "grouping should compress: {} groups from {} diagnostics",
562 grouped.len(),
563 total
564 );
565 for g in &grouped {
567 assert!(!g.examples.is_empty(), "group '{}' has no examples", g.rule);
568 assert!(
569 g.examples.len() <= MAX_EXAMPLES_PER_GROUP,
570 "group '{}' has too many examples",
571 g.rule
572 );
573 assert!(g.count > 0);
574 }
575 assert!(report.contains(&result.score.to_string()));
577 let severities: Vec<&str> = grouped.iter().map(|g| g.severity.as_str()).collect();
579 for window in severities.windows(2) {
580 let ord = |s: &str| -> u8 {
581 match s {
582 "error" => 0,
583 "warning" => 1,
584 _ => 2,
585 }
586 };
587 assert!(
588 ord(window[0]) <= ord(window[1]),
589 "groups not sorted by severity: {} before {}",
590 window[0],
591 window[1]
592 );
593 }
594 }
595
596 #[test]
597 fn test_score_output_structure() {
598 let manifest_dir = env!("CARGO_MANIFEST_DIR");
599 let (_dir, project_info, resolved) = discover_and_resolve(manifest_dir, false).unwrap();
600 let result = scan::scan_project(&project_info, &resolved, true, &[], true).unwrap();
601 let output = ScoreOutput {
602 score: result.score,
603 score_label: result.score_label,
604 };
605 assert!(output.score <= 100);
606 let json = serde_json::to_value(&output).unwrap();
608 assert!(json.get("score").is_some());
609 assert!(json.get("score_label").is_some());
610 }
611
612 #[tokio::test]
615 async fn test_scan_via_spawn_blocking_completes() {
616 let manifest_dir = env!("CARGO_MANIFEST_DIR");
617 let (_dir, project_info, resolved) = discover_and_resolve(manifest_dir, false).unwrap();
618
619 let result = tokio::task::spawn_blocking(move || {
620 scan::scan_project(&project_info, &resolved, true, &[], true)
621 })
622 .await;
623
624 assert!(result.is_ok(), "spawn_blocking should not panic");
625 let scan_result = result.unwrap();
626 assert!(scan_result.is_ok(), "scan_project should succeed");
627 let scan = scan_result.unwrap();
628 assert!(
629 scan.score <= 100,
630 "score should be 0-100, got {}",
631 scan.score
632 );
633 }
634
635 #[tokio::test]
636 async fn test_timeout_fires_for_slow_task() {
637 let result = tokio::time::timeout(
638 std::time::Duration::from_millis(1),
639 tokio::task::spawn_blocking(|| {
640 std::thread::sleep(std::time::Duration::from_secs(2));
641 42
642 }),
643 )
644 .await;
645
646 assert!(result.is_err(), "Expected timeout error");
647 }
648
649 #[tokio::test]
650 async fn test_spawn_blocking_panic_is_caught() {
651 let result = tokio::task::spawn_blocking(|| {
652 panic!("intentional test panic");
653 })
654 .await;
655
656 assert!(result.is_err(), "Expected JoinError from panic");
657 }
658
659 #[tokio::test]
660 async fn test_mcp_scan_pipeline_produces_valid_result() {
661 let manifest_dir = env!("CARGO_MANIFEST_DIR");
662 let (_dir, project_info, resolved) = discover_and_resolve(manifest_dir, false).unwrap();
663
664 let offline = true;
665 let result = tokio::time::timeout(
666 std::time::Duration::from_secs(300),
667 tokio::task::spawn_blocking(move || {
668 scan::scan_project(&project_info, &resolved, offline, &[], true)
669 }),
670 )
671 .await
672 .expect("scan should not time out")
673 .expect("spawn_blocking should not panic")
674 .expect("scan_project should succeed");
675
676 assert!(
677 result.score <= 100,
678 "score should be 0-100, got {}",
679 result.score
680 );
681 assert!(
682 !result.diagnostics.is_empty(),
683 "rust-doctor always has some findings on itself"
684 );
685 assert!(
686 result.source_file_count > 0,
687 "should have scanned at least one source file"
688 );
689 }
690
691 #[test]
694 fn test_cancellation_stops_scan_work() {
695 use std::sync::Arc;
696 use std::sync::atomic::AtomicBool;
697
698 let manifest_dir = env!("CARGO_MANIFEST_DIR");
699 let (_dir, project_info, resolved) = discover_and_resolve(manifest_dir, false).unwrap();
700
701 let cancelled = Arc::new(AtomicBool::new(true));
705 let stopped =
706 scan::scan_project_cancellable(&project_info, &resolved, true, &[], true, &cancelled)
707 .unwrap();
708 assert_eq!(
709 stopped.diagnostics.len(),
710 0,
711 "a cancelled scan must not run any passes"
712 );
713 assert_eq!(stopped.source_file_count, 0, "no files should be scanned");
714
715 let live = Arc::new(AtomicBool::new(false));
717 let full = scan::scan_project_cancellable(&project_info, &resolved, true, &[], true, &live)
718 .unwrap();
719 assert!(
720 full.source_file_count > 0 && !full.diagnostics.is_empty(),
721 "a live scan should scan files and find diagnostics"
722 );
723 }
724
725 #[test]
728 fn test_score_input_ignore_project_config_defaults_false() {
729 let input: super::types::ScoreInput =
730 serde_json::from_value(serde_json::json!({ "directory": "/x" })).unwrap();
731 assert!(
732 !input.ignore_project_config,
733 "ignore_project_config must default to false (aligned with ScanInput)"
734 );
735 }
736
737 #[test]
738 fn test_score_input_ignore_project_config_parsed() {
739 let input: super::types::ScoreInput = serde_json::from_value(
740 serde_json::json!({ "directory": "/x", "ignore_project_config": true }),
741 )
742 .unwrap();
743 assert!(input.ignore_project_config);
744 }
745}