1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
5#![doc(
6 html_favicon_url = "https://cloudcdn.pro/html-generator/v1/favicon.ico",
7 html_logo_url = "https://cloudcdn.pro/html-generator/v1/logos/html-generator.svg",
8 html_root_url = "https://docs.rs/html-generator"
9)]
10#![crate_name = "html_generator"]
11#![crate_type = "lib"]
12
13use std::{
14 fmt,
15 fs::File,
16 io::{self, BufReader, BufWriter, Read, Write},
17 path::{Component, Path},
18};
19
20const MAX_BUFFER_SIZE: usize = 16 * 1024 * 1024;
22
23pub mod accessibility;
25pub mod elements;
26pub mod emojis;
27pub mod error;
28pub mod generator;
29pub mod math;
30pub mod performance;
31pub mod seo;
32pub mod utils;
33
34#[cfg(feature = "wasm")]
37pub mod wasm;
38
39pub use crate::error::HtmlError;
41pub use accessibility::{add_aria_attributes, validate_wcag};
42pub use emojis::load_emoji_sequences;
43pub use generator::{
44 generate_html, generate_html_with_diagnostics, Diagnostic,
45 DiagnosticLevel, HtmlOutput,
46};
47#[cfg(feature = "async")]
48pub use performance::async_generate_html;
49pub use performance::{minify_html, minify_html_string};
50pub use seo::{generate_meta_tags, generate_structured_data};
51pub use utils::{
52 extract_front_matter, extract_front_matter_data,
53 format_header_with_id_class,
54};
55
56pub mod constants {
70 pub const DEFAULT_MAX_INPUT_SIZE: usize = 5 * 1024 * 1024;
79
80 pub const MIN_INPUT_SIZE: usize = 1024;
89
90 pub const DEFAULT_LANGUAGE: &str = "en-GB";
99
100 pub const DEFAULT_SYNTAX_THEME: &str = "github";
109
110 pub const MAX_PATH_LENGTH: usize = 4096;
119
120 pub const LANGUAGE_CODE_PATTERN: &str = r"^[a-z]{2}-[A-Z]{2}$";
132
133 const _: () = assert!(MIN_INPUT_SIZE <= DEFAULT_MAX_INPUT_SIZE);
135 const _: () = assert!(MAX_PATH_LENGTH > 0);
136}
137
138pub type Result<T> = std::result::Result<T, HtmlError>;
151
152#[deprecated(
157 since = "0.0.4",
158 note = "use HtmlConfig directly — encoding is now a field on HtmlConfig"
159)]
160#[derive(Debug, Clone, Eq, PartialEq)]
161pub struct MarkdownConfig {
162 pub encoding: String,
164
165 pub html_config: HtmlConfig,
167}
168
169#[allow(deprecated)]
170impl Default for MarkdownConfig {
171 fn default() -> Self {
172 Self {
173 encoding: String::from("utf-8"),
174 html_config: HtmlConfig::default(),
175 }
176 }
177}
178
179#[allow(deprecated)]
180impl From<MarkdownConfig> for HtmlConfig {
181 fn from(mc: MarkdownConfig) -> Self {
182 let mut c = mc.html_config;
183 c.encoding = mc.encoding;
184 c
185 }
186}
187
188#[derive(Debug, thiserror::Error)]
199#[non_exhaustive]
200pub enum ConfigError {
201 #[error(
203 "Invalid input size: {0} bytes is below minimum of {1} bytes"
204 )]
205 InvalidInputSize(usize, usize),
206
207 #[error("Invalid language code: {0}")]
209 InvalidLanguageCode(String),
210
211 #[error("Invalid file path: {0}")]
213 InvalidFilePath(String),
214}
215
216#[non_exhaustive]
246pub enum OutputDestination {
247 File(String),
257
258 Writer(Box<dyn Write>),
273
274 Stdout,
286}
287
288impl Default for OutputDestination {
290 fn default() -> Self {
291 Self::Stdout
292 }
293}
294
295impl fmt::Debug for OutputDestination {
297 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298 match self {
299 Self::File(path) => {
300 f.debug_tuple("File").field(path).finish()
301 }
302 Self::Writer(_) => write!(f, "Writer(<dyn Write>)"),
303 Self::Stdout => write!(f, "Stdout"),
304 }
305 }
306}
307
308impl fmt::Display for OutputDestination {
310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311 match self {
312 OutputDestination::File(path) => {
313 write!(f, "File({})", path)
314 }
315 OutputDestination::Writer(_) => {
316 write!(f, "Writer(<dyn Write>)")
317 }
318 OutputDestination::Stdout => write!(f, "Stdout"),
319 }
320 }
321}
322
323#[derive(Debug, PartialEq, Eq, Clone)]
338pub struct HtmlConfig {
339 pub enable_syntax_highlighting: bool,
341
342 pub syntax_theme: Option<String>,
344
345 pub minify_output: bool,
347
348 pub add_aria_attributes: bool,
350
351 pub generate_structured_data: bool,
353
354 pub max_input_size: usize,
356
357 pub language: String,
359
360 pub generate_toc: bool,
362
363 pub allow_unsafe_html: bool,
370
371 pub sanitize_html: bool,
382
383 pub generate_full_document: bool,
398
399 pub max_buffer_size: usize,
405
406 pub encoding: String,
411
412 pub enable_math: bool,
419
420 pub enable_diagrams: bool,
431}
432
433impl Default for HtmlConfig {
434 fn default() -> Self {
435 Self {
436 enable_syntax_highlighting: true,
437 syntax_theme: Some(
438 constants::DEFAULT_SYNTAX_THEME.to_string(),
439 ),
440 minify_output: false,
441 add_aria_attributes: true,
442 generate_structured_data: false,
443 max_input_size: constants::DEFAULT_MAX_INPUT_SIZE,
444 language: String::from(constants::DEFAULT_LANGUAGE),
445 generate_toc: false,
446 allow_unsafe_html: false,
447 sanitize_html: false,
448 generate_full_document: false,
449 max_buffer_size: 16 * 1024 * 1024,
450 encoding: String::from("utf-8"),
451 enable_math: false,
452 enable_diagrams: false,
453 }
454 }
455}
456
457impl HtmlConfig {
458 pub fn builder() -> HtmlConfigBuilder {
472 HtmlConfigBuilder::default()
473 }
474
475 pub fn validate(&self) -> Result<()> {
500 if self.max_input_size < constants::MIN_INPUT_SIZE {
501 return Err(HtmlError::InvalidInput(format!(
502 "Input size must be at least {} bytes",
503 constants::MIN_INPUT_SIZE
504 )));
505 }
506 if !validate_language_code(&self.language) {
507 return Err(HtmlError::InvalidInput(format!(
508 "Invalid language code: {}",
509 self.language
510 )));
511 }
512 Ok(())
513 }
514
515 pub(crate) fn validate_file_path(
529 path: impl AsRef<Path>,
530 ) -> Result<()> {
531 let path = path.as_ref();
532 let path_str = path.to_string_lossy();
533
534 if path_str.is_empty() {
535 return Err(HtmlError::InvalidInput(
536 "File path cannot be empty".to_string(),
537 ));
538 }
539
540 if path_str.len() > constants::MAX_PATH_LENGTH {
541 return Err(HtmlError::InvalidInput(format!(
542 "File path exceeds maximum length of {} characters",
543 constants::MAX_PATH_LENGTH
544 )));
545 }
546
547 if path_str.as_bytes().contains(&0) {
551 return Err(HtmlError::InvalidInput(
552 "File path must not contain NUL bytes".to_string(),
553 ));
554 }
555
556 if path.components().any(|c| matches!(c, Component::ParentDir))
557 {
558 return Err(HtmlError::InvalidInput(
559 "Directory traversal is not allowed in file paths"
560 .to_string(),
561 ));
562 }
563
564 if let Some(ext) = path.extension() {
565 if !matches!(ext.to_string_lossy().as_ref(), "md" | "html")
566 {
567 return Err(HtmlError::InvalidInput(
568 "Invalid file extension: only .md and .html files are allowed".to_string(),
569 ));
570 }
571 }
572
573 Ok(())
574 }
575}
576
577#[derive(Debug, Default)]
595pub struct HtmlConfigBuilder {
596 config: HtmlConfig,
597}
598
599impl HtmlConfigBuilder {
600 pub fn new() -> Self {
610 Self::default()
611 }
612
613 #[must_use]
632 pub fn with_syntax_highlighting(
633 mut self,
634 enable: bool,
635 theme: Option<String>,
636 ) -> Self {
637 self.config.enable_syntax_highlighting = enable;
638 self.config.syntax_theme = if enable {
639 theme.or_else(|| {
640 Some(constants::DEFAULT_SYNTAX_THEME.to_string())
641 })
642 } else {
643 None
644 };
645 self
646 }
647
648 #[must_use]
662 pub fn with_language(
663 mut self,
664 language: impl Into<String>,
665 ) -> Self {
666 self.config.language = language.into();
667 self
668 }
669
670 #[must_use]
687 pub fn with_sanitization(mut self, enable: bool) -> Self {
688 self.config.sanitize_html = enable;
689 self
690 }
691
692 #[must_use]
709 pub fn with_full_document(mut self, enable: bool) -> Self {
710 self.config.generate_full_document = enable;
711 self
712 }
713
714 #[must_use]
728 pub fn with_max_buffer_size(mut self, size: usize) -> Self {
729 self.config.max_buffer_size = size;
730 self
731 }
732
733 #[must_use]
752 pub fn with_math(mut self, enable: bool) -> Self {
753 self.config.enable_math = enable;
754 self
755 }
756
757 #[must_use]
776 pub fn with_diagrams(mut self, enable: bool) -> Self {
777 self.config.enable_diagrams = enable;
778 self
779 }
780
781 pub fn build(self) -> Result<HtmlConfig> {
801 self.config.validate()?;
802 Ok(self.config)
803 }
804}
805
806#[allow(deprecated)]
839pub fn markdown_to_html(
840 content: &str,
841 config: Option<MarkdownConfig>,
842) -> Result<String> {
843 let html_config: HtmlConfig =
844 config.map_or_else(HtmlConfig::default, HtmlConfig::from);
845
846 if content.is_empty() {
847 return Err(HtmlError::InvalidInput(
848 "Input content is empty".to_string(),
849 ));
850 }
851
852 if content.len() > html_config.max_input_size {
853 return Err(HtmlError::InputTooLarge(content.len()));
854 }
855
856 generate_html(content, &html_config)
857}
858
859#[inline]
904#[allow(deprecated)]
905pub fn markdown_file_to_html(
906 input: Option<impl AsRef<Path>>,
907 output: Option<OutputDestination>,
908 config: Option<MarkdownConfig>,
909) -> Result<()> {
910 let config = config.unwrap_or_default();
911 let output = output.unwrap_or_default();
912
913 validate_paths(&input, &output)?;
915
916 let content = read_input(input)?;
918
919 let html = markdown_to_html(&content, Some(config))?;
921
922 write_output(output, html.as_bytes())
924}
925
926fn validate_paths(
928 input: &Option<impl AsRef<Path>>,
929 output: &OutputDestination,
930) -> Result<()> {
931 if let Some(path) = input.as_ref() {
932 HtmlConfig::validate_file_path(path)?;
933 }
934 if let OutputDestination::File(ref path) = output {
935 HtmlConfig::validate_file_path(path)?;
936 }
937 Ok(())
938}
939
940fn read_all_from_reader<R: Read>(
947 mut reader: R,
948 label: &str,
949) -> Result<String> {
950 let mut content = String::with_capacity(MAX_BUFFER_SIZE);
951 let _ = reader.read_to_string(&mut content).map_err(|e| {
953 HtmlError::Io(io::Error::new(
954 e.kind(),
955 format!("Failed to read from {label}: {e}"),
956 ))
957 })?;
958 Ok(content)
959}
960
961fn read_input(input: Option<impl AsRef<Path>>) -> Result<String> {
964 match input {
965 Some(path) => {
966 let file = File::open(path).map_err(HtmlError::Io)?;
967 let reader =
968 BufReader::with_capacity(MAX_BUFFER_SIZE, file);
969 read_all_from_reader(reader, "input")
970 }
971 None => {
972 let stdin = io::stdin();
973 let reader =
974 BufReader::with_capacity(MAX_BUFFER_SIZE, stdin.lock());
975 read_all_from_reader(reader, "stdin")
976 }
977 }
978}
979
980fn write_all_to_writer<W: Write>(
987 mut writer: W,
988 content: &[u8],
989 label: &str,
990) -> Result<()> {
991 writer.write_all(content).map_err(|e| {
992 HtmlError::Io(io::Error::new(
993 e.kind(),
994 format!("Failed to write to {label}: {e}"),
995 ))
996 })?;
997 writer.flush().map_err(|e| {
998 HtmlError::Io(io::Error::new(
999 e.kind(),
1000 format!("Failed to flush {label}: {e}"),
1001 ))
1002 })?;
1003 Ok(())
1004}
1005
1006fn write_output(
1008 output: OutputDestination,
1009 content: &[u8],
1010) -> Result<()> {
1011 match output {
1012 OutputDestination::File(path) => {
1013 let file = File::create(&path).map_err(|e| {
1014 HtmlError::Io(io::Error::new(
1015 e.kind(),
1016 format!("Failed to create file '{}': {}", path, e),
1017 ))
1018 })?;
1019 write_all_to_writer(
1020 BufWriter::new(file),
1021 content,
1022 &format!("file '{path}'"),
1023 )
1024 }
1025 OutputDestination::Writer(mut writer) => write_all_to_writer(
1026 BufWriter::new(&mut writer),
1027 content,
1028 "output",
1029 ),
1030 OutputDestination::Stdout => {
1031 let stdout = io::stdout();
1032 write_all_to_writer(
1033 BufWriter::new(stdout.lock()),
1034 content,
1035 "stdout",
1036 )
1037 }
1038 }
1039}
1040
1041pub fn validate_language_code(lang: &str) -> bool {
1065 use once_cell::sync::Lazy;
1066 use regex::Regex;
1067
1068 static LANG_REGEX: Lazy<Regex> = Lazy::new(|| {
1069 Regex::new(constants::LANGUAGE_CODE_PATTERN)
1070 .expect("static LANG_REGEX must compile")
1071 });
1072
1073 LANG_REGEX.is_match(lang)
1074}
1075
1076#[cfg(test)]
1077#[allow(deprecated)]
1078mod tests {
1079 use super::*;
1080 use regex::Regex;
1081 use std::io::Cursor;
1082 use tempfile::{tempdir, TempDir};
1083
1084 struct FailingReader;
1087
1088 impl Read for FailingReader {
1089 fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
1090 Err(io::Error::other("synthetic read failure"))
1091 }
1092 }
1093
1094 struct FailingWriter {
1097 flush_only: bool,
1100 }
1101
1102 impl Write for FailingWriter {
1103 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1104 if self.flush_only {
1105 Ok(buf.len())
1106 } else {
1107 Err(io::Error::other("synthetic write failure"))
1108 }
1109 }
1110 fn flush(&mut self) -> io::Result<()> {
1111 Err(io::Error::other("synthetic flush failure"))
1112 }
1113 }
1114
1115 #[test]
1116 fn test_read_all_from_reader_success() {
1117 let input = Cursor::new(b"hello world".to_vec());
1118 let s = read_all_from_reader(input, "memory").unwrap();
1119 assert_eq!(s, "hello world");
1120 }
1121
1122 #[test]
1123 fn test_read_all_from_reader_surfaces_io_error() {
1124 let err =
1125 read_all_from_reader(FailingReader, "stdin").unwrap_err();
1126 match err {
1127 HtmlError::Io(e) => {
1128 let msg = e.to_string();
1129 assert!(
1130 msg.contains("Failed to read from stdin"),
1131 "unexpected error: {msg}"
1132 );
1133 }
1134 other => panic!("expected Io, got {other:?}"),
1135 }
1136 }
1137
1138 #[test]
1139 fn test_write_all_to_writer_success_covers_stdout_path() {
1140 let mut buf: Vec<u8> = Vec::new();
1141 write_all_to_writer(&mut buf, b"hi", "memory").unwrap();
1142 assert_eq!(buf, b"hi");
1143 }
1144
1145 #[test]
1146 fn test_write_all_to_writer_surfaces_write_error() {
1147 let err = write_all_to_writer(
1148 FailingWriter { flush_only: false },
1149 b"x",
1150 "output",
1151 )
1152 .unwrap_err();
1153 assert!(
1154 matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to write to output"))
1155 );
1156 }
1157
1158 #[test]
1159 fn test_write_all_to_writer_surfaces_flush_error() {
1160 let err = write_all_to_writer(
1161 FailingWriter { flush_only: true },
1162 b"x",
1163 "output",
1164 )
1165 .unwrap_err();
1166 assert!(
1167 matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to flush output"))
1168 );
1169 }
1170
1171 fn setup_test_dir() -> TempDir {
1176 tempdir().expect("Failed to create temporary directory")
1177 }
1178
1179 fn create_test_file(
1190 dir: &TempDir,
1191 content: &str,
1192 ) -> std::path::PathBuf {
1193 let path = dir.path().join("test.md");
1194 std::fs::write(&path, content)
1195 .expect("Failed to write test file");
1196 path
1197 }
1198
1199 mod config_tests {
1200 use super::*;
1201
1202 #[test]
1203 fn test_config_validation() {
1204 let config = HtmlConfig {
1206 max_input_size: 100, ..Default::default()
1208 };
1209 assert!(config.validate().is_err());
1210
1211 let config = HtmlConfig {
1213 language: "invalid".to_string(),
1214 ..Default::default()
1215 };
1216 assert!(config.validate().is_err());
1217
1218 let config = HtmlConfig::default();
1220 assert!(config.validate().is_ok());
1221 }
1222
1223 #[test]
1224 fn test_config_builder() {
1225 let result = HtmlConfigBuilder::new()
1226 .with_syntax_highlighting(
1227 true,
1228 Some("monokai".to_string()),
1229 )
1230 .with_language("en-GB")
1231 .build();
1232
1233 assert!(result.is_ok());
1234 let config = result.unwrap();
1235 assert!(config.enable_syntax_highlighting);
1236 assert_eq!(
1237 config.syntax_theme,
1238 Some("monokai".to_string())
1239 );
1240 assert_eq!(config.language, "en-GB");
1241 }
1242
1243 #[test]
1244 fn test_config_builder_invalid() {
1245 let result = HtmlConfigBuilder::new()
1246 .with_language("invalid")
1247 .build();
1248
1249 assert!(matches!(
1250 result,
1251 Err(HtmlError::InvalidInput(msg)) if msg.contains("Invalid language code")
1252 ));
1253 }
1254
1255 #[test]
1256 fn test_html_config_with_no_syntax_theme() {
1257 let config = HtmlConfig {
1258 enable_syntax_highlighting: true,
1259 syntax_theme: None,
1260 ..Default::default()
1261 };
1262
1263 assert!(config.validate().is_ok());
1264 }
1265
1266 #[test]
1267 fn test_file_conversion_with_large_output() -> Result<()> {
1268 let temp_dir = setup_test_dir();
1269 let input_path = create_test_file(
1270 &temp_dir,
1271 "# Large\n\nContent".repeat(10_000).as_str(),
1272 );
1273 let output_path = temp_dir.path().join("large_output.html");
1274
1275 let result = markdown_file_to_html(
1276 Some(&input_path),
1277 Some(OutputDestination::File(
1278 output_path.to_string_lossy().into(),
1279 )),
1280 None,
1281 );
1282
1283 assert!(result.is_ok());
1284 let content = std::fs::read_to_string(output_path)?;
1285 assert!(content.contains("<h1>Large</h1>"));
1286
1287 Ok(())
1288 }
1289
1290 #[test]
1291 fn test_markdown_with_broken_syntax() {
1292 let markdown = "# Unmatched Header\n**Bold start";
1293 let result = markdown_to_html(markdown, None);
1294 assert!(result.is_ok());
1295 let html = result.unwrap();
1296 assert!(html.contains("<h1>Unmatched Header</h1>"));
1297 assert!(html.contains("**Bold start</p>")); }
1299
1300 #[test]
1301 fn test_language_code_with_custom_regex() {
1302 let custom_lang_regex =
1303 Regex::new(r"^[a-z]{2}-[A-Z]{2}$").unwrap();
1304 assert!(custom_lang_regex.is_match("en-GB"));
1305 assert!(!custom_lang_regex.is_match("EN-gb")); }
1307
1308 #[test]
1309 fn test_markdown_to_html_error_handling() {
1310 let result = markdown_to_html("", None);
1311 assert!(matches!(result, Err(HtmlError::InvalidInput(_))));
1312
1313 let oversized_input =
1314 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1315 let result = markdown_to_html(&oversized_input, None);
1316 assert!(matches!(result, Err(HtmlError::InputTooLarge(_))));
1317 }
1318
1319 #[test]
1320 fn test_performance_with_nested_lists() {
1321 let nested_list = "- Item\n".repeat(1000);
1322 let result = markdown_to_html(&nested_list, None);
1323 assert!(result.is_ok());
1324 let html = result.unwrap();
1325 assert!(html.matches("<li>").count() == 1000);
1326 }
1327 }
1328
1329 mod file_validation_tests {
1330 use super::*;
1331 use std::path::PathBuf;
1332
1333 #[test]
1334 fn test_valid_paths() {
1335 let valid_paths = [
1336 PathBuf::from("test.md"),
1337 PathBuf::from("test.html"),
1338 PathBuf::from("subfolder/test.md"),
1339 ];
1340
1341 for path in valid_paths {
1342 assert!(
1343 HtmlConfig::validate_file_path(&path).is_ok(),
1344 "Path should be valid: {:?}",
1345 path
1346 );
1347 }
1348 }
1349
1350 #[test]
1351 fn test_invalid_paths() {
1352 let invalid_paths = [
1353 PathBuf::from(""), PathBuf::from("../test.md"), PathBuf::from("test.exe"), PathBuf::from(
1357 "a".repeat(constants::MAX_PATH_LENGTH + 1),
1358 ), ];
1360
1361 for path in invalid_paths {
1362 assert!(
1363 HtmlConfig::validate_file_path(&path).is_err(),
1364 "Path should be invalid: {:?}",
1365 path
1366 );
1367 }
1368 }
1369 }
1370
1371 mod markdown_conversion_tests {
1372 use super::*;
1373
1374 #[test]
1375 fn test_basic_conversion() {
1376 let markdown = "# Test\n\nHello world";
1377 let result = markdown_to_html(markdown, None);
1378 assert!(result.is_ok());
1379
1380 let html = result.unwrap();
1381 assert!(html.contains("<h1>Test</h1>"));
1382 assert!(html.contains("<p>Hello world</p>"));
1383 }
1384
1385 #[test]
1386 fn test_conversion_with_config() {
1387 let markdown = "# Test\n```rust\nfn main() {}\n```";
1388 let config = MarkdownConfig {
1389 html_config: HtmlConfig {
1390 enable_syntax_highlighting: true,
1391 ..Default::default()
1392 },
1393 ..Default::default()
1394 };
1395
1396 let result = markdown_to_html(markdown, Some(config));
1397 assert!(result.is_ok());
1398 assert!(result.unwrap().contains("language-rust"));
1399 }
1400
1401 #[test]
1402 fn test_empty_content() {
1403 assert!(matches!(
1404 markdown_to_html("", None),
1405 Err(HtmlError::InvalidInput(_))
1406 ));
1407 }
1408
1409 #[test]
1410 fn test_content_too_large() {
1411 let large_content =
1412 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1413 assert!(matches!(
1414 markdown_to_html(&large_content, None),
1415 Err(HtmlError::InputTooLarge(_))
1416 ));
1417 }
1418 }
1419
1420 mod file_operation_tests {
1421 use super::*;
1422
1423 #[test]
1424 fn test_file_conversion() -> Result<()> {
1425 let temp_dir = setup_test_dir();
1426 let input_path =
1427 create_test_file(&temp_dir, "# Test\n\nHello world");
1428 let output_path = temp_dir.path().join("test.html");
1429
1430 markdown_file_to_html(
1431 Some(&input_path),
1432 Some(OutputDestination::File(
1433 output_path.to_string_lossy().into(),
1434 )),
1435 None::<MarkdownConfig>,
1436 )?;
1437
1438 let content = std::fs::read_to_string(output_path)?;
1439 assert!(content.contains("<h1>Test</h1>"));
1440
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn test_writer_output() {
1446 let temp_dir = setup_test_dir();
1447 let input_path =
1448 create_test_file(&temp_dir, "# Test\nHello");
1449 let buffer = Box::new(Cursor::new(Vec::new()));
1450
1451 let result = markdown_file_to_html(
1452 Some(&input_path),
1453 Some(OutputDestination::Writer(buffer)),
1454 None,
1455 );
1456
1457 assert!(result.is_ok());
1458 }
1459
1460 #[test]
1461 fn test_writer_output_no_input() {
1462 let buffer = Box::new(Cursor::new(Vec::new()));
1463
1464 let result = markdown_file_to_html(
1465 Some(Path::new("nonexistent.md")),
1466 Some(OutputDestination::Writer(buffer)),
1467 None,
1468 );
1469
1470 assert!(result.is_err());
1471 }
1472 }
1473
1474 mod language_validation_tests {
1475 use super::*;
1476
1477 #[test]
1478 fn test_valid_language_codes() {
1479 let valid_codes =
1480 ["en-GB", "fr-FR", "de-DE", "es-ES", "zh-CN"];
1481
1482 for code in valid_codes {
1483 assert!(
1484 validate_language_code(code),
1485 "Language code '{}' should be valid",
1486 code
1487 );
1488 }
1489 }
1490
1491 #[test]
1492 fn test_invalid_language_codes() {
1493 let invalid_codes = [
1494 "", "en", "eng-GBR", "en_GB", "123-45", "GB-en", "en-gb", ];
1502
1503 for code in invalid_codes {
1504 assert!(
1505 !validate_language_code(code),
1506 "Language code '{}' should be invalid",
1507 code
1508 );
1509 }
1510 }
1511 }
1512
1513 mod integration_tests {
1514 use super::*;
1515
1516 #[test]
1517 fn test_end_to_end_conversion() -> Result<()> {
1518 let temp_dir = setup_test_dir();
1519 let content = r#"---
1520title: Test Document
1521---
1522
1523# Hello World
1524
1525This is a test document with:
1526- A list
1527- And some **bold** text
1528"#;
1529 let input_path = create_test_file(&temp_dir, content);
1530 let output_path = temp_dir.path().join("test.html");
1531
1532 let config = MarkdownConfig {
1533 html_config: HtmlConfig {
1534 enable_syntax_highlighting: true,
1535 generate_toc: true,
1536 ..Default::default()
1537 },
1538 ..Default::default()
1539 };
1540
1541 markdown_file_to_html(
1542 Some(&input_path),
1543 Some(OutputDestination::File(
1544 output_path.to_string_lossy().into(),
1545 )),
1546 Some(config),
1547 )?;
1548
1549 let html = std::fs::read_to_string(&output_path)?;
1550 assert!(html.contains("<h1>Hello World</h1>"));
1551 assert!(html.contains("<strong>bold</strong>"));
1552 assert!(html.contains("<ul>"));
1553
1554 Ok(())
1555 }
1556
1557 #[test]
1558 fn test_output_destination_debug() {
1559 assert_eq!(
1560 format!(
1561 "{:?}",
1562 OutputDestination::File("test.html".to_string())
1563 ),
1564 r#"File("test.html")"#
1565 );
1566 assert_eq!(
1567 format!("{:?}", OutputDestination::Stdout),
1568 "Stdout"
1569 );
1570
1571 let writer = Box::new(Cursor::new(Vec::new()));
1572 assert_eq!(
1573 format!("{:?}", OutputDestination::Writer(writer)),
1574 "Writer(<dyn Write>)"
1575 );
1576 }
1577 }
1578
1579 mod markdown_config_tests {
1580 use super::*;
1581
1582 #[test]
1583 fn test_markdown_config_custom_encoding() {
1584 let config = MarkdownConfig {
1585 encoding: "latin1".to_string(),
1586 html_config: HtmlConfig::default(),
1587 };
1588 assert_eq!(config.encoding, "latin1");
1589 }
1590
1591 #[test]
1592 fn test_markdown_config_default() {
1593 let config = MarkdownConfig::default();
1594 assert_eq!(config.encoding, "utf-8");
1595 assert_eq!(config.html_config, HtmlConfig::default());
1596 }
1597
1598 #[test]
1599 fn test_markdown_config_clone() {
1600 let config = MarkdownConfig::default();
1601 let cloned = config.clone();
1602 assert_eq!(config, cloned);
1603 }
1604 }
1605
1606 mod config_error_tests {
1607 use super::*;
1608
1609 #[test]
1610 fn test_config_error_display() {
1611 let error = ConfigError::InvalidInputSize(100, 1024);
1612 assert!(error.to_string().contains("Invalid input size"));
1613
1614 let error =
1615 ConfigError::InvalidLanguageCode("xx".to_string());
1616 assert!(error
1617 .to_string()
1618 .contains("Invalid language code"));
1619
1620 let error =
1621 ConfigError::InvalidFilePath("../bad/path".to_string());
1622 assert!(error.to_string().contains("Invalid file path"));
1623 }
1624 }
1625
1626 mod output_destination_tests {
1627 use super::*;
1628
1629 #[test]
1630 fn test_output_destination_default() {
1631 assert!(matches!(
1632 OutputDestination::default(),
1633 OutputDestination::Stdout
1634 ));
1635 }
1636
1637 #[test]
1638 fn test_output_destination_file() {
1639 let dest = OutputDestination::File("test.html".to_string());
1640 assert!(matches!(dest, OutputDestination::File(_)));
1641 }
1642
1643 #[test]
1644 fn test_output_destination_writer() {
1645 let writer = Box::new(Cursor::new(Vec::new()));
1646 let dest = OutputDestination::Writer(writer);
1647 assert!(matches!(dest, OutputDestination::Writer(_)));
1648 }
1649 }
1650
1651 mod html_config_tests {
1652 use super::*;
1653
1654 #[test]
1655 fn test_html_config_builder_all_options() {
1656 let config = HtmlConfig::builder()
1657 .with_syntax_highlighting(
1658 true,
1659 Some("dracula".to_string()),
1660 )
1661 .with_language("en-US")
1662 .build()
1663 .unwrap();
1664
1665 assert!(config.enable_syntax_highlighting);
1666 assert_eq!(
1667 config.syntax_theme,
1668 Some("dracula".to_string())
1669 );
1670 assert_eq!(config.language, "en-US");
1671 }
1672
1673 #[test]
1674 fn test_html_config_validation_edge_cases() {
1675 let config = HtmlConfig {
1676 max_input_size: constants::MIN_INPUT_SIZE,
1677 ..Default::default()
1678 };
1679 assert!(config.validate().is_ok());
1680
1681 let config = HtmlConfig {
1682 max_input_size: constants::MIN_INPUT_SIZE - 1,
1683 ..Default::default()
1684 };
1685 assert!(config.validate().is_err());
1686 }
1687 }
1688
1689 mod markdown_processing_tests {
1690 use super::*;
1691
1692 #[test]
1693 fn test_markdown_to_html_with_front_matter() -> Result<()> {
1694 let markdown = r#"---
1695title: Test
1696author: Test Author
1697---
1698# Heading
1699Content"#;
1700 let html = markdown_to_html(markdown, None)?;
1701 assert!(html.contains("<h1>Heading</h1>"));
1702 assert!(html.contains("<p>Content</p>"));
1703 Ok(())
1704 }
1705
1706 #[test]
1707 fn test_markdown_to_html_with_code_blocks() -> Result<()> {
1708 let markdown = r#"```rust
1709fn main() {
1710 println!("Hello");
1711}
1712```"#;
1713 let config = MarkdownConfig {
1714 html_config: HtmlConfig {
1715 enable_syntax_highlighting: true,
1716 ..Default::default()
1717 },
1718 ..Default::default()
1719 };
1720 let html = markdown_to_html(markdown, Some(config))?;
1721 assert!(html.contains("language-rust"));
1722 Ok(())
1723 }
1724
1725 #[test]
1726 fn test_markdown_to_html_with_tables() -> Result<()> {
1727 let markdown = r#"
1728| Header 1 | Header 2 |
1729|----------|----------|
1730| Cell 1 | Cell 2 |
1731"#;
1732 let html = markdown_to_html(markdown, None)?;
1733 println!("Generated HTML for table: {}", html);
1735 assert!(html.contains("Header 1"));
1737 assert!(html.contains("Cell 1"));
1738 assert!(html.contains("Cell 2"));
1739 Ok(())
1740 }
1741
1742 #[test]
1743 fn test_invalid_encoding_handling() {
1744 let config = MarkdownConfig {
1745 encoding: "unsupported-encoding".to_string(),
1746 html_config: HtmlConfig::default(),
1747 };
1748 let result = markdown_to_html("# Test", Some(config));
1750 assert!(result.is_ok()); }
1752
1753 #[test]
1754 fn test_config_error_types() {
1755 let error = ConfigError::InvalidInputSize(512, 1024);
1756 assert_eq!(format!("{}", error), "Invalid input size: 512 bytes is below minimum of 1024 bytes");
1757 }
1758 }
1759
1760 mod file_processing_tests {
1761 use crate::constants;
1762 use crate::HtmlConfig;
1763 use crate::{
1764 markdown_file_to_html, HtmlError, OutputDestination,
1765 };
1766 use std::io::Cursor;
1767 use std::path::Path;
1768 use tempfile::NamedTempFile;
1769
1770 #[test]
1771 fn test_display_file() {
1772 let output =
1773 OutputDestination::File("output.html".to_string());
1774 let display = format!("{}", output);
1775 assert_eq!(display, "File(output.html)");
1776 }
1777
1778 #[test]
1779 fn test_display_stdout() {
1780 let output = OutputDestination::Stdout;
1781 let display = format!("{}", output);
1782 assert_eq!(display, "Stdout");
1783 }
1784
1785 #[test]
1786 fn test_display_writer() {
1787 let buffer = Cursor::new(Vec::new());
1788 let output = OutputDestination::Writer(Box::new(buffer));
1789 let display = format!("{}", output);
1790 assert_eq!(display, "Writer(<dyn Write>)");
1791 }
1792
1793 #[test]
1794 fn test_debug_file() {
1795 let output =
1796 OutputDestination::File("output.html".to_string());
1797 let debug = format!("{:?}", output);
1798 assert_eq!(debug, r#"File("output.html")"#);
1799 }
1800
1801 #[test]
1802 fn test_debug_stdout() {
1803 let output = OutputDestination::Stdout;
1804 let debug = format!("{:?}", output);
1805 assert_eq!(debug, "Stdout");
1806 }
1807
1808 #[test]
1809 fn test_debug_writer() {
1810 let buffer = Cursor::new(Vec::new());
1811 let output = OutputDestination::Writer(Box::new(buffer));
1812 let debug = format!("{:?}", output);
1813 assert_eq!(debug, "Writer(<dyn Write>)");
1814 }
1815
1816 #[test]
1817 fn test_file_to_html_invalid_input() {
1818 let result = markdown_file_to_html(
1819 Some(Path::new("nonexistent.md")),
1820 None,
1821 None,
1822 );
1823 assert!(matches!(result, Err(HtmlError::Io(_))));
1824 }
1825
1826 #[test]
1827 fn test_file_to_html_with_invalid_output_path(
1828 ) -> Result<(), HtmlError> {
1829 let input = NamedTempFile::new()?;
1830 std::fs::write(&input, "# Test")?;
1831
1832 let result = markdown_file_to_html(
1833 Some(input.path()),
1834 Some(OutputDestination::File(
1835 "/invalid/path/test.html".to_string(),
1836 )),
1837 None,
1838 );
1839 assert!(result.is_err());
1840 Ok(())
1841 }
1842
1843 #[test]
1845 fn test_output_destination_default() {
1846 let default = OutputDestination::default();
1847 assert!(matches!(default, OutputDestination::Stdout));
1848 }
1849
1850 #[test]
1852 fn test_output_destination_debug() {
1853 let file_debug = format!(
1854 "{:?}",
1855 OutputDestination::File(
1856 "path/to/file.html".to_string()
1857 )
1858 );
1859 assert_eq!(file_debug, r#"File("path/to/file.html")"#);
1860
1861 let writer_debug = format!(
1862 "{:?}",
1863 OutputDestination::Writer(Box::new(Cursor::new(
1864 Vec::new()
1865 )))
1866 );
1867 assert_eq!(writer_debug, "Writer(<dyn Write>)");
1868
1869 let stdout_debug =
1870 format!("{:?}", OutputDestination::Stdout);
1871 assert_eq!(stdout_debug, "Stdout");
1872 }
1873
1874 #[test]
1876 fn test_output_destination_display() {
1877 let file_display = format!(
1878 "{}",
1879 OutputDestination::File(
1880 "path/to/file.html".to_string()
1881 )
1882 );
1883 assert_eq!(file_display, "File(path/to/file.html)");
1884
1885 let writer_display = format!(
1886 "{}",
1887 OutputDestination::Writer(Box::new(Cursor::new(
1888 Vec::new()
1889 )))
1890 );
1891 assert_eq!(writer_display, "Writer(<dyn Write>)");
1892
1893 let stdout_display =
1894 format!("{}", OutputDestination::Stdout);
1895 assert_eq!(stdout_display, "Stdout");
1896 }
1897
1898 #[test]
1900 fn test_html_config_default() {
1901 let default = HtmlConfig::default();
1902 assert!(default.enable_syntax_highlighting);
1903 assert_eq!(
1904 default.syntax_theme,
1905 Some(constants::DEFAULT_SYNTAX_THEME.to_string())
1906 );
1907 assert!(!default.minify_output);
1908 assert!(default.add_aria_attributes);
1909 assert!(!default.generate_structured_data);
1910 assert_eq!(
1911 default.max_input_size,
1912 constants::DEFAULT_MAX_INPUT_SIZE
1913 );
1914 assert_eq!(
1915 default.language,
1916 constants::DEFAULT_LANGUAGE.to_string()
1917 );
1918 assert!(!default.generate_toc);
1919 }
1920
1921 #[test]
1923 fn test_html_config_builder() {
1924 let builder = HtmlConfig::builder()
1925 .with_syntax_highlighting(
1926 true,
1927 Some("monokai".to_string()),
1928 )
1929 .with_language("en-US")
1930 .build()
1931 .unwrap();
1932
1933 assert!(builder.enable_syntax_highlighting);
1934 assert_eq!(
1935 builder.syntax_theme,
1936 Some("monokai".to_string())
1937 );
1938 assert_eq!(builder.language, "en-US");
1939 }
1940
1941 #[test]
1943 fn test_long_file_path_validation() {
1944 let long_path = "a".repeat(constants::MAX_PATH_LENGTH + 1);
1945 let result = HtmlConfig::validate_file_path(long_path);
1946 assert!(
1947 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("File path exceeds maximum length"))
1948 );
1949 }
1950
1951 #[test]
1956 fn test_absolute_path_is_accepted() {
1957 let result = HtmlConfig::validate_file_path(
1958 "/absolute/path/to/file.md",
1959 );
1960 assert!(
1961 result.is_ok(),
1962 "absolute paths must be accepted, got {result:?}"
1963 );
1964 }
1965
1966 #[test]
1969 fn test_nul_byte_path_is_rejected() {
1970 let result = HtmlConfig::validate_file_path("safe.md\0bad");
1971 assert!(
1972 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("NUL")),
1973 "NUL byte in path must be rejected, got {result:?}"
1974 );
1975 }
1976 }
1977
1978 mod language_validation_extended_tests {
1979 use super::*;
1980
1981 #[test]
1982 fn test_language_code_edge_cases() {
1983 assert!(!validate_language_code(""));
1985
1986 assert!(!validate_language_code("a"));
1988
1989 assert!(!validate_language_code("EN-GB"));
1991 assert!(!validate_language_code("en-gb"));
1992
1993 assert!(!validate_language_code("en_GB"));
1995 assert!(!validate_language_code("en GB"));
1996
1997 assert!(!validate_language_code("en-GB-extra"));
1999 }
2000
2001 #[test]
2002 fn test_language_code_special_cases() {
2003 assert!(!validate_language_code("e1-GB"));
2005 assert!(!validate_language_code("en-G1"));
2006
2007 assert!(!validate_language_code("en-GB!"));
2009 assert!(!validate_language_code("en@GB"));
2010
2011 assert!(!validate_language_code("あa-GB"));
2013 assert!(!validate_language_code("en-あa"));
2014 }
2015 }
2016
2017 mod integration_extended_tests {
2018 use super::*;
2019
2020 #[test]
2021 fn test_full_conversion_pipeline() -> Result<()> {
2022 let temp_dir = tempdir()?;
2024 let input_path = temp_dir.path().join("test.md");
2025 let output_path = temp_dir.path().join("test.html");
2026
2027 let content = r#"---
2029title: Test Document
2030author: Test Author
2031---
2032
2033# Main Heading
2034
2035## Subheading
2036
2037This is a paragraph with *italic* and **bold** text.
2038
2039- List item 1
2040- List item 2
2041 - Nested item
2042 - Another nested item
2043
2044```rust
2045fn main() {
2046 println!("Hello, world!");
2047}
2048```
2049
2050| Column 1 | Column 2 |
2051|----------|----------|
2052| Cell 1 | Cell 2 |
2053
2054> This is a blockquote
2055
2056[Link text](https://example.com)"#;
2057
2058 std::fs::write(&input_path, content)?;
2059
2060 let config = MarkdownConfig {
2062 html_config: HtmlConfig {
2063 enable_syntax_highlighting: true,
2064 generate_toc: true,
2065 add_aria_attributes: true,
2066 generate_structured_data: true,
2067 minify_output: true,
2068 ..Default::default()
2069 },
2070 ..Default::default()
2071 };
2072
2073 markdown_file_to_html(
2074 Some(&input_path),
2075 Some(OutputDestination::File(
2076 output_path.to_string_lossy().into(),
2077 )),
2078 Some(config),
2079 )?;
2080
2081 let html = std::fs::read_to_string(&output_path)?;
2082
2083 println!("Generated HTML: {}", html);
2085 assert!(html.contains("<h1>"));
2086 assert!(html.contains("<h2>"));
2087 assert!(html.contains("<em>"));
2088 assert!(html.contains("<strong>"));
2089 assert!(html.contains("<ul>"));
2090 assert!(html.contains("<li>"));
2091 assert!(html.contains("language-rust"));
2092
2093 assert!(html.contains("Column 1"));
2095 assert!(html.contains("Column 2"));
2096 assert!(html.contains("Cell 1"));
2097 assert!(html.contains("Cell 2"));
2098
2099 assert!(html.contains("<blockquote>"));
2100 assert!(html.contains("<a href="));
2101
2102 Ok(())
2103 }
2104
2105 #[test]
2106 fn test_missing_html_config_fallback() {
2107 let config = MarkdownConfig {
2108 encoding: "utf-8".to_string(),
2109 html_config: HtmlConfig {
2110 enable_syntax_highlighting: false,
2111 syntax_theme: None,
2112 ..Default::default()
2113 },
2114 };
2115 let result = markdown_to_html("# Test", Some(config));
2116 assert!(result.is_ok());
2117 }
2118
2119 #[test]
2120 fn test_invalid_output_destination() {
2121 let result = markdown_file_to_html(
2122 Some(Path::new("test.md")),
2123 Some(OutputDestination::File(
2124 "/root/forbidden.html".to_string(),
2125 )),
2126 None,
2127 );
2128 assert!(result.is_err());
2129 }
2130 }
2131
2132 mod performance_tests {
2133 use super::*;
2134 use std::time::Instant;
2135
2136 #[test]
2137 fn test_large_document_performance() -> Result<()> {
2138 let base_content =
2139 "# Heading\n\nParagraph\n\n- List item\n\n";
2140 let large_content = base_content.repeat(1000);
2141
2142 let start = Instant::now();
2143 let html = markdown_to_html(&large_content, None)?;
2144 let duration = start.elapsed();
2145
2146 println!("Large document conversion took: {:?}", duration);
2148 println!("Input size: {} bytes", large_content.len());
2149 println!("Output size: {} bytes", html.len());
2150
2151 assert!(html.contains("<h1>"));
2153 assert!(html.contains("<p>"));
2154 assert!(html.contains("<ul>"));
2155
2156 Ok(())
2157 }
2158 }
2159}