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: &[
151 "clippy",
152 "--message-format=json",
153 "--",
154 "-W",
155 "clippy::all",
156 ],
157 category: ToolCategory::Linter,
158 parser: "cargo",
159 },
160 ToolConfig {
161 name: "cargo-audit",
162 binary: "cargo",
163 detection_binary: "cargo-audit", args: &["audit", "--json"],
165 category: ToolCategory::SecurityScanner,
166 parser: "cargo-audit",
167 },
168 ],
169 );
170
171 registry.insert(
173 "python".to_string(),
174 vec![
175 ToolConfig {
176 name: "ruff",
177 binary: "ruff",
178 detection_binary: "ruff",
179 args: &["check", "--select=E,F,B,S", "--output-format=json", "."],
180 category: ToolCategory::Linter,
181 parser: "ruff",
182 },
183 ToolConfig {
184 name: "pyright",
185 binary: "pyright",
186 detection_binary: "pyright",
187 args: &["--outputjson", "."],
188 category: ToolCategory::TypeChecker,
189 parser: "pyright",
190 },
191 ],
192 );
193
194 registry.insert(
196 "javascript".to_string(),
197 vec![ToolConfig {
198 name: "eslint",
199 binary: "eslint",
200 detection_binary: "eslint",
201 args: &["--format", "json", "."],
202 category: ToolCategory::Linter,
203 parser: "eslint",
204 }],
205 );
206
207 registry.insert(
209 "typescript".to_string(),
210 vec![ToolConfig {
211 name: "eslint",
212 binary: "eslint",
213 detection_binary: "eslint",
214 args: &["--format", "json", "."],
215 category: ToolCategory::Linter,
216 parser: "eslint",
217 }],
218 );
219
220 registry.insert(
222 "go".to_string(),
223 vec![ToolConfig {
224 name: "golangci-lint",
225 binary: "golangci-lint",
226 detection_binary: "golangci-lint",
227 args: &["run", "--out-format", "json"],
228 category: ToolCategory::Linter,
229 parser: "golangci-lint",
230 }],
231 );
232
233 registry.insert(
235 "ruby".to_string(),
236 vec![ToolConfig {
237 name: "rubocop",
238 binary: "rubocop",
239 detection_binary: "rubocop",
240 args: &["--format", "json"],
241 category: ToolCategory::Linter,
242 parser: "rubocop",
243 }],
244 );
245
246 registry.insert(
248 "java".to_string(),
249 vec![ToolConfig {
250 name: "checkstyle",
251 binary: "checkstyle",
252 detection_binary: "checkstyle",
253 args: &["-c", "/google_checks.xml", "-f", "plain", "."],
254 category: ToolCategory::Linter,
255 parser: "checkstyle",
256 }],
257 );
258
259 registry.insert(
261 "kotlin".to_string(),
262 vec![ToolConfig {
263 name: "ktlint",
264 binary: "ktlint",
265 detection_binary: "ktlint",
266 args: &["--reporter=json"],
267 category: ToolCategory::Linter,
268 parser: "ktlint",
269 }],
270 );
271
272 registry.insert(
274 "swift".to_string(),
275 vec![ToolConfig {
276 name: "swiftlint",
277 binary: "swiftlint",
278 detection_binary: "swiftlint",
279 args: &["lint", "--reporter", "json"],
280 category: ToolCategory::Linter,
281 parser: "swiftlint",
282 }],
283 );
284
285 registry.insert(
287 "c".to_string(),
288 vec![ToolConfig {
289 name: "cppcheck",
290 binary: "cppcheck",
291 detection_binary: "cppcheck",
292 args: &[
293 "--enable=all",
294 "--template={file}\t{line}\t{column}\t{severity}\t{id}\t{message}",
295 ".",
296 ],
297 category: ToolCategory::Linter,
298 parser: "cppcheck",
299 }],
300 );
301
302 registry.insert(
304 "cpp".to_string(),
305 vec![ToolConfig {
306 name: "cppcheck",
307 binary: "cppcheck",
308 detection_binary: "cppcheck",
309 args: &[
310 "--enable=all",
311 "--language=c++",
312 "--template={file}\t{line}\t{column}\t{severity}\t{id}\t{message}",
313 ".",
314 ],
315 category: ToolCategory::Linter,
316 parser: "cppcheck",
317 }],
318 );
319
320 registry.insert(
322 "php".to_string(),
323 vec![ToolConfig {
324 name: "phpstan",
325 binary: "phpstan",
326 detection_binary: "phpstan",
327 args: &["analyse", "--error-format=json", "--no-progress", "."],
328 category: ToolCategory::Linter,
329 parser: "phpstan",
330 }],
331 );
332
333 registry.insert(
335 "lua".to_string(),
336 vec![ToolConfig {
337 name: "luacheck",
338 binary: "luacheck",
339 detection_binary: "luacheck",
340 args: &["--formatter", "plain", "."],
341 category: ToolCategory::Linter,
342 parser: "luacheck",
343 }],
344 );
345
346 Self { registry }
347 }
348
349 pub fn tools_for_language(&self, lang: &str) -> Vec<&ToolConfig> {
353 self.registry
354 .get(lang)
355 .map(|tools| tools.iter().collect())
356 .unwrap_or_default()
357 }
358
359 pub fn detect_available_tools(&self, lang: &str) -> (Vec<&ToolConfig>, Vec<&ToolConfig>) {
367 let all_tools = self.tools_for_language(lang);
368 let mut available = Vec::new();
369 let mut missing = Vec::new();
370
371 for tool in all_tools {
372 if which::which(tool.detection_binary).is_ok() {
373 available.push(tool);
374 } else {
375 missing.push(tool);
376 }
377 }
378
379 (available, missing)
380 }
381
382 pub fn register_tool(&mut self, lang: &str, config: ToolConfig) {
386 self.registry
387 .entry(lang.to_string())
388 .or_default()
389 .push(config);
390 }
391}
392
393impl Default for ToolRegistry {
394 fn default() -> Self {
395 Self::new()
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn test_tool_category_serialization() {
405 let tc = ToolCategory::TypeChecker;
407 let json = serde_json::to_string(&tc).unwrap();
408 assert_eq!(json, "\"type_checker\"");
409
410 let linter = ToolCategory::Linter;
411 let json = serde_json::to_string(&linter).unwrap();
412 assert_eq!(json, "\"linter\"");
413
414 let scanner = ToolCategory::SecurityScanner;
415 let json = serde_json::to_string(&scanner).unwrap();
416 assert_eq!(json, "\"security_scanner\"");
417
418 let deser: ToolCategory = serde_json::from_str("\"security_scanner\"").unwrap();
420 assert_eq!(deser, ToolCategory::SecurityScanner);
421 }
422
423 #[test]
424 fn test_tool_result_serialization() {
425 let result = ToolResult {
426 name: "clippy".to_string(),
427 category: ToolCategory::Linter,
428 success: true,
429 duration_ms: 1234,
430 finding_count: 5,
431 error: None,
432 exit_code: Some(0),
433 };
434
435 let json = serde_json::to_string(&result).unwrap();
436 let deser: ToolResult = serde_json::from_str(&json).unwrap();
437
438 assert_eq!(deser.name, "clippy");
439 assert_eq!(deser.category, ToolCategory::Linter);
440 assert!(deser.success);
441 assert_eq!(deser.duration_ms, 1234);
442 assert_eq!(deser.finding_count, 5);
443 assert!(deser.error.is_none());
444 assert_eq!(deser.exit_code, Some(0));
445 }
446
447 #[test]
448 fn test_l1_finding_to_bugbot_finding() {
449 let l1 = L1Finding {
450 tool: "clippy".to_string(),
451 category: ToolCategory::Linter,
452 file: PathBuf::from("src/main.rs"),
453 line: 42,
454 column: 5,
455 native_severity: "warning".to_string(),
456 severity: "medium".to_string(),
457 message: "unused variable `x`".to_string(),
458 code: Some("clippy::unused_variables".to_string()),
459 };
460
461 let finding: BugbotFinding = l1.into();
462
463 assert_eq!(finding.finding_type, "tool:clippy");
464 assert_eq!(finding.severity, "medium");
465 assert_eq!(finding.file, PathBuf::from("src/main.rs"));
466 assert!(finding.function.is_empty());
467 assert_eq!(finding.line, 42);
468 assert_eq!(finding.message, "unused variable `x`");
469 }
470
471 #[test]
472 fn test_l1_finding_severity_preserved() {
473 let l1 = L1Finding {
474 tool: "test-tool".to_string(),
475 category: ToolCategory::SecurityScanner,
476 file: PathBuf::from("Cargo.lock"),
477 line: 1,
478 column: 1,
479 native_severity: "error".to_string(),
480 severity: "high".to_string(),
481 message: "vulnerability found".to_string(),
482 code: Some("RUSTSEC-2024-0001".to_string()),
483 };
484
485 let finding: BugbotFinding = l1.into();
486 assert_eq!(finding.severity, "high");
487 }
488
489 #[test]
490 fn test_l1_finding_evidence_contains_tool_info() {
491 let l1 = L1Finding {
492 tool: "clippy".to_string(),
493 category: ToolCategory::Linter,
494 file: PathBuf::from("src/lib.rs"),
495 line: 10,
496 column: 3,
497 native_severity: "warning".to_string(),
498 severity: "medium".to_string(),
499 message: "test".to_string(),
500 code: Some("clippy::needless_return".to_string()),
501 };
502
503 let finding: BugbotFinding = l1.into();
504 let evidence = &finding.evidence;
505
506 assert_eq!(evidence["tool"], "clippy");
507 assert_eq!(evidence["category"], "Linter");
508 assert_eq!(evidence["code"], "clippy::needless_return");
509 assert_eq!(evidence["native_severity"], "warning");
510 assert_eq!(evidence["column"], 3);
511 }
512
513 #[test]
514 fn test_l1_finding_empty_code() {
515 let l1 = L1Finding {
516 tool: "clippy".to_string(),
517 category: ToolCategory::Linter,
518 file: PathBuf::from("src/lib.rs"),
519 line: 5,
520 column: 1,
521 native_severity: "error".to_string(),
522 severity: "high".to_string(),
523 message: "cannot find type".to_string(),
524 code: None,
525 };
526
527 let finding: BugbotFinding = l1.into();
528 assert!(finding.evidence["code"].is_null());
529 }
530
531 #[test]
532 fn test_tool_result_no_error_skips_field() {
533 let result = ToolResult {
534 name: "clippy".to_string(),
535 category: ToolCategory::Linter,
536 success: true,
537 duration_ms: 100,
538 finding_count: 0,
539 error: None,
540 exit_code: None,
541 };
542
543 let json = serde_json::to_string(&result).unwrap();
544 assert!(
545 !json.contains("\"error\""),
546 "error field should be skipped when None, got: {}",
547 json
548 );
549 assert!(
550 !json.contains("\"exit_code\""),
551 "exit_code field should be skipped when None, got: {}",
552 json
553 );
554 }
555
556 #[test]
557 fn test_tool_result_with_error() {
558 let result = ToolResult {
559 name: "cargo-audit".to_string(),
560 category: ToolCategory::SecurityScanner,
561 success: false,
562 duration_ms: 50,
563 finding_count: 0,
564 error: Some("binary not found".to_string()),
565 exit_code: None,
566 };
567
568 let json = serde_json::to_string(&result).unwrap();
569 assert!(
570 json.contains("\"error\""),
571 "error field should be present when Some, got: {}",
572 json
573 );
574 assert!(
575 json.contains("binary not found"),
576 "error message should be serialized, got: {}",
577 json
578 );
579 }
580
581 #[test]
586 fn test_registry_rust_tools() {
587 let registry = ToolRegistry::new();
589 let tools = registry.tools_for_language("rust");
590 assert_eq!(tools.len(), 2, "expected 2 Rust tools, got {}", tools.len());
591
592 let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
593 assert!(names.contains(&"clippy"), "expected clippy in {:?}", names);
594 assert!(
595 names.contains(&"cargo-audit"),
596 "expected cargo-audit in {:?}",
597 names
598 );
599 }
600
601 #[test]
602 fn test_registry_no_cargo_check() {
603 let registry = ToolRegistry::new();
606 let tools = registry.tools_for_language("rust");
607
608 for tool in &tools {
609 assert_ne!(
610 tool.name, "cargo check",
611 "PM-1 violation: cargo check must not be in Rust registry"
612 );
613 assert_ne!(
614 tool.name, "check",
615 "PM-1 violation: 'check' tool must not be in Rust registry"
616 );
617 }
618
619 for tool in &tools {
621 let args_joined = tool.args.join(" ");
622 assert!(
624 !args_joined.starts_with("check"),
625 "PM-1 violation: tool '{}' has args starting with 'check': {}",
626 tool.name,
627 args_joined
628 );
629 }
630 }
631
632 #[test]
633 fn test_registry_unknown_language() {
634 let registry = ToolRegistry::new();
636 let tools = registry.tools_for_language("unknown");
637 assert!(
638 tools.is_empty(),
639 "expected empty Vec for unknown language, got {} tools",
640 tools.len()
641 );
642 }
643
644 #[test]
645 fn test_registry_clippy_detection_binary() {
646 let registry = ToolRegistry::new();
648 let tools = registry.tools_for_language("rust");
649 let clippy = tools.iter().find(|t| t.name == "clippy").unwrap();
650
651 assert_eq!(
652 clippy.detection_binary, "cargo-clippy",
653 "PM-2: clippy detection_binary should be 'cargo-clippy', got '{}'",
654 clippy.detection_binary
655 );
656 assert_ne!(
658 clippy.binary, clippy.detection_binary,
659 "PM-2: detection_binary should differ from binary for cargo subcommands"
660 );
661 }
662
663 #[test]
664 fn test_registry_cargo_audit_detection_binary() {
665 let registry = ToolRegistry::new();
667 let tools = registry.tools_for_language("rust");
668 let audit = tools.iter().find(|t| t.name == "cargo-audit").unwrap();
669
670 assert_eq!(
671 audit.detection_binary, "cargo-audit",
672 "PM-2: cargo-audit detection_binary should be 'cargo-audit', got '{}'",
673 audit.detection_binary
674 );
675 }
676
677 #[test]
678 fn test_registry_clippy_uses_message_format_json() {
679 let registry = ToolRegistry::new();
681 let tools = registry.tools_for_language("rust");
682 let clippy = tools.iter().find(|t| t.name == "clippy").unwrap();
683
684 assert!(
685 clippy.args.contains(&"--message-format=json"),
686 "clippy args should include '--message-format=json', got {:?}",
687 clippy.args
688 );
689 }
690
691 #[test]
692 fn test_registry_cargo_audit_args_include_json() {
693 let registry = ToolRegistry::new();
695 let tools = registry.tools_for_language("rust");
696 let audit = tools.iter().find(|t| t.name == "cargo-audit").unwrap();
697
698 assert!(
699 audit.args.contains(&"--json"),
700 "cargo-audit args should include '--json', got {:?}",
701 audit.args
702 );
703 }
704
705 #[test]
706 fn test_detect_available_filters_correctly() {
707 let mut registry = ToolRegistry::new();
710 registry.register_tool(
711 "test-lang",
712 ToolConfig {
713 name: "fake-tool",
714 binary: "nonexistent-binary-xyz-12345",
715 detection_binary: "nonexistent-binary-xyz-12345",
716 args: &[],
717 category: ToolCategory::Linter,
718 parser: "cargo",
719 },
720 );
721
722 let (available, missing) = registry.detect_available_tools("test-lang");
723
724 assert_eq!(
726 missing.len(),
727 1,
728 "expected 1 missing tool, got {}",
729 missing.len()
730 );
731 assert_eq!(missing[0].name, "fake-tool");
732 assert!(
733 available.is_empty(),
734 "expected no available tools for test-lang with fake binary"
735 );
736 }
737
738 #[test]
739 fn test_detect_real_cargo() {
740 let registry = ToolRegistry::new();
743 let (available, missing) = registry.detect_available_tools("rust");
744
745 let tools = registry.tools_for_language("rust");
748 assert_eq!(
749 available.len() + missing.len(),
750 tools.len(),
751 "available + missing should equal total tools"
752 );
753 }
754
755 #[test]
756 fn test_detect_unknown_language_returns_empty() {
757 let registry = ToolRegistry::new();
758 let (available, missing) = registry.detect_available_tools("unknown");
759 assert!(available.is_empty());
760 assert!(missing.is_empty());
761 }
762
763 #[test]
764 fn test_register_tool() {
765 let mut registry = ToolRegistry::new();
766
767 let before = registry.tools_for_language("python").len();
769
770 registry.register_tool(
771 "python",
772 ToolConfig {
773 name: "ruff",
774 binary: "ruff",
775 detection_binary: "ruff",
776 args: &["check", "--output-format=json"],
777 category: ToolCategory::Linter,
778 parser: "ruff",
779 },
780 );
781
782 let tools = registry.tools_for_language("python");
783 assert_eq!(tools.len(), before + 1);
784 assert!(tools.iter().any(|t| t.name == "ruff"));
785 }
786
787 #[test]
788 fn test_register_tool_appends() {
789 let mut registry = ToolRegistry::new();
791 let before = registry.tools_for_language("python").len();
792
793 registry.register_tool(
794 "python",
795 ToolConfig {
796 name: "bandit",
797 binary: "bandit",
798 detection_binary: "bandit",
799 args: &["-f", "json"],
800 category: ToolCategory::SecurityScanner,
801 parser: "bandit",
802 },
803 );
804
805 let tools = registry.tools_for_language("python");
806 assert_eq!(tools.len(), before + 1);
807 assert!(tools.iter().any(|t| t.name == "ruff"));
808 assert!(tools.iter().any(|t| t.name == "bandit"));
809 }
810
811 #[test]
812 fn test_default_impl_matches_new() {
813 let from_new = ToolRegistry::new();
815 let from_default = ToolRegistry::default();
816
817 let new_tools = from_new.tools_for_language("rust");
818 let default_tools = from_default.tools_for_language("rust");
819
820 assert_eq!(new_tools.len(), default_tools.len());
821 for (n, d) in new_tools.iter().zip(default_tools.iter()) {
822 assert_eq!(n.name, d.name);
823 assert_eq!(n.binary, d.binary);
824 assert_eq!(n.detection_binary, d.detection_binary);
825 }
826 }
827}