1#![doc = include_str!("../README.md")]
5#![doc(
6 html_favicon_url = "https://kura.pro/html-generator/images/favicon.ico",
7 html_logo_url = "https://kura.pro/html-generator/images/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 emojis;
26pub mod error;
27pub mod generator;
28pub mod performance;
29pub mod seo;
30pub mod utils;
31
32pub use crate::error::HtmlError;
34pub use accessibility::{add_aria_attributes, validate_wcag};
35pub use emojis::load_emoji_sequences;
36pub use generator::generate_html;
37pub use performance::{async_generate_html, minify_html};
38pub use seo::{generate_meta_tags, generate_structured_data};
39pub use utils::{extract_front_matter, format_header_with_id_class};
40
41pub mod constants {
46 pub const DEFAULT_MAX_INPUT_SIZE: usize = 5 * 1024 * 1024;
48
49 pub const MIN_INPUT_SIZE: usize = 1024;
51
52 pub const DEFAULT_LANGUAGE: &str = "en-GB";
54
55 pub const DEFAULT_SYNTAX_THEME: &str = "github";
57
58 pub const MAX_PATH_LENGTH: usize = 4096;
60
61 pub const LANGUAGE_CODE_PATTERN: &str = r"^[a-z]{2}-[A-Z]{2}$";
63
64 const _: () = assert!(MIN_INPUT_SIZE <= DEFAULT_MAX_INPUT_SIZE);
66 const _: () = assert!(MAX_PATH_LENGTH > 0);
67}
68
69pub type Result<T> = std::result::Result<T, HtmlError>;
71
72#[derive(Debug, Clone, Eq, PartialEq)]
77pub struct MarkdownConfig {
78 pub encoding: String,
80
81 pub html_config: HtmlConfig,
83}
84
85impl Default for MarkdownConfig {
86 fn default() -> Self {
87 Self {
88 encoding: String::from("utf-8"),
89 html_config: HtmlConfig::default(),
90 }
91 }
92}
93
94#[derive(Debug, thiserror::Error)]
96#[non_exhaustive]
97pub enum ConfigError {
98 #[error(
100 "Invalid input size: {0} bytes is below minimum of {1} bytes"
101 )]
102 InvalidInputSize(usize, usize),
103
104 #[error("Invalid language code: {0}")]
106 InvalidLanguageCode(String),
107
108 #[error("Invalid file path: {0}")]
110 InvalidFilePath(String),
111}
112
113#[non_exhaustive]
143pub enum OutputDestination {
144 File(String),
154
155 Writer(Box<dyn Write>),
170
171 Stdout,
183}
184
185impl Default for OutputDestination {
187 fn default() -> Self {
188 Self::Stdout
189 }
190}
191
192impl fmt::Debug for OutputDestination {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 match self {
196 Self::File(path) => {
197 f.debug_tuple("File").field(path).finish()
198 }
199 Self::Writer(_) => write!(f, "Writer(<dyn Write>)"),
200 Self::Stdout => write!(f, "Stdout"),
201 }
202 }
203}
204
205impl fmt::Display for OutputDestination {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 OutputDestination::File(path) => {
210 write!(f, "File({})", path)
211 }
212 OutputDestination::Writer(_) => {
213 write!(f, "Writer(<dyn Write>)")
214 }
215 OutputDestination::Stdout => write!(f, "Stdout"),
216 }
217 }
218}
219
220#[derive(Debug, PartialEq, Eq, Clone)]
225pub struct HtmlConfig {
226 pub enable_syntax_highlighting: bool,
228
229 pub syntax_theme: Option<String>,
231
232 pub minify_output: bool,
234
235 pub add_aria_attributes: bool,
237
238 pub generate_structured_data: bool,
240
241 pub max_input_size: usize,
243
244 pub language: String,
246
247 pub generate_toc: bool,
249}
250
251impl Default for HtmlConfig {
252 fn default() -> Self {
253 Self {
254 enable_syntax_highlighting: true,
255 syntax_theme: Some("github".to_string()),
256 minify_output: false,
257 add_aria_attributes: true,
258 generate_structured_data: false,
259 max_input_size: constants::DEFAULT_MAX_INPUT_SIZE,
260 language: String::from(constants::DEFAULT_LANGUAGE),
261 generate_toc: false,
262 }
263 }
264}
265
266impl HtmlConfig {
267 pub fn builder() -> HtmlConfigBuilder {
281 HtmlConfigBuilder::default()
282 }
283
284 pub fn validate(&self) -> Result<()> {
294 if self.max_input_size < constants::MIN_INPUT_SIZE {
295 return Err(HtmlError::InvalidInput(format!(
296 "Input size must be at least {} bytes",
297 constants::MIN_INPUT_SIZE
298 )));
299 }
300 if !validate_language_code(&self.language) {
301 return Err(HtmlError::InvalidInput(format!(
302 "Invalid language code: {}",
303 self.language
304 )));
305 }
306 Ok(())
307 }
308
309 pub(crate) fn validate_file_path(
320 path: impl AsRef<Path>,
321 ) -> Result<()> {
322 let path = path.as_ref();
323
324 if path.to_string_lossy().is_empty() {
325 return Err(HtmlError::InvalidInput(
326 "File path cannot be empty".to_string(),
327 ));
328 }
329
330 if path.to_string_lossy().len() > constants::MAX_PATH_LENGTH {
331 return Err(HtmlError::InvalidInput(format!(
332 "File path exceeds maximum length of {} characters",
333 constants::MAX_PATH_LENGTH
334 )));
335 }
336
337 if path.components().any(|c| matches!(c, Component::ParentDir))
338 {
339 return Err(HtmlError::InvalidInput(
340 "Directory traversal is not allowed in file paths"
341 .to_string(),
342 ));
343 }
344
345 #[cfg(not(test))]
346 if path.is_absolute() {
347 return Err(HtmlError::InvalidInput(
348 "Only relative file paths are allowed".to_string(),
349 ));
350 }
351
352 if let Some(ext) = path.extension() {
353 if !matches!(ext.to_string_lossy().as_ref(), "md" | "html")
354 {
355 return Err(HtmlError::InvalidInput(
356 "Invalid file extension: only .md and .html files are allowed".to_string(),
357 ));
358 }
359 }
360
361 Ok(())
362 }
363}
364
365#[derive(Debug, Default)]
370pub struct HtmlConfigBuilder {
371 config: HtmlConfig,
372}
373
374impl HtmlConfigBuilder {
375 pub fn new() -> Self {
377 Self::default()
378 }
379
380 #[must_use]
387 pub fn with_syntax_highlighting(
388 mut self,
389 enable: bool,
390 theme: Option<String>,
391 ) -> Self {
392 self.config.enable_syntax_highlighting = enable;
393 self.config.syntax_theme = if enable {
394 theme.or_else(|| Some("github".to_string()))
395 } else {
396 None
397 };
398 self
399 }
400
401 #[must_use]
407 pub fn with_language(
408 mut self,
409 language: impl Into<String>,
410 ) -> Self {
411 self.config.language = language.into();
412 self
413 }
414
415 pub fn build(self) -> Result<HtmlConfig> {
421 self.config.validate()?;
422 Ok(self.config)
423 }
424}
425
426pub fn markdown_to_html(
459 content: &str,
460 config: Option<MarkdownConfig>,
461) -> Result<String> {
462 let config = config.unwrap_or_default();
463
464 if content.is_empty() {
465 return Err(HtmlError::InvalidInput(
466 "Input content is empty".to_string(),
467 ));
468 }
469
470 if content.len() > config.html_config.max_input_size {
471 return Err(HtmlError::InputTooLarge(content.len()));
472 }
473
474 generate_html(content, &config.html_config)
475}
476
477#[inline]
522pub fn markdown_file_to_html(
523 input: Option<impl AsRef<Path>>,
524 output: Option<OutputDestination>,
525 config: Option<MarkdownConfig>,
526) -> Result<()> {
527 let config = config.unwrap_or_default();
528 let output = output.unwrap_or_default();
529
530 validate_paths(&input, &output)?;
532
533 let content = read_input(input)?;
535
536 let html = markdown_to_html(&content, Some(config))?;
538
539 write_output(output, html.as_bytes())
541}
542
543fn validate_paths(
545 input: &Option<impl AsRef<Path>>,
546 output: &OutputDestination,
547) -> Result<()> {
548 if let Some(path) = input.as_ref() {
549 HtmlConfig::validate_file_path(path)?;
550 }
551 if let OutputDestination::File(ref path) = output {
552 HtmlConfig::validate_file_path(path)?;
553 }
554 Ok(())
555}
556
557fn read_input(input: Option<impl AsRef<Path>>) -> Result<String> {
559 match input {
560 Some(path) => {
561 let file = File::open(path).map_err(HtmlError::Io)?;
562 let mut reader =
563 BufReader::with_capacity(MAX_BUFFER_SIZE, file);
564 let mut content = String::with_capacity(MAX_BUFFER_SIZE);
565 let _ =
566 reader.read_to_string(&mut content).map_err(|e| {
567 HtmlError::Io(io::Error::new(
568 e.kind(),
569 format!("Failed to read input: {}", e),
570 ))
571 })?;
572 Ok(content)
573 }
574 None => {
575 let stdin = io::stdin();
576 let mut reader =
577 BufReader::with_capacity(MAX_BUFFER_SIZE, stdin.lock());
578 let mut content = String::with_capacity(MAX_BUFFER_SIZE);
579 let _ =
580 reader.read_to_string(&mut content).map_err(|e| {
581 HtmlError::Io(io::Error::new(
582 e.kind(),
583 format!("Failed to read from stdin: {}", e),
584 ))
585 })?;
586 Ok(content)
587 }
588 }
589}
590
591fn write_output(
593 output: OutputDestination,
594 content: &[u8],
595) -> Result<()> {
596 match output {
597 OutputDestination::File(path) => {
598 let file = File::create(&path).map_err(|e| {
599 HtmlError::Io(io::Error::new(
600 e.kind(),
601 format!("Failed to create file '{}': {}", path, e),
602 ))
603 })?;
604 let mut writer = BufWriter::new(file);
605 writer.write_all(content).map_err(|e| {
606 HtmlError::Io(io::Error::new(
607 e.kind(),
608 format!(
609 "Failed to write to file '{}': {}",
610 path, e
611 ),
612 ))
613 })?;
614 writer.flush().map_err(|e| {
615 HtmlError::Io(io::Error::new(
616 e.kind(),
617 format!(
618 "Failed to flush output to file '{}': {}",
619 path, e
620 ),
621 ))
622 })?;
623 }
624 OutputDestination::Writer(mut writer) => {
625 let mut buffered = BufWriter::new(&mut writer);
626 buffered.write_all(content).map_err(|e| {
627 HtmlError::Io(io::Error::new(
628 e.kind(),
629 format!("Failed to write to output: {}", e),
630 ))
631 })?;
632 buffered.flush().map_err(|e| {
633 HtmlError::Io(io::Error::new(
634 e.kind(),
635 format!("Failed to flush output: {}", e),
636 ))
637 })?;
638 }
639 OutputDestination::Stdout => {
640 let stdout = io::stdout();
641 let mut writer = BufWriter::new(stdout.lock());
642 writer.write_all(content).map_err(|e| {
643 HtmlError::Io(io::Error::new(
644 e.kind(),
645 format!("Failed to write to stdout: {}", e),
646 ))
647 })?;
648 writer.flush().map_err(|e| {
649 HtmlError::Io(io::Error::new(
650 e.kind(),
651 format!("Failed to flush stdout: {}", e),
652 ))
653 })?;
654 }
655 }
656 Ok(())
657}
658
659pub fn validate_language_code(lang: &str) -> bool {
683 use once_cell::sync::Lazy;
684 use regex::Regex;
685
686 static LANG_REGEX: Lazy<Regex> = Lazy::new(|| {
688 Regex::new(r"^[a-z]{2}(?:-[A-Z]{2})$")
689 .expect("Failed to compile language code regex")
690 });
691
692 LANG_REGEX.is_match(lang)
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use regex::Regex;
700 use std::io::Cursor;
701 use tempfile::{tempdir, TempDir};
702
703 fn setup_test_dir() -> TempDir {
708 tempdir().expect("Failed to create temporary directory")
709 }
710
711 fn create_test_file(
722 dir: &TempDir,
723 content: &str,
724 ) -> std::path::PathBuf {
725 let path = dir.path().join("test.md");
726 std::fs::write(&path, content)
727 .expect("Failed to write test file");
728 path
729 }
730
731 mod config_tests {
732 use super::*;
733
734 #[test]
735 fn test_config_validation() {
736 let config = HtmlConfig {
738 max_input_size: 100, ..Default::default()
740 };
741 assert!(config.validate().is_err());
742
743 let config = HtmlConfig {
745 language: "invalid".to_string(),
746 ..Default::default()
747 };
748 assert!(config.validate().is_err());
749
750 let config = HtmlConfig::default();
752 assert!(config.validate().is_ok());
753 }
754
755 #[test]
756 fn test_config_builder() {
757 let result = HtmlConfigBuilder::new()
758 .with_syntax_highlighting(
759 true,
760 Some("monokai".to_string()),
761 )
762 .with_language("en-GB")
763 .build();
764
765 assert!(result.is_ok());
766 let config = result.unwrap();
767 assert!(config.enable_syntax_highlighting);
768 assert_eq!(
769 config.syntax_theme,
770 Some("monokai".to_string())
771 );
772 assert_eq!(config.language, "en-GB");
773 }
774
775 #[test]
776 fn test_config_builder_invalid() {
777 let result = HtmlConfigBuilder::new()
778 .with_language("invalid")
779 .build();
780
781 assert!(matches!(
782 result,
783 Err(HtmlError::InvalidInput(msg)) if msg.contains("Invalid language code")
784 ));
785 }
786
787 #[test]
788 fn test_html_config_with_no_syntax_theme() {
789 let config = HtmlConfig {
790 enable_syntax_highlighting: true,
791 syntax_theme: None,
792 ..Default::default()
793 };
794
795 assert!(config.validate().is_ok());
796 }
797
798 #[test]
799 fn test_file_conversion_with_large_output() -> Result<()> {
800 let temp_dir = setup_test_dir();
801 let input_path = create_test_file(
802 &temp_dir,
803 "# Large\n\nContent".repeat(10_000).as_str(),
804 );
805 let output_path = temp_dir.path().join("large_output.html");
806
807 let result = markdown_file_to_html(
808 Some(&input_path),
809 Some(OutputDestination::File(
810 output_path.to_string_lossy().into(),
811 )),
812 None,
813 );
814
815 assert!(result.is_ok());
816 let content = std::fs::read_to_string(output_path)?;
817 assert!(content.contains("<h1>Large</h1>"));
818
819 Ok(())
820 }
821
822 #[test]
823 fn test_markdown_with_broken_syntax() {
824 let markdown = "# Unmatched Header\n**Bold start";
825 let result = markdown_to_html(markdown, None);
826 assert!(result.is_ok());
827 let html = result.unwrap();
828 assert!(html.contains("<h1>Unmatched Header</h1>"));
829 assert!(html.contains("**Bold start</p>")); }
831
832 #[test]
833 fn test_language_code_with_custom_regex() {
834 let custom_lang_regex =
835 Regex::new(r"^[a-z]{2}-[A-Z]{2}$").unwrap();
836 assert!(custom_lang_regex.is_match("en-GB"));
837 assert!(!custom_lang_regex.is_match("EN-gb")); }
839
840 #[test]
841 fn test_markdown_to_html_error_handling() {
842 let result = markdown_to_html("", None);
843 assert!(matches!(result, Err(HtmlError::InvalidInput(_))));
844
845 let oversized_input =
846 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
847 let result = markdown_to_html(&oversized_input, None);
848 assert!(matches!(result, Err(HtmlError::InputTooLarge(_))));
849 }
850
851 #[test]
852 fn test_performance_with_nested_lists() {
853 let nested_list = "- Item\n".repeat(1000);
854 let result = markdown_to_html(&nested_list, None);
855 assert!(result.is_ok());
856 let html = result.unwrap();
857 assert!(html.matches("<li>").count() == 1000);
858 }
859 }
860
861 mod file_validation_tests {
862 use super::*;
863 use std::path::PathBuf;
864
865 #[test]
866 fn test_valid_paths() {
867 let valid_paths = [
868 PathBuf::from("test.md"),
869 PathBuf::from("test.html"),
870 PathBuf::from("subfolder/test.md"),
871 ];
872
873 for path in valid_paths {
874 assert!(
875 HtmlConfig::validate_file_path(&path).is_ok(),
876 "Path should be valid: {:?}",
877 path
878 );
879 }
880 }
881
882 #[test]
883 fn test_invalid_paths() {
884 let invalid_paths = [
885 PathBuf::from(""), PathBuf::from("../test.md"), PathBuf::from("test.exe"), PathBuf::from(
889 "a".repeat(constants::MAX_PATH_LENGTH + 1),
890 ), ];
892
893 for path in invalid_paths {
894 assert!(
895 HtmlConfig::validate_file_path(&path).is_err(),
896 "Path should be invalid: {:?}",
897 path
898 );
899 }
900 }
901 }
902
903 mod markdown_conversion_tests {
904 use super::*;
905
906 #[test]
907 fn test_basic_conversion() {
908 let markdown = "# Test\n\nHello world";
909 let result = markdown_to_html(markdown, None);
910 assert!(result.is_ok());
911
912 let html = result.unwrap();
913 assert!(html.contains("<h1>Test</h1>"));
914 assert!(html.contains("<p>Hello world</p>"));
915 }
916
917 #[test]
918 fn test_conversion_with_config() {
919 let markdown = "# Test\n```rust\nfn main() {}\n```";
920 let config = MarkdownConfig {
921 html_config: HtmlConfig {
922 enable_syntax_highlighting: true,
923 ..Default::default()
924 },
925 ..Default::default()
926 };
927
928 let result = markdown_to_html(markdown, Some(config));
929 assert!(result.is_ok());
930 assert!(result.unwrap().contains("language-rust"));
931 }
932
933 #[test]
934 fn test_empty_content() {
935 assert!(matches!(
936 markdown_to_html("", None),
937 Err(HtmlError::InvalidInput(_))
938 ));
939 }
940
941 #[test]
942 fn test_content_too_large() {
943 let large_content =
944 "a".repeat(constants::DEFAULT_MAX_INPUT_SIZE + 1);
945 assert!(matches!(
946 markdown_to_html(&large_content, None),
947 Err(HtmlError::InputTooLarge(_))
948 ));
949 }
950 }
951
952 mod file_operation_tests {
953 use super::*;
954
955 #[test]
956 fn test_file_conversion() -> Result<()> {
957 let temp_dir = setup_test_dir();
958 let input_path =
959 create_test_file(&temp_dir, "# Test\n\nHello world");
960 let output_path = temp_dir.path().join("test.html");
961
962 markdown_file_to_html(
963 Some(&input_path),
964 Some(OutputDestination::File(
965 output_path.to_string_lossy().into(),
966 )),
967 None::<MarkdownConfig>,
968 )?;
969
970 let content = std::fs::read_to_string(output_path)?;
971 assert!(content.contains("<h1>Test</h1>"));
972
973 Ok(())
974 }
975
976 #[test]
977 fn test_writer_output() {
978 let temp_dir = setup_test_dir();
979 let input_path =
980 create_test_file(&temp_dir, "# Test\nHello");
981 let buffer = Box::new(Cursor::new(Vec::new()));
982
983 let result = markdown_file_to_html(
984 Some(&input_path),
985 Some(OutputDestination::Writer(buffer)),
986 None,
987 );
988
989 assert!(result.is_ok());
990 }
991
992 #[test]
993 fn test_writer_output_no_input() {
994 let buffer = Box::new(Cursor::new(Vec::new()));
995
996 let result = markdown_file_to_html(
997 Some(Path::new("nonexistent.md")),
998 Some(OutputDestination::Writer(buffer)),
999 None,
1000 );
1001
1002 assert!(result.is_err());
1003 }
1004 }
1005
1006 mod language_validation_tests {
1007 use super::*;
1008
1009 #[test]
1010 fn test_valid_language_codes() {
1011 let valid_codes =
1012 ["en-GB", "fr-FR", "de-DE", "es-ES", "zh-CN"];
1013
1014 for code in valid_codes {
1015 assert!(
1016 validate_language_code(code),
1017 "Language code '{}' should be valid",
1018 code
1019 );
1020 }
1021 }
1022
1023 #[test]
1024 fn test_invalid_language_codes() {
1025 let invalid_codes = [
1026 "", "en", "eng-GBR", "en_GB", "123-45", "GB-en", "en-gb", ];
1034
1035 for code in invalid_codes {
1036 assert!(
1037 !validate_language_code(code),
1038 "Language code '{}' should be invalid",
1039 code
1040 );
1041 }
1042 }
1043 }
1044
1045 mod integration_tests {
1046 use super::*;
1047
1048 #[test]
1049 fn test_end_to_end_conversion() -> Result<()> {
1050 let temp_dir = setup_test_dir();
1051 let content = r#"---
1052title: Test Document
1053---
1054
1055# Hello World
1056
1057This is a test document with:
1058- A list
1059- And some **bold** text
1060"#;
1061 let input_path = create_test_file(&temp_dir, content);
1062 let output_path = temp_dir.path().join("test.html");
1063
1064 let config = MarkdownConfig {
1065 html_config: HtmlConfig {
1066 enable_syntax_highlighting: true,
1067 generate_toc: true,
1068 ..Default::default()
1069 },
1070 ..Default::default()
1071 };
1072
1073 markdown_file_to_html(
1074 Some(&input_path),
1075 Some(OutputDestination::File(
1076 output_path.to_string_lossy().into(),
1077 )),
1078 Some(config),
1079 )?;
1080
1081 let html = std::fs::read_to_string(&output_path)?;
1082 assert!(html.contains("<h1>Hello World</h1>"));
1083 assert!(html.contains("<strong>bold</strong>"));
1084 assert!(html.contains("<ul>"));
1085
1086 Ok(())
1087 }
1088
1089 #[test]
1090 fn test_output_destination_debug() {
1091 assert_eq!(
1092 format!(
1093 "{:?}",
1094 OutputDestination::File("test.html".to_string())
1095 ),
1096 r#"File("test.html")"#
1097 );
1098 assert_eq!(
1099 format!("{:?}", OutputDestination::Stdout),
1100 "Stdout"
1101 );
1102
1103 let writer = Box::new(Cursor::new(Vec::new()));
1104 assert_eq!(
1105 format!("{:?}", OutputDestination::Writer(writer)),
1106 "Writer(<dyn Write>)"
1107 );
1108 }
1109 }
1110
1111 mod markdown_config_tests {
1112 use super::*;
1113
1114 #[test]
1115 fn test_markdown_config_custom_encoding() {
1116 let config = MarkdownConfig {
1117 encoding: "latin1".to_string(),
1118 html_config: HtmlConfig::default(),
1119 };
1120 assert_eq!(config.encoding, "latin1");
1121 }
1122
1123 #[test]
1124 fn test_markdown_config_default() {
1125 let config = MarkdownConfig::default();
1126 assert_eq!(config.encoding, "utf-8");
1127 assert_eq!(config.html_config, HtmlConfig::default());
1128 }
1129
1130 #[test]
1131 fn test_markdown_config_clone() {
1132 let config = MarkdownConfig::default();
1133 let cloned = config.clone();
1134 assert_eq!(config, cloned);
1135 }
1136 }
1137
1138 mod config_error_tests {
1139 use super::*;
1140
1141 #[test]
1142 fn test_config_error_display() {
1143 let error = ConfigError::InvalidInputSize(100, 1024);
1144 assert!(error.to_string().contains("Invalid input size"));
1145
1146 let error =
1147 ConfigError::InvalidLanguageCode("xx".to_string());
1148 assert!(error
1149 .to_string()
1150 .contains("Invalid language code"));
1151
1152 let error =
1153 ConfigError::InvalidFilePath("../bad/path".to_string());
1154 assert!(error.to_string().contains("Invalid file path"));
1155 }
1156 }
1157
1158 mod output_destination_tests {
1159 use super::*;
1160
1161 #[test]
1162 fn test_output_destination_default() {
1163 assert!(matches!(
1164 OutputDestination::default(),
1165 OutputDestination::Stdout
1166 ));
1167 }
1168
1169 #[test]
1170 fn test_output_destination_file() {
1171 let dest = OutputDestination::File("test.html".to_string());
1172 assert!(matches!(dest, OutputDestination::File(_)));
1173 }
1174
1175 #[test]
1176 fn test_output_destination_writer() {
1177 let writer = Box::new(Cursor::new(Vec::new()));
1178 let dest = OutputDestination::Writer(writer);
1179 assert!(matches!(dest, OutputDestination::Writer(_)));
1180 }
1181 }
1182
1183 mod html_config_tests {
1184 use super::*;
1185
1186 #[test]
1187 fn test_html_config_builder_all_options() {
1188 let config = HtmlConfig::builder()
1189 .with_syntax_highlighting(
1190 true,
1191 Some("dracula".to_string()),
1192 )
1193 .with_language("en-US")
1194 .build()
1195 .unwrap();
1196
1197 assert!(config.enable_syntax_highlighting);
1198 assert_eq!(
1199 config.syntax_theme,
1200 Some("dracula".to_string())
1201 );
1202 assert_eq!(config.language, "en-US");
1203 }
1204
1205 #[test]
1206 fn test_html_config_validation_edge_cases() {
1207 let config = HtmlConfig {
1208 max_input_size: constants::MIN_INPUT_SIZE,
1209 ..Default::default()
1210 };
1211 assert!(config.validate().is_ok());
1212
1213 let config = HtmlConfig {
1214 max_input_size: constants::MIN_INPUT_SIZE - 1,
1215 ..Default::default()
1216 };
1217 assert!(config.validate().is_err());
1218 }
1219 }
1220
1221 mod markdown_processing_tests {
1222 use super::*;
1223
1224 #[test]
1225 fn test_markdown_to_html_with_front_matter() -> Result<()> {
1226 let markdown = r#"---
1227title: Test
1228author: Test Author
1229---
1230# Heading
1231Content"#;
1232 let html = markdown_to_html(markdown, None)?;
1233 assert!(html.contains("<h1>Heading</h1>"));
1234 assert!(html.contains("<p>Content</p>"));
1235 Ok(())
1236 }
1237
1238 #[test]
1239 fn test_markdown_to_html_with_code_blocks() -> Result<()> {
1240 let markdown = r#"```rust
1241fn main() {
1242 println!("Hello");
1243}
1244```"#;
1245 let config = MarkdownConfig {
1246 html_config: HtmlConfig {
1247 enable_syntax_highlighting: true,
1248 ..Default::default()
1249 },
1250 ..Default::default()
1251 };
1252 let html = markdown_to_html(markdown, Some(config))?;
1253 assert!(html.contains("language-rust"));
1254 Ok(())
1255 }
1256
1257 #[test]
1258 fn test_markdown_to_html_with_tables() -> Result<()> {
1259 let markdown = r#"
1260| Header 1 | Header 2 |
1261|----------|----------|
1262| Cell 1 | Cell 2 |
1263"#;
1264 let html = markdown_to_html(markdown, None)?;
1265 println!("Generated HTML for table: {}", html);
1267 assert!(html.contains("Header 1"));
1269 assert!(html.contains("Cell 1"));
1270 assert!(html.contains("Cell 2"));
1271 Ok(())
1272 }
1273
1274 #[test]
1275 fn test_invalid_encoding_handling() {
1276 let config = MarkdownConfig {
1277 encoding: "unsupported-encoding".to_string(),
1278 html_config: HtmlConfig::default(),
1279 };
1280 let result = markdown_to_html("# Test", Some(config));
1282 assert!(result.is_ok()); }
1284
1285 #[test]
1286 fn test_config_error_types() {
1287 let error = ConfigError::InvalidInputSize(512, 1024);
1288 assert_eq!(format!("{}", error), "Invalid input size: 512 bytes is below minimum of 1024 bytes");
1289 }
1290 }
1291
1292 mod file_processing_tests {
1293 use crate::constants;
1294 use crate::HtmlConfig;
1295 use crate::{
1296 markdown_file_to_html, HtmlError, OutputDestination,
1297 };
1298 use std::io::Cursor;
1299 use std::path::Path;
1300 use tempfile::NamedTempFile;
1301
1302 #[test]
1303 fn test_display_file() {
1304 let output =
1305 OutputDestination::File("output.html".to_string());
1306 let display = format!("{}", output);
1307 assert_eq!(display, "File(output.html)");
1308 }
1309
1310 #[test]
1311 fn test_display_stdout() {
1312 let output = OutputDestination::Stdout;
1313 let display = format!("{}", output);
1314 assert_eq!(display, "Stdout");
1315 }
1316
1317 #[test]
1318 fn test_display_writer() {
1319 let buffer = Cursor::new(Vec::new());
1320 let output = OutputDestination::Writer(Box::new(buffer));
1321 let display = format!("{}", output);
1322 assert_eq!(display, "Writer(<dyn Write>)");
1323 }
1324
1325 #[test]
1326 fn test_debug_file() {
1327 let output =
1328 OutputDestination::File("output.html".to_string());
1329 let debug = format!("{:?}", output);
1330 assert_eq!(debug, r#"File("output.html")"#);
1331 }
1332
1333 #[test]
1334 fn test_debug_stdout() {
1335 let output = OutputDestination::Stdout;
1336 let debug = format!("{:?}", output);
1337 assert_eq!(debug, "Stdout");
1338 }
1339
1340 #[test]
1341 fn test_debug_writer() {
1342 let buffer = Cursor::new(Vec::new());
1343 let output = OutputDestination::Writer(Box::new(buffer));
1344 let debug = format!("{:?}", output);
1345 assert_eq!(debug, "Writer(<dyn Write>)");
1346 }
1347
1348 #[test]
1349 fn test_file_to_html_invalid_input() {
1350 let result = markdown_file_to_html(
1351 Some(Path::new("nonexistent.md")),
1352 None,
1353 None,
1354 );
1355 assert!(matches!(result, Err(HtmlError::Io(_))));
1356 }
1357
1358 #[test]
1359 fn test_file_to_html_with_invalid_output_path(
1360 ) -> Result<(), HtmlError> {
1361 let input = NamedTempFile::new()?;
1362 std::fs::write(&input, "# Test")?;
1363
1364 let result = markdown_file_to_html(
1365 Some(input.path()),
1366 Some(OutputDestination::File(
1367 "/invalid/path/test.html".to_string(),
1368 )),
1369 None,
1370 );
1371 assert!(result.is_err());
1372 Ok(())
1373 }
1374
1375 #[test]
1377 fn test_output_destination_default() {
1378 let default = OutputDestination::default();
1379 assert!(matches!(default, OutputDestination::Stdout));
1380 }
1381
1382 #[test]
1384 fn test_output_destination_debug() {
1385 let file_debug = format!(
1386 "{:?}",
1387 OutputDestination::File(
1388 "path/to/file.html".to_string()
1389 )
1390 );
1391 assert_eq!(file_debug, r#"File("path/to/file.html")"#);
1392
1393 let writer_debug = format!(
1394 "{:?}",
1395 OutputDestination::Writer(Box::new(Cursor::new(
1396 Vec::new()
1397 )))
1398 );
1399 assert_eq!(writer_debug, "Writer(<dyn Write>)");
1400
1401 let stdout_debug =
1402 format!("{:?}", OutputDestination::Stdout);
1403 assert_eq!(stdout_debug, "Stdout");
1404 }
1405
1406 #[test]
1408 fn test_output_destination_display() {
1409 let file_display = format!(
1410 "{}",
1411 OutputDestination::File(
1412 "path/to/file.html".to_string()
1413 )
1414 );
1415 assert_eq!(file_display, "File(path/to/file.html)");
1416
1417 let writer_display = format!(
1418 "{}",
1419 OutputDestination::Writer(Box::new(Cursor::new(
1420 Vec::new()
1421 )))
1422 );
1423 assert_eq!(writer_display, "Writer(<dyn Write>)");
1424
1425 let stdout_display =
1426 format!("{}", OutputDestination::Stdout);
1427 assert_eq!(stdout_display, "Stdout");
1428 }
1429
1430 #[test]
1432 fn test_html_config_default() {
1433 let default = HtmlConfig::default();
1434 assert!(default.enable_syntax_highlighting);
1435 assert_eq!(
1436 default.syntax_theme,
1437 Some("github".to_string())
1438 );
1439 assert!(!default.minify_output);
1440 assert!(default.add_aria_attributes);
1441 assert!(!default.generate_structured_data);
1442 assert_eq!(
1443 default.max_input_size,
1444 constants::DEFAULT_MAX_INPUT_SIZE
1445 );
1446 assert_eq!(
1447 default.language,
1448 constants::DEFAULT_LANGUAGE.to_string()
1449 );
1450 assert!(!default.generate_toc);
1451 }
1452
1453 #[test]
1455 fn test_html_config_builder() {
1456 let builder = HtmlConfig::builder()
1457 .with_syntax_highlighting(
1458 true,
1459 Some("monokai".to_string()),
1460 )
1461 .with_language("en-US")
1462 .build()
1463 .unwrap();
1464
1465 assert!(builder.enable_syntax_highlighting);
1466 assert_eq!(
1467 builder.syntax_theme,
1468 Some("monokai".to_string())
1469 );
1470 assert_eq!(builder.language, "en-US");
1471 }
1472
1473 #[test]
1475 fn test_long_file_path_validation() {
1476 let long_path = "a".repeat(constants::MAX_PATH_LENGTH + 1);
1477 let result = HtmlConfig::validate_file_path(long_path);
1478 assert!(
1479 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("File path exceeds maximum length"))
1480 );
1481 }
1482
1483 #[test]
1485 fn test_relative_file_path_validation() {
1486 #[cfg(not(test))]
1487 {
1488 let absolute_path = "/absolute/path/to/file.md";
1489 let result =
1490 HtmlConfig::validate_file_path(absolute_path);
1491 assert!(
1492 matches!(result, Err(HtmlError::InvalidInput(ref msg)) if msg.contains("Only relative file paths are allowed"))
1493 );
1494 }
1495 }
1496 }
1497
1498 mod language_validation_extended_tests {
1499 use super::*;
1500
1501 #[test]
1502 fn test_language_code_edge_cases() {
1503 assert!(!validate_language_code(""));
1505
1506 assert!(!validate_language_code("a"));
1508
1509 assert!(!validate_language_code("EN-GB"));
1511 assert!(!validate_language_code("en-gb"));
1512
1513 assert!(!validate_language_code("en_GB"));
1515 assert!(!validate_language_code("en GB"));
1516
1517 assert!(!validate_language_code("en-GB-extra"));
1519 }
1520
1521 #[test]
1522 fn test_language_code_special_cases() {
1523 assert!(!validate_language_code("e1-GB"));
1525 assert!(!validate_language_code("en-G1"));
1526
1527 assert!(!validate_language_code("en-GB!"));
1529 assert!(!validate_language_code("en@GB"));
1530
1531 assert!(!validate_language_code("あa-GB"));
1533 assert!(!validate_language_code("en-あa"));
1534 }
1535 }
1536
1537 mod integration_extended_tests {
1538 use super::*;
1539
1540 #[test]
1541 fn test_full_conversion_pipeline() -> Result<()> {
1542 let temp_dir = tempdir()?;
1544 let input_path = temp_dir.path().join("test.md");
1545 let output_path = temp_dir.path().join("test.html");
1546
1547 let content = r#"---
1549title: Test Document
1550author: Test Author
1551---
1552
1553# Main Heading
1554
1555## Subheading
1556
1557This is a paragraph with *italic* and **bold** text.
1558
1559- List item 1
1560- List item 2
1561 - Nested item
1562 - Another nested item
1563
1564```rust
1565fn main() {
1566 println!("Hello, world!");
1567}
1568```
1569
1570| Column 1 | Column 2 |
1571|----------|----------|
1572| Cell 1 | Cell 2 |
1573
1574> This is a blockquote
1575
1576[Link text](https://example.com)"#;
1577
1578 std::fs::write(&input_path, content)?;
1579
1580 let config = MarkdownConfig {
1582 html_config: HtmlConfig {
1583 enable_syntax_highlighting: true,
1584 generate_toc: true,
1585 add_aria_attributes: true,
1586 generate_structured_data: true,
1587 minify_output: true,
1588 ..Default::default()
1589 },
1590 ..Default::default()
1591 };
1592
1593 markdown_file_to_html(
1594 Some(&input_path),
1595 Some(OutputDestination::File(
1596 output_path.to_string_lossy().into(),
1597 )),
1598 Some(config),
1599 )?;
1600
1601 let html = std::fs::read_to_string(&output_path)?;
1602
1603 println!("Generated HTML: {}", html);
1605 assert!(html.contains("<h1>"));
1606 assert!(html.contains("<h2>"));
1607 assert!(html.contains("<em>"));
1608 assert!(html.contains("<strong>"));
1609 assert!(html.contains("<ul>"));
1610 assert!(html.contains("<li>"));
1611 assert!(html.contains("language-rust"));
1612
1613 assert!(html.contains("Column 1"));
1615 assert!(html.contains("Column 2"));
1616 assert!(html.contains("Cell 1"));
1617 assert!(html.contains("Cell 2"));
1618
1619 assert!(html.contains("<blockquote>"));
1620 assert!(html.contains("<a href="));
1621
1622 Ok(())
1623 }
1624
1625 #[test]
1626 fn test_missing_html_config_fallback() {
1627 let config = MarkdownConfig {
1628 encoding: "utf-8".to_string(),
1629 html_config: HtmlConfig {
1630 enable_syntax_highlighting: false,
1631 syntax_theme: None,
1632 ..Default::default()
1633 },
1634 };
1635 let result = markdown_to_html("# Test", Some(config));
1636 assert!(result.is_ok());
1637 }
1638
1639 #[test]
1640 fn test_invalid_output_destination() {
1641 let result = markdown_file_to_html(
1642 Some(Path::new("test.md")),
1643 Some(OutputDestination::File(
1644 "/root/forbidden.html".to_string(),
1645 )),
1646 None,
1647 );
1648 assert!(result.is_err());
1649 }
1650 }
1651
1652 mod performance_tests {
1653 use super::*;
1654 use std::time::Instant;
1655
1656 #[test]
1657 fn test_large_document_performance() -> Result<()> {
1658 let base_content =
1659 "# Heading\n\nParagraph\n\n- List item\n\n";
1660 let large_content = base_content.repeat(1000);
1661
1662 let start = Instant::now();
1663 let html = markdown_to_html(&large_content, None)?;
1664 let duration = start.elapsed();
1665
1666 println!("Large document conversion took: {:?}", duration);
1668 println!("Input size: {} bytes", large_content.len());
1669 println!("Output size: {} bytes", html.len());
1670
1671 assert!(html.contains("<h1>"));
1673 assert!(html.contains("<p>"));
1674 assert!(html.contains("<ul>"));
1675
1676 Ok(())
1677 }
1678 }
1679}