1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use super::types::BugbotFinding;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolCategory {
19 TypeChecker,
22 Linter,
24 SecurityScanner,
26}
27
28#[derive(Debug, Clone)]
32pub struct ToolConfig {
33 pub name: &'static str,
35 pub binary: &'static str,
37 pub detection_binary: &'static str,
41 pub args: &'static [&'static str],
43 pub category: ToolCategory,
45 pub parser: &'static str,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ToolResult {
52 pub name: String,
54 pub category: ToolCategory,
56 pub success: bool,
58 pub duration_ms: u64,
60 pub finding_count: usize,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub error: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub exit_code: Option<i32>,
68}
69
70#[derive(Debug, Clone)]
75pub struct L1Finding {
76 pub tool: String,
78 pub category: ToolCategory,
80 pub file: PathBuf,
82 pub line: u32,
84 pub column: u32,
86 pub native_severity: String,
88 pub severity: String,
90 pub message: String,
92 pub code: Option<String>,
94}
95
96impl From<L1Finding> for BugbotFinding {
97 fn from(l1: L1Finding) -> Self {
98 BugbotFinding {
99 finding_type: format!("tool:{}", l1.tool),
100 severity: l1.severity,
101 file: l1.file,
102 function: String::new(), line: l1.line as usize,
104 message: l1.message,
105 evidence: serde_json::json!({
106 "tool": l1.tool,
107 "category": format!("{:?}", l1.category),
108 "code": l1.code,
109 "native_severity": l1.native_severity,
110 "column": l1.column,
111 }),
112 confidence: None,
113 finding_id: None,
114 }
115 }
116}
117
118pub struct ToolRegistry {
126 registry: HashMap<String, Vec<ToolConfig>>,
127}
128
129impl ToolRegistry {
130 pub fn new() -> Self {
139 let mut registry = HashMap::new();
140
141 registry.insert(
144 "rust".to_string(),
145 vec![
146 ToolConfig {
147 name: "clippy",
148 binary: "cargo",
149 detection_binary: "cargo-clippy", args: &["clippy", "--message-format=json", "--", "-W", "clippy::all"],
151 category: ToolCategory::Linter,
152 parser: "cargo",
153 },
154 ToolConfig {
155 name: "cargo-audit",
156 binary: "cargo",
157 detection_binary: "cargo-audit", args: &["audit", "--json"],
159 category: ToolCategory::SecurityScanner,
160 parser: "cargo-audit",
161 },
162 ],
163 );
164
165 registry.insert(
167 "python".to_string(),
168 vec![
169 ToolConfig {
170 name: "ruff",
171 binary: "ruff",
172 detection_binary: "ruff",
173 args: &["check", "--select=E,F,B,S", "--output-format=json", "."],
174 category: ToolCategory::Linter,
175 parser: "ruff",
176 },
177 ToolConfig {
178 name: "pyright",
179 binary: "pyright",
180 detection_binary: "pyright",
181 args: &["--outputjson", "."],
182 category: ToolCategory::TypeChecker,
183 parser: "pyright",
184 },
185 ],
186 );
187
188 registry.insert(
190 "javascript".to_string(),
191 vec![ToolConfig {
192 name: "eslint",
193 binary: "eslint",
194 detection_binary: "eslint",
195 args: &["--format", "json", "."],
196 category: ToolCategory::Linter,
197 parser: "eslint",
198 }],
199 );
200
201 registry.insert(
203 "typescript".to_string(),
204 vec![ToolConfig {
205 name: "eslint",
206 binary: "eslint",
207 detection_binary: "eslint",
208 args: &["--format", "json", "."],
209 category: ToolCategory::Linter,
210 parser: "eslint",
211 }],
212 );
213
214 registry.insert(
216 "go".to_string(),
217 vec![ToolConfig {
218 name: "golangci-lint",
219 binary: "golangci-lint",
220 detection_binary: "golangci-lint",
221 args: &["run", "--out-format", "json"],
222 category: ToolCategory::Linter,
223 parser: "golangci-lint",
224 }],
225 );
226
227 registry.insert(
229 "ruby".to_string(),
230 vec![ToolConfig {
231 name: "rubocop",
232 binary: "rubocop",
233 detection_binary: "rubocop",
234 args: &["--format", "json"],
235 category: ToolCategory::Linter,
236 parser: "rubocop",
237 }],
238 );
239
240 registry.insert(
242 "java".to_string(),
243 vec![ToolConfig {
244 name: "checkstyle",
245 binary: "checkstyle",
246 detection_binary: "checkstyle",
247 args: &["-c", "/google_checks.xml", "-f", "plain", "."],
248 category: ToolCategory::Linter,
249 parser: "checkstyle",
250 }],
251 );
252
253 registry.insert(
255 "kotlin".to_string(),
256 vec![ToolConfig {
257 name: "ktlint",
258 binary: "ktlint",
259 detection_binary: "ktlint",
260 args: &["--reporter=json"],
261 category: ToolCategory::Linter,
262 parser: "ktlint",
263 }],
264 );
265
266 registry.insert(
268 "swift".to_string(),
269 vec![ToolConfig {
270 name: "swiftlint",
271 binary: "swiftlint",
272 detection_binary: "swiftlint",
273 args: &["lint", "--reporter", "json"],
274 category: ToolCategory::Linter,
275 parser: "swiftlint",
276 }],
277 );
278
279 registry.insert(
281 "c".to_string(),
282 vec![ToolConfig {
283 name: "cppcheck",
284 binary: "cppcheck",
285 detection_binary: "cppcheck",
286 args: &[
287 "--enable=all",
288 "--template={file}\t{line}\t{column}\t{severity}\t{id}\t{message}",
289 ".",
290 ],
291 category: ToolCategory::Linter,
292 parser: "cppcheck",
293 }],
294 );
295
296 registry.insert(
298 "cpp".to_string(),
299 vec![ToolConfig {
300 name: "cppcheck",
301 binary: "cppcheck",
302 detection_binary: "cppcheck",
303 args: &[
304 "--enable=all",
305 "--language=c++",
306 "--template={file}\t{line}\t{column}\t{severity}\t{id}\t{message}",
307 ".",
308 ],
309 category: ToolCategory::Linter,
310 parser: "cppcheck",
311 }],
312 );
313
314 registry.insert(
316 "php".to_string(),
317 vec![ToolConfig {
318 name: "phpstan",
319 binary: "phpstan",
320 detection_binary: "phpstan",
321 args: &["analyse", "--error-format=json", "--no-progress", "."],
322 category: ToolCategory::Linter,
323 parser: "phpstan",
324 }],
325 );
326
327 registry.insert(
329 "lua".to_string(),
330 vec![ToolConfig {
331 name: "luacheck",
332 binary: "luacheck",
333 detection_binary: "luacheck",
334 args: &["--formatter", "plain", "."],
335 category: ToolCategory::Linter,
336 parser: "luacheck",
337 }],
338 );
339
340 Self { registry }
341 }
342
343 pub fn tools_for_language(&self, lang: &str) -> Vec<&ToolConfig> {
347 self.registry
348 .get(lang)
349 .map(|tools| tools.iter().collect())
350 .unwrap_or_default()
351 }
352
353 pub fn detect_available_tools(&self, lang: &str) -> (Vec<&ToolConfig>, Vec<&ToolConfig>) {
361 let all_tools = self.tools_for_language(lang);
362 let mut available = Vec::new();
363 let mut missing = Vec::new();
364
365 for tool in all_tools {
366 if which::which(tool.detection_binary).is_ok() {
367 available.push(tool);
368 } else {
369 missing.push(tool);
370 }
371 }
372
373 (available, missing)
374 }
375
376 pub fn register_tool(&mut self, lang: &str, config: ToolConfig) {
380 self.registry
381 .entry(lang.to_string())
382 .or_default()
383 .push(config);
384 }
385}
386
387impl Default for ToolRegistry {
388 fn default() -> Self {
389 Self::new()
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_tool_category_serialization() {
399 let tc = ToolCategory::TypeChecker;
401 let json = serde_json::to_string(&tc).unwrap();
402 assert_eq!(json, "\"type_checker\"");
403
404 let linter = ToolCategory::Linter;
405 let json = serde_json::to_string(&linter).unwrap();
406 assert_eq!(json, "\"linter\"");
407
408 let scanner = ToolCategory::SecurityScanner;
409 let json = serde_json::to_string(&scanner).unwrap();
410 assert_eq!(json, "\"security_scanner\"");
411
412 let deser: ToolCategory = serde_json::from_str("\"security_scanner\"").unwrap();
414 assert_eq!(deser, ToolCategory::SecurityScanner);
415 }
416
417 #[test]
418 fn test_tool_result_serialization() {
419 let result = ToolResult {
420 name: "clippy".to_string(),
421 category: ToolCategory::Linter,
422 success: true,
423 duration_ms: 1234,
424 finding_count: 5,
425 error: None,
426 exit_code: Some(0),
427 };
428
429 let json = serde_json::to_string(&result).unwrap();
430 let deser: ToolResult = serde_json::from_str(&json).unwrap();
431
432 assert_eq!(deser.name, "clippy");
433 assert_eq!(deser.category, ToolCategory::Linter);
434 assert!(deser.success);
435 assert_eq!(deser.duration_ms, 1234);
436 assert_eq!(deser.finding_count, 5);
437 assert!(deser.error.is_none());
438 assert_eq!(deser.exit_code, Some(0));
439 }
440
441 #[test]
442 fn test_l1_finding_to_bugbot_finding() {
443 let l1 = L1Finding {
444 tool: "clippy".to_string(),
445 category: ToolCategory::Linter,
446 file: PathBuf::from("src/main.rs"),
447 line: 42,
448 column: 5,
449 native_severity: "warning".to_string(),
450 severity: "medium".to_string(),
451 message: "unused variable `x`".to_string(),
452 code: Some("clippy::unused_variables".to_string()),
453 };
454
455 let finding: BugbotFinding = l1.into();
456
457 assert_eq!(finding.finding_type, "tool:clippy");
458 assert_eq!(finding.severity, "medium");
459 assert_eq!(finding.file, PathBuf::from("src/main.rs"));
460 assert!(finding.function.is_empty());
461 assert_eq!(finding.line, 42);
462 assert_eq!(finding.message, "unused variable `x`");
463 }
464
465 #[test]
466 fn test_l1_finding_severity_preserved() {
467 let l1 = L1Finding {
468 tool: "test-tool".to_string(),
469 category: ToolCategory::SecurityScanner,
470 file: PathBuf::from("Cargo.lock"),
471 line: 1,
472 column: 1,
473 native_severity: "error".to_string(),
474 severity: "high".to_string(),
475 message: "vulnerability found".to_string(),
476 code: Some("RUSTSEC-2024-0001".to_string()),
477 };
478
479 let finding: BugbotFinding = l1.into();
480 assert_eq!(finding.severity, "high");
481 }
482
483 #[test]
484 fn test_l1_finding_evidence_contains_tool_info() {
485 let l1 = L1Finding {
486 tool: "clippy".to_string(),
487 category: ToolCategory::Linter,
488 file: PathBuf::from("src/lib.rs"),
489 line: 10,
490 column: 3,
491 native_severity: "warning".to_string(),
492 severity: "medium".to_string(),
493 message: "test".to_string(),
494 code: Some("clippy::needless_return".to_string()),
495 };
496
497 let finding: BugbotFinding = l1.into();
498 let evidence = &finding.evidence;
499
500 assert_eq!(evidence["tool"], "clippy");
501 assert_eq!(evidence["category"], "Linter");
502 assert_eq!(evidence["code"], "clippy::needless_return");
503 assert_eq!(evidence["native_severity"], "warning");
504 assert_eq!(evidence["column"], 3);
505 }
506
507 #[test]
508 fn test_l1_finding_empty_code() {
509 let l1 = L1Finding {
510 tool: "clippy".to_string(),
511 category: ToolCategory::Linter,
512 file: PathBuf::from("src/lib.rs"),
513 line: 5,
514 column: 1,
515 native_severity: "error".to_string(),
516 severity: "high".to_string(),
517 message: "cannot find type".to_string(),
518 code: None,
519 };
520
521 let finding: BugbotFinding = l1.into();
522 assert!(finding.evidence["code"].is_null());
523 }
524
525 #[test]
526 fn test_tool_result_no_error_skips_field() {
527 let result = ToolResult {
528 name: "clippy".to_string(),
529 category: ToolCategory::Linter,
530 success: true,
531 duration_ms: 100,
532 finding_count: 0,
533 error: None,
534 exit_code: None,
535 };
536
537 let json = serde_json::to_string(&result).unwrap();
538 assert!(
539 !json.contains("\"error\""),
540 "error field should be skipped when None, got: {}",
541 json
542 );
543 assert!(
544 !json.contains("\"exit_code\""),
545 "exit_code field should be skipped when None, got: {}",
546 json
547 );
548 }
549
550 #[test]
551 fn test_tool_result_with_error() {
552 let result = ToolResult {
553 name: "cargo-audit".to_string(),
554 category: ToolCategory::SecurityScanner,
555 success: false,
556 duration_ms: 50,
557 finding_count: 0,
558 error: Some("binary not found".to_string()),
559 exit_code: None,
560 };
561
562 let json = serde_json::to_string(&result).unwrap();
563 assert!(
564 json.contains("\"error\""),
565 "error field should be present when Some, got: {}",
566 json
567 );
568 assert!(
569 json.contains("binary not found"),
570 "error message should be serialized, got: {}",
571 json
572 );
573 }
574
575 #[test]
580 fn test_registry_rust_tools() {
581 let registry = ToolRegistry::new();
583 let tools = registry.tools_for_language("rust");
584 assert_eq!(tools.len(), 2, "expected 2 Rust tools, got {}", tools.len());
585
586 let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
587 assert!(names.contains(&"clippy"), "expected clippy in {:?}", names);
588 assert!(
589 names.contains(&"cargo-audit"),
590 "expected cargo-audit in {:?}",
591 names
592 );
593 }
594
595 #[test]
596 fn test_registry_no_cargo_check() {
597 let registry = ToolRegistry::new();
600 let tools = registry.tools_for_language("rust");
601
602 for tool in &tools {
603 assert_ne!(
604 tool.name, "cargo check",
605 "PM-1 violation: cargo check must not be in Rust registry"
606 );
607 assert_ne!(
608 tool.name, "check",
609 "PM-1 violation: 'check' tool must not be in Rust registry"
610 );
611 }
612
613 for tool in &tools {
615 let args_joined = tool.args.join(" ");
616 assert!(
618 !args_joined.starts_with("check"),
619 "PM-1 violation: tool '{}' has args starting with 'check': {}",
620 tool.name,
621 args_joined
622 );
623 }
624 }
625
626 #[test]
627 fn test_registry_unknown_language() {
628 let registry = ToolRegistry::new();
630 let tools = registry.tools_for_language("unknown");
631 assert!(
632 tools.is_empty(),
633 "expected empty Vec for unknown language, got {} tools",
634 tools.len()
635 );
636 }
637
638 #[test]
639 fn test_registry_clippy_detection_binary() {
640 let registry = ToolRegistry::new();
642 let tools = registry.tools_for_language("rust");
643 let clippy = tools.iter().find(|t| t.name == "clippy").unwrap();
644
645 assert_eq!(
646 clippy.detection_binary, "cargo-clippy",
647 "PM-2: clippy detection_binary should be 'cargo-clippy', got '{}'",
648 clippy.detection_binary
649 );
650 assert_ne!(
652 clippy.binary, clippy.detection_binary,
653 "PM-2: detection_binary should differ from binary for cargo subcommands"
654 );
655 }
656
657 #[test]
658 fn test_registry_cargo_audit_detection_binary() {
659 let registry = ToolRegistry::new();
661 let tools = registry.tools_for_language("rust");
662 let audit = tools.iter().find(|t| t.name == "cargo-audit").unwrap();
663
664 assert_eq!(
665 audit.detection_binary, "cargo-audit",
666 "PM-2: cargo-audit detection_binary should be 'cargo-audit', got '{}'",
667 audit.detection_binary
668 );
669 }
670
671 #[test]
672 fn test_registry_clippy_uses_message_format_json() {
673 let registry = ToolRegistry::new();
675 let tools = registry.tools_for_language("rust");
676 let clippy = tools.iter().find(|t| t.name == "clippy").unwrap();
677
678 assert!(
679 clippy.args.contains(&"--message-format=json"),
680 "clippy args should include '--message-format=json', got {:?}",
681 clippy.args
682 );
683 }
684
685 #[test]
686 fn test_registry_cargo_audit_args_include_json() {
687 let registry = ToolRegistry::new();
689 let tools = registry.tools_for_language("rust");
690 let audit = tools.iter().find(|t| t.name == "cargo-audit").unwrap();
691
692 assert!(
693 audit.args.contains(&"--json"),
694 "cargo-audit args should include '--json', got {:?}",
695 audit.args
696 );
697 }
698
699 #[test]
700 fn test_detect_available_filters_correctly() {
701 let mut registry = ToolRegistry::new();
704 registry.register_tool(
705 "test-lang",
706 ToolConfig {
707 name: "fake-tool",
708 binary: "nonexistent-binary-xyz-12345",
709 detection_binary: "nonexistent-binary-xyz-12345",
710 args: &[],
711 category: ToolCategory::Linter,
712 parser: "cargo",
713 },
714 );
715
716 let (available, missing) = registry.detect_available_tools("test-lang");
717
718 assert_eq!(
720 missing.len(),
721 1,
722 "expected 1 missing tool, got {}",
723 missing.len()
724 );
725 assert_eq!(missing[0].name, "fake-tool");
726 assert!(
727 available.is_empty(),
728 "expected no available tools for test-lang with fake binary"
729 );
730 }
731
732 #[test]
733 fn test_detect_real_cargo() {
734 let registry = ToolRegistry::new();
737 let (available, missing) = registry.detect_available_tools("rust");
738
739 let tools = registry.tools_for_language("rust");
742 assert_eq!(
743 available.len() + missing.len(),
744 tools.len(),
745 "available + missing should equal total tools"
746 );
747 }
748
749 #[test]
750 fn test_detect_unknown_language_returns_empty() {
751 let registry = ToolRegistry::new();
752 let (available, missing) = registry.detect_available_tools("unknown");
753 assert!(available.is_empty());
754 assert!(missing.is_empty());
755 }
756
757 #[test]
758 fn test_register_tool() {
759 let mut registry = ToolRegistry::new();
760
761 let before = registry.tools_for_language("python").len();
763
764 registry.register_tool(
765 "python",
766 ToolConfig {
767 name: "ruff",
768 binary: "ruff",
769 detection_binary: "ruff",
770 args: &["check", "--output-format=json"],
771 category: ToolCategory::Linter,
772 parser: "ruff",
773 },
774 );
775
776 let tools = registry.tools_for_language("python");
777 assert_eq!(tools.len(), before + 1);
778 assert!(tools.iter().any(|t| t.name == "ruff"));
779 }
780
781 #[test]
782 fn test_register_tool_appends() {
783 let mut registry = ToolRegistry::new();
785 let before = registry.tools_for_language("python").len();
786
787 registry.register_tool(
788 "python",
789 ToolConfig {
790 name: "bandit",
791 binary: "bandit",
792 detection_binary: "bandit",
793 args: &["-f", "json"],
794 category: ToolCategory::SecurityScanner,
795 parser: "bandit",
796 },
797 );
798
799 let tools = registry.tools_for_language("python");
800 assert_eq!(tools.len(), before + 1);
801 assert!(tools.iter().any(|t| t.name == "ruff"));
802 assert!(tools.iter().any(|t| t.name == "bandit"));
803 }
804
805 #[test]
806 fn test_default_impl_matches_new() {
807 let from_new = ToolRegistry::new();
809 let from_default = ToolRegistry::default();
810
811 let new_tools = from_new.tools_for_language("rust");
812 let default_tools = from_default.tools_for_language("rust");
813
814 assert_eq!(new_tools.len(), default_tools.len());
815 for (n, d) in new_tools.iter().zip(default_tools.iter()) {
816 assert_eq!(n.name, d.name);
817 assert_eq!(n.binary, d.binary);
818 assert_eq!(n.detection_binary, d.detection_binary);
819 }
820 }
821}