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            fs::write(&file_path, [0xFF, 0xFE, 0xFD, 0xFC])
397                .expect("write bytes");
398            let err = minify_html(&file_path).unwrap_err();
399            assert!(matches!(err, HtmlError::MinificationError(_)));
400        }
401
402        /// Test for uncommon HTML structures in minify_html.
403        #[test]
404        fn test_minify_html_uncommon_structures() {
405            let html = r#"<div><span>Test<div><p>Nested</p></div></span></div>"#;
406            let (dir, file_path) = create_test_file(html);
407            let result = minify_html(&file_path);
408            assert!(result.is_ok());
409            assert_eq!(
410                result.unwrap(),
411                r#"<div><span>Test<div><p>Nested</p></div></span></div>"#
412            );
413            drop(dir);
414        }
415
416        /// Test for mixed encodings in minify_html.
417        #[test]
418        fn test_minify_html_mixed_encodings() {
419            let dir =
420                tempdir().expect("Failed to create temp directory");
421            let file_path = dir.path().join("mixed_encoding.html");
422            {
423                let mut file = File::create(&file_path)
424                    .expect("Failed to create test file");
425                file.write_all(&[0xFF, b'T', b'e', b's', b't', 0xFE])
426                    .expect("Failed to write test content");
427            }
428            let result = minify_html(&file_path);
429            assert!(matches!(
430                result,
431                Err(HtmlError::MinificationError(_))
432            ));
433            drop(dir);
434        }
435
436        /// Test for extremely large Markdown content in async_generate_html.
437        #[cfg(feature = "async")]
438        #[tokio::test]
439        async fn test_async_generate_html_extremely_large() {
440            let large_markdown = "# Large Content
441"
442            .to_string()
443                + &"Content
444"
445                .repeat(100_000);
446            let result = async_generate_html(&large_markdown).await;
447            assert!(result.is_ok());
448            let html = result.unwrap();
449            assert!(html.contains("<h1>Large Content</h1>"));
450        }
451
452        #[cfg(feature = "async")]
453        #[tokio::test]
454        async fn test_async_generate_html_spawn_blocking_failure() {
455            use tokio::task;
456
457            // Simulate failure by forcing a panic inside the `spawn_blocking` task
458            let _markdown = "# Valid Markdown"; // Normally valid Markdown
459
460            // Override the `spawn_blocking` behavior to simulate a failure
461            let result = task::spawn_blocking(|| {
462                panic!("Simulated task failure"); // Force the closure to fail
463            })
464            .await;
465
466            // Explicitly use `std::result::Result` to avoid alias conflicts
467            let converted_result: std::result::Result<
468                String,
469                HtmlError,
470            > = match result {
471                Err(e) => Err(HtmlError::MarkdownConversion {
472                    message: format!(
473                        "Asynchronous HTML generation failed: {e}"
474                    ),
475                    source: Some(std::io::Error::other(e.to_string())),
476                }),
477                Ok(_) => panic!("Expected a simulated failure"),
478            };
479
480            // Check that the error matches `HtmlError::MarkdownConversion`
481            assert!(matches!(
482                converted_result,
483                Err(HtmlError::MarkdownConversion { .. })
484            ));
485
486            if let Err(HtmlError::MarkdownConversion {
487                message,
488                source,
489            }) = converted_result
490            {
491                assert!(message
492                    .contains("Asynchronous HTML generation failed"));
493                assert!(source.is_some());
494
495                // Relax the assertion to match the general pattern of the panic message
496                let source_message = source.unwrap().to_string();
497                assert!(
498                    source_message.contains("Simulated task failure"),
499                    "Unexpected source message: {source_message}"
500                );
501            }
502        }
503        #[test]
504        fn test_minify_html_empty_content() {
505            let html = "";
506            let (dir, file_path) = create_test_file(html);
507            let result = minify_html(&file_path);
508            assert!(result.is_ok());
509            assert!(
510                result.unwrap().is_empty(),
511                "Minified content should be empty"
512            );
513            drop(dir);
514        }
515        #[test]
516        fn test_minify_html_unusual_whitespace() {
517            let html =
518                "<html>\n\n\t<body>\t<p>Test</p>\n\n</body>\n\n</html>";
519            let (dir, file_path) = create_test_file(html);
520            let result = minify_html(&file_path);
521            assert!(result.is_ok());
522            assert_eq!(
523                result.unwrap(),
524                "<html><body><p>Test</p></body></html>",
525                "Unexpected minified result for unusual whitespace"
526            );
527            drop(dir);
528        }
529        #[test]
530        fn test_minify_html_with_special_characters() {
531            let html = "<div>&lt;Special&gt; &amp; Characters</div>";
532            let (dir, file_path) = create_test_file(html);
533            let result = minify_html(&file_path);
534            assert!(result.is_ok());
535            assert_eq!(
536                result.unwrap(),
537                // Entities are preserved verbatim. minify-html used to
538                // decode `&gt;` to `>` and `&amp;` to `&` here, which
539                // the old expected value recorded — note it contradicted
540                // the assertion message right beside it. Emitting a bare
541                // `&` into text is ambiguous and can produce invalid
542                // HTML, so the native minifier leaves entities alone.
543                "<div>&lt;Special&gt; &amp; Characters</div>",
544                "Character entities must survive minification unchanged"
545            );
546            drop(dir);
547        }
548
549        #[cfg(feature = "async")]
550        #[tokio::test]
551        async fn test_async_generate_html_with_special_characters() {
552            let markdown =
553                "# Special & Characters\n\nContent with < > & \" '";
554            let result = async_generate_html(markdown).await;
555            assert!(result.is_ok());
556            let html = result.unwrap();
557            assert!(
558                html.contains("&lt;"),
559                "Less than sign not escaped"
560            );
561            assert!(
562                html.contains("&gt;"),
563                "Greater than sign not escaped"
564            );
565            assert!(html.contains("&amp;"), "Ampersand not escaped");
566            // Quotes only need escaping inside attribute values; in
567            // text content both forms are well-formed HTML.
568            assert!(
569                html.contains("&quot;") || html.contains('"'),
570                "Double quote not handled as expected"
571            );
572            assert!(
573                html.contains("&#39;") || html.contains('\''),
574                "Single quote not handled as expected"
575            );
576        }
577    }
578}