Skip to main content

mdbook_lint/
preprocessor.rs

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
20/// mdbook-lint preprocessor
21pub struct MdBookLint {
22    /// Linting engine with combined rules
23    pub engine: LintEngine,
24    /// Configuration options
25    pub config: Config,
26    /// Book source directory (set from PreprocessorContext in preprocessor mode)
27    book_src_dir: Option<PathBuf>,
28}
29
30impl MdBookLint {
31    /// Create a new preprocessor with default rules and config
32    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    /// Create a new preprocessor with custom config
58    #[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    /// Load configuration from preprocessor context
88    ///
89    /// Configuration is loaded from multiple sources with the following precedence
90    /// (later sources override earlier ones):
91    /// 1. Default configuration
92    /// 2. `.mdbook-lint.toml` file (if found in book root or parent directories)
93    /// 3. `[preprocessor.lint]` section in `book.toml`
94    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        // Compute the book source directory from context
101        // This is root + book.src (which defaults to "src")
102        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        // First, try to discover and load .mdbook-lint.toml config file
106        if let Some(discovered_path) = Config::discover_config(Some(book_root)) {
107            self.config = Config::from_file(&discovered_path)?;
108        }
109
110        // Then, merge with book.toml preprocessor config (takes precedence)
111        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        // Recreate the engine with the loaded configuration
122        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    /// Return true if a chapter is excluded by the global `ignore-paths` config.
138    ///
139    /// Patterns are matched against the chapter's `source_path`, which mdBook
140    /// reports relative to the book source directory. Matching the relative path
141    /// keeps a pattern such as `generated/` meaningful regardless of where the
142    /// book lives on disk, and matches how the CLI applies the same patterns.
143    ///
144    /// A chapter with no `source_path` (a generated or synthetic chapter) has
145    /// nothing to match against and is never ignored.
146    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    /// Process a chapter and return any violations found
159    fn process_chapter(&self, chapter: &Chapter) -> mdbook_lint_core::Result<Vec<Violation>> {
160        // Create document from chapter content
161        // When running in preprocessor mode, source_path is relative to the book source directory
162        // We need to resolve it to an absolute path for rules that check file existence
163        let source_path = chapter
164            .source_path
165            .as_ref()
166            .unwrap_or(&PathBuf::from("unknown.md"))
167            .clone();
168
169        // If we have a book source directory, resolve the path to absolute
170        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        // Use optimized checking (single AST parse) with configuration
183        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    /// Format violations for output
192    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    /// Determine if we should fail the build based on violations
208    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        // Process each chapter
236        for item in book.iter() {
237            if let BookItem::Chapter(chapter) = item {
238                // Honor the same global ignore-paths the CLI applies. Ignored
239                // chapters are left untouched in the returned book.
240                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                    // Print violations to stderr
256                    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        // Print summary
268        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        // Return the book unchanged (we're just linting, not modifying)
296        Ok(book)
297    }
298
299    fn supports_renderer(&self, renderer: &str) -> bool {
300        // We support all renderers since we don't modify content
301        match renderer {
302            "html" | "markdown" | "epub" | "pdf" => true,
303            _ => true, // Default to supporting unknown renderers
304        }
305    }
306}
307
308/// Parse preprocessor configuration from mdbook config
309fn 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    // Both spellings are accepted, matching the standalone config file.
388    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/// Parse preprocessor configuration from serde_json Value (for tests)
407#[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
489/// Recursively remove null values from a JSON value, except for specific fields.
490///
491/// This is necessary because mdbook sends JSON with null values (e.g., `"description": null`),
492/// but mdbook's Config struct deserializes through `toml::Value` which doesn't support null.
493/// By stripping nulls before deserialization, we avoid the "invalid type: null, expected any
494/// valid TOML value" error.
495///
496/// However, we preserve `__non_exhaustive` fields because mdbook's Book struct requires them
497/// for deserialization (they deserialize to `()` which accepts null).
498fn 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
511/// Normalize mdbook JSON format for compatibility between 0.4.x and 0.5.x.
512///
513/// mdbook 0.5.x made several changes to the JSON format:
514/// 1. Book struct's field name changed from `sections` to `items`
515/// 2. The `__non_exhaustive` field was removed from the JSON output
516///
517/// This function converts the 0.5.x format back to 0.4.x format so we can deserialize
518/// using the mdbook 0.4.x types we depend on.
519///
520/// The input is expected to be a JSON array: `[PreprocessorContext, Book]`
521/// Returns (normalized_value, is_mdbook_05) tuple.
522fn 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
528/// Detect if the JSON is in mdbook 0.5.x format (uses "items" instead of "sections")
529fn detect_mdbook_05_format(value: &Value) -> bool {
530    if let Value::Array(arr) = value {
531        // The Book object is the second element in the array
532        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
539/// Inner normalization function that converts items -> sections
540fn 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 this object has "items" but not "sections", it's a mdbook 0.5.x Book
547            // We need to:
548            // 1. Rename "items" to "sections"
549            // 2. Add "__non_exhaustive": null (required by mdbook 0.4.x deserialization)
550            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                // Add __non_exhaustive if not present (mdbook 0.5.x removed it)
556                if !obj.contains_key("__non_exhaustive") {
557                    obj.insert("__non_exhaustive".to_string(), Value::Null);
558                }
559            }
560
561            // Recursively normalize nested objects
562            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
572/// Denormalize output for mdbook 0.5.x compatibility.
573///
574/// Converts "sections" back to "items" and removes "__non_exhaustive" fields
575/// for mdbook 0.5.x compatibility.
576fn 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            // Remove __non_exhaustive (mdbook 0.5.x doesn't use it)
581            obj.remove("__non_exhaustive");
582
583            // If this object has "sections" but not "items", rename to "items"
584            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            // Recursively denormalize nested objects
592            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
602/// Handle the preprocessor protocol (stdin/stdout communication with mdbook)
603pub 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    // Parse as generic JSON first, then normalize and clean before deserializing.
611    // 1. Normalize: Convert mdbook 0.5.x format (items) to 0.4.x format (sections)
612    // 2. Strip nulls: Remove null values that toml::Value can't handle
613    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    // Serialize the book back to JSON
628    let output_value = serde_json::to_value(&processed_book).map_err(MdBookLintError::Json)?;
629
630    // If input was mdbook 0.5.x format, convert output back to 0.5.x format
631    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        // Content needs 50+ words to pass CONTENT003 (short chapter detection)
671        // and lines under 80 chars to pass MD013
672        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        // Print violations for debugging
707        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        // Test should not depend on specific ordering - just verify MD001 is present
716        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        // Default config should enable all non-deprecated rules
737        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")); // MD002 is deprecated
741    }
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")); // Explicitly enabled deprecated rule
765        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        // Processing empty content should not crash
823        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        // Processing whitespace-only content should not crash
838        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        // MD001 should be disabled, so no violations for header level skipping
865        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        // This should not panic or error
880        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        // Should include structure rules like MD001
905        assert!(enabled_rule_ids.contains(&"MD001"));
906        // Should not include non-structure rules
907        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        // Should include non-style rules like MD001
935        assert!(enabled_rule_ids.contains(&"MD001"));
936        // Should exclude style rules - check that some style rules are disabled
937        let style_rules = enabled_rules
938            .iter()
939            .filter(|r| matches!(r.metadata().category, RuleCategory::Formatting))
940            .count();
941        // There should be fewer formatting rules enabled when the category is disabled
942        assert!(style_rules < 50); // Should have most rules still enabled
943    }
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")); // Explicitly disabled
967        assert!(enabled_rule_ids.len() > 50); // Should still have most rules
968    }
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    /// Build a chapter with the given source path.
1001    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    /// Build a preprocessor whose config sets `ignore-paths` to `patterns`.
1011    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        // Issue #463: the preprocessor must honor the same ignore-paths the CLI
1026        // applies, matched against the chapter source_path.
1027        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        // No ignore-paths configured: nothing is skipped.
1063        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        // A synthetic chapter has nothing to match against.
1070        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        // A chapter that would otherwise produce violations produces none once
1079        // its path is ignored.
1080        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        // Without ignore-paths the chapter is linted and reports violations.
1089        let plain = MdBookLint::new();
1090        assert!(!plain.chapter_is_ignored(&chapter));
1091        assert!(!plain.process_chapter(&chapter).unwrap().is_empty());
1092
1093        // With ignore-paths the run loop skips it entirely.
1094        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        // Test with warning - should NOT fail build
1107        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        // Test with error - should fail build
1119        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        // Test that null values are stripped from JSON objects
1153        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        // Top-level null should be removed
1167        assert!(result.get("title").is_some());
1168        assert!(result.get("description").is_none());
1169        assert!(result.get("authors").is_some());
1170
1171        // Nested null should be removed
1172        let nested = result.get("nested").unwrap();
1173        assert!(nested.get("field").is_some());
1174        assert!(nested.get("null_field").is_none());
1175
1176        // Nulls in arrays are kept (arrays preserve structure)
1177        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        // __non_exhaustive fields should be preserved even when null
1184        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        // All non-null values should be preserved
1208        assert_eq!(result, input);
1209    }
1210
1211    #[test]
1212    fn test_normalize_mdbook_json_v04_format() {
1213        // mdbook 0.4.x format uses "sections"
1214        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        // Should not detect as mdbook 0.5.x
1222        assert!(!is_05);
1223
1224        // Should remain unchanged - already in 0.4.x format
1225        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        // mdbook 0.5.x format uses "items" and has no __non_exhaustive field
1233        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        // Should detect as mdbook 0.5.x
1241        assert!(is_05);
1242
1243        // "items" should be renamed to "sections"
1244        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        // __non_exhaustive should be added for mdbook 0.4.x compatibility
1249        assert!(book.get("__non_exhaustive").is_some());
1250
1251        // Content should be preserved
1252        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        // Chapters can have nested sub_items in 0.5.x
1259        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        // Top-level "items" should be renamed to "sections"
1280        let book = result.as_array().unwrap().get(1).unwrap();
1281        assert!(book.get("sections").is_some());
1282
1283        // sub_items should remain as "sub_items" (not renamed)
1284        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        // Ensure normalization doesn't affect unrelated fields
1292        // Note: This is a non-standard structure (not mdbook format), so we use the inner function
1293        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        // "items" at each level should become "sections"
1305        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        // Full mdbook 0.4.x JSON payload
1316        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        // Apply both normalization and null stripping
1352        let (normalized, is_05) = normalize_mdbook_json(input);
1353        let cleaned = strip_null_values(normalized);
1354
1355        // Should not detect as mdbook 0.5.x
1356        assert!(!is_05);
1357
1358        // Verify structure is correct for deserialization
1359        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        // Null values should be stripped from config
1371        let config = ctx.get("config").unwrap();
1372        let book_config = config.get("book").unwrap();
1373        assert!(book_config.get("description").is_none()); // null stripped
1374        assert!(book_config.get("title").is_some()); // non-null preserved
1375    }
1376
1377    #[test]
1378    fn test_full_mdbook_05_json_parsing() {
1379        // Full mdbook 0.5.x JSON payload (no __non_exhaustive field)
1380        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        // Apply both normalization and null stripping
1415        let (normalized, is_05) = normalize_mdbook_json(input);
1416        let cleaned = strip_null_values(normalized);
1417
1418        // Should detect as mdbook 0.5.x
1419        assert!(is_05);
1420
1421        // Verify structure is correct for deserialization
1422        let arr = cleaned.as_array().unwrap();
1423        assert_eq!(arr.len(), 2);
1424
1425        let book = &arr[1];
1426        // "items" should have been converted to "sections"
1427        assert!(book.get("sections").is_some());
1428        assert!(book.get("items").is_none());
1429        // __non_exhaustive should have been added
1430        assert!(book.get("__non_exhaustive").is_some());
1431    }
1432
1433    #[test]
1434    fn test_denormalize_for_mdbook_05() {
1435        // Test that output is properly converted back to mdbook 0.5.x format
1436        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        // "sections" should be renamed to "items"
1446        assert!(result.get("items").is_some());
1447        assert!(result.get("sections").is_none());
1448
1449        // __non_exhaustive should be removed
1450        assert!(result.get("__non_exhaustive").is_none());
1451    }
1452
1453    #[test]
1454    fn test_roundtrip_mdbook_05() {
1455        // Test full roundtrip: mdbook 0.5.x input -> normalize -> denormalize -> mdbook 0.5.x output
1456        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        // Verify normalization worked
1465        let book = normalized.as_array().unwrap().get(1).unwrap();
1466        assert!(book.get("sections").is_some());
1467
1468        // Denormalize the book part (simulating what handle_preprocessing does)
1469        let denormalized_book = denormalize_for_mdbook_05(book.clone());
1470
1471        // Should be back to mdbook 0.5.x format
1472        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}