Skip to main content

rust_doctor/mcp/
mod.rs

1mod handler;
2mod helpers;
3mod prompts;
4mod rules;
5mod tools;
6mod types;
7
8// Re-export the public API
9pub use handler::RustDoctorServer;
10pub use types::{
11    DeepAuditArgs, DiagnosticExample, DiagnosticGroup, ExplainRuleInput, HealthCheckArgs,
12    ScanInput, ScoreInput, ScoreOutput,
13};
14
15use rmcp::service::ServiceExt;
16
17// ---------------------------------------------------------------------------
18// Error type & public entry point
19// ---------------------------------------------------------------------------
20
21/// Typed error enum for MCP server failures — replaces `Box<dyn Error>` so
22/// callers can match on specific failure modes.
23#[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
35/// Run the MCP server over stdio. Called from main when `--mcp` is passed.
36///
37/// # Errors
38///
39/// Returns an error if the tokio runtime cannot be created, the MCP transport
40/// fails to initialize, or the server encounters a fatal error.
41pub 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    // --- RULE_DOCS completeness ---
63
64    #[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") // external tool rule, not AST
70            .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    // --- get_rule_explanation ---
118
119    #[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        // US-013: custom rules are flagged heuristic, with their known limitation.
127        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        // A custom rule with no documented blind spot still carries the marker.
134        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        // US-013: clippy lints are distinguished as type-aware, never heuristic.
142        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    // --- get_all_rules_listing ---
170
171    #[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        // US-013: the listing frames custom rules as heuristic, distinguishes
183        // type-aware clippy, and surfaces the known-FP limitations.
184        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    // --- ServerInfo ---
215
216    #[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    // --- Prompt registration ---
229
230    #[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    /// Extract text from a `PromptMessageContent::Text` variant.
280    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        // Directory is interpolated
299        assert!(
300            text.contains("/home/user/my-project"),
301            "directory should be interpolated into prompt"
302        );
303        // All 6 phases present
304        for phase in 1..=6 {
305            assert!(
306                text.contains(&format!("PHASE {phase}")),
307                "Missing PHASE {phase} in prompt"
308            );
309        }
310        // Decision options present
311        assert!(text.contains("Implement all fixes"));
312        assert!(text.contains("Generate a PRD"));
313        assert!(text.contains("Manual"));
314        // Hard rules present
315        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    // --- Tool registration ---
337
338    #[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        // scan returns CallToolResult (text summary + structuredContent),
358        // not Json<T>, so it has no auto-generated outputSchema.
359        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    // --- Tool annotations ---
380
381    #[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    // --- discover_and_resolve error mapping ---
423
424    #[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        // Should be invalid_params (not internal_error) for bad input
430        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        // Sanitized: must NOT contain the raw filesystem path
439        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        // /tmp is typically outside $HOME — should be rejected
448        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    // --- MCP e2e: scan + score on a real project ---
457
458    #[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        // Verify ScanResult structure
468        assert!(result.score <= 100);
469        assert!(result.source_file_count > 0);
470    }
471
472    // --- Diagnostic grouping unit tests ---
473
474    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    // --- Integration test: grouping on real project ---
547
548    #[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        // Grouping reduces count
559        assert!(
560            grouped.len() < total,
561            "grouping should compress: {} groups from {} diagnostics",
562            grouped.len(),
563            total
564        );
565        // Each group has examples
566        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        // Report is non-empty and contains score
576        assert!(report.contains(&result.score.to_string()));
577        // Sorted: errors before warnings before info
578        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        // Verify it serializes correctly
607        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    // --- US-009: MCP timeout wrapper & spawn_blocking integration tests ---
613
614    #[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    // --- US-007: cooperative cancellation actually stops the work ---
692
693    #[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        // Pre-cancelled: run_passes must break before launching any pass, so no
702        // diagnostics and no files are scanned — proves the WORK stops, not just
703        // that the caller sees an error (fixes the prior timeout-only coverage).
704        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        // Sanity: the same project with a live (never-set) flag does real work.
716        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    // --- US-009: score honors ignore_project_config ---
726
727    #[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}