Skip to main content

rumdl_lib/code_block_tools/
registry.rs

1//! Built-in tool registry with definitions for common formatters and linters.
2//!
3//! This module provides default configurations for popular tools like ruff, prettier,
4//! shellcheck, etc. Users can override these in their configuration.
5
6use super::config::ToolDefinition;
7use super::processor::RUMDL_BUILTIN_TOOL;
8use std::collections::BTreeMap;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Registry of built-in tool definitions.
13pub struct ToolRegistry {
14    /// User-defined tools (override built-ins)
15    user_tools: BTreeMap<String, ToolDefinition>,
16}
17
18impl ToolRegistry {
19    /// Create a new registry with user-defined tools.
20    pub fn new(user_tools: BTreeMap<String, ToolDefinition>) -> Self {
21        Self { user_tools }
22    }
23
24    /// Get a tool definition by ID.
25    ///
26    /// Checks user tools first, then falls back to built-in tools.
27    pub fn get(&self, tool_id: &str) -> Option<&ToolDefinition> {
28        self.user_tools.get(tool_id).or_else(|| BUILTIN_TOOLS.get(tool_id))
29    }
30
31    /// Check if a tool ID is valid (either user-defined or built-in).
32    pub fn contains(&self, tool_id: &str) -> bool {
33        self.user_tools.contains_key(tool_id) || BUILTIN_TOOLS.contains_key(tool_id)
34    }
35
36    /// List all available tool IDs.
37    pub fn list_tools(&self) -> Vec<&str> {
38        let mut tools: Vec<&str> = self.user_tools.keys().map(std::string::String::as_str).collect();
39        for key in BUILTIN_TOOLS.keys() {
40            if !self.user_tools.contains_key(*key) {
41                tools.push(key);
42            }
43        }
44        tools.sort_unstable();
45        tools
46    }
47}
48
49/// IDs of all built-in (registry) tools, sorted.
50///
51/// Exposed so the execution-test harness can assert every built-in is either
52/// verified by an execution test or explicitly exempted (see that harness). When you
53/// add a built-in tool, run it through rumdl and add an execution test; if it cannot
54/// be verified to work over stdin, do not ship it.
55pub fn builtin_tool_ids() -> Vec<&'static str> {
56    let mut ids: Vec<&'static str> = BUILTIN_TOOLS.keys().copied().collect();
57    ids.sort_unstable();
58    ids
59}
60
61impl Default for ToolRegistry {
62    fn default() -> Self {
63        Self::new(BTreeMap::new())
64    }
65}
66
67/// Built-in tool definitions.
68///
69/// These are common formatters and linters that work well with stdin/stdout.
70static BUILTIN_TOOLS: LazyLock<HashMap<&'static str, ToolDefinition>> = LazyLock::new(|| {
71    let mut m = HashMap::new();
72
73    // Python - ruff
74    m.insert(
75        "ruff:check",
76        ToolDefinition {
77            command: vec![
78                "ruff".to_string(),
79                "check".to_string(),
80                "--output-format=concise".to_string(),
81                "--stdin-filename=_.py".to_string(),
82                "-".to_string(),
83            ],
84            stdin: true,
85            stdout: true,
86            lint_args: vec![],
87            format_args: vec![],
88        },
89    );
90
91    m.insert(
92        "ruff:format",
93        ToolDefinition {
94            command: vec![
95                "ruff".to_string(),
96                "format".to_string(),
97                "--stdin-filename=_.py".to_string(),
98                "-".to_string(),
99            ],
100            stdin: true,
101            stdout: true,
102            lint_args: vec![],
103            format_args: vec![],
104        },
105    );
106
107    // Python - black
108    m.insert(
109        "black",
110        ToolDefinition {
111            command: vec!["black".to_string(), "--quiet".to_string(), "-".to_string()],
112            stdin: true,
113            stdout: true,
114            lint_args: vec!["--check".to_string()],
115            format_args: vec![],
116        },
117    );
118
119    // JavaScript/TypeScript - prettier
120    m.insert(
121        "prettier",
122        ToolDefinition {
123            command: vec!["prettier".to_string(), "--stdin-filepath=_.js".to_string()],
124            stdin: true,
125            stdout: true,
126            lint_args: vec!["--check".to_string()],
127            format_args: vec![],
128        },
129    );
130
131    m.insert(
132        "prettier:json",
133        ToolDefinition {
134            command: vec!["prettier".to_string(), "--stdin-filepath=_.json".to_string()],
135            stdin: true,
136            stdout: true,
137            lint_args: vec!["--check".to_string()],
138            format_args: vec![],
139        },
140    );
141
142    m.insert(
143        "prettier:yaml",
144        ToolDefinition {
145            command: vec!["prettier".to_string(), "--stdin-filepath=_.yaml".to_string()],
146            stdin: true,
147            stdout: true,
148            lint_args: vec!["--check".to_string()],
149            format_args: vec![],
150        },
151    );
152
153    m.insert(
154        "prettier:html",
155        ToolDefinition {
156            command: vec!["prettier".to_string(), "--stdin-filepath=_.html".to_string()],
157            stdin: true,
158            stdout: true,
159            lint_args: vec!["--check".to_string()],
160            format_args: vec![],
161        },
162    );
163
164    m.insert(
165        "prettier:css",
166        ToolDefinition {
167            command: vec!["prettier".to_string(), "--stdin-filepath=_.css".to_string()],
168            stdin: true,
169            stdout: true,
170            lint_args: vec!["--check".to_string()],
171            format_args: vec![],
172        },
173    );
174
175    m.insert(
176        "prettier:markdown",
177        ToolDefinition {
178            command: vec!["prettier".to_string(), "--stdin-filepath=_.md".to_string()],
179            stdin: true,
180            stdout: true,
181            lint_args: vec!["--check".to_string()],
182            format_args: vec![],
183        },
184    );
185
186    // Shell - shellcheck (lint only). `--shell=bash` because code blocks rarely carry
187    // a shebang; without it shellcheck emits a "target shell unknown" tip instead of
188    // real diagnostics. bash is the common, permissive default; override with a custom
189    // tool for sh/ksh/dash.
190    m.insert(
191        "shellcheck",
192        ToolDefinition {
193            command: vec!["shellcheck".to_string(), "--shell=bash".to_string(), "-".to_string()],
194            stdin: true,
195            stdout: true,
196            lint_args: vec![],
197            format_args: vec![],
198        },
199    );
200
201    // Shell - shfmt
202    m.insert(
203        "shfmt",
204        ToolDefinition {
205            command: vec!["shfmt".to_string()],
206            stdin: true,
207            stdout: true,
208            lint_args: vec!["-d".to_string()], // diff mode for lint
209            format_args: vec![],
210        },
211    );
212
213    // Shell - shuck (faster shellcheck/shfmt alternative). Bare `shuck` lints via the
214    // `check` subcommand; `shuck:format` formats via `format` (the processor resolves a
215    // bare `shuck` in a format slot to `shuck:format`). `--output-format concise` keeps
216    // shuck's one-line-per-diagnostic output, which parses via the same generic
217    // "file:line:col: message" path as other tools instead of needing a dedicated
218    // parser. Requires shuck >= 0.0.43 for `check -` stdin support (see rvben/rumdl#655
219    // and ewhauser/shuck#1123); `format -` reads stdin and writes the formatted source
220    // to stdout with no report contamination (verified against shuck 0.0.45).
221    m.insert(
222        "shuck",
223        ToolDefinition {
224            command: vec![
225                "shuck".to_string(),
226                "check".to_string(),
227                "--output-format".to_string(),
228                "concise".to_string(),
229                "-".to_string(),
230            ],
231            stdin: true,
232            stdout: true,
233            lint_args: vec![],
234            format_args: vec![],
235        },
236    );
237
238    m.insert(
239        "shuck:format",
240        ToolDefinition {
241            command: vec!["shuck".to_string(), "format".to_string(), "-".to_string()],
242            stdin: true,
243            stdout: true,
244            lint_args: vec![],
245            format_args: vec![],
246        },
247    );
248
249    // Rust - rustfmt
250    m.insert(
251        "rustfmt",
252        ToolDefinition {
253            command: vec!["rustfmt".to_string()],
254            stdin: true,
255            stdout: true,
256            lint_args: vec!["--check".to_string()],
257            format_args: vec![],
258        },
259    );
260
261    // Go - gofmt
262    m.insert(
263        "gofmt",
264        ToolDefinition {
265            command: vec!["gofmt".to_string()],
266            stdin: true,
267            stdout: true,
268            lint_args: vec!["-d".to_string()], // diff mode for lint
269            format_args: vec![],
270        },
271    );
272
273    // Go - goimports
274    m.insert(
275        "goimports",
276        ToolDefinition {
277            command: vec!["goimports".to_string()],
278            stdin: true,
279            stdout: true,
280            lint_args: vec!["-d".to_string()],
281            format_args: vec![],
282        },
283    );
284
285    // C/C++ - clang-format
286    m.insert(
287        "clang-format",
288        ToolDefinition {
289            command: vec!["clang-format".to_string()],
290            stdin: true,
291            stdout: true,
292            lint_args: vec!["--dry-run".to_string(), "--Werror".to_string()],
293            format_args: vec![],
294        },
295    );
296
297    // SQL - sqlfluff. Requires a dialect; without `--dialect` it errors ("No dialect
298    // was specified"). Default to ansi; override with a custom tool for other dialects.
299    m.insert(
300        "sqlfluff:lint",
301        ToolDefinition {
302            command: vec![
303                "sqlfluff".to_string(),
304                "lint".to_string(),
305                "--dialect".to_string(),
306                "ansi".to_string(),
307                "-".to_string(),
308            ],
309            stdin: true,
310            stdout: true,
311            lint_args: vec![],
312            format_args: vec![],
313        },
314    );
315
316    m.insert(
317        "sqlfluff:fix",
318        ToolDefinition {
319            command: vec![
320                "sqlfluff".to_string(),
321                "fix".to_string(),
322                "--dialect".to_string(),
323                "ansi".to_string(),
324                "-".to_string(),
325            ],
326            stdin: true,
327            stdout: true,
328            lint_args: vec![],
329            format_args: vec![],
330        },
331    );
332
333    // JSON - jq (format/lint)
334    m.insert(
335        "jq",
336        ToolDefinition {
337            command: vec!["jq".to_string(), ".".to_string()],
338            stdin: true,
339            stdout: true,
340            lint_args: vec![],
341            format_args: vec![],
342        },
343    );
344
345    // YAML - yamlfmt
346    m.insert(
347        "yamlfmt",
348        ToolDefinition {
349            command: vec!["yamlfmt".to_string()],
350            stdin: true,
351            stdout: true,
352            lint_args: vec!["-lint".to_string(), "-".to_string()],
353            format_args: vec!["-".to_string()],
354        },
355    );
356
357    // TOML - taplo
358    m.insert(
359        "taplo",
360        ToolDefinition {
361            command: vec!["taplo".to_string(), "fmt".to_string(), "-".to_string()],
362            stdin: true,
363            stdout: true,
364            lint_args: vec!["--check".to_string()],
365            format_args: vec![],
366        },
367    );
368
369    // Terraform - terraform fmt
370    m.insert(
371        "terraform-fmt",
372        ToolDefinition {
373            command: vec!["terraform".to_string(), "fmt".to_string(), "-".to_string()],
374            stdin: true,
375            stdout: true,
376            lint_args: vec!["-check".to_string()],
377            format_args: vec![],
378        },
379    );
380
381    // Nix - nixfmt (bare invocation is deprecated; `-` reads anonymous stdin)
382    m.insert(
383        "nixfmt",
384        ToolDefinition {
385            command: vec!["nixfmt".to_string(), "-".to_string()],
386            stdin: true,
387            stdout: true,
388            lint_args: vec!["--check".to_string()],
389            format_args: vec![],
390        },
391    );
392
393    // Lua - stylua
394    m.insert(
395        "stylua",
396        ToolDefinition {
397            command: vec!["stylua".to_string(), "-".to_string()],
398            stdin: true,
399            stdout: true,
400            lint_args: vec!["--check".to_string()],
401            format_args: vec![],
402        },
403    );
404
405    // Haskell - ormolu (stdin needs --stdin-input-file to pick up the .hs dialect)
406    m.insert(
407        "ormolu",
408        ToolDefinition {
409            command: vec!["ormolu".to_string(), "--stdin-input-file=_.hs".to_string()],
410            stdin: true,
411            stdout: true,
412            lint_args: vec!["--check-idempotence".to_string()],
413            format_args: vec![],
414        },
415    );
416
417    // Elm - elm-format
418    m.insert(
419        "elm-format",
420        ToolDefinition {
421            command: vec!["elm-format".to_string(), "--stdin".to_string()],
422            stdin: true,
423            stdout: true,
424            lint_args: vec!["--validate".to_string()],
425            format_args: vec![],
426        },
427    );
428
429    // Swift - swift-format (bare invocation is deprecated; `format -` reads stdin)
430    m.insert(
431        "swift-format",
432        ToolDefinition {
433            command: vec!["swift-format".to_string(), "format".to_string(), "-".to_string()],
434            stdin: true,
435            stdout: true,
436            lint_args: vec![],
437            format_args: vec![],
438        },
439    );
440
441    // Kotlin - ktfmt (`-` reads stdin; `--stdin` is not a valid flag)
442    m.insert(
443        "ktfmt",
444        ToolDefinition {
445            command: vec!["ktfmt".to_string(), "-".to_string()],
446            stdin: true,
447            stdout: true,
448            lint_args: vec![],
449            format_args: vec![],
450        },
451    );
452
453    // Jinja/HTML - djlint
454    m.insert(
455        "djlint",
456        ToolDefinition {
457            command: vec!["djlint".to_string(), "-".to_string()],
458            stdin: true,
459            stdout: true,
460            lint_args: vec![],
461            format_args: vec!["--reformat".to_string()],
462        },
463    );
464
465    m.insert(
466        "djlint:lint",
467        ToolDefinition {
468            command: vec!["djlint".to_string(), "-".to_string()],
469            stdin: true,
470            stdout: true,
471            lint_args: vec![],
472            format_args: vec![],
473        },
474    );
475
476    m.insert(
477        "djlint:reformat",
478        ToolDefinition {
479            command: vec!["djlint".to_string(), "-".to_string(), "--reformat".to_string()],
480            stdin: true,
481            stdout: true,
482            lint_args: vec![],
483            format_args: vec![],
484        },
485    );
486
487    // Shell - beautysh
488    m.insert(
489        "beautysh",
490        ToolDefinition {
491            command: vec!["beautysh".to_string(), "-".to_string()],
492            stdin: true,
493            stdout: true,
494            lint_args: vec!["--check".to_string()],
495            format_args: vec![],
496        },
497    );
498
499    // TOML - tombi (default runs `tombi lint` since users typically configure it in the lint slot)
500    m.insert(
501        "tombi",
502        ToolDefinition {
503            command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
504            stdin: true,
505            stdout: true,
506            lint_args: vec![],
507            format_args: vec![],
508        },
509    );
510
511    m.insert(
512        "tombi:format",
513        ToolDefinition {
514            command: vec!["tombi".to_string(), "format".to_string(), "-".to_string()],
515            stdin: true,
516            stdout: true,
517            lint_args: vec![],
518            format_args: vec![],
519        },
520    );
521
522    m.insert(
523        "tombi:lint",
524        ToolDefinition {
525            command: vec!["tombi".to_string(), "lint".to_string(), "-".to_string()],
526            stdin: true,
527            stdout: true,
528            lint_args: vec![],
529            format_args: vec![],
530        },
531    );
532
533    // JavaScript/CSS/HTML/JSON - oxfmt (OXC formatter)
534    m.insert(
535        "oxfmt",
536        ToolDefinition {
537            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
538            stdin: true,
539            stdout: true,
540            lint_args: vec!["--check".to_string()],
541            format_args: vec![],
542        },
543    );
544
545    m.insert(
546        "oxfmt:js",
547        ToolDefinition {
548            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.js".to_string()],
549            stdin: true,
550            stdout: true,
551            lint_args: vec!["--check".to_string()],
552            format_args: vec![],
553        },
554    );
555
556    m.insert(
557        "oxfmt:ts",
558        ToolDefinition {
559            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.ts".to_string()],
560            stdin: true,
561            stdout: true,
562            lint_args: vec!["--check".to_string()],
563            format_args: vec![],
564        },
565    );
566
567    m.insert(
568        "oxfmt:jsx",
569        ToolDefinition {
570            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.jsx".to_string()],
571            stdin: true,
572            stdout: true,
573            lint_args: vec!["--check".to_string()],
574            format_args: vec![],
575        },
576    );
577
578    m.insert(
579        "oxfmt:tsx",
580        ToolDefinition {
581            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.tsx".to_string()],
582            stdin: true,
583            stdout: true,
584            lint_args: vec!["--check".to_string()],
585            format_args: vec![],
586        },
587    );
588
589    m.insert(
590        "oxfmt:json",
591        ToolDefinition {
592            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.json".to_string()],
593            stdin: true,
594            stdout: true,
595            lint_args: vec!["--check".to_string()],
596            format_args: vec![],
597        },
598    );
599
600    m.insert(
601        "oxfmt:css",
602        ToolDefinition {
603            command: vec!["oxfmt".to_string(), "--stdin-filepath=_.css".to_string()],
604            stdin: true,
605            stdout: true,
606            lint_args: vec!["--check".to_string()],
607            format_args: vec![],
608        },
609    );
610
611    // Multi-language - deno fmt. stdin needs --ext to pick the parser, so one entry
612    // per supported extension. Bare "deno-fmt" defaults to TypeScript.
613    let deno_fmt = |ext: &str| ToolDefinition {
614        command: vec![
615            "deno".to_string(),
616            "fmt".to_string(),
617            format!("--ext={ext}"),
618            "-".to_string(),
619        ],
620        stdin: true,
621        stdout: true,
622        lint_args: vec!["--check".to_string()],
623        format_args: vec![],
624    };
625    m.insert("deno-fmt", deno_fmt("ts"));
626    m.insert("deno-fmt:ts", deno_fmt("ts"));
627    m.insert("deno-fmt:js", deno_fmt("js"));
628    m.insert("deno-fmt:json", deno_fmt("json"));
629    m.insert("deno-fmt:jsonc", deno_fmt("jsonc"));
630    m.insert("deno-fmt:md", deno_fmt("md"));
631
632    m
633});
634
635/// Whether a built-in tool lints, formats, or both, for the docs table "Type" column.
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637enum ToolKind {
638    Lint,
639    Format,
640    Both,
641}
642
643impl ToolKind {
644    const fn label(self) -> &'static str {
645        match self {
646            ToolKind::Lint => "Lint",
647            ToolKind::Format => "Format",
648            ToolKind::Both => "Both",
649        }
650    }
651}
652
653/// Documentation metadata for a built-in tool, paired with [`BUILTIN_TOOLS`] by `id`.
654///
655/// This is the source of the generated table in `docs/code-block-tools.md`. It carries
656/// the display-only columns (`language`, `kind`) that must not live on the user-facing
657/// `ToolDefinition`. Command text is NOT duplicated here: the table pulls it from the
658/// runtime definition unless `display_command` provides a curated override.
659///
660/// Invariants (enforced by tests, so CI fails on any drift):
661/// - every `runtime` id has a matching `BUILTIN_TOOLS` entry, and vice versa;
662/// - `DocsOnly` ids (`runtime == false`, e.g. `rumdl`, handled specially in the
663///   processor) are absent from `BUILTIN_TOOLS` and must set `display_command`;
664/// - a `doc_group` with more than one runtime entry must set `display_command` on each;
665/// - all entries in a `doc_group` share `language` and `kind`.
666struct ToolDocMeta {
667    id: &'static str,
668    language: &'static str,
669    kind: ToolKind,
670    /// Rows sharing a `doc_group` collapse into one table row (extension variants).
671    doc_group: &'static str,
672    /// Curated command for the table; falls back to the runtime command join.
673    display_command: Option<&'static str>,
674    /// True for real external tools (in `BUILTIN_TOOLS`); false for docs-only entries.
675    runtime: bool,
676}
677
678/// Display metadata for the built-in tools table. Order here is the table row order.
679const BUILTIN_TOOLS_DOCS: &[ToolDocMeta] = &[
680    ToolDocMeta {
681        id: "ruff:check",
682        language: "Python",
683        kind: ToolKind::Lint,
684        doc_group: "ruff:check",
685        display_command: Some("ruff check --output-format=concise -"),
686        runtime: true,
687    },
688    ToolDocMeta {
689        id: "ruff:format",
690        language: "Python",
691        kind: ToolKind::Format,
692        doc_group: "ruff:format",
693        display_command: Some("ruff format -"),
694        runtime: true,
695    },
696    ToolDocMeta {
697        id: "black",
698        language: "Python",
699        kind: ToolKind::Format,
700        doc_group: "black",
701        display_command: None,
702        runtime: true,
703    },
704    ToolDocMeta {
705        id: "prettier",
706        language: "Multi",
707        kind: ToolKind::Format,
708        doc_group: "prettier",
709        display_command: Some("prettier --stdin-filepath=_.EXT"),
710        runtime: true,
711    },
712    ToolDocMeta {
713        id: "prettier:json",
714        language: "Multi",
715        kind: ToolKind::Format,
716        doc_group: "prettier",
717        display_command: Some("prettier --stdin-filepath=_.EXT"),
718        runtime: true,
719    },
720    ToolDocMeta {
721        id: "prettier:yaml",
722        language: "Multi",
723        kind: ToolKind::Format,
724        doc_group: "prettier",
725        display_command: Some("prettier --stdin-filepath=_.EXT"),
726        runtime: true,
727    },
728    ToolDocMeta {
729        id: "prettier:html",
730        language: "Multi",
731        kind: ToolKind::Format,
732        doc_group: "prettier",
733        display_command: Some("prettier --stdin-filepath=_.EXT"),
734        runtime: true,
735    },
736    ToolDocMeta {
737        id: "prettier:css",
738        language: "Multi",
739        kind: ToolKind::Format,
740        doc_group: "prettier",
741        display_command: Some("prettier --stdin-filepath=_.EXT"),
742        runtime: true,
743    },
744    ToolDocMeta {
745        id: "prettier:markdown",
746        language: "Multi",
747        kind: ToolKind::Format,
748        doc_group: "prettier",
749        display_command: Some("prettier --stdin-filepath=_.EXT"),
750        runtime: true,
751    },
752    ToolDocMeta {
753        id: "shellcheck",
754        language: "Shell",
755        kind: ToolKind::Lint,
756        doc_group: "shellcheck",
757        display_command: None,
758        runtime: true,
759    },
760    ToolDocMeta {
761        id: "shfmt",
762        language: "Shell",
763        kind: ToolKind::Format,
764        doc_group: "shfmt",
765        display_command: None,
766        runtime: true,
767    },
768    ToolDocMeta {
769        id: "shuck",
770        language: "Shell",
771        kind: ToolKind::Lint,
772        doc_group: "shuck",
773        display_command: None,
774        runtime: true,
775    },
776    ToolDocMeta {
777        id: "shuck:format",
778        language: "Shell",
779        kind: ToolKind::Format,
780        doc_group: "shuck:format",
781        display_command: None,
782        runtime: true,
783    },
784    ToolDocMeta {
785        id: "rustfmt",
786        language: "Rust",
787        kind: ToolKind::Format,
788        doc_group: "rustfmt",
789        display_command: None,
790        runtime: true,
791    },
792    ToolDocMeta {
793        id: "gofmt",
794        language: "Go",
795        kind: ToolKind::Format,
796        doc_group: "gofmt",
797        display_command: None,
798        runtime: true,
799    },
800    ToolDocMeta {
801        id: "goimports",
802        language: "Go",
803        kind: ToolKind::Format,
804        doc_group: "goimports",
805        display_command: None,
806        runtime: true,
807    },
808    ToolDocMeta {
809        id: "clang-format",
810        language: "C/C++",
811        kind: ToolKind::Format,
812        doc_group: "clang-format",
813        display_command: None,
814        runtime: true,
815    },
816    ToolDocMeta {
817        id: "sqlfluff:lint",
818        language: "SQL",
819        kind: ToolKind::Lint,
820        doc_group: "sqlfluff:lint",
821        display_command: None,
822        runtime: true,
823    },
824    ToolDocMeta {
825        id: "sqlfluff:fix",
826        language: "SQL",
827        kind: ToolKind::Format,
828        doc_group: "sqlfluff:fix",
829        display_command: None,
830        runtime: true,
831    },
832    ToolDocMeta {
833        id: "jq",
834        language: "JSON",
835        kind: ToolKind::Both,
836        doc_group: "jq",
837        display_command: None,
838        runtime: true,
839    },
840    ToolDocMeta {
841        id: "yamlfmt",
842        language: "YAML",
843        kind: ToolKind::Format,
844        doc_group: "yamlfmt",
845        display_command: None,
846        runtime: true,
847    },
848    ToolDocMeta {
849        id: "taplo",
850        language: "TOML",
851        kind: ToolKind::Format,
852        doc_group: "taplo",
853        display_command: None,
854        runtime: true,
855    },
856    ToolDocMeta {
857        id: "terraform-fmt",
858        language: "Terraform",
859        kind: ToolKind::Format,
860        doc_group: "terraform-fmt",
861        display_command: None,
862        runtime: true,
863    },
864    ToolDocMeta {
865        id: "nixfmt",
866        language: "Nix",
867        kind: ToolKind::Format,
868        doc_group: "nixfmt",
869        display_command: None,
870        runtime: true,
871    },
872    ToolDocMeta {
873        id: "stylua",
874        language: "Lua",
875        kind: ToolKind::Format,
876        doc_group: "stylua",
877        display_command: None,
878        runtime: true,
879    },
880    ToolDocMeta {
881        id: "ormolu",
882        language: "Haskell",
883        kind: ToolKind::Format,
884        doc_group: "ormolu",
885        display_command: None,
886        runtime: true,
887    },
888    ToolDocMeta {
889        id: "elm-format",
890        language: "Elm",
891        kind: ToolKind::Format,
892        doc_group: "elm-format",
893        display_command: None,
894        runtime: true,
895    },
896    ToolDocMeta {
897        id: "swift-format",
898        language: "Swift",
899        kind: ToolKind::Format,
900        doc_group: "swift-format",
901        display_command: None,
902        runtime: true,
903    },
904    ToolDocMeta {
905        id: "ktfmt",
906        language: "Kotlin",
907        kind: ToolKind::Format,
908        doc_group: "ktfmt",
909        display_command: None,
910        runtime: true,
911    },
912    ToolDocMeta {
913        id: "djlint",
914        language: "Jinja/HTML",
915        kind: ToolKind::Both,
916        doc_group: "djlint",
917        display_command: None,
918        runtime: true,
919    },
920    ToolDocMeta {
921        id: "djlint:lint",
922        language: "Jinja/HTML",
923        kind: ToolKind::Lint,
924        doc_group: "djlint:lint",
925        display_command: None,
926        runtime: true,
927    },
928    ToolDocMeta {
929        id: "djlint:reformat",
930        language: "Jinja/HTML",
931        kind: ToolKind::Format,
932        doc_group: "djlint:reformat",
933        display_command: None,
934        runtime: true,
935    },
936    ToolDocMeta {
937        id: "beautysh",
938        language: "Shell",
939        kind: ToolKind::Both,
940        doc_group: "beautysh",
941        display_command: None,
942        runtime: true,
943    },
944    ToolDocMeta {
945        id: "tombi",
946        language: "TOML",
947        kind: ToolKind::Lint,
948        doc_group: "tombi",
949        display_command: None,
950        runtime: true,
951    },
952    ToolDocMeta {
953        id: "tombi:format",
954        language: "TOML",
955        kind: ToolKind::Format,
956        doc_group: "tombi:format",
957        display_command: None,
958        runtime: true,
959    },
960    ToolDocMeta {
961        id: "tombi:lint",
962        language: "TOML",
963        kind: ToolKind::Lint,
964        doc_group: "tombi:lint",
965        display_command: None,
966        runtime: true,
967    },
968    ToolDocMeta {
969        id: "oxfmt",
970        language: "Multi",
971        kind: ToolKind::Format,
972        doc_group: "oxfmt",
973        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
974        runtime: true,
975    },
976    ToolDocMeta {
977        id: "oxfmt:js",
978        language: "Multi",
979        kind: ToolKind::Format,
980        doc_group: "oxfmt",
981        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
982        runtime: true,
983    },
984    ToolDocMeta {
985        id: "oxfmt:ts",
986        language: "Multi",
987        kind: ToolKind::Format,
988        doc_group: "oxfmt",
989        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
990        runtime: true,
991    },
992    ToolDocMeta {
993        id: "oxfmt:jsx",
994        language: "Multi",
995        kind: ToolKind::Format,
996        doc_group: "oxfmt",
997        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
998        runtime: true,
999    },
1000    ToolDocMeta {
1001        id: "oxfmt:tsx",
1002        language: "Multi",
1003        kind: ToolKind::Format,
1004        doc_group: "oxfmt",
1005        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1006        runtime: true,
1007    },
1008    ToolDocMeta {
1009        id: "oxfmt:json",
1010        language: "Multi",
1011        kind: ToolKind::Format,
1012        doc_group: "oxfmt",
1013        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1014        runtime: true,
1015    },
1016    ToolDocMeta {
1017        id: "oxfmt:css",
1018        language: "Multi",
1019        kind: ToolKind::Format,
1020        doc_group: "oxfmt",
1021        display_command: Some("oxfmt --stdin-filepath=_.EXT"),
1022        runtime: true,
1023    },
1024    ToolDocMeta {
1025        id: "deno-fmt",
1026        language: "Multi",
1027        kind: ToolKind::Format,
1028        doc_group: "deno-fmt",
1029        display_command: Some("deno fmt --ext=EXT -"),
1030        runtime: true,
1031    },
1032    ToolDocMeta {
1033        id: "deno-fmt:ts",
1034        language: "Multi",
1035        kind: ToolKind::Format,
1036        doc_group: "deno-fmt",
1037        display_command: Some("deno fmt --ext=EXT -"),
1038        runtime: true,
1039    },
1040    ToolDocMeta {
1041        id: "deno-fmt:js",
1042        language: "Multi",
1043        kind: ToolKind::Format,
1044        doc_group: "deno-fmt",
1045        display_command: Some("deno fmt --ext=EXT -"),
1046        runtime: true,
1047    },
1048    ToolDocMeta {
1049        id: "deno-fmt:json",
1050        language: "Multi",
1051        kind: ToolKind::Format,
1052        doc_group: "deno-fmt",
1053        display_command: Some("deno fmt --ext=EXT -"),
1054        runtime: true,
1055    },
1056    ToolDocMeta {
1057        id: "deno-fmt:jsonc",
1058        language: "Multi",
1059        kind: ToolKind::Format,
1060        doc_group: "deno-fmt",
1061        display_command: Some("deno fmt --ext=EXT -"),
1062        runtime: true,
1063    },
1064    ToolDocMeta {
1065        id: "deno-fmt:md",
1066        language: "Multi",
1067        kind: ToolKind::Format,
1068        doc_group: "deno-fmt",
1069        display_command: Some("deno fmt --ext=EXT -"),
1070        runtime: true,
1071    },
1072    // Docs-only: rumdl's own markdown linting, short-circuited in the processor before
1073    // tool resolution (never a registry entry).
1074    ToolDocMeta {
1075        id: RUMDL_BUILTIN_TOOL,
1076        language: "Markdown",
1077        kind: ToolKind::Lint,
1078        doc_group: RUMDL_BUILTIN_TOOL,
1079        display_command: Some("built-in markdown linting"),
1080        runtime: false,
1081    },
1082];
1083
1084/// Markers fencing the generated table in `docs/code-block-tools.md`.
1085const TABLE_BEGIN: &str = "<!-- BEGIN builtin-tools (generated) -->";
1086const TABLE_END: &str = "<!-- END builtin-tools (generated) -->";
1087
1088/// Error from splicing the generated docs into `docs/code-block-tools.md`.
1089#[derive(Debug, Clone, PartialEq, Eq)]
1090pub enum DocsError {
1091    /// A begin/end marker pair is missing.
1092    MissingMarker,
1093    /// A begin or end marker appears more than once.
1094    DuplicateMarker,
1095    /// The end marker precedes the begin marker.
1096    MarkerOrder,
1097    /// The "Built-in tools" count row was not found in the comparison table.
1098    CountRowMissing,
1099    /// More than one "Built-in tools" count row was found.
1100    CountRowAmbiguous,
1101    /// The "Built-in tools" count row does not have the expected cell layout.
1102    CountRowMalformed,
1103}
1104
1105impl std::fmt::Display for DocsError {
1106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1107        let msg = match self {
1108            DocsError::MissingMarker => "missing `<!-- BEGIN/END builtin-tools (generated) -->` marker pair",
1109            DocsError::DuplicateMarker => "duplicate builtin-tools marker",
1110            DocsError::MarkerOrder => "END builtin-tools marker precedes BEGIN",
1111            DocsError::CountRowMissing => "`| Built-in tools` count row not found",
1112            DocsError::CountRowAmbiguous => "multiple `| Built-in tools` count rows found",
1113            DocsError::CountRowMalformed => "`| Built-in tools` count row has an unexpected layout",
1114        };
1115        f.write_str(msg)
1116    }
1117}
1118
1119impl std::error::Error for DocsError {}
1120
1121/// Number of rows the generated table renders (distinct `doc_group`s).
1122fn builtin_tools_group_count() -> usize {
1123    let mut seen: Vec<&str> = Vec::new();
1124    for m in BUILTIN_TOOLS_DOCS {
1125        if !seen.contains(&m.doc_group) {
1126            seen.push(m.doc_group);
1127        }
1128    }
1129    seen.len()
1130}
1131
1132/// Render the built-in tools table (the markdown fenced by the doc markers).
1133///
1134/// One row per `doc_group`, in list order. Columns are padded to their widest cell so
1135/// the output matches rumdl's own table formatting, keeping the generated docs
1136/// `rumdl fmt --check`-clean (the project config normalizes table column widths).
1137pub fn render_builtin_tools_table() -> String {
1138    let headers = ["Tool ID", "Language", "Type", "Command"];
1139    let mut rows: Vec<[String; 4]> = Vec::new();
1140
1141    let mut seen: Vec<&str> = Vec::new();
1142    for m in BUILTIN_TOOLS_DOCS {
1143        if seen.contains(&m.doc_group) {
1144            continue;
1145        }
1146        seen.push(m.doc_group);
1147
1148        // Docs-only entries have no runtime command to fall back to; runtime entries
1149        // render the exact mode-specific invocation the executor runs.
1150        let command = match m.display_command {
1151            Some(cmd) => cmd.to_string(),
1152            None if m.runtime => runtime_command_for_kind(m.id, m.kind),
1153            None => String::new(),
1154        };
1155
1156        rows.push([
1157            format!("`{}`", m.doc_group),
1158            m.language.to_string(),
1159            m.kind.label().to_string(),
1160            format!("`{command}`"),
1161        ]);
1162    }
1163
1164    // Column widths = widest cell (header included), counted in chars (all ASCII here).
1165    let mut widths = headers.map(str::chars).map(Iterator::count);
1166    for row in &rows {
1167        for (i, cell) in row.iter().enumerate() {
1168            widths[i] = widths[i].max(cell.chars().count());
1169        }
1170    }
1171
1172    let mut out = String::new();
1173    push_table_row(&mut out, &headers.map(String::from), &widths);
1174    let separators = std::array::from_fn(|i| "-".repeat(widths[i]));
1175    push_table_row(&mut out, &separators, &widths);
1176    for row in &rows {
1177        push_table_row(&mut out, row, &widths);
1178    }
1179    out
1180}
1181
1182/// The exact command the executor runs for a built-in tool in its documented mode:
1183/// the base command plus the mode-specific args it appends (`lint_args` for Lint,
1184/// `format_args` for Format). `Both` tools show both invocations when they differ.
1185/// This keeps the table honest about what actually runs, rather than the bare command.
1186fn runtime_command_for_kind(id: &str, kind: ToolKind) -> String {
1187    let Some(def) = BUILTIN_TOOLS.get(id) else {
1188        return String::new();
1189    };
1190    let invocation =
1191        |extra: &[String]| -> String { def.command.iter().chain(extra).cloned().collect::<Vec<_>>().join(" ") };
1192    match kind {
1193        ToolKind::Lint => invocation(&def.lint_args),
1194        ToolKind::Format => invocation(&def.format_args),
1195        ToolKind::Both => {
1196            let lint = invocation(&def.lint_args);
1197            let format = invocation(&def.format_args);
1198            if lint == format {
1199                lint
1200            } else {
1201                format!("{lint} / {format}")
1202            }
1203        }
1204    }
1205}
1206
1207/// Append one `| cell | cell | ... |` row, left-padding each cell to its column width.
1208fn push_table_row(out: &mut String, cells: &[String; 4], widths: &[usize; 4]) {
1209    out.push('|');
1210    for (i, cell) in cells.iter().enumerate() {
1211        out.push(' ');
1212        out.push_str(cell);
1213        for _ in cell.chars().count()..widths[i] {
1214            out.push(' ');
1215        }
1216        out.push_str(" |");
1217    }
1218    out.push('\n');
1219}
1220
1221/// Splice the generated table and the "Built-in tools" count into the docs file text.
1222///
1223/// Replaces only the content between the marker pair and the single count cell in the
1224/// mdsf comparison table; all other prose is preserved. Fails loudly on malformed or
1225/// missing markers rather than silently corrupting the file.
1226pub fn splice_builtin_tools_docs(existing: &str) -> Result<String, DocsError> {
1227    if existing.matches(TABLE_BEGIN).count() == 0 || existing.matches(TABLE_END).count() == 0 {
1228        return Err(DocsError::MissingMarker);
1229    }
1230    if existing.matches(TABLE_BEGIN).count() > 1 || existing.matches(TABLE_END).count() > 1 {
1231        return Err(DocsError::DuplicateMarker);
1232    }
1233    let begin_pos = existing.find(TABLE_BEGIN).unwrap();
1234    let end_pos = existing.find(TABLE_END).unwrap();
1235    if end_pos < begin_pos {
1236        return Err(DocsError::MarkerOrder);
1237    }
1238
1239    let table = render_builtin_tools_table();
1240    let replacement = format!("{TABLE_BEGIN}\n\n{table}\n{TABLE_END}");
1241
1242    let mut result = String::with_capacity(existing.len() + replacement.len());
1243    result.push_str(&existing[..begin_pos]);
1244    result.push_str(&replacement);
1245    result.push_str(&existing[end_pos + TABLE_END.len()..]);
1246
1247    update_builtin_tools_count(&result, builtin_tools_group_count())
1248}
1249
1250/// Rewrite the count cell in the unique `| Built-in tools | N | ... |` comparison row,
1251/// preserving the cell's width so the hand-aligned comparison table stays tidy.
1252fn update_builtin_tools_count(text: &str, count: usize) -> Result<String, DocsError> {
1253    let lines: Vec<&str> = text.lines().collect();
1254    let mut target: Option<usize> = None;
1255    for (i, line) in lines.iter().enumerate() {
1256        if line.trim_start().starts_with("| Built-in tools") {
1257            if target.is_some() {
1258                return Err(DocsError::CountRowAmbiguous);
1259            }
1260            target = Some(i);
1261        }
1262    }
1263    let i = target.ok_or(DocsError::CountRowMissing)?;
1264    let new_line = replace_count_cell(lines[i], count).ok_or(DocsError::CountRowMalformed)?;
1265
1266    let mut out = String::with_capacity(text.len());
1267    for (j, line) in lines.iter().enumerate() {
1268        out.push_str(if j == i { &new_line } else { line });
1269        out.push('\n');
1270    }
1271    if !text.ends_with('\n') {
1272        out.pop();
1273    }
1274    Ok(out)
1275}
1276
1277/// Replace the second cell (the count) of a markdown table row, keeping its width.
1278fn replace_count_cell(line: &str, count: usize) -> Option<String> {
1279    let pipes: Vec<usize> = line.match_indices('|').map(|(i, _)| i).collect();
1280    if pipes.len() < 3 {
1281        return None;
1282    }
1283    let start = pipes[1] + 1;
1284    let end = pipes[2];
1285    let cell = line.get(start..end)?;
1286    let width = cell.len();
1287    let num = count.to_string();
1288
1289    let mut new_cell = String::with_capacity(width.max(num.len() + 2));
1290    new_cell.push(' ');
1291    new_cell.push_str(&num);
1292    while new_cell.len() < width {
1293        new_cell.push(' ');
1294    }
1295    if new_cell.len() > width {
1296        // Number grew past the original cell width; keep a single padding space.
1297        new_cell = format!(" {num} ");
1298    }
1299
1300    Some(format!("{}{}{}", &line[..start], new_cell, &line[end..]))
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306
1307    #[test]
1308    fn test_get_builtin_tool() {
1309        let registry = ToolRegistry::default();
1310
1311        let tool = registry.get("ruff:check").expect("Should find ruff:check");
1312        assert!(tool.command.contains(&"ruff".to_string()));
1313        assert!(tool.stdin);
1314        assert!(tool.stdout);
1315
1316        let tool = registry.get("shellcheck").expect("Should find shellcheck");
1317        assert!(tool.command.contains(&"shellcheck".to_string()));
1318    }
1319
1320    #[test]
1321    fn test_builtin_yamlfmt_lint_command_validates_stdin() {
1322        let registry = ToolRegistry::default();
1323
1324        let tool = registry.get("yamlfmt").expect("Should find yamlfmt");
1325        let mut argv = tool.command.clone();
1326        argv.extend(tool.lint_args.clone());
1327
1328        assert_eq!(argv, vec!["yamlfmt", "-lint", "-"]);
1329    }
1330
1331    #[test]
1332    fn test_get_user_tool_overrides_builtin() {
1333        let mut user_tools = BTreeMap::new();
1334        user_tools.insert(
1335            "ruff:check".to_string(),
1336            ToolDefinition {
1337                command: vec!["custom-ruff".to_string()],
1338                stdin: false,
1339                stdout: false,
1340                lint_args: vec![],
1341                format_args: vec![],
1342            },
1343        );
1344
1345        let registry = ToolRegistry::new(user_tools);
1346
1347        let tool = registry.get("ruff:check").expect("Should find ruff:check");
1348        assert_eq!(tool.command, vec!["custom-ruff"]);
1349        assert!(!tool.stdin); // User override
1350    }
1351
1352    #[test]
1353    fn test_contains() {
1354        let registry = ToolRegistry::default();
1355
1356        assert!(registry.contains("ruff:check"));
1357        assert!(registry.contains("prettier"));
1358        assert!(registry.contains("shellcheck"));
1359        assert!(!registry.contains("nonexistent-tool"));
1360    }
1361
1362    #[test]
1363    fn test_list_tools() {
1364        let registry = ToolRegistry::default();
1365        let tools = registry.list_tools();
1366
1367        assert!(tools.contains(&"ruff:check"));
1368        assert!(tools.contains(&"ruff:format"));
1369        assert!(tools.contains(&"prettier"));
1370        assert!(tools.contains(&"shellcheck"));
1371        assert!(tools.contains(&"shfmt"));
1372        assert!(tools.contains(&"rustfmt"));
1373        assert!(tools.contains(&"gofmt"));
1374    }
1375
1376    #[test]
1377    fn test_user_tools_in_list() {
1378        let mut user_tools = BTreeMap::new();
1379        user_tools.insert("my-custom-tool".to_string(), ToolDefinition::default());
1380
1381        let registry = ToolRegistry::new(user_tools);
1382        let tools = registry.list_tools();
1383
1384        assert!(tools.contains(&"my-custom-tool"));
1385        assert!(tools.contains(&"ruff:check")); // Built-in still available
1386    }
1387
1388    #[test]
1389    fn test_new_builtin_tools() {
1390        let registry = ToolRegistry::default();
1391
1392        // djlint
1393        let tool = registry.get("djlint").expect("Should find djlint");
1394        assert!(tool.command.contains(&"djlint".to_string()));
1395        assert!(tool.stdin);
1396
1397        // beautysh
1398        let tool = registry.get("beautysh").expect("Should find beautysh");
1399        assert!(tool.command.contains(&"beautysh".to_string()));
1400        assert!(tool.stdin);
1401
1402        // tombi
1403        let tool = registry.get("tombi").expect("Should find tombi");
1404        assert!(tool.command.contains(&"tombi".to_string()));
1405        assert!(tool.stdin);
1406
1407        let tool = registry.get("tombi:lint").expect("Should find tombi:lint");
1408        assert!(tool.command.contains(&"lint".to_string()));
1409
1410        let tool = registry.get("tombi:format").expect("Should find tombi:format");
1411        assert!(
1412            tool.command.contains(&"format".to_string()),
1413            "tombi:format should use 'format' subcommand, got: {:?}",
1414            tool.command
1415        );
1416
1417        // oxfmt
1418        let tool = registry.get("oxfmt").expect("Should find oxfmt");
1419        assert!(tool.command.contains(&"oxfmt".to_string()));
1420        assert!(tool.stdin);
1421
1422        let tool = registry.get("oxfmt:ts").expect("Should find oxfmt:ts");
1423        assert!(tool.command.iter().any(|s| s.contains("_.ts")));
1424    }
1425
1426    // =========================================================================
1427    // Issue #527: bare "tombi" in format slot resolves to lint command
1428    // =========================================================================
1429
1430    /// The bare "tombi" registry entry defaults to `tombi lint -`.
1431    /// The processor's `resolve_tool` method handles context-aware resolution:
1432    /// in format context, it resolves "tombi" to "tombi:format" automatically.
1433    #[test]
1434    fn test_bare_tombi_resolves_to_lint_not_format() {
1435        let registry = ToolRegistry::default();
1436
1437        let bare = registry.get("tombi").expect("Should find bare tombi");
1438        let format = registry.get("tombi:format").expect("Should find tombi:format");
1439
1440        // The bare entry uses `lint` subcommand
1441        assert!(
1442            bare.command.contains(&"lint".to_string()),
1443            "Bare 'tombi' uses lint subcommand: {:?}",
1444            bare.command
1445        );
1446
1447        // The format entry uses `format` subcommand
1448        assert!(
1449            format.command.contains(&"format".to_string()),
1450            "tombi:format uses format subcommand: {:?}",
1451            format.command
1452        );
1453
1454        // These are different commands — using bare "tombi" in format = [...] is a bug
1455        assert_ne!(
1456            bare.command, format.command,
1457            "Bare 'tombi' and 'tombi:format' should have different commands (this is the root cause of #527)"
1458        );
1459    }
1460
1461    /// Tools that have both lint and format variants should have distinct entries.
1462    /// The processor resolves bare names to context-specific variants automatically.
1463    #[test]
1464    fn test_tools_with_lint_format_variants_are_distinct() {
1465        let registry = ToolRegistry::default();
1466
1467        // ruff has both check and format
1468        let ruff_check = registry.get("ruff:check").expect("ruff:check");
1469        let ruff_format = registry.get("ruff:format").expect("ruff:format");
1470        assert_ne!(
1471            ruff_check.command, ruff_format.command,
1472            "ruff:check and ruff:format should be distinct"
1473        );
1474
1475        // tombi has both lint and format
1476        let tombi_lint = registry.get("tombi:lint").expect("tombi:lint");
1477        let tombi_format = registry.get("tombi:format").expect("tombi:format");
1478        assert_ne!(
1479            tombi_lint.command, tombi_format.command,
1480            "tombi:lint and tombi:format should be distinct"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_deno_fmt_has_per_extension_variants() {
1486        // deno fmt bakes the extension into each variant (the registry does no runtime
1487        // substitution), so a per-language slot picks the right parser.
1488        let registry = ToolRegistry::default();
1489
1490        let deno_json = registry.get("deno-fmt:json").expect("deno-fmt:json");
1491        assert!(deno_json.command.iter().any(|a| a == "--ext=json"));
1492        let deno_md = registry.get("deno-fmt:md").expect("deno-fmt:md");
1493        assert!(deno_md.command.iter().any(|a| a == "--ext=md"));
1494
1495        // Bare entry defaults to TypeScript.
1496        let deno = registry.get("deno-fmt").expect("deno-fmt");
1497        assert!(deno.command.iter().any(|a| a == "--ext=ts"));
1498    }
1499
1500    // =========================================================================
1501    // Docs metadata <-> registry invariants (lock the table to the registry)
1502    // =========================================================================
1503
1504    #[test]
1505    fn test_docs_metadata_ids_unique() {
1506        let mut seen = std::collections::BTreeSet::new();
1507        for m in BUILTIN_TOOLS_DOCS {
1508            assert!(seen.insert(m.id), "duplicate metadata id: {}", m.id);
1509        }
1510    }
1511
1512    #[test]
1513    fn test_runtime_metadata_matches_registry_keys() {
1514        let runtime_meta: std::collections::BTreeSet<&str> =
1515            BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime).map(|m| m.id).collect();
1516        let map_keys: std::collections::BTreeSet<&str> = BUILTIN_TOOLS.keys().copied().collect();
1517        assert_eq!(
1518            runtime_meta, map_keys,
1519            "BUILTIN_TOOLS_DOCS runtime ids must exactly match BUILTIN_TOOLS keys (add/remove the doc entry alongside the registry entry)"
1520        );
1521    }
1522
1523    #[test]
1524    fn test_docs_only_ids_absent_from_registry() {
1525        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
1526            assert!(
1527                !BUILTIN_TOOLS.contains_key(m.id),
1528                "docs-only id {} must not be a runtime registry tool",
1529                m.id
1530            );
1531        }
1532        // rumdl is the docs-only entry and tracks the processor constant.
1533        assert!(
1534            BUILTIN_TOOLS_DOCS
1535                .iter()
1536                .any(|m| !m.runtime && m.id == RUMDL_BUILTIN_TOOL),
1537            "rumdl must be present as a docs-only entry"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_docs_only_requires_display_command() {
1543        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| !m.runtime) {
1544            assert!(
1545                m.display_command.is_some(),
1546                "docs-only id {} needs a display_command (no runtime command to derive)",
1547                m.id
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn test_multi_runtime_group_requires_display_command() {
1554        let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
1555        for m in BUILTIN_TOOLS_DOCS.iter().filter(|m| m.runtime) {
1556            *counts.entry(m.doc_group).or_default() += 1;
1557        }
1558        for m in BUILTIN_TOOLS_DOCS {
1559            if counts.get(m.doc_group).copied().unwrap_or(0) > 1 {
1560                assert!(
1561                    m.display_command.is_some(),
1562                    "doc_group `{}` has multiple runtime entries; `{}` needs an explicit display_command",
1563                    m.doc_group,
1564                    m.id
1565                );
1566            }
1567        }
1568    }
1569
1570    #[test]
1571    fn test_doc_group_language_and_kind_consistent() {
1572        let mut groups: std::collections::BTreeMap<&str, (&str, ToolKind)> = std::collections::BTreeMap::new();
1573        for m in BUILTIN_TOOLS_DOCS {
1574            match groups.get(m.doc_group) {
1575                None => {
1576                    groups.insert(m.doc_group, (m.language, m.kind));
1577                }
1578                Some((lang, kind)) => {
1579                    assert_eq!(
1580                        *lang, m.language,
1581                        "doc_group `{}` has mismatched languages",
1582                        m.doc_group
1583                    );
1584                    assert_eq!(*kind, m.kind, "doc_group `{}` has mismatched kinds", m.doc_group);
1585                }
1586            }
1587        }
1588    }
1589
1590    // =========================================================================
1591    // Generator: golden rows (hand-authored, independent of `generate`)
1592    // =========================================================================
1593
1594    /// Parse the rendered table into trimmed `(id, language, type, command)` tuples,
1595    /// independent of column-padding width.
1596    fn rendered_rows(table: &str) -> Vec<(String, String, String, String)> {
1597        table
1598            .lines()
1599            .skip(2) // header + separator
1600            .filter(|l| l.starts_with('|'))
1601            .map(|l| {
1602                let cells: Vec<String> = l.trim().trim_matches('|').split('|').map(|c| c.trim().to_string()).collect();
1603                (cells[0].clone(), cells[1].clone(), cells[2].clone(), cells[3].clone())
1604            })
1605            .collect()
1606    }
1607
1608    #[test]
1609    fn test_render_table_golden_rows() {
1610        let table = render_builtin_tools_table();
1611        let rows = rendered_rows(&table);
1612        let has = |id: &str, lang: &str, kind: &str, cmd: &str| {
1613            rows.iter()
1614                .any(|(i, l, k, c)| i == id && l == lang && k == kind && c == cmd)
1615        };
1616        // One exact row per kind, plus a collapsed group and the docs-only row.
1617        assert!(has("`shellcheck`", "Shell", "Lint", "`shellcheck --shell=bash -`"));
1618        assert!(has("`rustfmt`", "Rust", "Format", "`rustfmt`"));
1619        assert!(has("`jq`", "JSON", "Both", "`jq .`"));
1620        assert!(has(
1621            "`prettier`",
1622            "Multi",
1623            "Format",
1624            "`prettier --stdin-filepath=_.EXT`"
1625        ));
1626        assert!(has("`rumdl`", "Markdown", "Lint", "`built-in markdown linting`"));
1627        // Extension variants collapse into their group row.
1628        assert!(
1629            !table.contains("prettier:json"),
1630            "prettier variants must collapse into one row"
1631        );
1632        assert!(!table.contains("oxfmt:ts"), "oxfmt variants must collapse into one row");
1633    }
1634
1635    #[test]
1636    fn test_render_table_one_row_per_group() {
1637        let table = render_builtin_tools_table();
1638        assert_eq!(rendered_rows(&table).len(), builtin_tools_group_count());
1639    }
1640
1641    #[test]
1642    fn test_render_table_command_fallback_from_registry() {
1643        // A row with display_command: None pulls its command from BUILTIN_TOOLS.
1644        let table = render_builtin_tools_table();
1645        assert!(
1646            rendered_rows(&table)
1647                .iter()
1648                .any(|(i, _, _, c)| i == "`tombi`" && c == "`tombi lint -`")
1649        );
1650    }
1651
1652    #[test]
1653    fn test_render_command_reflects_mode_args() {
1654        let table = render_builtin_tools_table();
1655        let rows = rendered_rows(&table);
1656        let cmd = |id: &str| {
1657            let row = rows
1658                .iter()
1659                .find(|(i, _, _, _)| i == id)
1660                .unwrap_or_else(|| panic!("row {id} not found"));
1661            row.3.clone()
1662        };
1663        // Format-typed tools include format_args, not just the bare command.
1664        assert_eq!(cmd("`yamlfmt`"), "`yamlfmt -`");
1665        // Both-typed tools show the lint and format invocations when they differ.
1666        assert_eq!(cmd("`djlint`"), "`djlint - / djlint - --reformat`");
1667        // Lint-typed tools include their subcommand args.
1668        assert_eq!(cmd("`sqlfluff:lint`"), "`sqlfluff lint --dialect ansi -`");
1669    }
1670
1671    #[test]
1672    fn test_runtime_command_for_kind_both_collapses_when_equal() {
1673        // jq lints and formats with the same invocation, so it renders once.
1674        assert_eq!(runtime_command_for_kind("jq", ToolKind::Both), "jq .");
1675    }
1676
1677    // =========================================================================
1678    // Splice / marker handling (fail-loud)
1679    // =========================================================================
1680
1681    #[test]
1682    fn test_splice_replaces_region_and_preserves_prose() {
1683        let doc = format!(
1684            "# Title\n\nIntro.\n\n{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\nAfter.\n\n| Built-in tools | 31 | 339 |\n"
1685        );
1686        let out = splice_builtin_tools_docs(&doc).expect("splice");
1687        assert!(out.starts_with("# Title\n\nIntro.\n\n"));
1688        assert!(out.contains("After.\n"));
1689        assert!(!out.contains("stale"));
1690        assert!(
1691            out.contains(&render_builtin_tools_table()),
1692            "generated table is spliced in verbatim"
1693        );
1694        // Count updated to the group count, width preserved (2-digit -> 2-digit here).
1695        assert!(out.contains(&format!("| Built-in tools | {} | 339 |", builtin_tools_group_count())));
1696    }
1697
1698    #[test]
1699    fn test_splice_missing_marker_errors() {
1700        let doc = "# Title\n\nno markers\n\n| Built-in tools | 31 | 339 |\n";
1701        assert_eq!(splice_builtin_tools_docs(doc), Err(DocsError::MissingMarker));
1702    }
1703
1704    #[test]
1705    fn test_splice_duplicate_marker_errors() {
1706        let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n{TABLE_BEGIN}\n\ny\n\n{TABLE_END}\n");
1707        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::DuplicateMarker));
1708    }
1709
1710    #[test]
1711    fn test_splice_marker_order_errors() {
1712        let doc = format!("{TABLE_END}\n\nx\n\n{TABLE_BEGIN}\n\n| Built-in tools | 31 | 339 |\n");
1713        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::MarkerOrder));
1714    }
1715
1716    #[test]
1717    fn test_splice_missing_count_row_errors() {
1718        let doc = format!("{TABLE_BEGIN}\n\nx\n\n{TABLE_END}\n\nno count row here\n");
1719        assert_eq!(splice_builtin_tools_docs(&doc), Err(DocsError::CountRowMissing));
1720    }
1721
1722    #[test]
1723    fn test_splice_is_idempotent() {
1724        let doc = format!("{TABLE_BEGIN}\n\nstale\n\n{TABLE_END}\n\n| Built-in tools | 31 | 339 |\n");
1725        let once = splice_builtin_tools_docs(&doc).expect("first");
1726        let twice = splice_builtin_tools_docs(&once).expect("second");
1727        assert_eq!(once, twice, "splice must be idempotent");
1728    }
1729}