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: &["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", // [PM-2]
158                    args: &["audit", "--json"],
159                    category: ToolCategory::SecurityScanner,
160                    parser: "cargo-audit",
161                },
162            ],
163        );
164
165        // Python tools -- ruff (fast linter) + pyright (type checker)
166        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        // JavaScript tools -- eslint
189        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        // TypeScript tools -- eslint (tsc is too slow for L1)
202        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        // Go tools -- golangci-lint
215        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        // Ruby tools -- rubocop
228        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        // Java tools -- checkstyle (plain format, parsed line by line)
241        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        // Kotlin tools -- ktlint
254        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        // Swift tools -- swiftlint
267        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        // C tools -- cppcheck (tab-separated template output)
280        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        // C++ tools -- cppcheck (same parser as C)
297        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        // PHP tools -- phpstan
315        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        // Lua tools -- luacheck (plain format, parsed line by line)
328        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    /// Get all configured tools for a language.
344    ///
345    /// Returns an empty `Vec` if the language has no registered tools.
346    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    /// Detect which tools are actually installed on the system.
354    ///
355    /// Probes `detection_binary` (not `binary`) to check availability [PM-2].
356    /// For cargo subcommands, this correctly checks for e.g. "cargo-clippy"
357    /// rather than just "cargo".
358    ///
359    /// Returns `(available, missing)` where each is a list of tool configs.
360    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    /// Register a tool for a language.
377    ///
378    /// Appends the tool to any existing tools for the language.
379    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        // ToolCategory serializes to snake_case
400        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        // Roundtrip
413        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    // =========================================================================
576    // ToolRegistry tests
577    // =========================================================================
578
579    #[test]
580    fn test_registry_rust_tools() {
581        // ToolRegistry::new() has exactly 2 Rust tools: clippy + cargo-audit
582        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        // CRITICAL PM-1 regression guard: Rust registry does NOT contain
598        // "cargo check" or anything named "check"
599        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        // Also verify no tool has args that would run `cargo check`
614        for tool in &tools {
615            let args_joined = tool.args.join(" ");
616            // clippy args start with "clippy", not "check"
617            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        // tools_for_language("unknown") returns empty Vec
629        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        // clippy tool has detection_binary "cargo-clippy", not "cargo" [PM-2]
641        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        // Verify it's different from binary
651        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        // cargo-audit tool has detection_binary "cargo-audit", not "cargo" [PM-2]
660        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        // clippy args include "--message-format=json" for parseable output
674        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        // cargo-audit args include "--json" for JSON output
688        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        // Register a fake tool with a nonexistent detection_binary.
702        // It should end up in the missing list.
703        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        // The fake tool should be missing
719        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        // cargo should always be available in a Rust dev environment.
735        // This test verifies detect_available_tools doesn't panic.
736        let registry = ToolRegistry::new();
737        let (available, missing) = registry.detect_available_tools("rust");
738
739        // Don't assert specific counts -- CI environments may differ.
740        // Just verify the partition covers all tools.
741        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        // Python has ruff by default; registering adds more
762        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        // Registering additional tools for a language appends to defaults
784        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        // Default::default() should produce the same registry as new()
808        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}