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