html_generator/
performance.rs

1// Copyright © 2025 HTML Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Performance optimization functionality for HTML processing.
5//!
6//! This module provides optimized utilities for HTML minification and generation,
7//! with both synchronous and asynchronous interfaces. The module focuses on:
8//!
9//! - Efficient HTML minification with configurable options
10//! - Non-blocking asynchronous HTML generation
11//! - Memory-efficient string handling
12//! - Thread-safe operations
13//!
14//! # Performance Characteristics
15//!
16//! - Minification: O(n) time complexity, ~1.5x peak memory usage
17//! - HTML Generation: O(n) time complexity, proportional memory usage
18//! - All operations are thread-safe and support concurrent access
19//!
20//! # Examples
21//!
22//! Basic HTML minification:
23//! ```no_run
24//! # use html_generator::performance::minify_html;
25//! # use std::path::Path;
26//! # fn example() -> Result<(), html_generator::error::HtmlError> {
27//! let path = Path::new("index.html");
28//! let minified = minify_html(path)?;
29//! println!("Minified size: {} bytes", minified.len());
30//! # Ok(())
31//! # }
32//! ```
33
34use crate::{HtmlError, Result};
35use comrak::{markdown_to_html, ComrakOptions};
36use minify_html::{minify, Cfg};
37use std::{fs, path::Path};
38use tokio::task;
39
40/// Maximum allowed file size for minification (10 MB).
41pub const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
42
43/// Initial capacity for string buffers (1 KB).
44const INITIAL_HTML_CAPACITY: usize = 1024;
45
46/// Configuration for HTML minification with optimized defaults.
47///
48/// Provides a set of minification options that preserve HTML semantics
49/// while reducing file size. The configuration balances compression
50/// with standards compliance.
51#[derive(Clone)]
52struct MinifyConfig {
53    /// Internal minification configuration from minify-html crate
54    cfg: Cfg,
55}
56
57impl Default for MinifyConfig {
58    fn default() -> Self {
59        let mut cfg = Cfg::new();
60        // Preserve HTML semantics and compatibility
61        cfg.do_not_minify_doctype = true;
62        cfg.ensure_spec_compliant_unquoted_attribute_values = true;
63        cfg.keep_closing_tags = true;
64        cfg.keep_html_and_head_opening_tags = true;
65        cfg.keep_spaces_between_attributes = true;
66        // Enable safe minification for non-structural elements
67        cfg.keep_comments = false;
68        cfg.minify_css = true;
69        cfg.minify_js = true;
70        cfg.remove_bangs = true;
71        cfg.remove_processing_instructions = true;
72
73        Self { cfg }
74    }
75}
76
77impl std::fmt::Debug for MinifyConfig {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("MinifyConfig")
80            .field(
81                "do_not_minify_doctype",
82                &self.cfg.do_not_minify_doctype,
83            )
84            .field("minify_css", &self.cfg.minify_css)
85            .field("minify_js", &self.cfg.minify_js)
86            .field("keep_comments", &self.cfg.keep_comments)
87            .finish()
88    }
89}
90
91/// Minifies HTML content from a file with optimized performance.
92///
93/// Reads an HTML file and applies efficient minification techniques to reduce
94/// its size while maintaining functionality and standards compliance.
95///
96/// # Arguments
97///
98/// * `file_path` - Path to the HTML file to minify
99///
100/// # Returns
101///
102/// Returns the minified HTML content as a string if successful.
103///
104/// # Errors
105///
106/// Returns [`HtmlError`] if:
107/// - File reading fails
108/// - File size exceeds [`MAX_FILE_SIZE`]
109/// - Content is not valid UTF-8
110/// - Minification process fails
111///
112/// # Examples
113///
114/// ```no_run
115/// # use html_generator::performance::minify_html;
116/// # use std::path::Path;
117/// # fn example() -> Result<(), html_generator::error::HtmlError> {
118/// let path = Path::new("index.html");
119/// let minified = minify_html(path)?;
120/// println!("Minified HTML: {} bytes", minified.len());
121/// # Ok(())
122/// # }
123/// ```
124pub fn minify_html(file_path: &Path) -> Result<String> {
125    let metadata = fs::metadata(file_path).map_err(|e| {
126        HtmlError::MinificationError(format!(
127            "Failed to read file metadata for '{}': {e}",
128            file_path.display()
129        ))
130    })?;
131
132    let file_size = metadata.len() as usize;
133    if file_size > MAX_FILE_SIZE {
134        return Err(HtmlError::MinificationError(format!(
135            "File size {file_size} bytes exceeds maximum of {MAX_FILE_SIZE} bytes"
136        )));
137    }
138
139    let content = fs::read_to_string(file_path).map_err(|e| {
140        if e.to_string().contains("stream did not contain valid UTF-8")
141        {
142            HtmlError::MinificationError(format!(
143                "Invalid UTF-8 in input file '{}': {e}",
144                file_path.display()
145            ))
146        } else {
147            HtmlError::MinificationError(format!(
148                "Failed to read file '{}': {e}",
149                file_path.display()
150            ))
151        }
152    })?;
153
154    let config = MinifyConfig::default();
155    let minified = minify(content.as_bytes(), &config.cfg);
156
157    String::from_utf8(minified).map_err(|e| {
158        HtmlError::MinificationError(format!(
159            "Invalid UTF-8 in minified content: {e}"
160        ))
161    })
162}
163
164/// Asynchronously generates HTML from Markdown content.
165///
166/// Processes Markdown in a separate thread to avoid blocking the async runtime,
167/// optimized for efficient memory usage with larger content.
168///
169/// # Arguments
170///
171/// * `markdown` - Markdown content to convert to HTML
172///
173/// # Returns
174///
175/// Returns the generated HTML content if successful.
176///
177/// # Errors
178///
179/// Returns [`HtmlError`] if:
180/// - Thread spawning fails
181/// - Markdown processing fails
182///
183/// # Examples
184///
185/// ```
186/// # use html_generator::performance::async_generate_html;
187/// #
188/// # #[tokio::main]
189/// # async fn main() -> Result<(), html_generator::error::HtmlError> {
190/// let markdown = "# Hello\n\nThis is a test.";
191/// let html = async_generate_html(markdown).await?;
192/// println!("Generated HTML length: {}", html.len());
193/// # Ok(())
194/// # }
195/// ```
196pub async fn async_generate_html(markdown: &str) -> Result<String> {
197    // Optimize string allocation based on content size
198    let markdown = if markdown.len() < INITIAL_HTML_CAPACITY {
199        markdown.to_string()
200    } else {
201        // Pre-allocate for larger content
202        let mut string = String::with_capacity(markdown.len());
203        string.push_str(markdown);
204        string
205    };
206
207    task::spawn_blocking(move || {
208        let options = ComrakOptions::default();
209        Ok(markdown_to_html(&markdown, &options))
210    })
211    .await
212    .map_err(|e| HtmlError::MarkdownConversion {
213        message: format!("Asynchronous HTML generation failed: {e}"),
214        source: Some(std::io::Error::new(
215            std::io::ErrorKind::Other,
216            e.to_string(),
217        )),
218    })?
219}
220
221/// Synchronously generates HTML from Markdown content.
222///
223/// Provides a simple, synchronous interface for Markdown to HTML conversion
224/// when asynchronous processing isn't required.
225///
226/// # Arguments
227///
228/// * `markdown` - Markdown content to convert to HTML
229///
230/// # Returns
231///
232/// Returns the generated HTML content if successful.
233///
234/// # Examples
235///
236/// ```
237/// # use html_generator::performance::generate_html;
238/// # fn example() -> Result<(), html_generator::error::HtmlError> {
239/// let markdown = "# Hello\n\nThis is a test.";
240/// let html = generate_html(markdown)?;
241/// println!("Generated HTML length: {}", html.len());
242/// # Ok(())
243/// # }
244/// ```
245#[inline]
246pub fn generate_html(markdown: &str) -> Result<String> {
247    Ok(markdown_to_html(markdown, &ComrakOptions::default()))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::fs::File;
254    use std::io::Write;
255    use tempfile::tempdir;
256
257    /// Helper function to create a temporary HTML file for testing.
258    ///
259    /// # Arguments
260    ///
261    /// * `content` - HTML content to write to the file.
262    ///
263    /// # Returns
264    ///
265    /// A tuple containing the temporary directory and file path.
266    fn create_test_file(
267        content: &str,
268    ) -> (tempfile::TempDir, std::path::PathBuf) {
269        let dir = tempdir().expect("Failed to create temp directory");
270        let file_path = dir.path().join("test.html");
271        let mut file = File::create(&file_path)
272            .expect("Failed to create test file");
273        file.write_all(content.as_bytes())
274            .expect("Failed to write test content");
275        (dir, file_path)
276    }
277
278    mod minify_html_tests {
279        use super::*;
280
281        #[test]
282        fn test_minify_basic_html() {
283            let html =
284                "<html>  <body>    <p>Test</p>  </body>  </html>";
285            let (dir, file_path) = create_test_file(html);
286            let result = minify_html(&file_path);
287            assert!(result.is_ok());
288            assert_eq!(
289                result.unwrap(),
290                "<html><body><p>Test</p></body></html>"
291            );
292            drop(dir);
293        }
294
295        #[test]
296        fn test_minify_with_comments() {
297            let html =
298                "<html><!-- Comment --><body><p>Test</p></body></html>";
299            let (dir, file_path) = create_test_file(html);
300            let result = minify_html(&file_path);
301            assert!(result.is_ok());
302            assert_eq!(
303                result.unwrap(),
304                "<html><body><p>Test</p></body></html>"
305            );
306            drop(dir);
307        }
308
309        #[test]
310        fn test_minify_invalid_path() {
311            let result = minify_html(Path::new("nonexistent.html"));
312            assert!(result.is_err());
313            assert!(matches!(
314                result,
315                Err(HtmlError::MinificationError(_))
316            ));
317        }
318
319        #[test]
320        fn test_minify_exceeds_max_size() {
321            let large_content = "a".repeat(MAX_FILE_SIZE + 1);
322            let (dir, file_path) = create_test_file(&large_content);
323            let result = minify_html(&file_path);
324            assert!(matches!(
325                result,
326                Err(HtmlError::MinificationError(_))
327            ));
328            let err_msg = result.unwrap_err().to_string();
329            assert!(err_msg.contains("exceeds maximum"));
330            drop(dir);
331        }
332
333        #[test]
334        fn test_minify_invalid_utf8() {
335            let dir =
336                tempdir().expect("Failed to create temp directory");
337            let file_path = dir.path().join("invalid.html");
338            {
339                let mut file = File::create(&file_path)
340                    .expect("Failed to create test file");
341                file.write_all(&[0xFF, 0xFF])
342                    .expect("Failed to write test content");
343            }
344
345            let result = minify_html(&file_path);
346            assert!(matches!(
347                result,
348                Err(HtmlError::MinificationError(_))
349            ));
350            let err_msg = result.unwrap_err().to_string();
351            assert!(err_msg.contains("Invalid UTF-8 in input file"));
352            drop(dir);
353        }
354
355        #[test]
356        fn test_minify_utf8_content() {
357            let html = "<html><body><p>Test 你好 🦀</p></body></html>";
358            let (dir, file_path) = create_test_file(html);
359            let result = minify_html(&file_path);
360            assert!(result.is_ok());
361            assert_eq!(
362                result.unwrap(),
363                "<html><body><p>Test 你好 🦀</p></body></html>"
364            );
365            drop(dir);
366        }
367    }
368
369    mod async_generate_html_tests {
370        use super::*;
371
372        #[tokio::test]
373        async fn test_async_generate_html() {
374            let markdown = "# Test\n\nThis is a test.";
375            let result = async_generate_html(markdown).await;
376            assert!(result.is_ok());
377            let html = result.unwrap();
378            assert!(html.contains("<h1>Test</h1>"));
379            assert!(html.contains("<p>This is a test.</p>"));
380        }
381
382        #[tokio::test]
383        async fn test_async_generate_html_empty() {
384            let result = async_generate_html("").await;
385            assert!(result.is_ok());
386            assert!(result.unwrap().is_empty());
387        }
388
389        #[tokio::test]
390        async fn test_async_generate_html_large_content() {
391            let large_markdown =
392                "# Test\n\n".to_string() + &"Content\n".repeat(10_000);
393            let result = async_generate_html(&large_markdown).await;
394            assert!(result.is_ok());
395            let html = result.unwrap();
396            assert!(html.contains("<h1>Test</h1>"));
397        }
398    }
399
400    mod generate_html_tests {
401        use super::*;
402
403        #[test]
404        fn test_sync_generate_html() {
405            let markdown = "# Test\n\nThis is a test.";
406            let result = generate_html(markdown);
407            assert!(result.is_ok());
408            let html = result.unwrap();
409            assert!(html.contains("<h1>Test</h1>"));
410            assert!(html.contains("<p>This is a test.</p>"));
411        }
412
413        #[test]
414        fn test_sync_generate_html_empty() {
415            let result = generate_html("");
416            assert!(result.is_ok());
417            assert!(result.unwrap().is_empty());
418        }
419
420        #[test]
421        fn test_sync_generate_html_large_content() {
422            let large_markdown =
423                "# Test\n\n".to_string() + &"Content\n".repeat(10_000);
424            let result = generate_html(&large_markdown);
425            assert!(result.is_ok());
426            let html = result.unwrap();
427            assert!(html.contains("<h1>Test</h1>"));
428        }
429    }
430
431    mod additional_tests {
432        use super::*;
433        use std::fs::File;
434        use std::io::Write;
435        use tempfile::tempdir;
436
437        /// Test for default MinifyConfig values.
438        #[test]
439        fn test_minify_config_default() {
440            let config = MinifyConfig::default();
441            assert!(config.cfg.do_not_minify_doctype);
442            assert!(config.cfg.minify_css);
443            assert!(config.cfg.minify_js);
444            assert!(!config.cfg.keep_comments);
445        }
446
447        /// Test for custom MinifyConfig values.
448        #[test]
449        fn test_minify_config_custom() {
450            let mut config = MinifyConfig::default();
451            config.cfg.keep_comments = true;
452            assert!(config.cfg.keep_comments);
453        }
454
455        /// Test for uncommon HTML structures in minify_html.
456        #[test]
457        fn test_minify_html_uncommon_structures() {
458            let html = r#"<div><span>Test<div><p>Nested</p></div></span></div>"#;
459            let (dir, file_path) = create_test_file(html);
460            let result = minify_html(&file_path);
461            assert!(result.is_ok());
462            assert_eq!(
463                result.unwrap(),
464                r#"<div><span>Test<div><p>Nested</p></div></span></div>"#
465            );
466            drop(dir);
467        }
468
469        /// Test for mixed encodings in minify_html.
470        #[test]
471        fn test_minify_html_mixed_encodings() {
472            let dir =
473                tempdir().expect("Failed to create temp directory");
474            let file_path = dir.path().join("mixed_encoding.html");
475            {
476                let mut file = File::create(&file_path)
477                    .expect("Failed to create test file");
478                file.write_all(&[0xFF, b'T', b'e', b's', b't', 0xFE])
479                    .expect("Failed to write test content");
480            }
481            let result = minify_html(&file_path);
482            assert!(matches!(
483                result,
484                Err(HtmlError::MinificationError(_))
485            ));
486            drop(dir);
487        }
488
489        /// Test for extremely large Markdown content in async_generate_html.
490        #[tokio::test]
491        async fn test_async_generate_html_extremely_large() {
492            let large_markdown = "# Large Content
493"
494            .to_string()
495                + &"Content
496"
497                .repeat(100_000);
498            let result = async_generate_html(&large_markdown).await;
499            assert!(result.is_ok());
500            let html = result.unwrap();
501            assert!(html.contains("<h1>Large Content</h1>"));
502        }
503
504        /// Test for very small Markdown content in generate_html.
505        #[test]
506        fn test_generate_html_very_small() {
507            let markdown = "A";
508            let result = generate_html(markdown);
509            assert!(result.is_ok());
510            assert_eq!(
511                result.unwrap(),
512                "<p>A</p>
513"
514            );
515        }
516
517        #[tokio::test]
518        async fn test_async_generate_html_spawn_blocking_failure() {
519            use tokio::task;
520
521            // Simulate failure by forcing a panic inside the `spawn_blocking` task
522            let _markdown = "# Valid Markdown"; // Normally valid Markdown
523
524            // Override the `spawn_blocking` behavior to simulate a failure
525            let result = task::spawn_blocking(|| {
526                panic!("Simulated task failure"); // Force the closure to fail
527            })
528            .await;
529
530            // Explicitly use `std::result::Result` to avoid alias conflicts
531            let converted_result: std::result::Result<
532                String,
533                HtmlError,
534            > = match result {
535                Err(e) => Err(HtmlError::MarkdownConversion {
536                    message: format!(
537                        "Asynchronous HTML generation failed: {e}"
538                    ),
539                    source: Some(std::io::Error::new(
540                        std::io::ErrorKind::Other,
541                        e.to_string(),
542                    )),
543                }),
544                Ok(_) => panic!("Expected a simulated failure"),
545            };
546
547            // Check that the error matches `HtmlError::MarkdownConversion`
548            assert!(matches!(
549                converted_result,
550                Err(HtmlError::MarkdownConversion { .. })
551            ));
552
553            if let Err(HtmlError::MarkdownConversion {
554                message,
555                source,
556            }) = converted_result
557            {
558                assert!(message
559                    .contains("Asynchronous HTML generation failed"));
560                assert!(source.is_some());
561
562                // Relax the assertion to match the general pattern of the panic message
563                let source_message = source.unwrap().to_string();
564                assert!(
565                    source_message.contains("Simulated task failure"),
566                    "Unexpected source message: {source_message}"
567                );
568            }
569        }
570
571        #[test]
572        fn test_minify_html_empty_content() {
573            let html = "";
574            let (dir, file_path) = create_test_file(html);
575            let result = minify_html(&file_path);
576            assert!(result.is_ok());
577            assert!(
578                result.unwrap().is_empty(),
579                "Minified content should be empty"
580            );
581            drop(dir);
582        }
583
584        #[test]
585        fn test_minify_html_unusual_whitespace() {
586            let html =
587                "<html>\n\n\t<body>\t<p>Test</p>\n\n</body>\n\n</html>";
588            let (dir, file_path) = create_test_file(html);
589            let result = minify_html(&file_path);
590            assert!(result.is_ok());
591            assert_eq!(
592                result.unwrap(),
593                "<html><body><p>Test</p></body></html>",
594                "Unexpected minified result for unusual whitespace"
595            );
596            drop(dir);
597        }
598
599        #[test]
600        fn test_minify_html_with_special_characters() {
601            let html = "<div>&lt;Special&gt; &amp; Characters</div>";
602            let (dir, file_path) = create_test_file(html);
603            let result = minify_html(&file_path);
604            assert!(result.is_ok());
605            assert_eq!(
606        result.unwrap(),
607        "<div>&LTSpecial> & Characters</div>",
608        "Special characters were unexpectedly modified during minification"
609    );
610            drop(dir);
611        }
612
613        #[tokio::test]
614        async fn test_async_generate_html_with_special_characters() {
615            let markdown =
616                "# Special & Characters\n\nContent with < > & \" '";
617            let result = async_generate_html(markdown).await;
618            assert!(result.is_ok());
619            let html = result.unwrap();
620            assert!(
621                html.contains("&lt;"),
622                "Less than sign not escaped"
623            );
624            assert!(
625                html.contains("&gt;"),
626                "Greater than sign not escaped"
627            );
628            assert!(html.contains("&amp;"), "Ampersand not escaped");
629            assert!(
630                html.contains("&quot;"),
631                "Double quote not escaped"
632            );
633            assert!(
634                html.contains("&#39;") || html.contains("'"),
635                "Single quote not handled as expected"
636            );
637        }
638    }
639}