use crate::{HtmlError, Result};
use comrak::{markdown_to_html, Options};
use minify_html::{minify, Cfg};
use std::{fs, path::Path};
use tokio::task;
pub const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
const INITIAL_HTML_CAPACITY: usize = 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| {
if e.to_string().contains("stream did not contain valid UTF-8")
{
HtmlError::MinificationError(format!(
"Invalid UTF-8 in input file '{}': {e}",
file_path.display()
))
} else {
HtmlError::MinificationError(format!(
"Failed to read file '{}': {e}",
file_path.display()
))
}
})?;
let config = MinifyConfig::default();
let minified = minify(content.as_bytes(), &config.cfg);
String::from_utf8(minified).map_err(|e| {
HtmlError::MinificationError(format!(
"Invalid UTF-8 in minified content: {e}"
))
})
}
pub async fn async_generate_html(markdown: &str) -> Result<String> {
let markdown = if markdown.len() < INITIAL_HTML_CAPACITY {
markdown.to_string()
} else {
let mut string = String::with_capacity(markdown.len());
string.push_str(markdown);
string
};
task::spawn_blocking(move || {
let options = Options::default();
Ok(markdown_to_html(&markdown, &options))
})
.await
.map_err(|e| HtmlError::MarkdownConversion {
message: format!("Asynchronous HTML generation failed: {e}"),
source: Some(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
)),
})?
}
#[inline]
pub fn generate_html(markdown: &str) -> Result<String> {
Ok(markdown_to_html(markdown, &Options::default()))
}
#[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_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);
}
}
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 generate_html_tests {
use super::*;
#[test]
fn test_sync_generate_html() {
let markdown = "# Test\n\nThis is a test.";
let result = generate_html(markdown);
assert!(result.is_ok());
let html = result.unwrap();
assert!(html.contains("<h1>Test</h1>"));
assert!(html.contains("<p>This is a test.</p>"));
}
#[test]
fn test_sync_generate_html_empty() {
let result = generate_html("");
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_sync_generate_html_large_content() {
let large_markdown =
"# Test\n\n".to_string() + &"Content\n".repeat(10_000);
let result = generate_html(&large_markdown);
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_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);
}
#[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>"));
}
#[test]
fn test_generate_html_very_small() {
let markdown = "A";
let result = generate_html(markdown);
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"<p>A</p>
"
);
}
#[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::new(
std::io::ErrorKind::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);
}
#[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"
);
}
}
}