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