use crate::{HtmlError, Result};
use minify_html::{minify, Cfg};
use std::{fs, path::Path};
#[cfg(feature = "async")]
use tokio::task;
pub const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
#[derive(Clone)]
struct MinifyConfig {
cfg: Cfg,
}
impl Default for MinifyConfig {
fn default() -> Self {
let mut cfg = Cfg::new();
cfg.minify_doctype = false;
cfg.allow_noncompliant_unquoted_attribute_values = false;
cfg.keep_closing_tags = true;
cfg.keep_html_and_head_opening_tags = true;
cfg.allow_removing_spaces_between_attributes = false;
cfg.keep_comments = false;
cfg.minify_css = true;
cfg.minify_js = true;
cfg.remove_bangs = true;
cfg.remove_processing_instructions = true;
Self { cfg }
}
}
impl std::fmt::Debug for MinifyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MinifyConfig")
.field("minify_doctype", &self.cfg.minify_doctype)
.field("minify_css", &self.cfg.minify_css)
.field("minify_js", &self.cfg.minify_js)
.field("keep_comments", &self.cfg.keep_comments)
.finish()
}
}
pub fn minify_html(file_path: &Path) -> Result<String> {
let metadata = fs::metadata(file_path).map_err(|e| {
HtmlError::MinificationError(format!(
"Failed to read file metadata for '{}': {e}",
file_path.display()
))
})?;
let file_size = metadata.len() as usize;
if file_size > MAX_FILE_SIZE {
return Err(HtmlError::MinificationError(format!(
"File size {file_size} bytes exceeds maximum of {MAX_FILE_SIZE} bytes"
)));
}
let content = fs::read_to_string(file_path).map_err(|e| {
let kind = if e
.to_string()
.contains("stream did not contain valid UTF-8")
{
"Invalid UTF-8 in input file"
} else {
"Failed to read file"
};
HtmlError::MinificationError(format!(
"{kind} '{}': {e}",
file_path.display()
))
})?;
let config = MinifyConfig::default();
let minified = minify(content.as_bytes(), &config.cfg);
Ok(String::from_utf8_lossy(&minified).into_owned())
}
pub fn minify_html_string(html: &str) -> Result<String> {
if html.len() > MAX_FILE_SIZE {
return Err(HtmlError::MinificationError(format!(
"Input size {} bytes exceeds maximum of {MAX_FILE_SIZE} bytes",
html.len()
)));
}
let config = MinifyConfig::default();
let minified = minify(html.as_bytes(), &config.cfg);
Ok(String::from_utf8_lossy(&minified).into_owned())
}
#[cfg(feature = "async")]
pub async fn async_generate_html(markdown: &str) -> Result<String> {
let markdown = markdown.to_string();
task::spawn_blocking(move || {
crate::generator::markdown_to_html_with_extensions(&markdown)
})
.await
.map_err(|e| HtmlError::MarkdownConversion {
message: format!("Asynchronous HTML generation failed: {e}"),
source: Some(std::io::Error::other(e.to_string())),
})?
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
fn create_test_file(
content: &str,
) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempdir().expect("Failed to create temp directory");
let file_path = dir.path().join("test.html");
let mut file = File::create(&file_path)
.expect("Failed to create test file");
file.write_all(content.as_bytes())
.expect("Failed to write test content");
(dir, file_path)
}
mod minify_html_tests {
use super::*;
#[test]
fn test_minify_basic_html() {
let html =
"<html> <body> <p>Test</p> </body> </html>";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<html><body><p>Test</p></body></html>"
);
drop(dir);
}
#[test]
fn test_minify_with_comments() {
let html =
"<html><!-- Comment --><body><p>Test</p></body></html>";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<html><body><p>Test</p></body></html>"
);
drop(dir);
}
#[test]
fn test_minify_invalid_path() {
let result = minify_html(Path::new("nonexistent.html"));
assert!(result.is_err());
assert!(matches!(
result,
Err(HtmlError::MinificationError(_))
));
}
#[test]
fn test_minify_exceeds_max_size() {
let large_content = "a".repeat(MAX_FILE_SIZE + 1);
let (dir, file_path) = create_test_file(&large_content);
let result = minify_html(&file_path);
assert!(matches!(
result,
Err(HtmlError::MinificationError(_))
));
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("exceeds maximum"));
drop(dir);
}
#[test]
fn test_minify_invalid_utf8() {
let dir =
tempdir().expect("Failed to create temp directory");
let file_path = dir.path().join("invalid.html");
{
let mut file = File::create(&file_path)
.expect("Failed to create test file");
file.write_all(&[0xFF, 0xFF])
.expect("Failed to write test content");
}
let result = minify_html(&file_path);
assert!(matches!(
result,
Err(HtmlError::MinificationError(_))
));
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid UTF-8 in input file"));
drop(dir);
}
#[test]
fn test_minify_non_utf8_failure_path_via_directory_path() {
let dir =
tempdir().expect("Failed to create temp directory");
let result = minify_html(dir.path());
assert!(matches!(
result,
Err(HtmlError::MinificationError(_))
));
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Failed to read file"),
"expected 'Failed to read file' branch, got: {err_msg}"
);
drop(dir);
}
#[test]
fn test_minify_utf8_content() {
let html = "<html><body><p>Test 你好 🦀</p></body></html>";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<html><body><p>Test 你好 🦀</p></body></html>"
);
drop(dir);
}
}
#[cfg(feature = "async")]
mod async_generate_html_tests {
use super::*;
#[tokio::test]
async fn test_async_generate_html() {
let markdown = "# Test\n\nThis is a test.";
let result = async_generate_html(markdown).await;
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("<h1>Test</h1>"));
assert!(html.contains("<p>This is a test.</p>"));
}
#[tokio::test]
async fn test_async_generate_html_empty() {
let result = async_generate_html("").await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_async_generate_html_large_content() {
let large_markdown =
"# Test\n\n".to_string() + &"Content\n".repeat(10_000);
let result = async_generate_html(&large_markdown).await;
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("<h1>Test</h1>"));
}
}
mod additional_tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_minify_config_default() {
let config = MinifyConfig::default();
assert!(!config.cfg.minify_doctype);
assert!(config.cfg.minify_css);
assert!(config.cfg.minify_js);
assert!(!config.cfg.keep_comments);
}
#[test]
fn test_minify_config_custom() {
let mut config = MinifyConfig::default();
config.cfg.keep_comments = true;
assert!(config.cfg.keep_comments);
}
#[test]
fn test_minify_config_debug_impl() {
let config = MinifyConfig::default();
let rendered = format!("{config:?}");
assert!(rendered.contains("MinifyConfig"));
assert!(rendered.contains("minify_css"));
}
#[test]
fn test_minify_html_rejects_non_utf8_path_content() {
let dir = tempdir().expect("failed to create temp dir");
let file_path = dir.path().join("non-utf8.html");
let mut f = File::create(&file_path).expect("create file");
f.write_all(&[0xFF, 0xFE, 0xFD, 0xFC])
.expect("write bytes");
drop(f);
let err = minify_html(&file_path).unwrap_err();
assert!(matches!(err, HtmlError::MinificationError(_)));
}
#[test]
fn test_minify_html_uncommon_structures() {
let html = r#"<div><span>Test<div><p>Nested</p></div></span></div>"#;
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
r#"<div><span>Test<div><p>Nested</p></div></span></div>"#
);
drop(dir);
}
#[test]
fn test_minify_html_mixed_encodings() {
let dir =
tempdir().expect("Failed to create temp directory");
let file_path = dir.path().join("mixed_encoding.html");
{
let mut file = File::create(&file_path)
.expect("Failed to create test file");
file.write_all(&[0xFF, b'T', b'e', b's', b't', 0xFE])
.expect("Failed to write test content");
}
let result = minify_html(&file_path);
assert!(matches!(
result,
Err(HtmlError::MinificationError(_))
));
drop(dir);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_async_generate_html_extremely_large() {
let large_markdown = "# Large Content
"
.to_string()
+ &"Content
"
.repeat(100_000);
let result = async_generate_html(&large_markdown).await;
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("<h1>Large Content</h1>"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_async_generate_html_spawn_blocking_failure() {
use tokio::task;
let _markdown = "# Valid Markdown";
let result = task::spawn_blocking(|| {
panic!("Simulated task failure"); })
.await;
let converted_result: std::result::Result<
String,
HtmlError,
> = match result {
Err(e) => Err(HtmlError::MarkdownConversion {
message: format!(
"Asynchronous HTML generation failed: {e}"
),
source: Some(std::io::Error::other(e.to_string())),
}),
Ok(_) => panic!("Expected a simulated failure"),
};
assert!(matches!(
converted_result,
Err(HtmlError::MarkdownConversion { .. })
));
if let Err(HtmlError::MarkdownConversion {
message,
source,
}) = converted_result
{
assert!(message
.contains("Asynchronous HTML generation failed"));
assert!(source.is_some());
let source_message = source.unwrap().to_string();
assert!(
source_message.contains("Simulated task failure"),
"Unexpected source message: {source_message}"
);
}
}
#[test]
fn test_minify_html_empty_content() {
let html = "";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert!(
result.unwrap().is_empty(),
"Minified content should be empty"
);
drop(dir);
}
#[test]
fn test_minify_html_unusual_whitespace() {
let html =
"<html>\n\n\t<body>\t<p>Test</p>\n\n</body>\n\n</html>";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<html><body><p>Test</p></body></html>",
"Unexpected minified result for unusual whitespace"
);
drop(dir);
}
#[test]
fn test_minify_html_with_special_characters() {
let html = "<div><Special> & Characters</div>";
let (dir, file_path) = create_test_file(html);
let result = minify_html(&file_path);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<div><Special> & Characters</div>",
"Special characters were unexpectedly modified during minification"
);
drop(dir);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_async_generate_html_with_special_characters() {
let markdown =
"# Special & Characters\n\nContent with < > & \" '";
let result = async_generate_html(markdown).await;
assert!(result.is_ok());
let html = result.unwrap();
assert!(
html.contains("<"),
"Less than sign not escaped"
);
assert!(
html.contains(">"),
"Greater than sign not escaped"
);
assert!(html.contains("&"), "Ampersand not escaped");
assert!(
html.contains("""),
"Double quote not escaped"
);
assert!(
html.contains("'") || html.contains("'"),
"Single quote not handled as expected"
);
}
}
}