Skip to main content

tldr_cli/commands/bugbot/
tools.rs

1//! L1 commodity tool types and conversion impls
2//!
3//! Defines types for the L1 diagnostic tool orchestration layer:
4//! - `ToolCategory`: classification of tools (linter, security scanner, etc.)
5//! - `ToolConfig`: static configuration for a single diagnostic tool
6//! - `ToolResult`: execution result from running a tool
7//! - `L1Finding`: raw finding from a tool before conversion to `BugbotFinding`
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use super::types::BugbotFinding;
14
15/// Category of commodity diagnostic tool
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolCategory {
19    /// Language type checker (e.g., pyright, tsc). Not used for Rust
20    /// since clippy subsumes cargo check.
21    TypeChecker,
22    /// Linter (e.g., clippy, eslint)
23    Linter,
24    /// Security vulnerability scanner (e.g., cargo-audit)
25    SecurityScanner,
26}
27
28/// Configuration for a single diagnostic tool
29///
30/// Uses `&'static str` and `&'static [&'static str]` for zero-allocation registry.
31#[derive(Debug, Clone)]
32pub struct ToolConfig {
33    /// Display name (e.g., "clippy", "cargo-audit")
34    pub name: &'static str,
35    /// Binary to execute (e.g., "cargo")
36    pub binary: &'static str,
37    /// Binary to check for availability (e.g., "cargo-clippy").
38    /// Different from `binary` for cargo subcommands where the main
39    /// binary is "cargo" but detection needs "cargo-clippy". [PM-2]
40    pub detection_binary: &'static str,
41    /// Arguments to pass (e.g., ["clippy", "--message-format=json"])
42    pub args: &'static [&'static str],
43    /// Tool category
44    pub category: ToolCategory,
45    /// Parser identifier (e.g., "cargo", "cargo-audit")
46    pub parser: &'static str,
47}
48
49/// Result from running a single tool
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ToolResult {
52    /// Tool name
53    pub name: String,
54    /// Tool category
55    pub category: ToolCategory,
56    /// Whether the tool ran successfully
57    pub success: bool,
58    /// Execution time in milliseconds
59    pub duration_ms: u64,
60    /// Number of findings produced
61    pub finding_count: usize,
62    /// Error message if the tool failed
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub error: Option<String>,
65    /// Process exit code
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub exit_code: Option<i32>,
68}
69
70/// L1 finding from a commodity tool before conversion to `BugbotFinding`.
71///
72/// The `tool` field is set by `ToolRunner` after parsing, not by the parser
73/// itself. Parsers set `tool` to an empty string. [PM-6]
74#[derive(Debug, Clone)]
75pub struct L1Finding {
76    /// Tool that produced the finding (set by runner, not parser)
77    pub tool: String,
78    /// Tool category
79    pub category: ToolCategory,
80    /// File path (relative to project root)
81    pub file: PathBuf,
82    /// Line number
83    pub line: u32,
84    /// Column number
85    pub column: u32,
86    /// Severity as reported by the tool (e.g., "warning", "error")
87    pub native_severity: String,
88    /// Normalized severity: "high", "medium", "low", "info"
89    pub severity: String,
90    /// Human-readable description
91    pub message: String,
92    /// Tool-specific error/lint code (e.g., "clippy::needless_return")
93    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(), // L1 findings lack function context
103            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
118/// Registry of commodity diagnostic tools per language.
119///
120/// Maps language names (lowercase strings like "rust", "python") to their
121/// configured diagnostic tools. The default registry includes:
122/// - Rust: clippy + cargo-audit (NO cargo check -- clippy subsumes it) [PM-1]
123///
124/// Uses `detection_binary` (not `binary`) for availability probing [PM-2].
125pub struct ToolRegistry {
126    registry: HashMap<String, Vec<ToolConfig>>,
127}
128
129impl ToolRegistry {
130    /// Create a new registry with default tool registrations.
131    ///
132    /// Default Rust tools:
133    /// - clippy (linter, detection_binary: "cargo-clippy")
134    /// - cargo-audit (security scanner, detection_binary: "cargo-audit")
135    ///
136    /// CRITICAL [PM-1]: cargo check is NOT included. Clippy subsumes it and
137    /// running both would produce duplicate diagnostics plus double compile time.
138    pub fn new() -> Self {
139        let mut registry = HashMap::new();
140
141        // Rust tools -- ONLY clippy + cargo-audit [PM-1]: cargo check removed,
142        // clippy subsumes it.
143        registry.insert(
144            "rust".to_string(),
145            vec![
146                ToolConfig {
147                    name: "clippy",
148                    binary: "cargo",
149                    detection_binary: "cargo-clippy", // [PM-2]
150                    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", // [PM-2]
164                    args: &["audit", "--json"],
165                    category: ToolCategory::SecurityScanner,
166                    parser: "cargo-audit",
167                },
168            ],
169        );
170
171        // Python tools -- ruff (fast linter) + pyright (type checker)
172        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        // JavaScript tools -- eslint
195        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        // TypeScript tools -- eslint (tsc is too slow for L1)
208        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        // Go tools -- golangci-lint
221        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        // Ruby tools -- rubocop
234        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        // Java tools -- checkstyle (plain format, parsed line by line)
247        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        // Kotlin tools -- ktlint
260        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        // Swift tools -- swiftlint
273        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        // C tools -- cppcheck (tab-separated template output)
286        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        // C++ tools -- cppcheck (same parser as C)
303        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        // PHP tools -- phpstan
321        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        // Lua tools -- luacheck (plain format, parsed line by line)
334        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    /// Get all configured tools for a language.
350    ///
351    /// Returns an empty `Vec` if the language has no registered tools.
352    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    /// Detect which tools are actually installed on the system.
360    ///
361    /// Probes `detection_binary` (not `binary`) to check availability [PM-2].
362    /// For cargo subcommands, this correctly checks for e.g. "cargo-clippy"
363    /// rather than just "cargo".
364    ///
365    /// Returns `(available, missing)` where each is a list of tool configs.
366    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    /// Register a tool for a language.
383    ///
384    /// Appends the tool to any existing tools for the language.
385    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        // ToolCategory serializes to snake_case
406        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        // Roundtrip
419        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    // =========================================================================
582    // ToolRegistry tests
583    // =========================================================================
584
585    #[test]
586    fn test_registry_rust_tools() {
587        // ToolRegistry::new() has exactly 2 Rust tools: clippy + cargo-audit
588        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        // CRITICAL PM-1 regression guard: Rust registry does NOT contain
604        // "cargo check" or anything named "check"
605        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        // Also verify no tool has args that would run `cargo check`
620        for tool in &tools {
621            let args_joined = tool.args.join(" ");
622            // clippy args start with "clippy", not "check"
623            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        // tools_for_language("unknown") returns empty Vec
635        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        // clippy tool has detection_binary "cargo-clippy", not "cargo" [PM-2]
647        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        // Verify it's different from binary
657        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        // cargo-audit tool has detection_binary "cargo-audit", not "cargo" [PM-2]
666        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        // clippy args include "--message-format=json" for parseable output
680        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        // cargo-audit args include "--json" for JSON output
694        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        // Register a fake tool with a nonexistent detection_binary.
708        // It should end up in the missing list.
709        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        // The fake tool should be missing
725        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        // cargo should always be available in a Rust dev environment.
741        // This test verifies detect_available_tools doesn't panic.
742        let registry = ToolRegistry::new();
743        let (available, missing) = registry.detect_available_tools("rust");
744
745        // Don't assert specific counts -- CI environments may differ.
746        // Just verify the partition covers all tools.
747        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        // Python has ruff by default; registering adds more
768        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        // Registering additional tools for a language appends to defaults
790        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        // Default::default() should produce the same registry as new()
814        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}