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
251        #[test]
252        fn test_minify_basic_html() {
253            let html =
254                "<html>  <body>    <p>Test</p>  </body>  </html>";
255            let (dir, file_path) = create_test_file(html);
256            let result = minify_html(&file_path);
257            assert!(result.is_ok());
258            assert_eq!(
259                result.unwrap(),
260                "<html><body><p>Test</p></body></html>"
261            );
262            drop(dir);
263        }
264
265        #[test]
266        fn test_minify_with_comments() {
267            let html =
268                "<html><!-- Comment --><body><p>Test</p></body></html>";
269            let (dir, file_path) = create_test_file(html);
270            let result = minify_html(&file_path);
271            assert!(result.is_ok());
272            assert_eq!(
273                result.unwrap(),
274                "<html><body><p>Test</p></body></html>"
275            );
276            drop(dir);
277        }
278
279        #[test]
280        fn test_minify_invalid_path() {
281            let result = minify_html(Path::new("nonexistent.html"));
282            assert!(result.is_err());
283            assert!(matches!(
284                result,
285                Err(HtmlError::MinificationError(_))
286            ));
287        }
288
289        #[test]
290        fn test_minify_exceeds_max_size() {
291            let large_content = "a".repeat(MAX_FILE_SIZE + 1);
292            let (dir, file_path) = create_test_file(&large_content);
293            let result = minify_html(&file_path);
294            assert!(matches!(
295                result,
296                Err(HtmlError::MinificationError(_))
297            ));
298            let err_msg = result.unwrap_err().to_string();
299            assert!(err_msg.contains("exceeds maximum"));
300            drop(dir);
301        }
302
303        #[test]
304        fn test_minify_invalid_utf8() {
305            let dir =
306                tempdir().expect("Failed to create temp directory");
307            let file_path = dir.path().join("invalid.html");
308            {
309                let mut file = File::create(&file_path)
310                    .expect("Failed to create test file");
311                file.write_all(&[0xFF, 0xFF])
312                    .expect("Failed to write test content");
313            }
314
315            let result = minify_html(&file_path);
316            assert!(matches!(
317                result,
318                Err(HtmlError::MinificationError(_))
319            ));
320            let err_msg = result.unwrap_err().to_string();
321            assert!(err_msg.contains("Invalid UTF-8 in input file"));
322            drop(dir);
323        }
324
325        #[test]
326        fn test_minify_non_utf8_failure_path_via_directory_path() {
327            // Pointing `minify_html` at a directory exercises the
328            // non-UTF-8 *fallback* arm in the read-error mapping —
329            // `fs::read_to_string` on a directory fails with
330            // "Is a directory" (or platform-equivalent), which does
331            // not match the UTF-8 substring and so routes to the
332            // "Failed to read file" branch.
333            let dir =
334                tempdir().expect("Failed to create temp directory");
335            let result = minify_html(dir.path());
336            assert!(matches!(
337                result,
338                Err(HtmlError::MinificationError(_))
339            ));
340            let err_msg = result.unwrap_err().to_string();
341            assert!(
342                err_msg.contains("Failed to read file"),
343                "expected 'Failed to read file' branch, got: {err_msg}"
344            );
345            drop(dir);
346        }
347
348        #[test]
349        fn test_minify_utf8_content() {
350            let html = "<html><body><p>Test 你好 🦀</p></body></html>";
351            let (dir, file_path) = create_test_file(html);
352            let result = minify_html(&file_path);
353            assert!(result.is_ok());
354            assert_eq!(
355                result.unwrap(),
356                "<html><body><p>Test 你好 🦀</p></body></html>"
357            );
358            drop(dir);
359        }
360    }
361
362    #[cfg(feature = "async")]
363    mod async_generate_html_tests {
364        use super::*;
365
366        #[tokio::test]
367        async fn test_async_generate_html() {
368            let markdown = "# Test\n\nThis is a test.";
369            let result = async_generate_html(markdown).await;
370            assert!(result.is_ok());
371            let html = result.unwrap();
372            assert!(html.contains("<h1>Test</h1>"));
373            assert!(html.contains("<p>This is a test.</p>"));
374        }
375
376        #[tokio::test]
377        async fn test_async_generate_html_empty() {
378            let result = async_generate_html("").await;
379            assert!(result.is_ok());
380            assert!(result.unwrap().is_empty());
381        }
382
383        #[tokio::test]
384        async fn test_async_generate_html_large_content() {
385            let large_markdown =
386                "# Test\n\n".to_string() + &"Content\n".repeat(10_000);
387            let result = async_generate_html(&large_markdown).await;
388            assert!(result.is_ok());
389            let html = result.unwrap();
390            assert!(html.contains("<h1>Test</h1>"));
391        }
392    }
393
394    mod additional_tests {
395        use super::*;
396        use std::fs::File;
397        use std::io::Write;
398        use tempfile::tempdir;
399
400        /// `minify_html` must surface a `MinificationError` when the
401        /// source file cannot be read as UTF-8.
402        #[test]
403        fn test_minify_html_rejects_non_utf8_path_content() {
404            let dir = tempdir().expect("failed to create temp dir");
405            let file_path = dir.path().join("non-utf8.html");
406            let mut f = File::create(&file_path).expect("create file");
407            f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC])
408                .expect("write bytes");
409            drop(f);
410            let err = minify_html(&file_path).unwrap_err();
411            assert!(matches!(err, HtmlError::MinificationError(_)));
412        }
413
414        /// Test for uncommon HTML structures in minify_html.
415        #[test]
416        fn test_minify_html_uncommon_structures() {
417            let html = r#"<div><span>Test<div><p>Nested</p></div></span></div>"#;
418            let (dir, file_path) = create_test_file(html);
419            let result = minify_html(&file_path);
420            assert!(result.is_ok());
421            assert_eq!(
422                result.unwrap(),
423                r#"<div><span>Test<div><p>Nested</p></div></span></div>"#
424            );
425            drop(dir);
426        }
427
428        /// Test for mixed encodings in minify_html.
429        #[test]
430        fn test_minify_html_mixed_encodings() {
431            let dir =
432                tempdir().expect("Failed to create temp directory");
433            let file_path = dir.path().join("mixed_encoding.html");
434            {
435                let mut file = File::create(&file_path)
436                    .expect("Failed to create test file");
437                file.write_all(&[0xFF, b'T', b'e', b's', b't', 0xFE])
438                    .expect("Failed to write test content");
439            }
440            let result = minify_html(&file_path);
441            assert!(matches!(
442                result,
443                Err(HtmlError::MinificationError(_))
444            ));
445            drop(dir);
446        }
447
448        /// Test for extremely large Markdown content in async_generate_html.
449        #[cfg(feature = "async")]
450        #[tokio::test]
451        async fn test_async_generate_html_extremely_large() {
452            let large_markdown = "# Large Content
453"
454            .to_string()
455                + &"Content
456"
457                .repeat(100_000);
458            let result = async_generate_html(&large_markdown).await;
459            assert!(result.is_ok());
460            let html = result.unwrap();
461            assert!(html.contains("<h1>Large Content</h1>"));
462        }
463
464        #[cfg(feature = "async")]
465        #[tokio::test]
466        async fn test_async_generate_html_spawn_blocking_failure() {
467            use tokio::task;
468
469            // Simulate failure by forcing a panic inside the `spawn_blocking` task
470            let _markdown = "# Valid Markdown"; // Normally valid Markdown
471
472            // Override the `spawn_blocking` behavior to simulate a failure
473            let result = task::spawn_blocking(|| {
474                panic!("Simulated task failure"); // Force the closure to fail
475            })
476            .await;
477
478            // Explicitly use `std::result::Result` to avoid alias conflicts
479            let converted_result: std::result::Result<
480                String,
481                HtmlError,
482            > = match result {
483                Err(e) => Err(HtmlError::MarkdownConversion {
484                    message: format!(
485                        "Asynchronous HTML generation failed: {e}"
486                    ),
487                    source: Some(std::io::Error::other(e.to_string())),
488                }),
489                Ok(_) => panic!("Expected a simulated failure"),
490            };
491
492            // Check that the error matches `HtmlError::MarkdownConversion`
493            assert!(matches!(
494                converted_result,
495                Err(HtmlError::MarkdownConversion { .. })
496            ));
497
498            if let Err(HtmlError::MarkdownConversion {
499                message,
500                source,
501            }) = converted_result
502            {
503                assert!(message
504                    .contains("Asynchronous HTML generation failed"));
505                assert!(source.is_some());
506
507                // Relax the assertion to match the general pattern of the panic message
508                let source_message = source.unwrap().to_string();
509                assert!(
510                    source_message.contains("Simulated task failure"),
511                    "Unexpected source message: {source_message}"
512                );
513            }
514        }
515
516        #[test]
517        fn test_minify_html_empty_content() {
518            let html = "";
519            let (dir, file_path) = create_test_file(html);
520            let result = minify_html(&file_path);
521            assert!(result.is_ok());
522            assert!(
523                result.unwrap().is_empty(),
524                "Minified content should be empty"
525            );
526            drop(dir);
527        }
528
529        #[test]
530        fn test_minify_html_unusual_whitespace() {
531            let html =
532                "<html>\n\n\t<body>\t<p>Test</p>\n\n</body>\n\n</html>";
533            let (dir, file_path) = create_test_file(html);
534            let result = minify_html(&file_path);
535            assert!(result.is_ok());
536            assert_eq!(
537                result.unwrap(),
538                "<html><body><p>Test</p></body></html>",
539                "Unexpected minified result for unusual whitespace"
540            );
541            drop(dir);
542        }
543
544        #[test]
545        fn test_minify_html_with_special_characters() {
546            let html = "<div>&lt;Special&gt; &amp; Characters</div>";
547            let (dir, file_path) = create_test_file(html);
548            let result = minify_html(&file_path);
549            assert!(result.is_ok());
550            assert_eq!(
551                result.unwrap(),
552                // Entities are preserved verbatim. minify-html used to
553                // decode `&gt;` to `>` and `&amp;` to `&` here, which
554                // the old expected value recorded — note it contradicted
555                // the assertion message right beside it. Emitting a bare
556                // `&` into text is ambiguous and can produce invalid
557                // HTML, so the native minifier leaves entities alone.
558                "<div>&lt;Special&gt; &amp; Characters</div>",
559                "Character entities must survive minification unchanged"
560            );
561            drop(dir);
562        }
563
564        #[cfg(feature = "async")]
565        #[tokio::test]
566        async fn test_async_generate_html_with_special_characters() {
567            let markdown =
568                "# Special & Characters\n\nContent with < > & \" '";
569            let result = async_generate_html(markdown).await;
570            assert!(result.is_ok());
571            let html = result.unwrap();
572            assert!(
573                html.contains("&lt;"),
574                "Less than sign not escaped"
575            );
576            assert!(
577                html.contains("&gt;"),
578                "Greater than sign not escaped"
579            );
580            assert!(html.contains("&amp;"), "Ampersand not escaped");
581            // Quotes only need escaping inside attribute values; in
582            // text content both forms are well-formed HTML.
583            assert!(
584                html.contains("&quot;") || html.contains('"'),
585                "Double quote not handled as expected"
586            );
587            assert!(
588                html.contains("&#39;") || html.contains('\''),
589                "Single quote not handled as expected"
590            );
591        }
592    }
593}