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;
30mod minifier;
31pub mod performance;
32pub mod seo;
33pub mod utils;
34
35#[cfg(feature = "wasm")]
38pub mod wasm;
39
40pub use crate::error::HtmlError;
42pub use accessibility::{add_aria_attributes, validate_wcag};
43pub use emojis::load_emoji_sequences;
44pub use generator::{
45 generate_html, generate_html_with_diagnostics, Diagnostic,
46 DiagnosticLevel, HtmlOutput,
47};
48#[cfg(feature = "async")]
49pub use performance::async_generate_html;
50pub use performance::{minify_html, minify_html_string};
51pub use seo::{generate_meta_tags, generate_structured_data};
52pub use utils::{
53 extract_front_matter, extract_front_matter_data,
54 format_header_with_id_class,
55};
56
57pub mod constants {
71 pub const DEFAULT_MAX_INPUT_SIZE: usize = 5 * 1024 * 1024;
80
81 pub const MIN_INPUT_SIZE: usize = 1024;
90
91 pub const DEFAULT_LANGUAGE: &str = "en-GB";
100
101 pub const DEFAULT_SYNTAX_THEME: &str = "InspiredGitHub";
116
117 pub const MAX_PATH_LENGTH: usize = 4096;
126
127 pub const LANGUAGE_CODE_PATTERN: &str = r"^[a-z]{2}-[A-Z]{2}$";
139
140 const _: () = assert!(MIN_INPUT_SIZE <= DEFAULT_MAX_INPUT_SIZE);
142 const _: () = assert!(MAX_PATH_LENGTH > 0);
143}
144
145pub type Result<T> = std::result::Result<T, HtmlError>;
158
159#[deprecated(
164 since = "0.0.4",
165 note = "use HtmlConfig directly — encoding is now a field on HtmlConfig"
166)]
167#[derive(Debug, Clone, Eq, PartialEq)]
168pub struct MarkdownConfig {
169 pub encoding: String,
171
172 pub html_config: HtmlConfig,
174}
175
176#[allow(deprecated)]
177impl Default for MarkdownConfig {
178 fn default() -> Self {
179 Self {
180 encoding: String::from("utf-8"),
181 html_config: HtmlConfig::default(),
182 }
183 }
184}
185
186#[allow(deprecated)]
187impl From<MarkdownConfig> for HtmlConfig {
188 fn from(mc: MarkdownConfig) -> Self {
189 let mut c = mc.html_config;
190 c.encoding = mc.encoding;
191 c
192 }
193}
194
195#[derive(Debug, thiserror::Error)]
206#[non_exhaustive]
207pub enum ConfigError {
208 #[error(
210 "Invalid input size: {0} bytes is below minimum of {1} bytes"
211 )]
212 InvalidInputSize(usize, usize),
213
214 #[error("Invalid language code: {0}")]
216 InvalidLanguageCode(String),
217
218 #[error("Invalid file path: {0}")]
220 InvalidFilePath(String),
221}
222
223#[non_exhaustive]
253pub enum OutputDestination {
254 File(String),
264
265 Writer(Box<dyn Write>),
280
281 Stdout,
293}
294
295impl Default for OutputDestination {
297 fn default() -> Self {
298 Self::Stdout
299 }
300}
301
302impl fmt::Debug for OutputDestination {
304 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 match self {
306 Self::File(path) => {
307 f.debug_tuple("File").field(path).finish()
308 }
309 Self::Writer(_) => write!(f, "Writer(<dyn Write>)"),
310 Self::Stdout => write!(f, "Stdout"),
311 }
312 }
313}
314
315impl fmt::Display for OutputDestination {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 match self {
319 OutputDestination::File(path) => {
320 write!(f, "File({})", path)
321 }
322 OutputDestination::Writer(_) => {
323 write!(f, "Writer(<dyn Write>)")
324 }
325 OutputDestination::Stdout => write!(f, "Stdout"),
326 }
327 }
328}
329
330#[derive(Debug, PartialEq, Eq, Clone)]
345pub struct HtmlConfig {
346 pub enable_syntax_highlighting: bool,
348
349 pub syntax_theme: Option<String>,
351
352 pub minify_output: bool,
354
355 pub add_aria_attributes: bool,
357
358 pub generate_structured_data: bool,
360
361 pub max_input_size: usize,
363
364 pub language: String,
366
367 pub generate_toc: bool,
369
370 pub allow_unsafe_html: bool,
377
378 pub sanitize_html: bool,
389
390 pub generate_full_document: bool,
405
406 pub max_buffer_size: usize,
412
413 pub encoding: String,
418
419 pub enable_math: bool,
426
427 pub enable_diagrams: bool,
438}
439
440impl Default for HtmlConfig {
441 fn default() -> Self {
442 Self {
443 enable_syntax_highlighting: true,
444 syntax_theme: Some(
445 constants::DEFAULT_SYNTAX_THEME.to_string(),
446 ),
447 minify_output: false,
448 add_aria_attributes: true,
449 generate_structured_data: false,
450 max_input_size: constants::DEFAULT_MAX_INPUT_SIZE,
451 language: String::from(constants::DEFAULT_LANGUAGE),
452 generate_toc: false,
453 allow_unsafe_html: false,
454 sanitize_html: false,
455 generate_full_document: false,
456 max_buffer_size: 16 * 1024 * 1024,
457 encoding: String::from("utf-8"),
458 enable_math: false,
459 enable_diagrams: false,
460 }
461 }
462}
463
464impl HtmlConfig {
465 pub fn builder() -> HtmlConfigBuilder {
479 HtmlConfigBuilder::default()
480 }
481
482 pub fn validate(&self) -> Result<()> {
507 if self.max_input_size < constants::MIN_INPUT_SIZE {
508 return Err(HtmlError::InvalidInput(format!(
509 "Input size must be at least {} bytes",
510 constants::MIN_INPUT_SIZE
511 )));
512 }
513 if !validate_language_code(&self.language) {
514 return Err(HtmlError::InvalidInput(format!(
515 "Invalid language code: {}",
516 self.language
517 )));
518 }
519 Ok(())
520 }
521
522 pub(crate) fn validate_file_path(
536 path: impl AsRef<Path>,
537 ) -> Result<()> {
538 let path = path.as_ref();
539 let path_str = path.to_string_lossy();
540
541 if path_str.is_empty() {
542 return Err(HtmlError::InvalidInput(
543 "File path cannot be empty".to_string(),
544 ));
545 }
546
547 if path_str.len() > constants::MAX_PATH_LENGTH {
548 return Err(HtmlError::InvalidInput(format!(
549 "File path exceeds maximum length of {} characters",
550 constants::MAX_PATH_LENGTH
551 )));
552 }
553
554 if path_str.as_bytes().contains(&0) {
558 return Err(HtmlError::InvalidInput(
559 "File path must not contain NUL bytes".to_string(),
560 ));
561 }
562
563 if path.components().any(|c| matches!(c, Component::ParentDir))
564 {
565 return Err(HtmlError::InvalidInput(
566 "Directory traversal is not allowed in file paths"
567 .to_string(),
568 ));
569 }
570
571 if let Some(ext) = path.extension() {
572 if !matches!(ext.to_string_lossy().as_ref(), "md" | "html")
573 {
574 return Err(HtmlError::InvalidInput(
575 "Invalid file extension: only .md and .html files are allowed".to_string(),
576 ));
577 }
578 }
579
580 Ok(())
581 }
582}
583
584#[derive(Debug, Default)]
602pub struct HtmlConfigBuilder {
603 config: HtmlConfig,
604}
605
606impl HtmlConfigBuilder {
607 pub fn new() -> Self {
617 Self::default()
618 }
619
620 #[must_use]
639 pub fn with_syntax_highlighting(
640 mut self,
641 enable: bool,
642 theme: Option<String>,
643 ) -> Self {
644 self.config.enable_syntax_highlighting = enable;
645 self.config.syntax_theme = if enable {
646 theme.or_else(|| {
647 Some(constants::DEFAULT_SYNTAX_THEME.to_string())
648 })
649 } else {
650 None
651 };
652 self
653 }
654
655 #[must_use]
669 pub fn with_language(
670 mut self,
671 language: impl Into<String>,
672 ) -> Self {
673 self.config.language = language.into();
674 self
675 }
676
677 #[must_use]
694 pub fn with_sanitization(mut self, enable: bool) -> Self {
695 self.config.sanitize_html = enable;
696 self
697 }
698
699 #[must_use]
716 pub fn with_full_document(mut self, enable: bool) -> Self {
717 self.config.generate_full_document = enable;
718 self
719 }
720
721 #[must_use]
735 pub fn with_max_buffer_size(mut self, size: usize) -> Self {
736 self.config.max_buffer_size = size;
737 self
738 }
739
740 #[must_use]
759 pub fn with_math(mut self, enable: bool) -> Self {
760 self.config.enable_math = enable;
761 self
762 }
763
764 #[must_use]
783 pub fn with_diagrams(mut self, enable: bool) -> Self {
784 self.config.enable_diagrams = enable;
785 self
786 }
787
788 pub fn build(self) -> Result<HtmlConfig> {
808 self.config.validate()?;
809 Ok(self.config)
810 }
811}
812
813#[allow(deprecated)]
846pub fn markdown_to_html(
847 content: &str,
848 config: Option<MarkdownConfig>,
849) -> Result<String> {
850 let html_config: HtmlConfig =
851 config.map_or_else(HtmlConfig::default, HtmlConfig::from);
852
853 if content.is_empty() {
854 return Err(HtmlError::InvalidInput(
855 "Input content is empty".to_string(),
856 ));
857 }
858
859 if content.len() > html_config.max_input_size {
860 return Err(HtmlError::InputTooLarge(content.len()));
861 }
862
863 generate_html(content, &html_config)
864}
865
866#[inline]
911#[allow(deprecated)]
912pub fn markdown_file_to_html(
913 input: Option<impl AsRef<Path>>,
914 output: Option<OutputDestination>,
915 config: Option<MarkdownConfig>,
916) -> Result<()> {
917 let config = config.unwrap_or_default();
918 let output = output.unwrap_or_default();
919
920 validate_paths(&input, &output)?;
922
923 let content = read_input(input)?;
925
926 let html = markdown_to_html(&content, Some(config))?;
928
929 write_output(output, html.as_bytes())
931}
932
933fn validate_paths(
935 input: &Option<impl AsRef<Path>>,
936 output: &OutputDestination,
937) -> Result<()> {
938 if let Some(path) = input.as_ref() {
939 HtmlConfig::validate_file_path(path)?;
940 }
941 if let OutputDestination::File(ref path) = output {
942 HtmlConfig::validate_file_path(path)?;
943 }
944 Ok(())
945}
946
947fn read_all_from_reader<R: Read>(
954 mut reader: R,
955 label: &str,
956) -> Result<String> {
957 let mut content = String::with_capacity(MAX_BUFFER_SIZE);
958 let _ = reader.read_to_string(&mut content).map_err(|e| {
960 HtmlError::Io(io::Error::new(
961 e.kind(),
962 format!("Failed to read from {label}: {e}"),
963 ))
964 })?;
965 Ok(content)
966}
967
968fn read_input(input: Option<impl AsRef<Path>>) -> Result<String> {
971 match input {
972 Some(path) => {
973 let file = File::open(path).map_err(HtmlError::Io)?;
974 let reader =
975 BufReader::with_capacity(MAX_BUFFER_SIZE, file);
976 read_all_from_reader(reader, "input")
977 }
978 None => {
979 let stdin = io::stdin();
980 let reader =
981 BufReader::with_capacity(MAX_BUFFER_SIZE, stdin.lock());
982 read_all_from_reader(reader, "stdin")
983 }
984 }
985}
986
987fn write_all_to_writer<W: Write>(
994 mut writer: W,
995 content: &[u8],
996 label: &str,
997) -> Result<()> {
998 writer.write_all(content).map_err(|e| {
999 HtmlError::Io(io::Error::new(
1000 e.kind(),
1001 format!("Failed to write to {label}: {e}"),
1002 ))
1003 })?;
1004 writer.flush().map_err(|e| {
1005 HtmlError::Io(io::Error::new(
1006 e.kind(),
1007 format!("Failed to flush {label}: {e}"),
1008 ))
1009 })?;
1010 Ok(())
1011}
1012
1013fn write_output(
1015 output: OutputDestination,
1016 content: &[u8],
1017) -> Result<()> {
1018 match output {
1019 OutputDestination::File(path) => {
1020 let file = File::create(&path).map_err(|e| {
1021 HtmlError::Io(io::Error::new(
1022 e.kind(),
1023 format!("Failed to create file '{}': {}", path, e),
1024 ))
1025 })?;
1026 write_all_to_writer(
1027 BufWriter::new(file),
1028 content,
1029 &format!("file '{path}'"),
1030 )
1031 }
1032 OutputDestination::Writer(mut writer) => write_all_to_writer(
1033 BufWriter::new(&mut writer),
1034 content,
1035 "output",
1036 ),
1037 OutputDestination::Stdout => {
1038 let stdout = io::stdout();
1039 write_all_to_writer(
1040 BufWriter::new(stdout.lock()),
1041 content,
1042 "stdout",
1043 )
1044 }
1045 }
1046}
1047
1048pub fn validate_language_code(lang: &str) -> bool {
1072 use once_cell::sync::Lazy;
1073 use regex::Regex;
1074
1075 static LANG_REGEX: Lazy<Regex> = Lazy::new(|| {
1076 Regex::new(constants::LANGUAGE_CODE_PATTERN)
1077 .expect("static LANG_REGEX must compile")
1078 });
1079
1080 LANG_REGEX.is_match(lang)
1081}
1082
1083#[cfg(test)]
1084#[allow(deprecated)]
1085mod tests {
1086 use super::*;
1087 use regex::Regex;
1088 use std::io::Cursor;
1089 use tempfile::{tempdir, TempDir};
1090
1091 struct FailingReader;
1094
1095 impl Read for FailingReader {
1096 fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
1097 Err(io::Error::other("synthetic read failure"))
1098 }
1099 }
1100
1101 struct FailingWriter {
1104 flush_only: bool,
1107 }
1108
1109 impl Write for FailingWriter {
1110 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1111 if self.flush_only {
1112 Ok(buf.len())
1113 } else {
1114 Err(io::Error::other("synthetic write failure"))
1115 }
1116 }
1117 fn flush(&mut self) -> io::Result<()> {
1118 Err(io::Error::other("synthetic flush failure"))
1119 }
1120 }
1121
1122 #[test]
1123 fn test_read_all_from_reader_success() {
1124 let input = Cursor::new(b"hello world".to_vec());
1125 let s = read_all_from_reader(input, "memory").unwrap();
1126 assert_eq!(s, "hello world");
1127 }
1128
1129 #[test]
1130 fn test_read_all_from_reader_surfaces_io_error() {
1131 let err =
1132 read_all_from_reader(FailingReader, "stdin").unwrap_err();
1133 match err {
1134 HtmlError::Io(e) => {
1135 let msg = e.to_string();
1136 assert!(
1137 msg.contains("Failed to read from stdin"),
1138 "unexpected error: {msg}"
1139 );
1140 }
1141 other => panic!("expected Io, got {other:?}"),
1142 }
1143 }
1144
1145 #[test]
1146 fn test_write_all_to_writer_success_covers_stdout_path() {
1147 let mut buf: Vec<u8> = Vec::new();
1148 write_all_to_writer(&mut buf, b"hi", "memory").unwrap();
1149 assert_eq!(buf, b"hi");
1150 }
1151
1152 #[test]
1153 fn test_write_all_to_writer_surfaces_write_error() {
1154 let err = write_all_to_writer(
1155 FailingWriter { flush_only: false },
1156 b"x",
1157 "output",
1158 )
1159 .unwrap_err();
1160 assert!(
1161 matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to write to output"))
1162 );
1163 }
1164
1165 #[test]
1166 fn test_write_all_to_writer_surfaces_flush_error() {
1167 let err = write_all_to_writer(
1168 FailingWriter { flush_only: true },
1169 b"x",
1170 "output",
1171 )
1172 .unwrap_err();
1173 assert!(
1174 matches!(err, HtmlError::Io(ref e) if e.to_string().contains("Failed to flush output"))
1175 );
1176 }
1177
1178 fn setup_test_dir() -> TempDir {
1183 tempdir().expect("Failed to create temporary directory")
1184 }
1185
1186 fn create_test_file(
1197 dir: &TempDir,
1198 content: &str,
1199 ) -> std::path::PathBuf {
1200 let path = dir.path().join("test.md");
1201 std::fs::write(&path, content)
1202 .expect("Failed to write test file");
1203 path
1204 }
1205
1206 mod config_tests {
1207 use super::*;
1208
1209 #[test]
1210 fn test_config_validation() {
1211 let config = HtmlConfig {
1213 max_input_size: 100, ..Default::default()
1215 };
1216 assert!(config.validate().is_err());
1217
1218 let config = HtmlConfig {
1220 language: "invalid".to_string(),
1221 ..Default::default()
1222 };
1223 assert!(config.validate().is_err());
1224
1225 let config = HtmlConfig::default();
1227 assert!(config.validate().is_ok());
1228 }
1229
1230 #[test]
1231 fn test_config_builder() {
1232 let result = HtmlConfigBuilder::new()
1233 .with_syntax_highlighting(
1234 true,
1235 Some("monokai".to_string()),
1236 )
1237 .with_language("en-GB")
1238 .build();
1239
1240 assert!(result.is_ok());
1241 let config = result.unwrap();
1242 assert!(config.enable_syntax_highlighting);
1243 assert_eq!(
1244 config.syntax_theme,
1245 Some("monokai".to_string())
1246 );
1247 assert_eq!(config.language, "en-GB");
1248 }
1249
1250 #[test]
1251 fn test_config_builder_invalid() {
1252 let result = HtmlConfigBuilder::new()
1253 .with_language("invalid")
1254 .build();
1255
1256 assert!(matches!(
1257 result,
1258 Err(HtmlError::InvalidInput(msg)) if msg.contains("Invalid language code")
1259 ));
1260 }
1261
1262 #[test]
1263 fn test_html_config_with_no_syntax_theme() {
1264 let config = HtmlConfig {
1265 enable_syntax_highlighting: true,
1266 syntax_theme: None,
1267 ..Default::default()
1268 };
1269
1270 assert!(config.validate().is_ok());
1271 }
1272
1273 #[test]
1274 fn test_file_conversion_with_large_output() -> Result<()> {
1275 let temp_dir = setup_test_dir();
1276 let input_path = create_test_file(
1277 &temp_dir,
1278 "# Large\n\nContent".repeat(10_000).as_str(),
1279 );
1280 let output_path = temp_dir.path().join("large_output.html");
1281
1282 let result = markdown_file_to_html(
1283 Some(&input_path),
1284 Some(OutputDestination::File(
1285 output_path.to_string_lossy().into(),
1286 )),
1287 None,
1288 );
1289
1290 assert!(result.is_ok());
1291 let content = std::fs::read_to_string(output_path)?;
1292 assert!(content.contains("<h1>Large</h1>"));
1293
1294 Ok(())
1295 }
1296
1297 #[test]
1298 fn test_markdown_with_broken_syntax() {
1299 let markdown = "# Unmatched Header\n**Bold start";
1300 let result = markdown_to_html(markdown, None);
1301 assert!(result.is_ok());
1302 let html = result.unwrap();
1303 assert!(html.contains("<h1>Unmatched Header</h1>"));
1304 assert!(html.contains("**Bold start</p>")); }
1306
1307 #[test]
1308 fn test_language_code_with_custom_regex() {
1309 let custom_lang_regex =
1310 Regex::new(r"^[a-z]{2}-[A-Z]{2}$").unwrap();
1311 assert!(custom_lang_regex.is_match("en-GB"));
1312 assert!(!custom_lang_regex.is_match("EN-gb")); }
1314
1315 #[test]
1316 fn test_markdown_to_html_error_handling() {
1317 let result = markdown_to_html("", None);
1318 assert!(matches!(result, Err(HtmlError::InvalidInput(_))));
1319
1320 let oversized_input =
1321 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1322 let result = markdown_to_html(&oversized_input, None);
1323 assert!(matches!(result, Err(HtmlError::InputTooLarge(_))));
1324 }
1325
1326 #[test]
1327 fn test_performance_with_nested_lists() {
1328 let nested_list = "- Item\n".repeat(1000);
1329 let result = markdown_to_html(&nested_list, None);
1330 assert!(result.is_ok());
1331 let html = result.unwrap();
1332 assert!(html.matches("<li>").count() == 1000);
1333 }
1334 }
1335
1336 mod file_validation_tests {
1337 use super::*;
1338 use std::path::PathBuf;
1339
1340 #[test]
1341 fn test_valid_paths() {
1342 let valid_paths = [
1343 PathBuf::from("test.md"),
1344 PathBuf::from("test.html"),
1345 PathBuf::from("subfolder/test.md"),
1346 ];
1347
1348 for path in valid_paths {
1349 assert!(
1350 HtmlConfig::validate_file_path(&path).is_ok(),
1351 "Path should be valid: {:?}",
1352 path
1353 );
1354 }
1355 }
1356
1357 #[test]
1358 fn test_invalid_paths() {
1359 let invalid_paths = [
1360 PathBuf::from(""), PathBuf::from("../test.md"), PathBuf::from("test.exe"), PathBuf::from(
1364 "a".repeat(constants::MAX_PATH_LENGTH + 1),
1365 ), ];
1367
1368 for path in invalid_paths {
1369 assert!(
1370 HtmlConfig::validate_file_path(&path).is_err(),
1371 "Path should be invalid: {:?}",
1372 path
1373 );
1374 }
1375 }
1376 }
1377
1378 mod markdown_conversion_tests {
1379 use super::*;
1380
1381 #[test]
1382 fn test_basic_conversion() {
1383 let markdown = "# Test\n\nHello world";
1384 let result = markdown_to_html(markdown, None);
1385 assert!(result.is_ok());
1386
1387 let html = result.unwrap();
1388 assert!(html.contains("<h1>Test</h1>"));
1389 assert!(html.contains("<p>Hello world</p>"));
1390 }
1391
1392 #[test]
1393 fn test_conversion_with_config() {
1394 let markdown = "# Test\n```rust\nfn main() {}\n```";
1395 let config = MarkdownConfig {
1396 html_config: HtmlConfig {
1397 enable_syntax_highlighting: true,
1398 ..Default::default()
1399 },
1400 ..Default::default()
1401 };
1402
1403 let result = markdown_to_html(markdown, Some(config));
1404 assert!(result.is_ok());
1405 assert!(result.unwrap().contains("language-rust"));
1406 }
1407
1408 #[test]
1409 fn test_empty_content() {
1410 assert!(matches!(
1411 markdown_to_html("", None),
1412 Err(HtmlError::InvalidInput(_))
1413 ));
1414 }
1415
1416 #[test]
1417 fn test_content_too_large() {
1418 let large_content =
1419 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
1420 assert!(matches!(
1421 markdown_to_html(&large_content, None),
1422 Err(HtmlError::InputTooLarge(_))
1423 ));
1424 }
1425 }
1426
1427 mod file_operation_tests {
1428 use super::*;
1429
1430 #[test]
1431 fn test_file_conversion() -> Result<()> {
1432 let temp_dir = setup_test_dir();
1433 let input_path =
1434 create_test_file(&temp_dir, "# Test\n\nHello world");
1435 let output_path = temp_dir.path().join("test.html");
1436
1437 markdown_file_to_html(
1438 Some(&input_path),
1439 Some(OutputDestination::File(
1440 output_path.to_string_lossy().into(),
1441 )),
1442 None::<MarkdownConfig>,
1443 )?;
1444
1445 let content = std::fs::read_to_string(output_path)?;
1446 assert!(content.contains("<h1>Test</h1>"));
1447
1448 Ok(())
1449 }
1450
1451 #[test]
1452 fn test_writer_output() {
1453 let temp_dir = setup_test_dir();
1454 let input_path =
1455 create_test_file(&temp_dir, "# Test\nHello");
1456 let buffer = Box::new(Cursor::new(Vec::new()));
1457
1458 let result = markdown_file_to_html(
1459 Some(&input_path),
1460 Some(OutputDestination::Writer(buffer)),
1461 None,
1462 );
1463
1464 assert!(result.is_ok());
1465 }
1466
1467 #[test]
1468 fn test_writer_output_no_input() {
1469 let buffer = Box::new(Cursor::new(Vec::new()));
1470
1471 let result = markdown_file_to_html(
1472 Some(Path::new("nonexistent.md")),
1473 Some(OutputDestination::Writer(buffer)),
1474 None,
1475 );
1476
1477 assert!(result.is_err());
1478 }
1479 }
1480
1481 mod language_validation_tests {
1482 use super::*;
1483
1484 #[test]
1485 fn test_valid_language_codes() {
1486 let valid_codes =
1487 ["en-GB", "fr-FR", "de-DE", "es-ES", "zh-CN"];
1488
1489 for code in valid_codes {
1490 assert!(
1491 validate_language_code(code),
1492 "Language code '{}' should be valid",
1493 code
1494 );
1495 }
1496 }
1497
1498 #[test]
1499 fn test_invalid_language_codes() {
1500 let invalid_codes = [
1501 "", "en", "eng-GBR", "en_GB", "123-45", "GB-en", "en-gb", ];
1509
1510 for code in invalid_codes {
1511 assert!(
1512 !validate_language_code(code),
1513 "Language code '{}' should be invalid",
1514 code
1515 );
1516 }
1517 }
1518 }
1519
1520 mod integration_tests {
1521 use super::*;
1522
1523 #[test]
1524 fn test_end_to_end_conversion() -> Result<()> {
1525 let temp_dir = setup_test_dir();
1526 let content = r#"---
1527title: Test Document
1528---
1529
1530# Hello World
1531
1532This is a test document with:
1533- A list
1534- And some **bold** text
1535"#;
1536 let input_path = create_test_file(&temp_dir, content);
1537 let output_path = temp_dir.path().join("test.html");
1538
1539 let config = MarkdownConfig {
1540 html_config: HtmlConfig {
1541 enable_syntax_highlighting: true,
1542 generate_toc: true,
1543 ..Default::default()
1544 },
1545 ..Default::default()
1546 };
1547
1548 markdown_file_to_html(
1549 Some(&input_path),
1550 Some(OutputDestination::File(
1551 output_path.to_string_lossy().into(),
1552 )),
1553 Some(config),
1554 )?;
1555
1556 let html = std::fs::read_to_string(&output_path)?;
1557 assert!(html.contains("<h1>Hello World</h1>"));
1558 assert!(html.contains("<strong>bold</strong>"));
1559 assert!(html.contains("<ul>"));
1560
1561 Ok(())
1562 }
1563
1564 #[test]
1565 fn test_output_destination_debug() {
1566 assert_eq!(
1567 format!(
1568 "{:?}",
1569 OutputDestination::File("test.html".to_string())
1570 ),
1571 r#"File("test.html")"#
1572 );
1573 assert_eq!(
1574 format!("{:?}", OutputDestination::Stdout),
1575 "Stdout"
1576 );
1577
1578 let writer = Box::new(Cursor::new(Vec::new()));
1579 assert_eq!(
1580 format!("{:?}", OutputDestination::Writer(writer)),
1581 "Writer(<dyn Write>)"
1582 );
1583 }
1584 }
1585
1586 mod markdown_config_tests {
1587 use super::*;
1588
1589 #[test]
1590 fn test_markdown_config_custom_encoding() {
1591 let config = MarkdownConfig {
1592 encoding: "latin1".to_string(),
1593 html_config: HtmlConfig::default(),
1594 };
1595 assert_eq!(config.encoding, "latin1");
1596 }
1597
1598 #[test]
1599 fn test_markdown_config_default() {
1600 let config = MarkdownConfig::default();
1601 assert_eq!(config.encoding, "utf-8");
1602 assert_eq!(config.html_config, HtmlConfig::default());
1603 }
1604
1605 #[test]
1606 fn test_markdown_config_clone() {
1607 let config = MarkdownConfig::default();
1608 let cloned = config.clone();
1609 assert_eq!(config, cloned);
1610 }
1611 }
1612
1613 mod config_error_tests {
1614 use super::*;
1615
1616 #[test]
1617 fn test_config_error_display() {
1618 let error = ConfigError::InvalidInputSize(100, 1024);
1619 assert!(error.to_string().contains("Invalid input size"));
1620
1621 let error =
1622 ConfigError::InvalidLanguageCode("xx".to_string());
1623 assert!(error
1624 .to_string()
1625 .contains("Invalid language code"));
1626
1627 let error =
1628 ConfigError::InvalidFilePath("../bad/path".to_string());
1629 assert!(error.to_string().contains("Invalid file path"));
1630 }
1631 }
1632
1633 mod output_destination_tests {
1634 use super::*;
1635
1636 #[test]
1637 fn test_output_destination_default() {
1638 assert!(matches!(
1639 OutputDestination::default(),
1640 OutputDestination::Stdout
1641 ));
1642 }
1643
1644 #[test]
1645 fn test_output_destination_file() {
1646 let dest = OutputDestination::File("test.html".to_string());
1647 assert!(matches!(dest, OutputDestination::File(_)));
1648 }
1649
1650 #[test]
1651 fn test_output_destination_writer() {
1652 let writer = Box::new(Cursor::new(Vec::new()));
1653 let dest = OutputDestination::Writer(writer);
1654 assert!(matches!(dest, OutputDestination::Writer(_)));
1655 }
1656 }
1657
1658 mod html_config_tests {
1659 use super::*;
1660
1661 #[test]
1662 fn test_html_config_builder_all_options() {
1663 let config = HtmlConfig::builder()
1664 .with_syntax_highlighting(
1665 true,
1666 Some("dracula".to_string()),
1667 )
1668 .with_language("en-US")
1669 .build()
1670 .unwrap();
1671
1672 assert!(config.enable_syntax_highlighting);
1673 assert_eq!(
1674 config.syntax_theme,
1675 Some("dracula".to_string())
1676 );
1677 assert_eq!(config.language, "en-US");
1678 }
1679
1680 #[test]
1681 fn test_html_config_validation_edge_cases() {
1682 let config = HtmlConfig {
1683 max_input_size: constants::MIN_INPUT_SIZE,
1684 ..Default::default()
1685 };
1686 assert!(config.validate().is_ok());
1687
1688 let config = HtmlConfig {
1689 max_input_size: constants::MIN_INPUT_SIZE - 1,
1690 ..Default::default()
1691 };
1692 assert!(config.validate().is_err());
1693 }
1694 }
1695
1696 mod markdown_processing_tests {
1697 use super::*;
1698
1699 #[test]
1700 fn test_markdown_to_html_with_front_matter() -> Result<()> {
1701 let markdown = r#"---
1702title: Test
1703author: Test Author
1704---
1705# Heading
1706Content"#;
1707 let html = markdown_to_html(markdown, None)?;
1708 assert!(html.contains("<h1>Heading</h1>"));
1709 assert!(html.contains("<p>Content</p>"));
1710 Ok(())
1711 }
1712
1713 #[test]
1714 fn test_markdown_to_html_with_code_blocks() -> Result<()> {
1715 let markdown = r#"```rust
1716fn main() {
1717 println!("Hello");
1718}
1719```"#;
1720 let config = MarkdownConfig {
1721 html_config: HtmlConfig {
1722 enable_syntax_highlighting: true,
1723 ..Default::default()
1724 },
1725 ..Default::default()
1726 };
1727 let html = markdown_to_html(markdown, Some(config))?;
1728 assert!(html.contains("language-rust"));
1729 Ok(())
1730 }
1731
1732 #[test]
1733 fn test_markdown_to_html_with_tables() -> Result<()> {
1734 let markdown = r#"
1735| Header 1 | Header 2 |
1736|----------|----------|
1737| Cell 1 | Cell 2 |
1738"#;
1739 let html = markdown_to_html(markdown, None)?;
1740 println!("Generated HTML for table: {}", html);
1742 assert!(html.contains("Header 1"));
1744 assert!(html.contains("Cell 1"));
1745 assert!(html.contains("Cell 2"));
1746 Ok(())
1747 }
1748
1749 #[test]
1750 fn test_invalid_encoding_handling() {
1751 let config = MarkdownConfig {
1752 encoding: "unsupported-encoding".to_string(),
1753 html_config: HtmlConfig::default(),
1754 };
1755 let result = markdown_to_html("# Test", Some(config));
1757 assert!(result.is_ok()); }
1759
1760 #[test]
1761 fn test_config_error_types() {
1762 let error = ConfigError::InvalidInputSize(512, 1024);
1763 assert_eq!(format!("{}", error), "Invalid input size: 512 bytes is below minimum of 1024 bytes");
1764 }
1765 }
1766
1767 mod file_processing_tests {
1768 use crate::constants;
1769 use crate::HtmlConfig;
1770 use crate::{
1771 markdown_file_to_html, HtmlError, OutputDestination,
1772 };
1773 use std::io::Cursor;
1774 use std::path::Path;
1775 use tempfile::NamedTempFile;
1776
1777 #[test]
1778 fn test_display_file() {
1779 let output =
1780 OutputDestination::File("output.html".to_string());
1781 let display = format!("{}", output);
1782 assert_eq!(display, "File(output.html)");
1783 }
1784
1785 #[test]
1786 fn test_display_stdout() {
1787 let output = OutputDestination::Stdout;
1788 let display = format!("{}", output);
1789 assert_eq!(display, "Stdout");
1790 }
1791
1792 #[test]
1793 fn test_display_writer() {
1794 let buffer = Cursor::new(Vec::new());
1795 let output = OutputDestination::Writer(Box::new(buffer));
1796 let display = format!("{}", output);
1797 assert_eq!(display, "Writer(<dyn Write>)");
1798 }
1799
1800 #[test]
1801 fn test_debug_file() {
1802 let output =
1803 OutputDestination::File("output.html".to_string());
1804 let debug = format!("{:?}", output);
1805 assert_eq!(debug, r#"File("output.html")"#);
1806 }
1807
1808 #[test]
1809 fn test_debug_stdout() {
1810 let output = OutputDestination::Stdout;
1811 let debug = format!("{:?}", output);
1812 assert_eq!(debug, "Stdout");
1813 }
1814
1815 #[test]
1816 fn test_debug_writer() {
1817 let buffer = Cursor::new(Vec::new());
1818 let output = OutputDestination::Writer(Box::new(buffer));
1819 let debug = format!("{:?}", output);
1820 assert_eq!(debug, "Writer(<dyn Write>)");
1821 }
1822
1823 #[test]
1824 fn test_file_to_html_invalid_input() {
1825 let result = markdown_file_to_html(
1826 Some(Path::new("nonexistent.md")),
1827 None,
1828 None,
1829 );
1830 assert!(matches!(result, Err(HtmlError::Io(_))));
1831 }
1832
1833 #[test]
1834 fn test_file_to_html_with_invalid_output_path(
1835 ) -> Result<(), HtmlError> {
1836 let input = NamedTempFile::new()?;
1837 std::fs::write(&input, "# Test")?;
1838
1839 let result = markdown_file_to_html(
1840 Some(input.path()),
1841 Some(OutputDestination::File(
1842 "/invalid/path/test.html".to_string(),
1843 )),
1844 None,
1845 );
1846 assert!(result.is_err());
1847 Ok(())
1848 }
1849
1850 #[test]
1852 fn test_output_destination_default() {
1853 let default = OutputDestination::default();
1854 assert!(matches!(default, OutputDestination::Stdout));
1855 }
1856
1857 #[test]
1859 fn test_output_destination_debug() {
1860 let file_debug = format!(
1861 "{:?}",
1862 OutputDestination::File(
1863 "path/to/file.html".to_string()
1864 )
1865 );
1866 assert_eq!(file_debug, r#"File("path/to/file.html")"#);
1867
1868 let writer_debug = format!(
1869 "{:?}",
1870 OutputDestination::Writer(Box::new(Cursor::new(
1871 Vec::new()
1872 )))
1873 );
1874 assert_eq!(writer_debug, "Writer(<dyn Write>)");
1875
1876 let stdout_debug =
1877 format!("{:?}", OutputDestination::Stdout);
1878 assert_eq!(stdout_debug, "Stdout");
1879 }
1880
1881 #[test]
1883 fn test_output_destination_display() {
1884 let file_display = format!(
1885 "{}",
1886 OutputDestination::File(
1887 "path/to/file.html".to_string()
1888 )
1889 );
1890 assert_eq!(file_display, "File(path/to/file.html)");
1891
1892 let writer_display = format!(
1893 "{}",
1894 OutputDestination::Writer(Box::new(Cursor::new(
1895 Vec::new()
1896 )))
1897 );
1898 assert_eq!(writer_display, "Writer(<dyn Write>)");
1899
1900 let stdout_display =
1901 format!("{}", OutputDestination::Stdout);
1902 assert_eq!(stdout_display, "Stdout");
1903 }
1904
1905 #[test]
1907 fn test_html_config_default() {
1908 let default = HtmlConfig::default();
1909 assert!(default.enable_syntax_highlighting);
1910 assert_eq!(
1911 default.syntax_theme,
1912 Some(constants::DEFAULT_SYNTAX_THEME.to_string())
1913 );
1914 assert!(!default.minify_output);
1915 assert!(default.add_aria_attributes);
1916 assert!(!default.generate_structured_data);
1917 assert_eq!(
1918 default.max_input_size,
1919 constants::DEFAULT_MAX_INPUT_SIZE
1920 );
1921 assert_eq!(
1922 default.language,
1923 constants::DEFAULT_LANGUAGE.to_string()
1924 );
1925 assert!(!default.generate_toc);
1926 }
1927
1928 #[test]
1930 fn test_html_config_builder() {
1931 let builder = HtmlConfig::builder()
1932 .with_syntax_highlighting(
1933 true,
1934 Some("monokai".to_string()),
1935 )
1936 .with_language("en-US")
1937 .build()
1938 .unwrap();
1939
1940 assert!(builder.enable_syntax_highlighting);
1941 assert_eq!(
1942 builder.syntax_theme,
1943 Some("monokai".to_string())
1944 );
1945 assert_eq!(builder.language, "en-US");
1946 }
1947
1948 #[test]
1950 fn test_long_file_path_validation() {
1951 let long_path = "a".repeat(constants::MAX_PATH_LENGTH + 1);
1952 let result = HtmlConfig::validate_file_path(long_path);
1953 assert!(
1954 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("File path exceeds maximum length"))
1955 );
1956 }
1957
1958 #[test]
1963 fn test_absolute_path_is_accepted() {
1964 let result = HtmlConfig::validate_file_path(
1965 "/absolute/path/to/file.md",
1966 );
1967 assert!(
1968 result.is_ok(),
1969 "absolute paths must be accepted, got {result:?}"
1970 );
1971 }
1972
1973 #[test]
1976 fn test_nul_byte_path_is_rejected() {
1977 let result = HtmlConfig::validate_file_path("safe.md\0bad");
1978 assert!(
1979 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("NUL")),
1980 "NUL byte in path must be rejected, got {result:?}"
1981 );
1982 }
1983 }
1984
1985 mod language_validation_extended_tests {
1986 use super::*;
1987
1988 #[test]
1989 fn test_language_code_edge_cases() {
1990 assert!(!validate_language_code(""));
1992
1993 assert!(!validate_language_code("a"));
1995
1996 assert!(!validate_language_code("EN-GB"));
1998 assert!(!validate_language_code("en-gb"));
1999
2000 assert!(!validate_language_code("en_GB"));
2002 assert!(!validate_language_code("en GB"));
2003
2004 assert!(!validate_language_code("en-GB-extra"));
2006 }
2007
2008 #[test]
2009 fn test_language_code_special_cases() {
2010 assert!(!validate_language_code("e1-GB"));
2012 assert!(!validate_language_code("en-G1"));
2013
2014 assert!(!validate_language_code("en-GB!"));
2016 assert!(!validate_language_code("en@GB"));
2017
2018 assert!(!validate_language_code("あa-GB"));
2020 assert!(!validate_language_code("en-あa"));
2021 }
2022 }
2023
2024 mod integration_extended_tests {
2025 use super::*;
2026
2027 #[test]
2028 fn test_full_conversion_pipeline() -> Result<()> {
2029 let temp_dir = tempdir()?;
2031 let input_path = temp_dir.path().join("test.md");
2032 let output_path = temp_dir.path().join("test.html");
2033
2034 let content = r#"---
2036title: Test Document
2037author: Test Author
2038---
2039
2040# Main Heading
2041
2042## Subheading
2043
2044This is a paragraph with *italic* and **bold** text.
2045
2046- List item 1
2047- List item 2
2048 - Nested item
2049 - Another nested item
2050
2051```rust
2052fn main() {
2053 println!("Hello, world!");
2054}
2055```
2056
2057| Column 1 | Column 2 |
2058|----------|----------|
2059| Cell 1 | Cell 2 |
2060
2061> This is a blockquote
2062
2063[Link text](https://example.com)"#;
2064
2065 std::fs::write(&input_path, content)?;
2066
2067 let config = MarkdownConfig {
2069 html_config: HtmlConfig {
2070 enable_syntax_highlighting: true,
2071 generate_toc: true,
2072 add_aria_attributes: true,
2073 generate_structured_data: true,
2074 minify_output: true,
2075 ..Default::default()
2076 },
2077 ..Default::default()
2078 };
2079
2080 markdown_file_to_html(
2081 Some(&input_path),
2082 Some(OutputDestination::File(
2083 output_path.to_string_lossy().into(),
2084 )),
2085 Some(config),
2086 )?;
2087
2088 let html = std::fs::read_to_string(&output_path)?;
2089
2090 println!("Generated HTML: {}", html);
2092 assert!(html.contains("<h1>"));
2093 assert!(html.contains("<h2>"));
2094 assert!(html.contains("<em>"));
2095 assert!(html.contains("<strong>"));
2096 assert!(html.contains("<ul>"));
2097 assert!(html.contains("<li>"));
2098 assert!(html.contains("language-rust"));
2099
2100 assert!(html.contains("Column 1"));
2102 assert!(html.contains("Column 2"));
2103 assert!(html.contains("Cell 1"));
2104 assert!(html.contains("Cell 2"));
2105
2106 assert!(html.contains("<blockquote>"));
2107 assert!(html.contains("<a "));
2111 assert!(html.contains("https://example.com"));
2112
2113 Ok(())
2114 }
2115
2116 #[test]
2117 fn test_missing_html_config_fallback() {
2118 let config = MarkdownConfig {
2119 encoding: "utf-8".to_string(),
2120 html_config: HtmlConfig {
2121 enable_syntax_highlighting: false,
2122 syntax_theme: None,
2123 ..Default::default()
2124 },
2125 };
2126 let result = markdown_to_html("# Test", Some(config));
2127 assert!(result.is_ok());
2128 }
2129
2130 #[test]
2131 fn test_invalid_output_destination() {
2132 let result = markdown_file_to_html(
2133 Some(Path::new("test.md")),
2134 Some(OutputDestination::File(
2135 "/root/forbidden.html".to_string(),
2136 )),
2137 None,
2138 );
2139 assert!(result.is_err());
2140 }
2141 }
2142
2143 mod performance_tests {
2144 use super::*;
2145 use std::time::Instant;
2146
2147 #[test]
2148 fn test_large_document_performance() -> Result<()> {
2149 let base_content =
2150 "# Heading\n\nParagraph\n\n- List item\n\n";
2151 let large_content = base_content.repeat(1000);
2152
2153 let start = Instant::now();
2154 let html = markdown_to_html(&large_content, None)?;
2155 let duration = start.elapsed();
2156
2157 println!("Large document conversion took: {:?}", duration);
2159 println!("Input size: {} bytes", large_content.len());
2160 println!("Output size: {} bytes", html.len());
2161
2162 assert!(html.contains("<h1>"));
2164 assert!(html.contains("<p>"));
2165 assert!(html.contains("<ul>"));
2166
2167 Ok(())
2168 }
2169 }
2170}