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