Skip to main content

html_generator/
lib.rs

1#![forbid(unsafe_code)]
2// Copyright © 2025 HTML Generator. All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4#![doc = include_str!("../README.md")]
5#![doc(
6    html_favicon_url = "https://cloudcdn.pro/html-generator/v1/favicon.ico",
7    html_logo_url = "https://cloudcdn.pro/html-generator/v1/logos/html-generator.svg",
8    html_root_url = "https://docs.rs/html-generator"
9)]
10#![crate_name = "html_generator"]
11#![crate_type = "lib"]
12
13use std::{
14    fmt,
15    fs::File,
16    io::{self, BufReader, BufWriter, Read, Write},
17    path::{Component, Path},
18};
19
20/// Maximum buffer size for reading files (16MB)
21const MAX_BUFFER_SIZE: usize = 16 * 1024 * 1024;
22
23// Re-export public modules
24pub mod accessibility;
25pub mod elements;
26pub mod emojis;
27pub mod error;
28pub mod generator;
29pub mod math;
30mod minifier;
31pub mod performance;
32pub mod seo;
33pub mod utils;
34
35// WebAssembly bindings — compiled in only when the crate is built
36// with `--features wasm`.
37#[cfg(feature = "wasm")]
38pub mod wasm;
39
40// Re-export primary types and functions for convenience
41pub use crate::error::HtmlError;
42pub use accessibility::{add_aria_attributes, validate_wcag};
43pub use emojis::load_emoji_sequences;
44pub use generator::{
45    generate_html, generate_html_with_diagnostics, Diagnostic,
46    DiagnosticLevel, HtmlOutput,
47};
48#[cfg(feature = "async")]
49pub use performance::async_generate_html;
50pub use performance::{minify_html, minify_html_string};
51pub use seo::{generate_meta_tags, generate_structured_data};
52pub use utils::{
53    extract_front_matter, extract_front_matter_data,
54    format_header_with_id_class,
55};
56
57/// Common constants used throughout the library.
58///
59/// This module contains configuration values and limits that help ensure
60/// secure and efficient operation of the library.
61///
62/// # Examples
63///
64/// ```
65/// use html_generator::constants::{DEFAULT_LANGUAGE, DEFAULT_MAX_INPUT_SIZE};
66///
67/// assert_eq!(DEFAULT_LANGUAGE, "en-GB");
68/// assert!(DEFAULT_MAX_INPUT_SIZE > 0);
69/// ```
70pub mod constants {
71    /// Maximum allowed input size (5MB) to prevent denial of service attacks.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use html_generator::constants::DEFAULT_MAX_INPUT_SIZE;
77    /// assert_eq!(DEFAULT_MAX_INPUT_SIZE, 5 * 1024 * 1024);
78    /// ```
79    pub const DEFAULT_MAX_INPUT_SIZE: usize = 5 * 1024 * 1024;
80
81    /// Minimum required input size (1KB) for meaningful processing.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use html_generator::constants::MIN_INPUT_SIZE;
87    /// assert_eq!(MIN_INPUT_SIZE, 1024);
88    /// ```
89    pub const MIN_INPUT_SIZE: usize = 1024;
90
91    /// Default language code for HTML generation (British English).
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use html_generator::constants::DEFAULT_LANGUAGE;
97    /// assert_eq!(DEFAULT_LANGUAGE, "en-GB");
98    /// ```
99    pub const DEFAULT_LANGUAGE: &str = "en-GB";
100
101    /// Default syntax highlighting theme (`InspiredGitHub`).
102    ///
103    /// Must name a theme bundled with `syntect`, because `mdx-gen`
104    /// validates `syntax_theme` against
105    /// `SyntectAdapter::available_themes()` and rejects anything
106    /// outside that set. `InspiredGitHub` is the bundled
107    /// GitHub-flavoured light theme.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use html_generator::constants::DEFAULT_SYNTAX_THEME;
113    /// assert_eq!(DEFAULT_SYNTAX_THEME, "InspiredGitHub");
114    /// ```
115    pub const DEFAULT_SYNTAX_THEME: &str = "InspiredGitHub";
116
117    /// Maximum file path length.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use html_generator::constants::MAX_PATH_LENGTH;
123    /// assert_eq!(MAX_PATH_LENGTH, 4096);
124    /// ```
125    pub const MAX_PATH_LENGTH: usize = 4096;
126
127    /// Regular expression pattern for validating language codes.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use html_generator::constants::LANGUAGE_CODE_PATTERN;
133    /// use regex::Regex;
134    ///
135    /// let re = Regex::new(LANGUAGE_CODE_PATTERN).unwrap();
136    /// assert!(re.is_match("en-GB"));
137    /// ```
138    pub const LANGUAGE_CODE_PATTERN: &str = r"^[a-z]{2}-[A-Z]{2}$";
139
140    /// Verify invariants at compile time
141    const _: () = assert!(MIN_INPUT_SIZE <= DEFAULT_MAX_INPUT_SIZE);
142    const _: () = assert!(MAX_PATH_LENGTH > 0);
143}
144
145/// Result type alias for library operations.
146///
147/// # Examples
148///
149/// ```
150/// use html_generator::{error::HtmlError, Result};
151///
152/// fn run() -> Result<()> {
153///     Err(HtmlError::InvalidInput("demo".into()))
154/// }
155/// assert!(run().is_err());
156/// ```
157pub type Result<T> = std::result::Result<T, HtmlError>;
158
159/// Legacy configuration type — use [`HtmlConfig`] directly instead.
160///
161/// This type is kept for backward compatibility. The `encoding` field
162/// has been moved into `HtmlConfig` itself.
163#[deprecated(
164    since = "0.0.4",
165    note = "use HtmlConfig directly — encoding is now a field on HtmlConfig"
166)]
167#[derive(Debug, Clone, Eq, PartialEq)]
168pub struct MarkdownConfig {
169    /// The encoding to use for input/output (defaults to "utf-8")
170    pub encoding: String,
171
172    /// HTML generation configuration
173    pub html_config: HtmlConfig,
174}
175
176#[allow(deprecated)]
177impl Default for MarkdownConfig {
178    fn default() -> Self {
179        Self {
180            encoding: String::from("utf-8"),
181            html_config: HtmlConfig::default(),
182        }
183    }
184}
185
186#[allow(deprecated)]
187impl From<MarkdownConfig> for HtmlConfig {
188    fn from(mc: MarkdownConfig) -> Self {
189        let mut c = mc.html_config;
190        c.encoding = mc.encoding;
191        c
192    }
193}
194
195/// Errors that can occur during configuration.
196///
197/// # Examples
198///
199/// ```
200/// use html_generator::ConfigError;
201///
202/// let err = ConfigError::InvalidLanguageCode("xx".into());
203/// assert!(err.to_string().contains("Invalid language code"));
204/// ```
205#[derive(Debug, thiserror::Error)]
206#[non_exhaustive]
207pub enum ConfigError {
208    /// Error for invalid input size configuration
209    #[error(
210        "Invalid input size: {0} bytes is below minimum of {1} bytes"
211    )]
212    InvalidInputSize(usize, usize),
213
214    /// Error for invalid language code
215    #[error("Invalid language code: {0}")]
216    InvalidLanguageCode(String),
217
218    /// Error for invalid file path
219    #[error("Invalid file path: {0}")]
220    InvalidFilePath(String),
221}
222
223/// Output destination for HTML generation.
224///
225/// Specifies where the generated HTML content should be written.
226///
227/// # Examples
228///
229/// Writing HTML to a file:
230/// ```
231/// use std::fs::File;
232/// use html_generator::OutputDestination;
233///
234/// let output = OutputDestination::File("output.html".to_string());
235/// ```
236///
237/// Writing HTML to an in-memory buffer:
238/// ```
239/// use std::io::Cursor;
240/// use html_generator::OutputDestination;
241///
242/// let buffer = Cursor::new(Vec::new());
243/// let output = OutputDestination::Writer(Box::new(buffer));
244/// ```
245///
246/// Writing HTML to standard output:
247/// ```
248/// use html_generator::OutputDestination;
249///
250/// let output = OutputDestination::Stdout;
251/// ```
252#[non_exhaustive]
253pub enum OutputDestination {
254    /// Write output to a file at the specified path.
255    ///
256    /// # Example
257    ///
258    /// ```
259    /// use html_generator::OutputDestination;
260    ///
261    /// let output = OutputDestination::File("output.html".to_string());
262    /// ```
263    File(String),
264
265    /// Write output using a custom writer implementation.
266    ///
267    /// This can be used for in-memory buffers, network streams,
268    /// or other custom output destinations.
269    ///
270    /// # Example
271    ///
272    /// ```
273    /// use std::io::Cursor;
274    /// use html_generator::OutputDestination;
275    ///
276    /// let buffer = Cursor::new(Vec::new());
277    /// let output = OutputDestination::Writer(Box::new(buffer));
278    /// ```
279    Writer(Box<dyn Write>),
280
281    /// Write output to standard output (default).
282    ///
283    /// This is useful for command-line tools and scripts.
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// use html_generator::OutputDestination;
289    ///
290    /// let output = OutputDestination::Stdout;
291    /// ```
292    Stdout,
293}
294
295/// Default implementation for OutputDestination.
296impl Default for OutputDestination {
297    fn default() -> Self {
298        Self::Stdout
299    }
300}
301
302/// Debug implementation for OutputDestination.
303impl fmt::Debug for OutputDestination {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        match self {
306            Self::File(path) => {
307                f.debug_tuple("File").field(path).finish()
308            }
309            Self::Writer(_) => write!(f, "Writer(<dyn Write>)"),
310            Self::Stdout => write!(f, "Stdout"),
311        }
312    }
313}
314
315/// Implements `Display` for `OutputDestination`.
316impl fmt::Display for OutputDestination {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            OutputDestination::File(path) => {
320                write!(f, "File({})", path)
321            }
322            OutputDestination::Writer(_) => {
323                write!(f, "Writer(<dyn Write>)")
324            }
325            OutputDestination::Stdout => write!(f, "Stdout"),
326        }
327    }
328}
329
330/// Configuration options for HTML generation.
331///
332/// Controls various aspects of the HTML generation process including
333/// syntax highlighting, accessibility features, and output formatting.
334///
335/// # Examples
336///
337/// ```
338/// use html_generator::HtmlConfig;
339///
340/// let cfg = HtmlConfig::default();
341/// assert!(cfg.add_aria_attributes);
342/// assert_eq!(cfg.language, "en-GB");
343/// ```
344#[derive(Debug, PartialEq, Eq, Clone)]
345pub struct HtmlConfig {
346    /// Enable syntax highlighting for code blocks
347    pub enable_syntax_highlighting: bool,
348
349    /// Theme to use for syntax highlighting
350    pub syntax_theme: Option<String>,
351
352    /// Minify the generated HTML output
353    pub minify_output: bool,
354
355    /// Automatically add ARIA attributes for accessibility
356    pub add_aria_attributes: bool,
357
358    /// Generate structured data (JSON-LD) based on content
359    pub generate_structured_data: bool,
360
361    /// Maximum size (in bytes) for input content
362    pub max_input_size: usize,
363
364    /// Language for generated content
365    pub language: String,
366
367    /// Enable table of contents generation
368    pub generate_toc: bool,
369
370    /// Allow raw HTML passthrough in Markdown conversion.
371    ///
372    /// When `false` (the default), raw HTML tags in Markdown input are
373    /// stripped from the output, preventing XSS when processing
374    /// untrusted content. Set to `true` only when the Markdown source
375    /// is fully trusted.
376    pub allow_unsafe_html: bool,
377
378    /// Sanitize raw HTML using ammonia instead of stripping it.
379    ///
380    /// When `true` and `allow_unsafe_html` is also `true`, the library
381    /// runs ammonia over the final output to strip dangerous elements
382    /// (`<script>`, `onclick`, etc.) while preserving safe tags like
383    /// `<div>`, `<span>`, and `<img>`. This provides a secure
384    /// middle-ground for user-authored HTML.
385    ///
386    /// Has no effect when `allow_unsafe_html` is `false` (HTML is
387    /// already stripped by the Markdown renderer).
388    pub sanitize_html: bool,
389
390    /// Wrap output in a full HTML5 document.
391    ///
392    /// When `true`, the pipeline wraps the generated body in:
393    /// ```html
394    /// <!DOCTYPE html>
395    /// <html lang="{language}">
396    /// <head><meta charset="utf-8"><title>…</title>{meta}{json-ld}</head>
397    /// <body>{content}</body>
398    /// </html>
399    /// ```
400    ///
401    /// SEO meta tags and JSON-LD are placed in `<head>`, and the
402    /// `language` field is injected as the `lang` attribute. When
403    /// `false` (the default), only an HTML fragment is returned.
404    pub generate_full_document: bool,
405
406    /// Maximum buffer size for file I/O operations (default: 16MB).
407    ///
408    /// Controls the upper bound on buffer allocation when reading
409    /// input files. Adjust this if you need to process unusually
410    /// large documents or want to constrain memory usage.
411    pub max_buffer_size: usize,
412
413    /// The encoding for file I/O (defaults to "utf-8").
414    ///
415    /// This field is used by [`markdown_file_to_html`] when reading
416    /// or writing files. In-memory functions ignore it.
417    pub encoding: String,
418
419    /// Render `$..$` and `$$..$$` LaTeX math spans to inline MathML.
420    ///
421    /// Pure server-side: no client-side JavaScript bundle required,
422    /// browsers render MathML natively. Powered by `pulldown-latex`
423    /// behind the `math` feature (on by default). When `false`, math
424    /// spans are passed through as-is.
425    pub enable_math: bool,
426
427    /// Rewrite `\u{60}\u{60}\u{60}mermaid` fenced code blocks for client-side
428    /// mermaid.js.
429    ///
430    /// The CommonMark engine emits these as
431    /// `<pre><code class="language-mermaid">…</code></pre>`. With this
432    /// flag on, the post-processing step rewrites them to
433    /// `<pre class="mermaid">…</pre>` so the standard mermaid.js
434    /// loader picks them up. The page must still include
435    /// `<script type="module">…mermaid.initialize…</script>` for the
436    /// diagrams to actually render.
437    pub enable_diagrams: bool,
438}
439
440impl Default for HtmlConfig {
441    fn default() -> Self {
442        Self {
443            enable_syntax_highlighting: true,
444            syntax_theme: Some(
445                constants::DEFAULT_SYNTAX_THEME.to_string(),
446            ),
447            minify_output: false,
448            add_aria_attributes: true,
449            generate_structured_data: false,
450            max_input_size: constants::DEFAULT_MAX_INPUT_SIZE,
451            language: String::from(constants::DEFAULT_LANGUAGE),
452            generate_toc: false,
453            allow_unsafe_html: false,
454            sanitize_html: false,
455            generate_full_document: false,
456            max_buffer_size: 16 * 1024 * 1024,
457            encoding: String::from("utf-8"),
458            enable_math: false,
459            enable_diagrams: false,
460        }
461    }
462}
463
464impl HtmlConfig {
465    /// Creates a new `HtmlConfig` using the builder pattern.
466    ///
467    /// # Examples
468    ///
469    /// ```rust
470    /// use html_generator::HtmlConfig;
471    ///
472    /// let config = HtmlConfig::builder()
473    ///     .with_syntax_highlighting(true, Some("monokai".to_string()))
474    ///     .with_language("en-GB")
475    ///     .build()
476    ///     .unwrap();
477    /// ```
478    pub fn builder() -> HtmlConfigBuilder {
479        HtmlConfigBuilder::default()
480    }
481
482    /// Validates the configuration settings.
483    ///
484    /// Checks that all configuration values are within acceptable ranges
485    /// and conform to required formats.
486    ///
487    /// # Returns
488    ///
489    /// Returns `Ok(())` if the configuration is valid, or an appropriate
490    /// error if validation fails.
491    ///
492    /// # Examples
493    ///
494    /// ```
495    /// use html_generator::HtmlConfig;
496    ///
497    /// let cfg = HtmlConfig::default();
498    /// cfg.validate().unwrap();
499    /// ```
500    ///
501    /// # Errors
502    ///
503    /// Returns [`crate::error::HtmlError::InvalidInput`] if `language`
504    /// is not a valid BCP 47 code or `max_input_size` is below
505    /// [`constants::MIN_INPUT_SIZE`].
506    pub fn validate(&self) -> Result<()> {
507        if self.max_input_size < constants::MIN_INPUT_SIZE {
508            return Err(HtmlError::InvalidInput(format!(
509                "Input size must be at least {} bytes",
510                constants::MIN_INPUT_SIZE
511            )));
512        }
513        if !validate_language_code(&self.language) {
514            return Err(HtmlError::InvalidInput(format!(
515                "Invalid language code: {}",
516                self.language
517            )));
518        }
519        Ok(())
520    }
521
522    /// Validates a file path before it is opened by
523    /// [`markdown_file_to_html`].
524    ///
525    /// Rejects paths that are empty, too long, contain a NUL byte, contain
526    /// any `..` component (directory traversal), or use an extension other
527    /// than `.md` or `.html`.
528    ///
529    /// This validator is defensive only: it does **not** decide whether a
530    /// caller is authorised to read the target file. Callers that expose
531    /// this API to untrusted input must enforce their own authorisation
532    /// (e.g. chroot, a sandbox root directory, or an allow-list) on top
533    /// of this check. Absolute paths are accepted deliberately so that
534    /// CLI tools can be invoked with fully qualified filenames.
535    pub(crate) fn validate_file_path(
536        path: impl AsRef<Path>,
537    ) -> Result<()> {
538        let path = path.as_ref();
539        let path_str = path.to_string_lossy();
540
541        if path_str.is_empty() {
542            return Err(HtmlError::InvalidInput(
543                "File path cannot be empty".to_string(),
544            ));
545        }
546
547        if path_str.len() > constants::MAX_PATH_LENGTH {
548            return Err(HtmlError::InvalidInput(format!(
549                "File path exceeds maximum length of {} characters",
550                constants::MAX_PATH_LENGTH
551            )));
552        }
553
554        // Reject NUL bytes: on Unix, C-string path handling silently
555        // truncates at the first NUL, which is a classic smuggling vector
556        // (e.g. "safe.md\0/etc/passwd").
557        if path_str.as_bytes().contains(&0) {
558            return Err(HtmlError::InvalidInput(
559                "File path must not contain NUL bytes".to_string(),
560            ));
561        }
562
563        if path.components().any(|c| matches!(c, Component::ParentDir))
564        {
565            return Err(HtmlError::InvalidInput(
566                "Directory traversal is not allowed in file paths"
567                    .to_string(),
568            ));
569        }
570
571        if let Some(ext) = path.extension() {
572            if !matches!(ext.to_string_lossy().as_ref(), "md" | "html")
573            {
574                return Err(HtmlError::InvalidInput(
575                    "Invalid file extension: only .md and .html files are allowed".to_string(),
576                ));
577            }
578        }
579
580        Ok(())
581    }
582}
583
584/// Builder for constructing `HtmlConfig` instances.
585///
586/// Provides a fluent interface for creating and customizing HTML
587/// configuration options.
588///
589/// # Examples
590///
591/// ```
592/// use html_generator::HtmlConfigBuilder;
593///
594/// let cfg = HtmlConfigBuilder::new()
595///     .with_language("en-GB")
596///     .with_full_document(true)
597///     .build()
598///     .unwrap();
599/// assert!(cfg.generate_full_document);
600/// ```
601#[derive(Debug, Default)]
602pub struct HtmlConfigBuilder {
603    config: HtmlConfig,
604}
605
606impl HtmlConfigBuilder {
607    /// Creates a new `HtmlConfigBuilder` with default options.
608    ///
609    /// # Examples
610    ///
611    /// ```
612    /// use html_generator::HtmlConfigBuilder;
613    ///
614    /// let _ = HtmlConfigBuilder::new();
615    /// ```
616    pub fn new() -> Self {
617        Self::default()
618    }
619
620    /// Enables or disables syntax highlighting for code blocks.
621    ///
622    /// # Arguments
623    ///
624    /// * `enable` - Whether to enable syntax highlighting
625    /// * `theme` - Optional theme name for syntax highlighting
626    ///
627    /// # Examples
628    ///
629    /// ```
630    /// use html_generator::HtmlConfigBuilder;
631    ///
632    /// let cfg = HtmlConfigBuilder::new()
633    ///     .with_syntax_highlighting(true, Some("monokai".into()))
634    ///     .build()
635    ///     .unwrap();
636    /// assert_eq!(cfg.syntax_theme.as_deref(), Some("monokai"));
637    /// ```
638    #[must_use]
639    pub fn with_syntax_highlighting(
640        mut self,
641        enable: bool,
642        theme: Option<String>,
643    ) -> Self {
644        self.config.enable_syntax_highlighting = enable;
645        self.config.syntax_theme = if enable {
646            theme.or_else(|| {
647                Some(constants::DEFAULT_SYNTAX_THEME.to_string())
648            })
649        } else {
650            None
651        };
652        self
653    }
654
655    /// Sets the language for generated content.
656    ///
657    /// # Examples
658    ///
659    /// ```
660    /// use html_generator::HtmlConfigBuilder;
661    ///
662    /// let cfg = HtmlConfigBuilder::new()
663    ///     .with_language("fr-FR")
664    ///     .build()
665    ///     .unwrap();
666    /// assert_eq!(cfg.language, "fr-FR");
667    /// ```
668    #[must_use]
669    pub fn with_language(
670        mut self,
671        language: impl Into<String>,
672    ) -> Self {
673        self.config.language = language.into();
674        self
675    }
676
677    /// Enables or disables HTML sanitization via ammonia.
678    ///
679    /// When enabled alongside `allow_unsafe_html`, dangerous elements
680    /// are stripped while safe tags are preserved.
681    ///
682    /// # Examples
683    ///
684    /// ```
685    /// use html_generator::HtmlConfigBuilder;
686    ///
687    /// let cfg = HtmlConfigBuilder::new()
688    ///     .with_sanitization(true)
689    ///     .build()
690    ///     .unwrap();
691    /// assert!(cfg.sanitize_html);
692    /// ```
693    #[must_use]
694    pub fn with_sanitization(mut self, enable: bool) -> Self {
695        self.config.sanitize_html = enable;
696        self
697    }
698
699    /// Enables or disables full HTML5 document wrapping.
700    ///
701    /// When enabled, the output is wrapped in `<!DOCTYPE html>` with
702    /// `<head>` (containing meta/JSON-LD) and `<body>`.
703    ///
704    /// # Examples
705    ///
706    /// ```
707    /// use html_generator::HtmlConfigBuilder;
708    ///
709    /// let cfg = HtmlConfigBuilder::new()
710    ///     .with_full_document(true)
711    ///     .build()
712    ///     .unwrap();
713    /// assert!(cfg.generate_full_document);
714    /// ```
715    #[must_use]
716    pub fn with_full_document(mut self, enable: bool) -> Self {
717        self.config.generate_full_document = enable;
718        self
719    }
720
721    /// Sets the maximum buffer size for file I/O operations.
722    ///
723    /// # Examples
724    ///
725    /// ```
726    /// use html_generator::HtmlConfigBuilder;
727    ///
728    /// let cfg = HtmlConfigBuilder::new()
729    ///     .with_max_buffer_size(8 * 1024 * 1024)
730    ///     .build()
731    ///     .unwrap();
732    /// assert_eq!(cfg.max_buffer_size, 8 * 1024 * 1024);
733    /// ```
734    #[must_use]
735    pub fn with_max_buffer_size(mut self, size: usize) -> Self {
736        self.config.max_buffer_size = size;
737        self
738    }
739
740    /// Enables or disables server-side LaTeX → MathML rendering.
741    ///
742    /// When enabled, `$..$` and `$$..$$` spans in the rendered HTML
743    /// are replaced with `<math>…</math>` elements. Browsers render
744    /// MathML natively, so no client-side JS is needed. Requires
745    /// the `math` feature (on by default).
746    ///
747    /// # Examples
748    ///
749    /// ```
750    /// use html_generator::HtmlConfigBuilder;
751    ///
752    /// let cfg = HtmlConfigBuilder::new()
753    ///     .with_math(true)
754    ///     .build()
755    ///     .unwrap();
756    /// assert!(cfg.enable_math);
757    /// ```
758    #[must_use]
759    pub fn with_math(mut self, enable: bool) -> Self {
760        self.config.enable_math = enable;
761        self
762    }
763
764    /// Enables or disables Mermaid diagram passthrough.
765    ///
766    /// When enabled, `\u{60}\u{60}\u{60}mermaid` fenced code blocks are rewritten
767    /// from `<pre><code class="language-mermaid">` to
768    /// `<pre class="mermaid">` so client-side mermaid.js renders
769    /// them.
770    ///
771    /// # Examples
772    ///
773    /// ```
774    /// use html_generator::HtmlConfigBuilder;
775    ///
776    /// let cfg = HtmlConfigBuilder::new()
777    ///     .with_diagrams(true)
778    ///     .build()
779    ///     .unwrap();
780    /// assert!(cfg.enable_diagrams);
781    /// ```
782    #[must_use]
783    pub fn with_diagrams(mut self, enable: bool) -> Self {
784        self.config.enable_diagrams = enable;
785        self
786    }
787
788    /// Builds the configuration, validating all settings.
789    ///
790    /// # Examples
791    ///
792    /// ```
793    /// use html_generator::HtmlConfigBuilder;
794    ///
795    /// let cfg = HtmlConfigBuilder::new()
796    ///     .with_language("en-GB")
797    ///     .build()
798    ///     .unwrap();
799    /// assert_eq!(cfg.language, "en-GB");
800    /// ```
801    ///
802    /// # Errors
803    ///
804    /// Returns the first [`crate::error::HtmlError::InvalidInput`]
805    /// produced by [`HtmlConfig::validate`] (e.g. an unknown language
806    /// code or a `max_input_size` below the minimum).
807    pub fn build(self) -> Result<HtmlConfig> {
808        self.config.validate()?;
809        Ok(self.config)
810    }
811}
812
813/// Converts Markdown content to HTML.
814///
815/// This function processes Unicode Markdown content and returns HTML output.
816/// The input must be valid Unicode - if your input is encoded (e.g., UTF-8),
817/// you must decode it before passing it to this function.
818///
819/// # Arguments
820///
821/// * `content` - The Markdown content as a Unicode string
822/// * `config` - Optional configuration for the conversion
823///
824/// # Returns
825///
826/// Returns the generated HTML as a Unicode string wrapped in a `Result`
827///
828/// # Errors
829///
830/// Returns an error if:
831/// * The input content is invalid Unicode
832/// * HTML generation fails
833/// * Input size exceeds configured maximum
834///
835/// # Examples
836///
837/// ```rust
838/// use html_generator::{markdown_to_html, MarkdownConfig};
839///
840/// let markdown = "# Hello\n\nWorld";
841/// let html = markdown_to_html(markdown, None)?;
842/// assert!(html.contains("<h1>Hello</h1>"));
843/// # Ok::<(), html_generator::error::HtmlError>(())
844/// ```
845#[allow(deprecated)]
846pub fn markdown_to_html(
847    content: &str,
848    config: Option<MarkdownConfig>,
849) -> Result<String> {
850    let html_config: HtmlConfig =
851        config.map_or_else(HtmlConfig::default, HtmlConfig::from);
852
853    if content.is_empty() {
854        return Err(HtmlError::InvalidInput(
855            "Input content is empty".to_string(),
856        ));
857    }
858
859    if content.len() > html_config.max_input_size {
860        return Err(HtmlError::InputTooLarge(content.len()));
861    }
862
863    generate_html(content, &html_config)
864}
865
866/// Converts a Markdown file to HTML.
867///
868/// This function reads from a file or stdin and writes the generated HTML to
869/// a specified destination. It handles encoding/decoding of content.
870///
871/// # Arguments
872///
873/// * `input` - The input source (file path or None for stdin)
874/// * `output` - The output destination (defaults to stdout)
875/// * `config` - Optional configuration including encoding settings
876///
877/// # Returns
878///
879/// Returns `Result<()>` indicating success or failure of the operation.
880///
881/// # Errors
882///
883/// Returns an error if:
884/// * Input file is not found or cannot be read
885/// * Output file cannot be written
886/// * Configuration is invalid
887/// * Input size exceeds configured maximum
888///
889/// # Examples
890///
891/// ```no_run
892/// use html_generator::{markdown_file_to_html, OutputDestination, MarkdownConfig};
893/// use std::path::{Path, PathBuf};
894///
895/// // Convert file to HTML and write to stdout
896/// markdown_file_to_html(
897///     Some(PathBuf::from("input.md")),
898///     None,
899///     None,
900/// )?;
901///
902/// // Convert stdin to HTML file
903/// markdown_file_to_html(
904///     None::<PathBuf>,  // Explicit type annotation
905///     Some(OutputDestination::File("output.html".into())),
906///     Some(MarkdownConfig::default()),
907/// )?;
908/// # Ok::<(), html_generator::error::HtmlError>(())
909/// ```
910#[inline]
911#[allow(deprecated)]
912pub fn markdown_file_to_html(
913    input: Option<impl AsRef<Path>>,
914    output: Option<OutputDestination>,
915    config: Option<MarkdownConfig>,
916) -> Result<()> {
917    let config = config.unwrap_or_default();
918    let output = output.unwrap_or_default();
919
920    // Validate paths first
921    validate_paths(&input, &output)?;
922
923    // Read and process input
924    let content = read_input(input)?;
925
926    // Generate HTML
927    let html = markdown_to_html(&content, Some(config))?;
928
929    // Write output
930    write_output(output, html.as_bytes())
931}
932
933/// Validates input and output paths
934fn validate_paths(
935    input: &Option<impl AsRef<Path>>,
936    output: &OutputDestination,
937) -> Result<()> {
938    if let Some(path) = input.as_ref() {
939        HtmlConfig::validate_file_path(path)?;
940    }
941    if let OutputDestination::File(ref path) = output {
942        HtmlConfig::validate_file_path(path)?;
943    }
944    Ok(())
945}
946
947/// Reads the full contents of `reader` into a UTF-8 string, wrapping
948/// any I/O error as `HtmlError::Io` with the given label for context
949/// (e.g. `"input"` or `"stdin"`).
950///
951/// Extracted so the stdin path of [`read_input`] is testable against
952/// an in-memory reader without needing a child process.
953fn read_all_from_reader<R: Read>(
954    mut reader: R,
955    label: &str,
956) -> Result<String> {
957    let mut content = String::with_capacity(MAX_BUFFER_SIZE);
958    // read_to_string returns the byte count; we only need the String.
959    let _ = reader.read_to_string(&mut content).map_err(|e| {
960        HtmlError::Io(io::Error::new(
961            e.kind(),
962            format!("Failed to read from {label}: {e}"),
963        ))
964    })?;
965    Ok(content)
966}
967
968/// Reads content from the input source (a file path, or stdin when
969/// `None`).
970fn read_input(input: Option<impl AsRef<Path>>) -> Result<String> {
971    match input {
972        Some(path) => {
973            let file = File::open(path).map_err(HtmlError::Io)?;
974            let reader =
975                BufReader::with_capacity(MAX_BUFFER_SIZE, file);
976            read_all_from_reader(reader, "input")
977        }
978        None => {
979            let stdin = io::stdin();
980            let reader =
981                BufReader::with_capacity(MAX_BUFFER_SIZE, stdin.lock());
982            read_all_from_reader(reader, "stdin")
983        }
984    }
985}
986
987/// Writes `content` to `writer`, wrapping any I/O error as
988/// `HtmlError::Io` with a label like `"file '…'"` or `"stdout"`.
989///
990/// Extracted so every destination in [`write_output`] shares one
991/// tested implementation, and so the error paths can be exercised by
992/// a failing in-memory writer.
993fn write_all_to_writer<W: Write>(
994    mut writer: W,
995    content: &[u8],
996    label: &str,
997) -> Result<()> {
998    writer.write_all(content).map_err(|e| {
999        HtmlError::Io(io::Error::new(
1000            e.kind(),
1001            format!("Failed to write to {label}: {e}"),
1002        ))
1003    })?;
1004    writer.flush().map_err(|e| {
1005        HtmlError::Io(io::Error::new(
1006            e.kind(),
1007            format!("Failed to flush {label}: {e}"),
1008        ))
1009    })?;
1010    Ok(())
1011}
1012
1013/// Writes content to the output destination.
1014fn write_output(
1015    output: OutputDestination,
1016    content: &[u8],
1017) -> Result<()> {
1018    match output {
1019        OutputDestination::File(path) => {
1020            let file = File::create(&path).map_err(|e| {
1021                HtmlError::Io(io::Error::new(
1022                    e.kind(),
1023                    format!("Failed to create file '{}': {}", path, e),
1024                ))
1025            })?;
1026            write_all_to_writer(
1027                BufWriter::new(file),
1028                content,
1029                &format!("file '{path}'"),
1030            )
1031        }
1032        OutputDestination::Writer(mut writer) => write_all_to_writer(
1033            BufWriter::new(&mut writer),
1034            content,
1035            "output",
1036        ),
1037        OutputDestination::Stdout => {
1038            let stdout = io::stdout();
1039            write_all_to_writer(
1040                BufWriter::new(stdout.lock()),
1041                content,
1042                "stdout",
1043            )
1044        }
1045    }
1046}
1047
1048/// Validates that a language code matches the BCP 47 format (e.g., "en-GB").
1049///
1050/// This function checks if a given language code follows the BCP 47 format,
1051/// which requires both language and region codes.
1052///
1053/// # Arguments
1054///
1055/// * `lang` - The language code to validate
1056///
1057/// # Returns
1058///
1059/// Returns true if the language code is valid (e.g., "en-GB"), false otherwise.
1060///
1061/// # Examples
1062///
1063/// ```
1064/// use html_generator::validate_language_code;
1065///
1066/// assert!(validate_language_code("en-GB"));  // Valid
1067/// assert!(!validate_language_code("en"));    // Invalid - missing region
1068/// assert!(!validate_language_code("123"));   // Invalid - not a language code
1069/// assert!(!validate_language_code("en_GB")); // Invalid - wrong separator
1070/// ```
1071pub fn validate_language_code(lang: &str) -> bool {
1072    use once_cell::sync::Lazy;
1073    use regex::Regex;
1074
1075    static LANG_REGEX: Lazy<Regex> = Lazy::new(|| {
1076        Regex::new(constants::LANGUAGE_CODE_PATTERN)
1077            .expect("static LANG_REGEX must compile")
1078    });
1079
1080    LANG_REGEX.is_match(lang)
1081}
1082
1083#[cfg(test)]
1084#[allow(deprecated)]
1085mod tests {
1086    use super::*;
1087    use regex::Regex;
1088    use std::io::Cursor;
1089    use tempfile::{tempdir, TempDir};
1090
1091    /// A reader whose `read` call always fails — used to cover the
1092    /// stdin failure branch of [`read_all_from_reader`].
1093    struct FailingReader;
1094
1095    impl Read for FailingReader {
1096        fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
1097            Err(io::Error::other("synthetic read failure"))
1098        }
1099    }
1100
1101    /// A writer whose `write` + `flush` both fail — used to cover the
1102    /// write/flush error branches of [`write_all_to_writer`].
1103    struct FailingWriter {
1104        /// If `true`, fail on `flush` only (writes succeed), otherwise
1105        /// fail immediately on `write`.
1106        flush_only: bool,
1107    }
1108
1109    impl Write for FailingWriter {
1110        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1111            if self.flush_only {
1112                Ok(buf.len())
1113            } else {
1114                Err(io::Error::other("synthetic write failure"))
1115            }
1116        }
1117        fn flush(&mut self) -> io::Result<()> {
1118            Err(io::Error::other("synthetic flush failure"))
1119        }
1120    }
1121
1122    #[test]
1123    fn test_read_all_from_reader_success() {
1124        let input = Cursor::new(b"hello world".to_vec());
1125        let s = read_all_from_reader(input, "memory").unwrap();
1126        assert_eq!(s, "hello world");
1127    }
1128
1129    #[test]
1130    fn test_read_all_from_reader_surfaces_io_error() {
1131        let err =
1132            read_all_from_reader(FailingReader, "stdin").unwrap_err();
1133        match err {
1134            HtmlError::Io(e) => {
1135                let msg = e.to_string();
1136                assert!(
1137                    msg.contains("Failed to read from stdin"),
1138                    "unexpected error: {msg}"
1139                );
1140            }
1141            other => panic!("expected Io, got {other:?}"),
1142        }
1143    }
1144
1145    #[test]
1146    fn test_write_all_to_writer_success_covers_stdout_path() {
1147        let mut buf: Vec<u8> = Vec::new();
1148        write_all_to_writer(&mut buf, b"hi", "memory").unwrap();
1149        assert_eq!(buf, b"hi");
1150    }
1151
1152    #[test]
1153    fn test_write_all_to_writer_surfaces_write_error() {
1154        let err = write_all_to_writer(
1155            FailingWriter { flush_only: false },
1156            b"x",
1157            "output",
1158        )
1159        .unwrap_err();
1160        assert!(
1161            matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to write to output"))
1162        );
1163    }
1164
1165    #[test]
1166    fn test_write_all_to_writer_surfaces_flush_error() {
1167        let err = write_all_to_writer(
1168            FailingWriter { flush_only: true },
1169            b"x",
1170            "output",
1171        )
1172        .unwrap_err();
1173        assert!(
1174            matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to flush output"))
1175        );
1176    }
1177
1178    /// Creates a temporary test directory for file operations.
1179    ///
1180    /// The directory and its contents are automatically cleaned up when
1181    /// the returned TempDir is dropped.
1182    fn setup_test_dir() -> TempDir {
1183        tempdir().expect("Failed to create temporary directory")
1184    }
1185
1186    /// Creates a test file with the given content.
1187    ///
1188    /// # Arguments
1189    ///
1190    /// * `dir` - The temporary directory to create the file in
1191    /// * `content` - The content to write to the file
1192    ///
1193    /// # Returns
1194    ///
1195    /// Returns the path to the created file.
1196    fn create_test_file(
1197        dir: &TempDir,
1198        content: &str,
1199    ) -> std::path::PathBuf {
1200        let path = dir.path().join("test.md");
1201        std::fs::write(&path, content)
1202            .expect("Failed to write test file");
1203        path
1204    }
1205
1206    mod config_tests {
1207        use super::*;
1208
1209        #[test]
1210        fn test_config_validation() {
1211            // Test invalid input size
1212            let config = HtmlConfig {
1213                max_input_size: 100, // Too small
1214                ..Default::default()
1215            };
1216            assert!(config.validate().is_err());
1217
1218            // Test invalid language code
1219            let config = HtmlConfig {
1220                language: "invalid".to_string(),
1221                ..Default::default()
1222            };
1223            assert!(config.validate().is_err());
1224
1225            // Test valid default configuration
1226            let config = HtmlConfig::default();
1227            assert!(config.validate().is_ok());
1228        }
1229
1230        #[test]
1231        fn test_config_builder() {
1232            let result = HtmlConfigBuilder::new()
1233                .with_syntax_highlighting(
1234                    true,
1235                    Some("monokai".to_string()),
1236                )
1237                .with_language("en-GB")
1238                .build();
1239
1240            assert!(result.is_ok());
1241            let config = result.unwrap();
1242            assert!(config.enable_syntax_highlighting);
1243            assert_eq!(
1244                config.syntax_theme,
1245                Some("monokai".to_string())
1246            );
1247            assert_eq!(config.language, "en-GB");
1248        }
1249
1250        #[test]
1251        fn test_config_builder_invalid() {
1252            let result = HtmlConfigBuilder::new()
1253                .with_language("invalid")
1254                .build();
1255
1256            assert!(matches!(
1257                result,
1258                Err(HtmlError::InvalidInput(msg)) if msg.contains("Invalid language code")
1259            ));
1260        }
1261
1262        #[test]
1263        fn test_html_config_with_no_syntax_theme() {
1264            let config = HtmlConfig {
1265                enable_syntax_highlighting: true,
1266                syntax_theme: None,
1267                ..Default::default()
1268            };
1269
1270            assert!(config.validate().is_ok());
1271        }
1272
1273        #[test]
1274        fn test_file_conversion_with_large_output() -> Result<()> {
1275            let temp_dir = setup_test_dir();
1276            let input_path = create_test_file(
1277                &temp_dir,
1278                "# Large\n\nContent".repeat(10_000).as_str(),
1279            );
1280            let output_path = temp_dir.path().join("large_output.html");
1281
1282            let result = markdown_file_to_html(
1283                Some(&input_path),
1284                Some(OutputDestination::File(
1285                    output_path.to_string_lossy().into(),
1286                )),
1287                None,
1288            );
1289
1290            assert!(result.is_ok());
1291            let content = std::fs::read_to_string(output_path)?;
1292            assert!(content.contains("<h1>Large</h1>"));
1293
1294            Ok(())
1295        }
1296
1297        #[test]
1298        fn test_markdown_with_broken_syntax() {
1299            let markdown = "# Unmatched Header\n**Bold start";
1300            let result = markdown_to_html(markdown, None);
1301            assert!(result.is_ok());
1302            let html = result.unwrap();
1303            assert!(html.contains("<h1>Unmatched Header</h1>"));
1304            assert!(html.contains("**Bold start</p>")); // Ensure content is preserved
1305        }
1306
1307        #[test]
1308        fn test_language_code_with_custom_regex() {
1309            let custom_lang_regex =
1310                Regex::new(r"^[a-z]{2}-[A-Z]{2}$").unwrap();
1311            assert!(custom_lang_regex.is_match("en-GB"));
1312            assert!(!custom_lang_regex.is_match("EN-gb")); // Case-sensitive check
1313        }
1314
1315        #[test]
1316        fn test_markdown_to_html_error_handling() {
1317            let result = markdown_to_html("", None);
1318            assert!(matches!(result, Err(HtmlError::InvalidInput(_))));
1319
1320            let oversized_input =
1321                "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1322            let result = markdown_to_html(&oversized_input, None);
1323            assert!(matches!(result, Err(HtmlError::InputTooLarge(_))));
1324        }
1325
1326        #[test]
1327        fn test_performance_with_nested_lists() {
1328            let nested_list = "- Item\n".repeat(1000);
1329            let result = markdown_to_html(&nested_list, None);
1330            assert!(result.is_ok());
1331            let html = result.unwrap();
1332            assert!(html.matches("<li>").count() == 1000);
1333        }
1334    }
1335
1336    mod file_validation_tests {
1337        use super::*;
1338        use std::path::PathBuf;
1339
1340        #[test]
1341        fn test_valid_paths() {
1342            let valid_paths = [
1343                PathBuf::from("test.md"),
1344                PathBuf::from("test.html"),
1345                PathBuf::from("subfolder/test.md"),
1346            ];
1347
1348            for path in valid_paths {
1349                assert!(
1350                    HtmlConfig::validate_file_path(&path).is_ok(),
1351                    "Path should be valid: {:?}",
1352                    path
1353                );
1354            }
1355        }
1356
1357        #[test]
1358        fn test_invalid_paths() {
1359            let invalid_paths = [
1360                PathBuf::from(""),           // Empty path
1361                PathBuf::from("../test.md"), // Directory traversal
1362                PathBuf::from("test.exe"),   // Invalid extension
1363                PathBuf::from(
1364                    "a".repeat(constants::MAX_PATH_LENGTH + 1),
1365                ), // Too long
1366            ];
1367
1368            for path in invalid_paths {
1369                assert!(
1370                    HtmlConfig::validate_file_path(&path).is_err(),
1371                    "Path should be invalid: {:?}",
1372                    path
1373                );
1374            }
1375        }
1376    }
1377
1378    mod markdown_conversion_tests {
1379        use super::*;
1380
1381        #[test]
1382        fn test_basic_conversion() {
1383            let markdown = "# Test\n\nHello world";
1384            let result = markdown_to_html(markdown, None);
1385            assert!(result.is_ok());
1386
1387            let html = result.unwrap();
1388            assert!(html.contains("<h1>Test</h1>"));
1389            assert!(html.contains("<p>Hello world</p>"));
1390        }
1391
1392        #[test]
1393        fn test_conversion_with_config() {
1394            let markdown = "# Test\n```rust\nfn main() {}\n```";
1395            let config = MarkdownConfig {
1396                html_config: HtmlConfig {
1397                    enable_syntax_highlighting: true,
1398                    ..Default::default()
1399                },
1400                ..Default::default()
1401            };
1402
1403            let result = markdown_to_html(markdown, Some(config));
1404            assert!(result.is_ok());
1405            assert!(result.unwrap().contains("language-rust"));
1406        }
1407
1408        #[test]
1409        fn test_empty_content() {
1410            assert!(matches!(
1411                markdown_to_html("", None),
1412                Err(HtmlError::InvalidInput(_))
1413            ));
1414        }
1415
1416        #[test]
1417        fn test_content_too_large() {
1418            let large_content =
1419                "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1420            assert!(matches!(
1421                markdown_to_html(&large_content, None),
1422                Err(HtmlError::InputTooLarge(_))
1423            ));
1424        }
1425    }
1426
1427    mod file_operation_tests {
1428        use super::*;
1429
1430        #[test]
1431        fn test_file_conversion() -> Result<()> {
1432            let temp_dir = setup_test_dir();
1433            let input_path =
1434                create_test_file(&temp_dir, "# Test\n\nHello world");
1435            let output_path = temp_dir.path().join("test.html");
1436
1437            markdown_file_to_html(
1438                Some(&input_path),
1439                Some(OutputDestination::File(
1440                    output_path.to_string_lossy().into(),
1441                )),
1442                None::<MarkdownConfig>,
1443            )?;
1444
1445            let content = std::fs::read_to_string(output_path)?;
1446            assert!(content.contains("<h1>Test</h1>"));
1447
1448            Ok(())
1449        }
1450
1451        #[test]
1452        fn test_writer_output() {
1453            let temp_dir = setup_test_dir();
1454            let input_path =
1455                create_test_file(&temp_dir, "# Test\nHello");
1456            let buffer = Box::new(Cursor::new(Vec::new()));
1457
1458            let result = markdown_file_to_html(
1459                Some(&input_path),
1460                Some(OutputDestination::Writer(buffer)),
1461                None,
1462            );
1463
1464            assert!(result.is_ok());
1465        }
1466
1467        #[test]
1468        fn test_writer_output_no_input() {
1469            let buffer = Box::new(Cursor::new(Vec::new()));
1470
1471            let result = markdown_file_to_html(
1472                Some(Path::new("nonexistent.md")),
1473                Some(OutputDestination::Writer(buffer)),
1474                None,
1475            );
1476
1477            assert!(result.is_err());
1478        }
1479    }
1480
1481    mod language_validation_tests {
1482        use super::*;
1483
1484        #[test]
1485        fn test_valid_language_codes() {
1486            let valid_codes =
1487                ["en-GB", "fr-FR", "de-DE", "es-ES", "zh-CN"];
1488
1489            for code in valid_codes {
1490                assert!(
1491                    validate_language_code(code),
1492                    "Language code '{}' should be valid",
1493                    code
1494                );
1495            }
1496        }
1497
1498        #[test]
1499        fn test_invalid_language_codes() {
1500            let invalid_codes = [
1501                "",        // Empty
1502                "en",      // Missing region
1503                "eng-GBR", // Wrong format
1504                "en_GB",   // Wrong separator
1505                "123-45",  // Invalid characters
1506                "GB-en",   // Wrong order
1507                "en-gb",   // Wrong case
1508            ];
1509
1510            for code in invalid_codes {
1511                assert!(
1512                    !validate_language_code(code),
1513                    "Language code '{}' should be invalid",
1514                    code
1515                );
1516            }
1517        }
1518    }
1519
1520    mod integration_tests {
1521        use super::*;
1522
1523        #[test]
1524        fn test_end_to_end_conversion() -> Result<()> {
1525            let temp_dir = setup_test_dir();
1526            let content = r#"---
1527title: Test Document
1528---
1529
1530# Hello World
1531
1532This is a test document with:
1533- A list
1534- And some **bold** text
1535"#;
1536            let input_path = create_test_file(&temp_dir, content);
1537            let output_path = temp_dir.path().join("test.html");
1538
1539            let config = MarkdownConfig {
1540                html_config: HtmlConfig {
1541                    enable_syntax_highlighting: true,
1542                    generate_toc: true,
1543                    ..Default::default()
1544                },
1545                ..Default::default()
1546            };
1547
1548            markdown_file_to_html(
1549                Some(&input_path),
1550                Some(OutputDestination::File(
1551                    output_path.to_string_lossy().into(),
1552                )),
1553                Some(config),
1554            )?;
1555
1556            let html = std::fs::read_to_string(&output_path)?;
1557            assert!(html.contains("<h1>Hello World</h1>"));
1558            assert!(html.contains("<strong>bold</strong>"));
1559            assert!(html.contains("<ul>"));
1560
1561            Ok(())
1562        }
1563
1564        #[test]
1565        fn test_output_destination_debug() {
1566            assert_eq!(
1567                format!(
1568                    "{:?}",
1569                    OutputDestination::File("test.html".to_string())
1570                ),
1571                r#"File("test.html")"#
1572            );
1573            assert_eq!(
1574                format!("{:?}", OutputDestination::Stdout),
1575                "Stdout"
1576            );
1577
1578            let writer = Box::new(Cursor::new(Vec::new()));
1579            assert_eq!(
1580                format!("{:?}", OutputDestination::Writer(writer)),
1581                "Writer(<dyn Write>)"
1582            );
1583        }
1584    }
1585
1586    mod markdown_config_tests {
1587        use super::*;
1588
1589        #[test]
1590        fn test_markdown_config_custom_encoding() {
1591            let config = MarkdownConfig {
1592                encoding: "latin1".to_string(),
1593                html_config: HtmlConfig::default(),
1594            };
1595            assert_eq!(config.encoding, "latin1");
1596        }
1597
1598        #[test]
1599        fn test_markdown_config_default() {
1600            let config = MarkdownConfig::default();
1601            assert_eq!(config.encoding, "utf-8");
1602            assert_eq!(config.html_config, HtmlConfig::default());
1603        }
1604
1605        #[test]
1606        fn test_markdown_config_clone() {
1607            let config = MarkdownConfig::default();
1608            let cloned = config.clone();
1609            assert_eq!(config, cloned);
1610        }
1611    }
1612
1613    mod config_error_tests {
1614        use super::*;
1615
1616        #[test]
1617        fn test_config_error_display() {
1618            let error = ConfigError::InvalidInputSize(100, 1024);
1619            assert!(error.to_string().contains("Invalid input size"));
1620
1621            let error =
1622                ConfigError::InvalidLanguageCode("xx".to_string());
1623            assert!(error
1624                .to_string()
1625                .contains("Invalid language code"));
1626
1627            let error =
1628                ConfigError::InvalidFilePath("../bad/path".to_string());
1629            assert!(error.to_string().contains("Invalid file path"));
1630        }
1631    }
1632
1633    mod output_destination_tests {
1634        use super::*;
1635
1636        #[test]
1637        fn test_output_destination_default() {
1638            assert!(matches!(
1639                OutputDestination::default(),
1640                OutputDestination::Stdout
1641            ));
1642        }
1643
1644        #[test]
1645        fn test_output_destination_file() {
1646            let dest = OutputDestination::File("test.html".to_string());
1647            assert!(matches!(dest, OutputDestination::File(_)));
1648        }
1649
1650        #[test]
1651        fn test_output_destination_writer() {
1652            let writer = Box::new(Cursor::new(Vec::new()));
1653            let dest = OutputDestination::Writer(writer);
1654            assert!(matches!(dest, OutputDestination::Writer(_)));
1655        }
1656    }
1657
1658    mod html_config_tests {
1659        use super::*;
1660
1661        #[test]
1662        fn test_html_config_builder_all_options() {
1663            let config = HtmlConfig::builder()
1664                .with_syntax_highlighting(
1665                    true,
1666                    Some("dracula".to_string()),
1667                )
1668                .with_language("en-US")
1669                .build()
1670                .unwrap();
1671
1672            assert!(config.enable_syntax_highlighting);
1673            assert_eq!(
1674                config.syntax_theme,
1675                Some("dracula".to_string())
1676            );
1677            assert_eq!(config.language, "en-US");
1678        }
1679
1680        #[test]
1681        fn test_html_config_validation_edge_cases() {
1682            let config = HtmlConfig {
1683                max_input_size: constants::MIN_INPUT_SIZE,
1684                ..Default::default()
1685            };
1686            assert!(config.validate().is_ok());
1687
1688            let config = HtmlConfig {
1689                max_input_size: constants::MIN_INPUT_SIZE - 1,
1690                ..Default::default()
1691            };
1692            assert!(config.validate().is_err());
1693        }
1694    }
1695
1696    mod markdown_processing_tests {
1697        use super::*;
1698
1699        #[test]
1700        fn test_markdown_to_html_with_front_matter() -> Result<()> {
1701            let markdown = r#"---
1702title: Test
1703author: Test Author
1704---
1705# Heading
1706Content"#;
1707            let html = markdown_to_html(markdown, None)?;
1708            assert!(html.contains("<h1>Heading</h1>"));
1709            assert!(html.contains("<p>Content</p>"));
1710            Ok(())
1711        }
1712
1713        #[test]
1714        fn test_markdown_to_html_with_code_blocks() -> Result<()> {
1715            let markdown = r#"```rust
1716fn main() {
1717    println!("Hello");
1718}
1719```"#;
1720            let config = MarkdownConfig {
1721                html_config: HtmlConfig {
1722                    enable_syntax_highlighting: true,
1723                    ..Default::default()
1724                },
1725                ..Default::default()
1726            };
1727            let html = markdown_to_html(markdown, Some(config))?;
1728            assert!(html.contains("language-rust"));
1729            Ok(())
1730        }
1731
1732        #[test]
1733        fn test_markdown_to_html_with_tables() -> Result<()> {
1734            let markdown = r#"
1735| Header 1 | Header 2 |
1736|----------|----------|
1737| Cell 1   | Cell 2   |
1738"#;
1739            let html = markdown_to_html(markdown, None)?;
1740            // First verify the HTML output to see what we're getting
1741            println!("Generated HTML for table: {}", html);
1742            // Check for common table elements - div wrapper is often used for table responsiveness
1743            assert!(html.contains("Header 1"));
1744            assert!(html.contains("Cell 1"));
1745            assert!(html.contains("Cell 2"));
1746            Ok(())
1747        }
1748
1749        #[test]
1750        fn test_invalid_encoding_handling() {
1751            let config = MarkdownConfig {
1752                encoding: "unsupported-encoding".to_string(),
1753                html_config: HtmlConfig::default(),
1754            };
1755            // Simulate usage where encoding matters
1756            let result = markdown_to_html("# Test", Some(config));
1757            assert!(result.is_ok()); // Assuming encoding isn't directly validated during processing
1758        }
1759
1760        #[test]
1761        fn test_config_error_types() {
1762            let error = ConfigError::InvalidInputSize(512, 1024);
1763            assert_eq!(format!("{}", error), "Invalid input size: 512 bytes is below minimum of 1024 bytes");
1764        }
1765    }
1766
1767    mod file_processing_tests {
1768        use crate::constants;
1769        use crate::HtmlConfig;
1770        use crate::{
1771            markdown_file_to_html, HtmlError, OutputDestination,
1772        };
1773        use std::io::Cursor;
1774        use std::path::Path;
1775        use tempfile::NamedTempFile;
1776
1777        #[test]
1778        fn test_display_file() {
1779            let output =
1780                OutputDestination::File("output.html".to_string());
1781            let display = format!("{}", output);
1782            assert_eq!(display, "File(output.html)");
1783        }
1784
1785        #[test]
1786        fn test_display_stdout() {
1787            let output = OutputDestination::Stdout;
1788            let display = format!("{}", output);
1789            assert_eq!(display, "Stdout");
1790        }
1791
1792        #[test]
1793        fn test_display_writer() {
1794            let buffer = Cursor::new(Vec::new());
1795            let output = OutputDestination::Writer(Box::new(buffer));
1796            let display = format!("{}", output);
1797            assert_eq!(display, "Writer(<dyn Write>)");
1798        }
1799
1800        #[test]
1801        fn test_debug_file() {
1802            let output =
1803                OutputDestination::File("output.html".to_string());
1804            let debug = format!("{:?}", output);
1805            assert_eq!(debug, r#"File("output.html")"#);
1806        }
1807
1808        #[test]
1809        fn test_debug_stdout() {
1810            let output = OutputDestination::Stdout;
1811            let debug = format!("{:?}", output);
1812            assert_eq!(debug, "Stdout");
1813        }
1814
1815        #[test]
1816        fn test_debug_writer() {
1817            let buffer = Cursor::new(Vec::new());
1818            let output = OutputDestination::Writer(Box::new(buffer));
1819            let debug = format!("{:?}", output);
1820            assert_eq!(debug, "Writer(<dyn Write>)");
1821        }
1822
1823        #[test]
1824        fn test_file_to_html_invalid_input() {
1825            let result = markdown_file_to_html(
1826                Some(Path::new("nonexistent.md")),
1827                None,
1828                None,
1829            );
1830            assert!(matches!(result, Err(HtmlError::Io(_))));
1831        }
1832
1833        #[test]
1834        fn test_file_to_html_with_invalid_output_path(
1835        ) -> Result<(), HtmlError> {
1836            let input = NamedTempFile::new()?;
1837            std::fs::write(&input, "# Test")?;
1838
1839            let result = markdown_file_to_html(
1840                Some(input.path()),
1841                Some(OutputDestination::File(
1842                    "/invalid/path/test.html".to_string(),
1843                )),
1844                None,
1845            );
1846            assert!(result.is_err());
1847            Ok(())
1848        }
1849
1850        // Test for Default implementation of OutputDestination
1851        #[test]
1852        fn test_output_destination_default() {
1853            let default = OutputDestination::default();
1854            assert!(matches!(default, OutputDestination::Stdout));
1855        }
1856
1857        // Test for Debug implementation of OutputDestination
1858        #[test]
1859        fn test_output_destination_debug() {
1860            let file_debug = format!(
1861                "{:?}",
1862                OutputDestination::File(
1863                    "path/to/file.html".to_string()
1864                )
1865            );
1866            assert_eq!(file_debug, r#"File("path/to/file.html")"#);
1867
1868            let writer_debug = format!(
1869                "{:?}",
1870                OutputDestination::Writer(Box::new(Cursor::new(
1871                    Vec::new()
1872                )))
1873            );
1874            assert_eq!(writer_debug, "Writer(<dyn Write>)");
1875
1876            let stdout_debug =
1877                format!("{:?}", OutputDestination::Stdout);
1878            assert_eq!(stdout_debug, "Stdout");
1879        }
1880
1881        // Test for Display implementation of OutputDestination
1882        #[test]
1883        fn test_output_destination_display() {
1884            let file_display = format!(
1885                "{}",
1886                OutputDestination::File(
1887                    "path/to/file.html".to_string()
1888                )
1889            );
1890            assert_eq!(file_display, "File(path/to/file.html)");
1891
1892            let writer_display = format!(
1893                "{}",
1894                OutputDestination::Writer(Box::new(Cursor::new(
1895                    Vec::new()
1896                )))
1897            );
1898            assert_eq!(writer_display, "Writer(<dyn Write>)");
1899
1900            let stdout_display =
1901                format!("{}", OutputDestination::Stdout);
1902            assert_eq!(stdout_display, "Stdout");
1903        }
1904
1905        // Test for Default implementation of HtmlConfig
1906        #[test]
1907        fn test_html_config_default() {
1908            let default = HtmlConfig::default();
1909            assert!(default.enable_syntax_highlighting);
1910            assert_eq!(
1911                default.syntax_theme,
1912                Some(constants::DEFAULT_SYNTAX_THEME.to_string())
1913            );
1914            assert!(!default.minify_output);
1915            assert!(default.add_aria_attributes);
1916            assert!(!default.generate_structured_data);
1917            assert_eq!(
1918                default.max_input_size,
1919                constants::DEFAULT_MAX_INPUT_SIZE
1920            );
1921            assert_eq!(
1922                default.language,
1923                constants::DEFAULT_LANGUAGE.to_string()
1924            );
1925            assert!(!default.generate_toc);
1926        }
1927
1928        // Test for HtmlConfigBuilder
1929        #[test]
1930        fn test_html_config_builder() {
1931            let builder = HtmlConfig::builder()
1932                .with_syntax_highlighting(
1933                    true,
1934                    Some("monokai".to_string()),
1935                )
1936                .with_language("en-US")
1937                .build()
1938                .unwrap();
1939
1940            assert!(builder.enable_syntax_highlighting);
1941            assert_eq!(
1942                builder.syntax_theme,
1943                Some("monokai".to_string())
1944            );
1945            assert_eq!(builder.language, "en-US");
1946        }
1947
1948        // Test for long file path validation
1949        #[test]
1950        fn test_long_file_path_validation() {
1951            let long_path = "a".repeat(constants::MAX_PATH_LENGTH + 1);
1952            let result = HtmlConfig::validate_file_path(long_path);
1953            assert!(
1954                matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("File path exceeds maximum length"))
1955            );
1956        }
1957
1958        /// Absolute paths are deliberately accepted: CLI tools invoke the
1959        /// library with fully qualified filenames. Authorisation is the
1960        /// caller's responsibility; see [`HtmlConfig::validate_file_path`]
1961        /// docs.
1962        #[test]
1963        fn test_absolute_path_is_accepted() {
1964            let result = HtmlConfig::validate_file_path(
1965                "/absolute/path/to/file.md",
1966            );
1967            assert!(
1968                result.is_ok(),
1969                "absolute paths must be accepted, got {result:?}"
1970            );
1971        }
1972
1973        /// NUL byte smuggling must be rejected — on Unix, C-string path
1974        /// handling silently truncates at the first NUL.
1975        #[test]
1976        fn test_nul_byte_path_is_rejected() {
1977            let result = HtmlConfig::validate_file_path("safe.md\0bad");
1978            assert!(
1979                matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("NUL")),
1980                "NUL byte in path must be rejected, got {result:?}"
1981            );
1982        }
1983    }
1984
1985    mod language_validation_extended_tests {
1986        use super::*;
1987
1988        #[test]
1989        fn test_language_code_edge_cases() {
1990            // Test empty string
1991            assert!(!validate_language_code(""));
1992
1993            // Test single character
1994            assert!(!validate_language_code("a"));
1995
1996            // Test incorrect casing
1997            assert!(!validate_language_code("EN-GB"));
1998            assert!(!validate_language_code("en-gb"));
1999
2000            // Test invalid separators
2001            assert!(!validate_language_code("en_GB"));
2002            assert!(!validate_language_code("en GB"));
2003
2004            // Test too many segments
2005            assert!(!validate_language_code("en-GB-extra"));
2006        }
2007
2008        #[test]
2009        fn test_language_code_special_cases() {
2010            // Test with numbers
2011            assert!(!validate_language_code("e1-GB"));
2012            assert!(!validate_language_code("en-G1"));
2013
2014            // Test with special characters
2015            assert!(!validate_language_code("en-GB!"));
2016            assert!(!validate_language_code("en@GB"));
2017
2018            // Test with Unicode characters
2019            assert!(!validate_language_code("あa-GB"));
2020            assert!(!validate_language_code("en-あa"));
2021        }
2022    }
2023
2024    mod integration_extended_tests {
2025        use super::*;
2026
2027        #[test]
2028        fn test_full_conversion_pipeline() -> Result<()> {
2029            // Create temporary files
2030            let temp_dir = tempdir()?;
2031            let input_path = temp_dir.path().join("test.md");
2032            let output_path = temp_dir.path().join("test.html");
2033
2034            // Test content with various Markdown features
2035            let content = r#"---
2036title: Test Document
2037author: Test Author
2038---
2039
2040# Main Heading
2041
2042## Subheading
2043
2044This is a paragraph with *italic* and **bold** text.
2045
2046- List item 1
2047- List item 2
2048  - Nested item
2049  - Another nested item
2050
2051```rust
2052fn main() {
2053    println!("Hello, world!");
2054}
2055```
2056
2057| Column 1 | Column 2 |
2058|----------|----------|
2059| Cell 1   | Cell 2   |
2060
2061> This is a blockquote
2062
2063[Link text](https://example.com)"#;
2064
2065            std::fs::write(&input_path, content)?;
2066
2067            // Configure with all features enabled
2068            let config = MarkdownConfig {
2069                html_config: HtmlConfig {
2070                    enable_syntax_highlighting: true,
2071                    generate_toc: true,
2072                    add_aria_attributes: true,
2073                    generate_structured_data: true,
2074                    minify_output: true,
2075                    ..Default::default()
2076                },
2077                ..Default::default()
2078            };
2079
2080            markdown_file_to_html(
2081                Some(&input_path),
2082                Some(OutputDestination::File(
2083                    output_path.to_string_lossy().into(),
2084                )),
2085                Some(config),
2086            )?;
2087
2088            let html = std::fs::read_to_string(&output_path)?;
2089
2090            // Verify all expected elements are present
2091            println!("Generated HTML: {}", html);
2092            assert!(html.contains("<h1>"));
2093            assert!(html.contains("<h2>"));
2094            assert!(html.contains("<em>"));
2095            assert!(html.contains("<strong>"));
2096            assert!(html.contains("<ul>"));
2097            assert!(html.contains("<li>"));
2098            assert!(html.contains("language-rust"));
2099
2100            // Verify table content instead of specific HTML structure
2101            assert!(html.contains("Column 1"));
2102            assert!(html.contains("Column 2"));
2103            assert!(html.contains("Cell 1"));
2104            assert!(html.contains("Cell 2"));
2105
2106            assert!(html.contains("<blockquote>"));
2107            // The sanitizer adds `rel="noopener noreferrer"` and the
2108            // minifier may reorder/unquote attributes, so assert on the
2109            // anchor and its target rather than a literal `<a href=`.
2110            assert!(html.contains("<a "));
2111            assert!(html.contains("https://example.com"));
2112
2113            Ok(())
2114        }
2115
2116        #[test]
2117        fn test_missing_html_config_fallback() {
2118            let config = MarkdownConfig {
2119                encoding: "utf-8".to_string(),
2120                html_config: HtmlConfig {
2121                    enable_syntax_highlighting: false,
2122                    syntax_theme: None,
2123                    ..Default::default()
2124                },
2125            };
2126            let result = markdown_to_html("# Test", Some(config));
2127            assert!(result.is_ok());
2128        }
2129
2130        #[test]
2131        fn test_invalid_output_destination() {
2132            let result = markdown_file_to_html(
2133                Some(Path::new("test.md")),
2134                Some(OutputDestination::File(
2135                    "/root/forbidden.html".to_string(),
2136                )),
2137                None,
2138            );
2139            assert!(result.is_err());
2140        }
2141    }
2142
2143    mod performance_tests {
2144        use super::*;
2145        use std::time::Instant;
2146
2147        #[test]
2148        fn test_large_document_performance() -> Result<()> {
2149            let base_content =
2150                "# Heading\n\nParagraph\n\n- List item\n\n";
2151            let large_content = base_content.repeat(1000);
2152
2153            let start = Instant::now();
2154            let html = markdown_to_html(&large_content, None)?;
2155            let duration = start.elapsed();
2156
2157            // Log performance metrics
2158            println!("Large document conversion took: {:?}", duration);
2159            println!("Input size: {} bytes", large_content.len());
2160            println!("Output size: {} bytes", html.len());
2161
2162            // Basic validation
2163            assert!(html.contains("<h1>"));
2164            assert!(html.contains("<p>"));
2165            assert!(html.contains("<ul>"));
2166
2167            Ok(())
2168        }
2169    }
2170}