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