Skip to main content

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