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