1use mdbook::book::{Book, BookItem, Chapter};
2use mdbook::preprocess::{Preprocessor, PreprocessorContext};
3
4use crate::Config;
5use crate::ignore::path_is_ignored;
6#[cfg(test)]
7use mdbook_lint_core::RuleCategory;
8use mdbook_lint_core::{
9 Document, LintEngine, MdBookLintError, PluginRegistry, Severity, Violation,
10};
11#[cfg(feature = "adr")]
12use mdbook_lint_rulesets::AdrRuleProvider;
13#[cfg(feature = "content")]
14use mdbook_lint_rulesets::ContentRuleProvider;
15use mdbook_lint_rulesets::{MdBookRuleProvider, RulePreset, StandardRuleProvider};
16use serde_json::Value;
17use std::io::{self, Read};
18use std::path::PathBuf;
19
20pub struct MdBookLint {
22 pub engine: LintEngine,
24 pub config: Config,
26 book_src_dir: Option<PathBuf>,
28}
29
30impl MdBookLint {
31 pub fn new() -> Self {
33 let mut registry = PluginRegistry::new();
34 registry
35 .register_provider(Box::new(StandardRuleProvider))
36 .expect("Failed to register standard rules");
37 registry
38 .register_provider(Box::new(MdBookRuleProvider))
39 .expect("Failed to register mdbook rules");
40 #[cfg(feature = "content")]
41 registry
42 .register_provider(Box::new(ContentRuleProvider))
43 .expect("Failed to register content rules");
44 #[cfg(feature = "adr")]
45 registry
46 .register_provider(Box::new(AdrRuleProvider))
47 .expect("Failed to register ADR rules");
48 let engine = registry.create_engine().expect("Failed to create engine");
49
50 Self {
51 config: Config::default(),
52 engine,
53 book_src_dir: None,
54 }
55 }
56
57 #[allow(dead_code)]
59 pub fn with_config(config: Config) -> Self {
60 let mut registry = PluginRegistry::new();
61 registry
62 .register_provider(Box::new(StandardRuleProvider))
63 .expect("Failed to register standard rules");
64 registry
65 .register_provider(Box::new(MdBookRuleProvider))
66 .expect("Failed to register mdbook rules");
67 #[cfg(feature = "content")]
68 registry
69 .register_provider(Box::new(ContentRuleProvider))
70 .expect("Failed to register content rules");
71 #[cfg(feature = "adr")]
72 registry
73 .register_provider(Box::new(AdrRuleProvider))
74 .expect("Failed to register ADR rules");
75 let effective_core = config.effective_core_config();
76 let engine = registry
77 .create_engine_with_config(Some(&effective_core))
78 .expect("Failed to create engine");
79
80 Self {
81 config,
82 engine,
83 book_src_dir: None,
84 }
85 }
86
87 pub fn load_config_from_context(
95 &mut self,
96 ctx: &PreprocessorContext,
97 ) -> mdbook_lint_core::Result<()> {
98 let book_root = &ctx.root;
99
100 let src_dir_name = ctx.config.book.src.to_str().unwrap_or("src").to_string();
103 self.book_src_dir = Some(book_root.join(&src_dir_name));
104
105 if let Some(discovered_path) = Config::discover_config(Some(book_root)) {
107 self.config = Config::from_file(&discovered_path)?;
108 }
109
110 let preprocessor_config = ctx
112 .config
113 .get_preprocessor("mdbook-lint")
114 .or_else(|| ctx.config.get_preprocessor("lint"));
115
116 if let Some(config) = preprocessor_config {
117 let book_toml_config = parse_mdbook_config(config)?;
118 self.config.merge(book_toml_config);
119 }
120
121 let mut registry = PluginRegistry::new();
123 registry
124 .register_provider(Box::new(StandardRuleProvider))
125 .expect("Failed to register standard rules");
126 registry
127 .register_provider(Box::new(MdBookRuleProvider))
128 .expect("Failed to register mdbook rules");
129 let effective_core = self.config.effective_core_config();
130 self.engine = registry
131 .create_engine_with_config(Some(&effective_core))
132 .expect("Failed to create configured engine");
133
134 Ok(())
135 }
136
137 pub fn chapter_is_ignored(&self, chapter: &Chapter) -> bool {
147 let patterns = &self.config.core.ignore_paths;
148 if patterns.is_empty() {
149 return false;
150 }
151
152 chapter
153 .source_path
154 .as_ref()
155 .is_some_and(|path| path_is_ignored(path, patterns))
156 }
157
158 fn process_chapter(&self, chapter: &Chapter) -> mdbook_lint_core::Result<Vec<Violation>> {
160 let source_path = chapter
164 .source_path
165 .as_ref()
166 .unwrap_or(&PathBuf::from("unknown.md"))
167 .clone();
168
169 let resolved_path = if let Some(ref book_src) = self.book_src_dir {
171 book_src.join(&source_path)
172 } else {
173 source_path
174 };
175
176 let document = Document::with_book_src_dir(
177 chapter.content.clone(),
178 resolved_path,
179 self.book_src_dir.clone(),
180 )?;
181
182 let effective_core = self.config.effective_core_config();
184 let violations = self
185 .engine
186 .lint_document_with_config(&document, &effective_core)?;
187
188 Ok(violations)
189 }
190
191 fn format_violations(&self, violations: &[Violation], chapter_path: &str) -> String {
193 if violations.is_empty() {
194 return String::new();
195 }
196
197 let mut output = String::new();
198 for violation in violations {
199 output.push_str(&format!(
200 "{}:{}:{}: {}\n",
201 chapter_path, violation.line, violation.column, violation
202 ));
203 }
204 output
205 }
206
207 fn should_fail_build(&self, violations: &[Violation]) -> bool {
209 for violation in violations {
210 match violation.severity {
211 Severity::Error if self.config.fail_on_errors => return true,
212 Severity::Warning if self.config.fail_on_warnings => return true,
213 _ => {}
214 }
215 }
216 false
217 }
218}
219
220impl Default for MdBookLint {
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226impl Preprocessor for MdBookLint {
227 fn name(&self) -> &str {
228 "lint"
229 }
230
231 fn run(&self, _ctx: &PreprocessorContext, book: Book) -> mdbook::errors::Result<Book> {
232 let mut total_violations = Vec::new();
233 let mut should_fail = false;
234
235 for item in book.iter() {
237 if let BookItem::Chapter(chapter) = item {
238 if self.chapter_is_ignored(chapter) {
241 continue;
242 }
243
244 let violations = self.process_chapter(chapter).map_err(|e| {
245 mdbook::errors::Error::msg(format!("Failed to process chapter: {e}"))
246 })?;
247
248 if !violations.is_empty() {
249 let chapter_path = chapter
250 .source_path
251 .as_ref()
252 .map(|p| p.to_string_lossy())
253 .unwrap_or("unknown".into());
254
255 eprint!("{}", self.format_violations(&violations, &chapter_path));
257
258 if self.should_fail_build(&violations) {
259 should_fail = true;
260 }
261
262 total_violations.extend(violations);
263 }
264 }
265 }
266
267 if !total_violations.is_empty() {
269 let error_count = total_violations
270 .iter()
271 .filter(|v| v.severity == Severity::Error)
272 .count();
273 let warning_count = total_violations
274 .iter()
275 .filter(|v| v.severity == Severity::Warning)
276 .count();
277 let info_count = total_violations
278 .iter()
279 .filter(|v| v.severity == Severity::Info)
280 .count();
281
282 eprintln!(
283 "mdbook-lint: {error_count} error(s), {warning_count} warning(s), {info_count} info"
284 );
285
286 if should_fail {
287 return Err(mdbook::errors::Error::msg(format!(
288 "mdbook-lint: Build failed due to {error_count} error(s) and {warning_count} warning(s)"
289 )));
290 }
291 } else {
292 eprintln!("mdbook-lint: No issues found");
293 }
294
295 Ok(book)
297 }
298
299 fn supports_renderer(&self, renderer: &str) -> bool {
300 match renderer {
302 "html" | "markdown" | "epub" | "pdf" => true,
303 _ => true, }
305 }
306}
307
308fn parse_mdbook_config(config: &toml::value::Table) -> mdbook_lint_core::Result<Config> {
310 let mut preprocessor_config = Config::default();
311
312 if let Some(preset) = config.get("preset") {
313 let preset = preset
314 .as_str()
315 .ok_or_else(|| MdBookLintError::config_error("preset must be a string"))?;
316 preprocessor_config.preset = Some(
317 preset
318 .parse::<RulePreset>()
319 .map_err(MdBookLintError::config_error)?,
320 );
321 }
322
323 if let Some(fail_on_warnings) = config.get("fail-on-warnings") {
324 preprocessor_config.fail_on_warnings = fail_on_warnings
325 .as_bool()
326 .ok_or_else(|| MdBookLintError::config_error("fail-on-warnings must be a boolean"))?;
327 }
328
329 if let Some(fail_on_errors) = config.get("fail-on-errors") {
330 preprocessor_config.fail_on_errors = fail_on_errors
331 .as_bool()
332 .ok_or_else(|| MdBookLintError::config_error("fail-on-errors must be a boolean"))?;
333 }
334
335 if let Some(enabled_categories) = config.get("enabled-categories")
336 && let Some(categories_array) = enabled_categories.as_array()
337 {
338 for category in categories_array {
339 if let Some(category_str) = category.as_str() {
340 preprocessor_config
341 .core
342 .enabled_categories
343 .push(category_str.to_string());
344 }
345 }
346 }
347
348 if let Some(disabled_categories) = config.get("disabled-categories")
349 && let Some(categories_array) = disabled_categories.as_array()
350 {
351 for category in categories_array {
352 if let Some(category_str) = category.as_str() {
353 preprocessor_config
354 .core
355 .disabled_categories
356 .push(category_str.to_string());
357 }
358 }
359 }
360
361 if let Some(enabled_rules) = config.get("enabled-rules")
362 && let Some(rules_array) = enabled_rules.as_array()
363 {
364 for rule in rules_array {
365 if let Some(rule_str) = rule.as_str() {
366 preprocessor_config
367 .core
368 .enabled_rules
369 .push(rule_str.to_string());
370 }
371 }
372 }
373
374 if let Some(disabled_rules) = config.get("disabled-rules")
375 && let Some(rules_array) = disabled_rules.as_array()
376 {
377 for rule in rules_array {
378 if let Some(rule_str) = rule.as_str() {
379 preprocessor_config
380 .core
381 .disabled_rules
382 .push(rule_str.to_string());
383 }
384 }
385 }
386
387 if let Some(ignore_paths) = config
389 .get("ignore-paths")
390 .or_else(|| config.get("ignore_paths"))
391 && let Some(paths_array) = ignore_paths.as_array()
392 {
393 for path in paths_array {
394 if let Some(path_str) = path.as_str() {
395 preprocessor_config
396 .core
397 .ignore_paths
398 .push(path_str.to_string());
399 }
400 }
401 }
402
403 Ok(preprocessor_config)
404}
405
406#[allow(dead_code)]
408fn parse_config(config: &Value) -> mdbook_lint_core::Result<Config> {
409 let mut preprocessor_config = Config::default();
410
411 if let Some(preset) = config.get("preset") {
412 let preset = preset
413 .as_str()
414 .ok_or_else(|| MdBookLintError::config_error("preset must be a string"))?;
415 preprocessor_config.preset = Some(
416 preset
417 .parse::<RulePreset>()
418 .map_err(MdBookLintError::config_error)?,
419 );
420 }
421
422 if let Some(fail_on_warnings) = config.get("fail-on-warnings") {
423 preprocessor_config.fail_on_warnings = fail_on_warnings
424 .as_bool()
425 .ok_or_else(|| MdBookLintError::config_error("fail-on-warnings must be a boolean"))?;
426 }
427
428 if let Some(fail_on_errors) = config.get("fail-on-errors") {
429 preprocessor_config.fail_on_errors = fail_on_errors
430 .as_bool()
431 .ok_or_else(|| MdBookLintError::config_error("fail-on-errors must be a boolean"))?;
432 }
433
434 if let Some(enabled_categories) = config.get("enabled-categories")
435 && let Some(categories_array) = enabled_categories.as_array()
436 {
437 for category in categories_array {
438 if let Some(category_str) = category.as_str() {
439 preprocessor_config
440 .core
441 .enabled_categories
442 .push(category_str.to_string());
443 }
444 }
445 }
446
447 if let Some(disabled_categories) = config.get("disabled-categories")
448 && let Some(categories_array) = disabled_categories.as_array()
449 {
450 for category in categories_array {
451 if let Some(category_str) = category.as_str() {
452 preprocessor_config
453 .core
454 .disabled_categories
455 .push(category_str.to_string());
456 }
457 }
458 }
459
460 if let Some(enabled_rules) = config.get("enabled-rules")
461 && let Some(rules_array) = enabled_rules.as_array()
462 {
463 for rule in rules_array {
464 if let Some(rule_str) = rule.as_str() {
465 preprocessor_config
466 .core
467 .enabled_rules
468 .push(rule_str.to_string());
469 }
470 }
471 }
472
473 if let Some(disabled_rules) = config.get("disabled-rules")
474 && let Some(rules_array) = disabled_rules.as_array()
475 {
476 for rule in rules_array {
477 if let Some(rule_str) = rule.as_str() {
478 preprocessor_config
479 .core
480 .disabled_rules
481 .push(rule_str.to_string());
482 }
483 }
484 }
485
486 Ok(preprocessor_config)
487}
488
489fn strip_null_values(value: Value) -> Value {
499 match value {
500 Value::Array(arr) => Value::Array(arr.into_iter().map(strip_null_values).collect()),
501 Value::Object(obj) => Value::Object(
502 obj.into_iter()
503 .filter(|(k, v)| !v.is_null() || k == "__non_exhaustive")
504 .map(|(k, v)| (k, strip_null_values(v)))
505 .collect(),
506 ),
507 other => other,
508 }
509}
510
511fn normalize_mdbook_json(value: Value) -> (Value, bool) {
523 let is_05 = detect_mdbook_05_format(&value);
524 let normalized = normalize_mdbook_json_inner(value);
525 (normalized, is_05)
526}
527
528fn detect_mdbook_05_format(value: &Value) -> bool {
530 if let Value::Array(arr) = value {
531 if let Some(book) = arr.get(1) {
533 return book.get("items").is_some() && book.get("sections").is_none();
534 }
535 }
536 false
537}
538
539fn normalize_mdbook_json_inner(value: Value) -> Value {
541 match value {
542 Value::Array(arr) => {
543 Value::Array(arr.into_iter().map(normalize_mdbook_json_inner).collect())
544 }
545 Value::Object(mut obj) => {
546 if obj.contains_key("items")
551 && !obj.contains_key("sections")
552 && let Some(items) = obj.remove("items")
553 {
554 obj.insert("sections".to_string(), normalize_mdbook_json_inner(items));
555 if !obj.contains_key("__non_exhaustive") {
557 obj.insert("__non_exhaustive".to_string(), Value::Null);
558 }
559 }
560
561 Value::Object(
563 obj.into_iter()
564 .map(|(k, v)| (k, normalize_mdbook_json_inner(v)))
565 .collect(),
566 )
567 }
568 other => other,
569 }
570}
571
572fn denormalize_for_mdbook_05(value: Value) -> Value {
577 match value {
578 Value::Array(arr) => Value::Array(arr.into_iter().map(denormalize_for_mdbook_05).collect()),
579 Value::Object(mut obj) => {
580 obj.remove("__non_exhaustive");
582
583 if obj.contains_key("sections")
585 && !obj.contains_key("items")
586 && let Some(sections) = obj.remove("sections")
587 {
588 obj.insert("items".to_string(), denormalize_for_mdbook_05(sections));
589 }
590
591 Value::Object(
593 obj.into_iter()
594 .map(|(k, v)| (k, denormalize_for_mdbook_05(v)))
595 .collect(),
596 )
597 }
598 other => other,
599 }
600}
601
602pub fn handle_preprocessing() -> mdbook_lint_core::Result<()> {
604 let mut stdin = io::stdin();
605 let mut input = String::new();
606 stdin
607 .read_to_string(&mut input)
608 .map_err(MdBookLintError::Io)?;
609
610 let json_value: Value = serde_json::from_str(&input).map_err(MdBookLintError::Json)?;
614 let (normalized, is_mdbook_05) = normalize_mdbook_json(json_value);
615 let cleaned = strip_null_values(normalized);
616
617 let (ctx, book): (PreprocessorContext, Book) =
618 serde_json::from_value(cleaned).map_err(MdBookLintError::Json)?;
619
620 let mut preprocessor = MdBookLint::new();
621 preprocessor.load_config_from_context(&ctx)?;
622
623 let processed_book = preprocessor
624 .run(&ctx, book)
625 .map_err(|e| MdBookLintError::document_error(format!("Preprocessor failed: {e}")))?;
626
627 let output_value = serde_json::to_value(&processed_book).map_err(MdBookLintError::Json)?;
629
630 let final_output = if is_mdbook_05 {
632 denormalize_for_mdbook_05(output_value)
633 } else {
634 output_value
635 };
636
637 let output = serde_json::to_string(&final_output).map_err(MdBookLintError::Json)?;
638
639 print!("{output}");
640 Ok(())
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use mdbook::book::Chapter;
647
648 use serde_json::json;
649 use std::path::PathBuf;
650
651 #[test]
652 fn test_preprocessor_name() {
653 let preprocessor = MdBookLint::new();
654 assert_eq!(preprocessor.name(), "lint");
655 }
656
657 #[test]
658 fn test_supports_renderer() {
659 let preprocessor = MdBookLint::new();
660 assert!(preprocessor.supports_renderer("html"));
661 assert!(preprocessor.supports_renderer("markdown"));
662 assert!(preprocessor.supports_renderer("epub"));
663 assert!(preprocessor.supports_renderer("pdf"));
664 assert!(preprocessor.supports_renderer("custom"));
665 }
666
667 #[test]
668 fn test_process_chapter_clean() {
669 let preprocessor = MdBookLint::new();
670 let content = "# Test Chapter
673
674This is a clean chapter with enough content to pass all linting rules.
675We need to write several sentences here to make sure we have at least
676fifty words in total for the content quality checks.
677
678Let me add some more text to ensure we definitely pass the minimum word
679count threshold that is required by the linter for content validation.
680";
681 let chapter = Chapter::new(
682 "Test Chapter",
683 content.to_string(),
684 PathBuf::from("test.md"),
685 Vec::new(),
686 );
687
688 let violations = preprocessor.process_chapter(&chapter).unwrap();
689 assert_eq!(violations.len(), 0);
690 }
691
692 #[test]
693 fn test_process_chapter_with_violations() {
694 let preprocessor = MdBookLint::new();
695 let content = "# Level 1\n### Level 3 - skipped level 2\n".to_string();
696 let chapter = Chapter::new(
697 "Test Chapter",
698 content,
699 PathBuf::from("test.md"),
700 Vec::new(),
701 );
702
703 let violations = preprocessor.process_chapter(&chapter).unwrap();
704 assert!(!violations.is_empty());
705
706 println!("Found {} violations:", violations.len());
708 for (i, violation) in violations.iter().enumerate() {
709 println!(
710 " {}: {} (line {}) - {}",
711 i, violation.rule_id, violation.line, violation.message
712 );
713 }
714
715 let md001_violations: Vec<_> = violations.iter().filter(|v| v.rule_id == "MD001").collect();
717 assert!(
718 !md001_violations.is_empty(),
719 "Should have at least one MD001 violation"
720 );
721 }
722
723 #[test]
724 fn test_rule_filtering_default() {
725 let mut registry = PluginRegistry::new();
726 registry
727 .register_provider(Box::new(StandardRuleProvider))
728 .unwrap();
729 registry
730 .register_provider(Box::new(MdBookRuleProvider))
731 .unwrap();
732 let engine = registry.create_engine().unwrap();
733 let config = Config::default();
734 let enabled_rules = engine.enabled_rules(&config.core);
735
736 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
738 assert!(enabled_rule_ids.contains(&"MD001"));
739 assert!(enabled_rule_ids.contains(&"MD013"));
740 assert!(!enabled_rule_ids.contains(&"MD002")); }
742
743 #[test]
744 fn test_rule_filtering_with_enabled_rules() {
745 let mut registry = PluginRegistry::new();
746 registry
747 .register_provider(Box::new(StandardRuleProvider))
748 .unwrap();
749 registry
750 .register_provider(Box::new(MdBookRuleProvider))
751 .unwrap();
752 let engine = registry.create_engine().unwrap();
753 let config = Config {
754 core: mdbook_lint_core::Config {
755 enabled_rules: vec!["MD001".to_string(), "MD002".to_string()],
756 ..Default::default()
757 },
758 ..Default::default()
759 };
760 let enabled_rules = engine.enabled_rules(&config.core);
761
762 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
763 assert!(enabled_rule_ids.contains(&"MD001"));
764 assert!(enabled_rule_ids.contains(&"MD002")); assert_eq!(enabled_rule_ids.len(), 2);
766 }
767
768 #[test]
769 fn test_rule_filtering_with_disabled_rules() {
770 let mut registry = PluginRegistry::new();
771 registry
772 .register_provider(Box::new(StandardRuleProvider))
773 .unwrap();
774 registry
775 .register_provider(Box::new(MdBookRuleProvider))
776 .unwrap();
777 let engine = registry.create_engine().unwrap();
778 let config = Config {
779 core: mdbook_lint_core::Config {
780 disabled_rules: vec!["MD001".to_string()],
781 ..Default::default()
782 },
783 ..Default::default()
784 };
785 let enabled_rules = engine.enabled_rules(&config.core);
786
787 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
788 assert!(!enabled_rule_ids.contains(&"MD001"));
789 assert!(enabled_rule_ids.contains(&"MD013"));
790 }
791
792 #[test]
793 fn test_with_config_constructor() {
794 let config = Config {
795 fail_on_warnings: true,
796 core: mdbook_lint_core::Config {
797 enabled_rules: vec!["MD001".to_string()],
798 ..Default::default()
799 },
800 ..Default::default()
801 };
802
803 let preprocessor = MdBookLint::with_config(config);
804 assert!(preprocessor.config.fail_on_warnings);
805 assert_eq!(
806 preprocessor.config.core.enabled_rules,
807 vec!["MD001".to_string()]
808 );
809 }
810
811 #[test]
812 fn test_process_chapter_with_empty_content() {
813 let preprocessor = MdBookLint::new();
814 let chapter = Chapter::new(
815 "Empty Chapter",
816 String::new(),
817 PathBuf::from("empty.md"),
818 Vec::new(),
819 );
820
821 let result = preprocessor.process_chapter(&chapter);
822 assert!(result.is_ok(), "Processing empty content should succeed");
824 }
825
826 #[test]
827 fn test_process_chapter_with_whitespace_only() {
828 let preprocessor = MdBookLint::new();
829 let chapter = Chapter::new(
830 "Whitespace Chapter",
831 " \n \n ".to_string(),
832 PathBuf::from("whitespace.md"),
833 Vec::new(),
834 );
835
836 let result = preprocessor.process_chapter(&chapter);
837 assert!(
839 result.is_ok(),
840 "Processing whitespace-only content should succeed"
841 );
842 }
843
844 #[test]
845 fn test_process_chapter_with_custom_config() {
846 let config = Config {
847 core: mdbook_lint_core::Config {
848 disabled_rules: vec!["MD001".to_string()],
849 ..Default::default()
850 },
851 ..Default::default()
852 };
853 let preprocessor = MdBookLint::with_config(config);
854
855 let content = "# Level 1\n### Level 3 - skipped level 2\n".to_string();
856 let chapter = Chapter::new(
857 "Test Chapter",
858 content,
859 PathBuf::from("test.md"),
860 Vec::new(),
861 );
862
863 let violations = preprocessor.process_chapter(&chapter).unwrap();
864 let md001_violations: Vec<_> = violations.iter().filter(|v| v.rule_id == "MD001").collect();
866 assert_eq!(md001_violations.len(), 0);
867 }
868
869 #[test]
870 fn test_process_chapter_error_handling() {
871 let preprocessor = MdBookLint::new();
872 let chapter = Chapter::new(
873 "Test Chapter",
874 "# Valid content".to_string(),
875 PathBuf::from("test.md"),
876 Vec::new(),
877 );
878
879 let result = preprocessor.process_chapter(&chapter);
881 assert!(result.is_ok());
882 }
883
884 #[test]
885 fn test_rule_filtering_with_categories() {
886 let mut registry = PluginRegistry::new();
887 registry
888 .register_provider(Box::new(StandardRuleProvider))
889 .unwrap();
890 registry
891 .register_provider(Box::new(MdBookRuleProvider))
892 .unwrap();
893 let engine = registry.create_engine().unwrap();
894 let config = Config {
895 core: mdbook_lint_core::Config {
896 enabled_categories: vec!["structure".to_string()],
897 ..Default::default()
898 },
899 ..Default::default()
900 };
901 let enabled_rules = engine.enabled_rules(&config.core);
902
903 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
904 assert!(enabled_rule_ids.contains(&"MD001"));
906 let structure_rules = enabled_rules
908 .iter()
909 .filter(|r| matches!(r.metadata().category, RuleCategory::Structure))
910 .count();
911 assert!(structure_rules > 0);
912 }
913
914 #[test]
915 fn test_rule_filtering_with_disabled_categories() {
916 let mut registry = PluginRegistry::new();
917 registry
918 .register_provider(Box::new(StandardRuleProvider))
919 .unwrap();
920 registry
921 .register_provider(Box::new(MdBookRuleProvider))
922 .unwrap();
923 let engine = registry.create_engine().unwrap();
924 let config = Config {
925 core: mdbook_lint_core::Config {
926 disabled_categories: vec!["style".to_string()],
927 ..Default::default()
928 },
929 ..Default::default()
930 };
931 let enabled_rules = engine.enabled_rules(&config.core);
932
933 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
934 assert!(enabled_rule_ids.contains(&"MD001"));
936 let style_rules = enabled_rules
938 .iter()
939 .filter(|r| matches!(r.metadata().category, RuleCategory::Formatting))
940 .count();
941 assert!(style_rules < 50); }
944
945 #[test]
946 fn test_rule_filtering_with_disabled_rules_comprehensive() {
947 let mut registry = PluginRegistry::new();
948 registry
949 .register_provider(Box::new(StandardRuleProvider))
950 .unwrap();
951 registry
952 .register_provider(Box::new(MdBookRuleProvider))
953 .unwrap();
954 let engine = registry.create_engine().unwrap();
955 let config = Config {
956 core: mdbook_lint_core::Config {
957 disabled_rules: vec!["MD013".to_string()],
958 ..Default::default()
959 },
960 ..Default::default()
961 };
962 let enabled_rules = engine.enabled_rules(&config.core);
963
964 let enabled_rule_ids: Vec<&str> = enabled_rules.iter().map(|r| r.id()).collect();
965 assert!(enabled_rule_ids.contains(&"MD001"));
966 assert!(!enabled_rule_ids.contains(&"MD013")); assert!(enabled_rule_ids.len() > 50); }
969
970 #[test]
971 fn test_parse_config() {
972 let config_json = json!({
973 "preset": "baseline",
974 "fail-on-warnings": true,
975 "fail-on-errors": false,
976 "enabled-rules": ["MD001", "MD013"],
977 "disabled-rules": ["MD002"]
978 });
979
980 let config = parse_config(&config_json).unwrap();
981 assert_eq!(config.preset, Some(RulePreset::Baseline));
982 assert!(config.fail_on_warnings);
983 assert!(!config.fail_on_errors);
984 assert_eq!(config.core.enabled_rules, vec!["MD001", "MD013"]);
985 assert_eq!(config.core.disabled_rules, vec!["MD002"]);
986 }
987
988 #[test]
989 fn test_parse_mdbook_config_accepts_baseline_preset() {
990 let value: toml::Value = toml::from_str("preset = \"baseline\"\n").unwrap();
991 let config = parse_mdbook_config(value.as_table().unwrap()).unwrap();
992
993 assert_eq!(config.preset, Some(RulePreset::Baseline));
994 assert_eq!(
995 config.effective_core_config().enabled_rules,
996 RulePreset::Baseline.rule_ids()
997 );
998 }
999
1000 fn chapter_at(source_path: &str) -> Chapter {
1002 Chapter::new(
1003 "Test Chapter",
1004 "# Heading\n".to_string(),
1005 PathBuf::from(source_path),
1006 Vec::new(),
1007 )
1008 }
1009
1010 fn preprocessor_ignoring(patterns: &[&str]) -> MdBookLint {
1012 let toml = format!(
1013 "ignore-paths = [{}]",
1014 patterns
1015 .iter()
1016 .map(|p| format!("\"{p}\""))
1017 .collect::<Vec<_>>()
1018 .join(", ")
1019 );
1020 MdBookLint::with_config(Config::from_toml_str(&toml).unwrap())
1021 }
1022
1023 #[test]
1024 fn test_chapter_is_ignored_matches_relative_source_path() {
1025 let pp = preprocessor_ignoring(&["generated/"]);
1028
1029 assert!(pp.chapter_is_ignored(&chapter_at("generated/api.md")));
1030 assert!(pp.chapter_is_ignored(&chapter_at("generated/deep/nested.md")));
1031 assert!(!pp.chapter_is_ignored(&chapter_at("guide/intro.md")));
1032 assert!(!pp.chapter_is_ignored(&chapter_at("generated.md")));
1033 }
1034
1035 #[test]
1036 fn test_chapter_is_ignored_supports_glob_patterns() {
1037 let pp = preprocessor_ignoring(&["*.backup.md", "vendor/"]);
1038
1039 assert!(pp.chapter_is_ignored(&chapter_at("notes.backup.md")));
1040 assert!(pp.chapter_is_ignored(&chapter_at("a/b/notes.backup.md")));
1041 assert!(pp.chapter_is_ignored(&chapter_at("vendor/thing.md")));
1042 assert!(!pp.chapter_is_ignored(&chapter_at("notes.md")));
1043 }
1044
1045 #[test]
1046 fn test_chapter_is_ignored_accepts_both_config_spellings() {
1047 for toml in [
1048 "ignore-paths = [\"generated/\"]",
1049 "ignore_paths = [\"generated/\"]",
1050 ] {
1051 let pp = MdBookLint::with_config(Config::from_toml_str(toml).unwrap());
1052 assert!(
1053 pp.chapter_is_ignored(&chapter_at("generated/api.md")),
1054 "spelling not honored: {toml}"
1055 );
1056 assert!(!pp.chapter_is_ignored(&chapter_at("guide/intro.md")));
1057 }
1058 }
1059
1060 #[test]
1061 fn test_chapter_is_ignored_defaults_to_linting_everything() {
1062 let pp = MdBookLint::new();
1064 assert!(!pp.chapter_is_ignored(&chapter_at("generated/api.md")));
1065 }
1066
1067 #[test]
1068 fn test_chapter_without_source_path_is_never_ignored() {
1069 let pp = preprocessor_ignoring(&["generated/"]);
1071 let mut chapter = chapter_at("generated/api.md");
1072 chapter.source_path = None;
1073 assert!(!pp.chapter_is_ignored(&chapter));
1074 }
1075
1076 #[test]
1077 fn test_ignored_chapter_is_not_linted() {
1078 let content = "# Level 1\n### Level 3 - skipped level 2\n";
1081 let chapter = Chapter::new(
1082 "Test Chapter",
1083 content.to_string(),
1084 PathBuf::from("generated/api.md"),
1085 Vec::new(),
1086 );
1087
1088 let plain = MdBookLint::new();
1090 assert!(!plain.chapter_is_ignored(&chapter));
1091 assert!(!plain.process_chapter(&chapter).unwrap().is_empty());
1092
1093 assert!(preprocessor_ignoring(&["generated/"]).chapter_is_ignored(&chapter));
1095 }
1096
1097 #[test]
1098 fn test_should_fail_build() {
1099 let config = Config {
1100 fail_on_warnings: false,
1101 fail_on_errors: true,
1102 ..Default::default()
1103 };
1104 let preprocessor = MdBookLint::with_config(config);
1105
1106 let warning_violations = vec![Violation {
1108 rule_id: "MD001".to_string(),
1109 rule_name: "test".to_string(),
1110 message: "test".to_string(),
1111 line: 1,
1112 column: 1,
1113 severity: Severity::Warning,
1114 fix: None,
1115 }];
1116 assert!(!preprocessor.should_fail_build(&warning_violations));
1117
1118 let error_violations = vec![Violation {
1120 rule_id: "MD001".to_string(),
1121 rule_name: "test".to_string(),
1122 message: "test".to_string(),
1123 line: 1,
1124 column: 1,
1125 severity: Severity::Error,
1126 fix: None,
1127 }];
1128 assert!(preprocessor.should_fail_build(&error_violations));
1129 }
1130
1131 #[test]
1132 fn test_format_violations() {
1133 let preprocessor = MdBookLint::new();
1134 let violations = vec![Violation {
1135 rule_id: "MD001".to_string(),
1136 rule_name: "heading-increment".to_string(),
1137 message: "Test violation".to_string(),
1138 line: 2,
1139 column: 1,
1140 severity: Severity::Error,
1141 fix: None,
1142 }];
1143
1144 let output = preprocessor.format_violations(&violations, "test.md");
1145 assert!(output.contains("test.md:2:1"));
1146 assert!(output.contains("MD001"));
1147 assert!(output.contains("Test violation"));
1148 }
1149
1150 #[test]
1151 fn test_strip_null_values() {
1152 let input = json!({
1154 "title": "My Book",
1155 "description": null,
1156 "authors": ["Author"],
1157 "nested": {
1158 "field": "value",
1159 "null_field": null
1160 },
1161 "array_with_nulls": [1, null, 3]
1162 });
1163
1164 let result = strip_null_values(input);
1165
1166 assert!(result.get("title").is_some());
1168 assert!(result.get("description").is_none());
1169 assert!(result.get("authors").is_some());
1170
1171 let nested = result.get("nested").unwrap();
1173 assert!(nested.get("field").is_some());
1174 assert!(nested.get("null_field").is_none());
1175
1176 let arr = result.get("array_with_nulls").unwrap().as_array().unwrap();
1178 assert_eq!(arr.len(), 3);
1179 }
1180
1181 #[test]
1182 fn test_strip_null_values_preserves_non_exhaustive() {
1183 let input = json!({
1185 "sections": [],
1186 "__non_exhaustive": null
1187 });
1188
1189 let result = strip_null_values(input);
1190
1191 assert!(result.get("sections").is_some());
1192 assert!(result.get("__non_exhaustive").is_some());
1193 }
1194
1195 #[test]
1196 fn test_strip_null_values_preserves_valid_data() {
1197 let input = json!({
1198 "string": "hello",
1199 "number": 42,
1200 "bool": true,
1201 "array": [1, 2, 3],
1202 "object": {"key": "value"}
1203 });
1204
1205 let result = strip_null_values(input.clone());
1206
1207 assert_eq!(result, input);
1209 }
1210
1211 #[test]
1212 fn test_normalize_mdbook_json_v04_format() {
1213 let input = json!([
1215 {"root": "/tmp", "config": {}, "renderer": "html", "mdbook_version": "0.4.52"},
1216 {"sections": [{"Chapter": {"name": "Test", "content": "# Test"}}], "__non_exhaustive": null}
1217 ]);
1218
1219 let (result, is_05) = normalize_mdbook_json(input.clone());
1220
1221 assert!(!is_05);
1223
1224 let book = result.as_array().unwrap().get(1).unwrap();
1226 assert!(book.get("sections").is_some());
1227 assert!(book.get("items").is_none());
1228 }
1229
1230 #[test]
1231 fn test_normalize_mdbook_json_v05_format() {
1232 let input = json!([
1234 {"root": "/tmp", "config": {}, "renderer": "html", "mdbook_version": "0.5.1"},
1235 {"items": [{"Chapter": {"name": "Test", "content": "# Test"}}]}
1236 ]);
1237
1238 let (result, is_05) = normalize_mdbook_json(input);
1239
1240 assert!(is_05);
1242
1243 let book = result.as_array().unwrap().get(1).unwrap();
1245 assert!(book.get("sections").is_some());
1246 assert!(book.get("items").is_none());
1247
1248 assert!(book.get("__non_exhaustive").is_some());
1250
1251 let sections = book.get("sections").unwrap().as_array().unwrap();
1253 assert_eq!(sections.len(), 1);
1254 }
1255
1256 #[test]
1257 fn test_normalize_mdbook_json_nested_items() {
1258 let input = json!([
1260 {"root": "/tmp", "config": {}, "renderer": "html", "mdbook_version": "0.5.1"},
1261 {
1262 "items": [
1263 {
1264 "Chapter": {
1265 "name": "Parent",
1266 "content": "# Parent",
1267 "sub_items": [
1268 {"Chapter": {"name": "Child", "content": "# Child"}}
1269 ]
1270 }
1271 }
1272 ],
1273 "__non_exhaustive": null
1274 }
1275 ]);
1276
1277 let (result, _is_05) = normalize_mdbook_json(input);
1278
1279 let book = result.as_array().unwrap().get(1).unwrap();
1281 assert!(book.get("sections").is_some());
1282
1283 let sections = book.get("sections").unwrap().as_array().unwrap();
1285 let chapter = sections[0].get("Chapter").unwrap();
1286 assert!(chapter.get("sub_items").is_some());
1287 }
1288
1289 #[test]
1290 fn test_normalize_preserves_other_fields() {
1291 let input = json!({
1294 "items": [1, 2, 3],
1295 "other_field": "value",
1296 "nested": {
1297 "items": ["a", "b"],
1298 "data": 42
1299 }
1300 });
1301
1302 let result = normalize_mdbook_json_inner(input);
1303
1304 assert!(result.get("sections").is_some());
1306 assert!(result.get("other_field").is_some());
1307
1308 let nested = result.get("nested").unwrap();
1309 assert!(nested.get("sections").is_some());
1310 assert!(nested.get("data").is_some());
1311 }
1312
1313 #[test]
1314 fn test_full_mdbook_04_json_parsing() {
1315 let input = json!([
1317 {
1318 "root": "/tmp/test",
1319 "config": {
1320 "book": {
1321 "title": "Test Book",
1322 "authors": ["Author"],
1323 "description": null,
1324 "language": "en",
1325 "text-direction": null
1326 },
1327 "output": {"html": {}},
1328 "preprocessor": {}
1329 },
1330 "renderer": "html",
1331 "mdbook_version": "0.4.52"
1332 },
1333 {
1334 "sections": [
1335 {
1336 "Chapter": {
1337 "name": "Introduction",
1338 "content": "# Introduction\n\nWelcome!",
1339 "number": [1],
1340 "sub_items": [],
1341 "path": "intro.md",
1342 "source_path": "intro.md",
1343 "parent_names": []
1344 }
1345 }
1346 ],
1347 "__non_exhaustive": null
1348 }
1349 ]);
1350
1351 let (normalized, is_05) = normalize_mdbook_json(input);
1353 let cleaned = strip_null_values(normalized);
1354
1355 assert!(!is_05);
1357
1358 let arr = cleaned.as_array().unwrap();
1360 assert_eq!(arr.len(), 2);
1361
1362 let ctx = &arr[0];
1363 assert!(ctx.get("root").is_some());
1364 assert!(ctx.get("config").is_some());
1365
1366 let book = &arr[1];
1367 assert!(book.get("sections").is_some());
1368 assert!(book.get("__non_exhaustive").is_some());
1369
1370 let config = ctx.get("config").unwrap();
1372 let book_config = config.get("book").unwrap();
1373 assert!(book_config.get("description").is_none()); assert!(book_config.get("title").is_some()); }
1376
1377 #[test]
1378 fn test_full_mdbook_05_json_parsing() {
1379 let input = json!([
1381 {
1382 "root": "/tmp/test",
1383 "config": {
1384 "book": {
1385 "title": "Test Book",
1386 "authors": ["Author"],
1387 "description": null,
1388 "language": "en",
1389 "text-direction": null
1390 },
1391 "output": {"html": {}},
1392 "preprocessor": {}
1393 },
1394 "renderer": "html",
1395 "mdbook_version": "0.5.1"
1396 },
1397 {
1398 "items": [
1399 {
1400 "Chapter": {
1401 "name": "Introduction",
1402 "content": "# Introduction\n\nWelcome!",
1403 "number": [1],
1404 "sub_items": [],
1405 "path": "intro.md",
1406 "source_path": "intro.md",
1407 "parent_names": []
1408 }
1409 }
1410 ]
1411 }
1412 ]);
1413
1414 let (normalized, is_05) = normalize_mdbook_json(input);
1416 let cleaned = strip_null_values(normalized);
1417
1418 assert!(is_05);
1420
1421 let arr = cleaned.as_array().unwrap();
1423 assert_eq!(arr.len(), 2);
1424
1425 let book = &arr[1];
1426 assert!(book.get("sections").is_some());
1428 assert!(book.get("items").is_none());
1429 assert!(book.get("__non_exhaustive").is_some());
1431 }
1432
1433 #[test]
1434 fn test_denormalize_for_mdbook_05() {
1435 let input = json!({
1437 "sections": [
1438 {"Chapter": {"name": "Test", "content": "# Test"}}
1439 ],
1440 "__non_exhaustive": null
1441 });
1442
1443 let result = denormalize_for_mdbook_05(input);
1444
1445 assert!(result.get("items").is_some());
1447 assert!(result.get("sections").is_none());
1448
1449 assert!(result.get("__non_exhaustive").is_none());
1451 }
1452
1453 #[test]
1454 fn test_roundtrip_mdbook_05() {
1455 let input = json!([
1457 {"root": "/tmp", "config": {}, "renderer": "html", "mdbook_version": "0.5.1"},
1458 {"items": [{"Chapter": {"name": "Test", "content": "# Test"}}]}
1459 ]);
1460
1461 let (normalized, is_05) = normalize_mdbook_json(input.clone());
1462 assert!(is_05);
1463
1464 let book = normalized.as_array().unwrap().get(1).unwrap();
1466 assert!(book.get("sections").is_some());
1467
1468 let denormalized_book = denormalize_for_mdbook_05(book.clone());
1470
1471 assert!(denormalized_book.get("items").is_some());
1473 assert!(denormalized_book.get("sections").is_none());
1474 assert!(denormalized_book.get("__non_exhaustive").is_none());
1475 }
1476}