From: Ruben Beltran del Rio Date: Fri, 3 Jan 2025 19:59:02 +0000 (+0100) Subject: Add first tests X-Git-Tag: 1.4.0~14 X-Git-Url: https://git.r.bdr.sh/rbdr/page/commitdiff_plain/260e8ec69b8e08b9fd105bf688e7a3a9fafecd61 Add first tests --- diff --git a/.gitignore b/.gitignore index ea8c4bf..0e05966 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ /target +tests/fixtures/empty +tests/fixtures/output +tests/fixtures/output_gemini +tests/fixtures/output_html +.DS_Store + +*.tar.gz +*.tar.gz.sha256 diff --git a/Makefile b/Makefile index 7efafa7..85cf266 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,12 @@ prepare: build: prepare cargo build --profile $(profile) --target $(target) +test: + cargo test + +coverage: + cargo tarpaulin + release: rpm tar deb @$(eval filename := $(app_name)-$(target)-$(channel)) @@ -68,4 +74,4 @@ else ifneq (,$(findstring refs/tags/,$(GIT_REF))) $(MAKE) -e profile=release -e channel=$(subst refs/tags/,,$(GIT_REF)) package endif -.PHONY: default build $(architectures) rpm package prepare set_rust ci release +.PHONY: default build $(architectures) rpm package prepare set_rust ci release test coverage diff --git a/README.md b/README.md index 3879878..d6d889c 100644 --- a/README.md +++ b/README.md @@ -70,3 +70,9 @@ Build dev version with `make` or `make build`. Build release with `make -e profile=release` or `make -e profile=release build`. +Run tests with `make test`. + +If you have [tarpaulin][tarpaulin], you can also run `make coverage` to get +coverage information. + +[tarpaulin]: https://github.com/xd009642/tarpaulin diff --git a/src/file_finder.rs b/src/file_finder.rs index bcd368d..a94aa8a 100644 --- a/src/file_finder.rs +++ b/src/file_finder.rs @@ -28,3 +28,89 @@ fn find_files_recursively(root_path: &PathBuf, directory_path: &PathBuf) -> Vec< } return result; } + + +#[cfg(test)] +mod tests { + use super::*; + use crate::file_handler::FileType; + use std::collections::HashSet; + use std::fs::create_dir_all; + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn get_paths(files: &Vec) -> HashSet { + files + .iter() + .map(|f| f.path.strip_prefix(&fixtures_dir()).unwrap().to_string_lossy().to_string()) + .collect() + } + + #[test] + fn finds_all_files() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + assert!(paths.contains("test1.gmi")); + assert!(paths.contains("_layout.html")); + assert!(paths.contains("nested/nested.gmi")); + assert!(paths.contains("assets/style.css")); + assert!(paths.contains("image.png")); + } + + #[test] + fn identifies_correct_file_types() { + let files = find_files(&fixtures_dir()); + + for file in files { + let extension = file.path.extension().and_then(|e| e.to_str()); + match extension { + Some("gmi") => assert_eq!(file.file_type, FileType::Gemini), + Some("html") => { + if file.path.ends_with("_layout.html") { + assert_eq!(file.file_type, FileType::Layout) + } else { + assert_eq!(file.file_type, FileType::File) + } + }, + _ => assert_eq!(file.file_type, FileType::File), + } + } + } + + #[test] + fn ignores_git_directory() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + // These files should exist in the fixtures but not be included + assert!(!paths.iter().any(|p| p.starts_with(".git/"))); + assert!(!paths.contains(".gitignore")); + } + + #[test] + fn handles_nested_directories() { + let files = find_files(&fixtures_dir()); + let paths = get_paths(&files); + + // Check that files in nested directories are found + assert!(paths.contains("nested/nested.gmi")); + assert!(paths.contains("assets/style.css")); + + // Verify directory structure is preserved in paths + let nested_files: Vec<_> = files.iter() + .filter(|f| f.path.starts_with(fixtures_dir().join("nested"))) + .collect(); + assert!(!nested_files.is_empty()); + } + + #[test] + fn returns_empty_for_empty_directory() { + let empty_dir = fixtures_dir().join("empty"); + let _ = create_dir_all(&empty_dir); + let files = find_files(&empty_dir); + assert!(files.is_empty()); + } +} diff --git a/src/file_handler/file_strategies/file.rs b/src/file_handler/file_strategies/file.rs index 2346128..5faa689 100644 --- a/src/file_handler/file_strategies/file.rs +++ b/src/file_handler/file_strategies/file.rs @@ -31,7 +31,7 @@ impl FileHandlerStrategy for Strategy { } } - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, _l: &String) { + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, _l: &str) { return self.handle(source, destination, file); } @@ -39,3 +39,143 @@ impl FileHandlerStrategy for Strategy { return self.handle(source, destination, file); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup() -> Strategy { + Strategy {} + } + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn fixture_path(filename: &str) -> PathBuf { + fixtures_dir().join(filename) + } + + mod file_type_tests { + use super::*; + + #[test] + fn identifies_regular_files() { + let strategy = setup(); + assert!(strategy.is(&fixture_path("image.png"))); + assert!(strategy.is(&fixture_path("style.css"))); + assert!(!strategy.is(&fixtures_dir())); + } + + #[test] + fn identifies_file_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::File)); + } + + #[test] + fn handles_correct_file_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Layout)); + assert!(!strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod file_handling_tests { + use super::*; + + #[test] + fn copies_single_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle(&source, &output, &file); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Verify file contents are identical + let original = fs::read(&file.path).unwrap(); + let copied = fs::read(&copied_path).unwrap(); + assert_eq!(original, copied); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + + #[test] + fn copies_nested_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("assets/style.css"), + file_type: FileType::File, + }; + + strategy.handle(&source, &output, &file); + + let copied_path = output.join("assets").join("style.css"); + assert!(copied_path.exists()); + + // Verify file contents are identical + let original = fs::read(&file.path).unwrap(); + let copied = fs::read(&copied_path).unwrap(); + assert_eq!(original, copied); + + // Cleanup + let _ = fs::remove_file(copied_path); + let _ = fs::remove_dir(output.join("assets")); + } + + #[test] + fn handle_html_copies_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output_html"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle_html(&source, &output, &file, "unused layout"); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + + #[test] + fn handle_gemini_copies_file() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output_gemini"); + + let file = File { + path: fixture_path("image.png"), + file_type: FileType::File, + }; + + strategy.handle_gemini(&source, &output, &file); + + let copied_path = output.join("image.png"); + assert!(copied_path.exists()); + + // Cleanup + let _ = fs::remove_file(copied_path); + } + } +} diff --git a/src/file_handler/file_strategies/gemini.rs b/src/file_handler/file_strategies/gemini.rs index 977464c..d8bde14 100644 --- a/src/file_handler/file_strategies/gemini.rs +++ b/src/file_handler/file_strategies/gemini.rs @@ -6,6 +6,7 @@ use std::fs::{create_dir_all, read_to_string, File as IOFile}; use crate::file_handler::{File, FileType, FileHandlerStrategy}; use crate::gemini_parser::parse; +use crate::html_renderer::render_html; impl Strategy { fn is_title(&self, line: &str) -> bool { @@ -44,7 +45,7 @@ impl FileHandlerStrategy for Strategy { } } - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &String) { + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &str) { let gemini_contents = read_to_string(&file.path).unwrap(); // Front matter extraction @@ -52,21 +53,23 @@ impl FileHandlerStrategy for Strategy { let mut lines_found = 0; let mut title = ""; let mut description = ""; - for line in lines[..2].iter() { - if self.is_title(&line) { - title = self.get_title(&line).trim(); - lines_found = lines_found + 1; - continue; - } - if self.is_description(&line) { - description = self.get_description(&line).trim(); - lines_found = lines_found + 1; - continue; + if let Some(slice) = lines.get(..2) { + for line in slice.iter() { + if self.is_title(&line) { + title = self.get_title(&line).trim(); + lines_found = lines_found + 1; + continue; + } + if self.is_description(&line) { + description = self.get_description(&line).trim(); + lines_found = lines_found + 1; + continue; + } } } let gemini_source = lines[lines_found..].join("\n"); - let content_html = parse(&gemini_source[..]); + let content_html = render_html(parse(&gemini_source[..])); let generated_html = layout .replace("{{ title }}", title) @@ -90,14 +93,16 @@ impl FileHandlerStrategy for Strategy { // Front matter extraction let lines: Vec<&str> = gemini_contents.split("\n").collect(); let mut lines_found = 0; - for line in lines[..2].iter() { - if self.is_title(&line) { - lines_found = lines_found + 1; - continue; - } - if self.is_description(&line) { - lines_found = lines_found + 1; - continue; + if let Some(slice) = lines.get(..2) { + for line in slice.iter() { + if self.is_title(&line) { + lines_found = lines_found + 1; + continue; + } + if self.is_description(&line) { + lines_found = lines_found + 1; + continue; + } } } @@ -112,3 +117,178 @@ impl FileHandlerStrategy for Strategy { destination_file.write_all(gemini_source.as_bytes()).unwrap(); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn setup() -> Strategy { + Strategy {} + } + + fn fixtures_dir() -> PathBuf { + PathBuf::from("tests/fixtures") + } + + fn fixture_path(filename: &str) -> PathBuf { + fixtures_dir().join(filename) + } + + fn read_fixture(filename: &str) -> String { + fs::read_to_string(fixture_path(filename)) + .unwrap_or_else(|_| panic!("Failed to read fixture file: {}", filename)) + } + + mod front_matter_tests { + use super::*; + + #[test] + fn detects_title() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let first_line = content.lines().next().unwrap(); + assert!(strategy.is_title(first_line)); + } + + #[test] + fn detects_description() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let second_line = content.lines().nth(1).unwrap(); + assert!(strategy.is_description(second_line)); + } + + #[test] + fn extracts_title() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let first_line = content.lines().next().unwrap(); + assert_eq!(strategy.get_title(first_line).trim(), "Test Title"); + } + + #[test] + fn extracts_description() { + let strategy = setup(); + let content = read_fixture("test1.gmi"); + let second_line = content.lines().nth(1).unwrap(); + assert_eq!(strategy.get_description(second_line).trim(), "Test Description"); + } + } + + mod file_type_tests { + use super::*; + + #[test] + fn identifies_gemini_files() { + let strategy = setup(); + assert!(strategy.is(&fixture_path("test1.gmi"))); + assert!(!strategy.is(&fixture_path("_layout.html"))); + assert!(!strategy.is(&fixtures_dir())); + } + + #[test] + fn identifies_file_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::Gemini)); + } + + #[test] + fn handles_correct_file_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Layout)); + assert!(!strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod file_handling_tests { + use super::*; + + #[test] + fn handles_html_generation() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + let layout = read_fixture("_layout.html"); + + let file = File { + path: fixture_path("test1.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_html( + &source, + &output, + &file, + &layout, + ); + + let generated_path = output.join("test1.html"); + assert!(generated_path.exists()); + + let content = fs::read_to_string(generated_path.clone()).unwrap(); + assert!(content.contains("Test Title")); + assert!(content.contains("

")); + + // Cleanup + let _ = fs::remove_file(generated_path); + } + + #[test] + fn handles_gemini_generation() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + + let file = File { + path: fixture_path("test1.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_gemini( + &source, + &output, + &file, + ); + + let generated_path = output.join("test1.gmi"); + assert!(generated_path.exists()); + + let content = fs::read_to_string(&generated_path).unwrap(); + assert!(content.contains("# Heading")); + assert!(!content.contains("Test Title")); // Front matter should be removed + + // Cleanup + let _ = fs::remove_file(generated_path); + } + + #[test] + fn handles_nested_structure() { + let strategy = setup(); + let source = fixtures_dir(); + let output = fixture_path("output"); + let layout = read_fixture("_layout.html"); + + let file = File { + path: fixture_path("nested/nested.gmi"), + file_type: FileType::Gemini, + }; + + strategy.handle_html( + &source, + &output, + &file, + &layout, + ); + + let generated_path = output.join("nested").join("nested.html"); + assert!(generated_path.exists()); + + // Cleanup + let _ = fs::remove_file(generated_path); + let _ = fs::remove_dir(output.join("nested")); + } + } +} diff --git a/src/file_handler/file_strategies/layout.rs b/src/file_handler/file_strategies/layout.rs index f51bf7a..44b5000 100644 --- a/src/file_handler/file_strategies/layout.rs +++ b/src/file_handler/file_strategies/layout.rs @@ -22,6 +22,121 @@ impl FileHandlerStrategy for Strategy { // We don't implement handling for layout, as we assume there's only one // and it got handled before. - fn handle_html(&self, _s: &PathBuf, _d: &PathBuf, _f: &File, _l: &String) {} + fn handle_html(&self, _s: &PathBuf, _d: &PathBuf, _f: &File, _l: &str) {} fn handle_gemini(&self, _s: &PathBuf, _d: &PathBuf, _f: &File) {} } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn setup() -> Strategy { + Strategy {} + } + + mod is_tests { + use super::*; + + #[test] + fn identifies_layout_file() { + let strategy = setup(); + let path = PathBuf::from("_layout.html"); + assert!(strategy.is(&path)); + } + + #[test] + fn rejects_non_layout_html() { + let strategy = setup(); + let path = PathBuf::from("regular.html"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_layout_with_different_extension() { + let strategy = setup(); + let path = PathBuf::from("_layout.txt"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_layout_with_prefix() { + let strategy = setup(); + let path = PathBuf::from("prefix_layout.html"); + assert!(!strategy.is(&path)); + } + + #[test] + fn rejects_directory_named_layout() { + let strategy = setup(); + let path = PathBuf::from("tests/fixtures"); + + assert!(!strategy.is(&path)); + } + } + + mod identify_tests { + use super::*; + + #[test] + fn returns_layout_type() { + let strategy = setup(); + assert!(matches!(strategy.identify(), FileType::Layout)); + } + } + + mod can_handle_tests { + use super::*; + + #[test] + fn handles_layout_type() { + let strategy = setup(); + assert!(strategy.can_handle(&FileType::Layout)); + } + + #[test] + fn rejects_non_layout_types() { + let strategy = setup(); + assert!(!strategy.can_handle(&FileType::File)); + assert!(!strategy.can_handle(&FileType::Gemini)); + assert!(!strategy.can_handle(&FileType::Unknown)); + } + } + + mod handle_methods { + use super::*; + + #[test] + fn handle_html_does_nothing() { + let strategy = setup(); + let file = File { + path: PathBuf::from("test.html"), + file_type: FileType::Layout, + }; + + // Should not panic + strategy.handle_html( + &PathBuf::from("source"), + &PathBuf::from("dest"), + &file, + "layout content" + ); + } + + #[test] + fn handle_gemini_does_nothing() { + let strategy = setup(); + let file = File { + path: PathBuf::from("test.gmi"), + file_type: FileType::Layout, + }; + + // Should not panic + strategy.handle_gemini( + &PathBuf::from("source"), + &PathBuf::from("dest"), + &file + ); + } + } +} diff --git a/src/file_handler/mod.rs b/src/file_handler/mod.rs index 9a0133a..8ba50d4 100644 --- a/src/file_handler/mod.rs +++ b/src/file_handler/mod.rs @@ -35,7 +35,7 @@ impl FileHandler { FileType::Unknown } - pub fn get_layout_or_panic(&mut self, files: &Vec) -> Result<(), &str> { + pub fn get_layout_or_panic(&mut self, files: &[File]) -> Result<(), &str> { for file in files { match file.file_type { FileType::Layout => { @@ -49,20 +49,22 @@ impl FileHandler { Err("No layout found. Please ensure there's a _layout.html file at the root") } - pub fn handle_all(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, files: &Vec) { - for file in files { + pub fn handle_all(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, files: &[File]) { + files.iter().for_each(|file| { self.handle(source, html_destination, gemini_destination, file); - } + }); } pub fn handle(&self, source: &PathBuf, html_destination: &PathBuf, gemini_destination: &PathBuf, file: &File) { - for strategy in self.strategies.iter() { - if strategy.can_handle(&file.file_type) { - let layout = self.layout.as_ref().unwrap(); + if let Some(strategy) = self.strategies + .iter() + .find(|s| s.can_handle(&file.file_type)) + { + let layout = self.layout.as_ref() + .expect("Layout should be initialized before handling files"); strategy.handle_html(source, html_destination, file, layout); strategy.handle_gemini(source, gemini_destination, file); return; - } } } } @@ -71,10 +73,11 @@ pub trait FileHandlerStrategy { fn is(&self, path: &PathBuf) -> bool; fn identify(&self) -> FileType; fn can_handle(&self, file_type: &FileType) -> bool; - fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &String); + fn handle_html(&self, source: &PathBuf, destination: &PathBuf, file: &File, layout: &str); fn handle_gemini(&self, source: &PathBuf, destination: &PathBuf, file: &File); } +#[derive(Debug, Clone, PartialEq)] pub enum FileType { Gemini, File, @@ -82,7 +85,177 @@ pub enum FileType { Unknown, } +#[derive(PartialEq, Debug)] pub struct File { pub path: PathBuf, pub file_type: FileType, } + + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn create_test_file(path: &str, file_type: FileType) -> File { + File { + path: PathBuf::from(path), + file_type, + } + } + + #[test] + fn test_identify_gemini_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("test.gmi"); + assert!(matches!(handler.identify(&path), FileType::Gemini)); + } + + #[test] + fn test_identify_layout_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("_layout.html"); + assert!(matches!(handler.identify(&path), FileType::Layout)); + } + + #[test] + fn test_identify_regular_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("regular.html"); + assert!(matches!(handler.identify(&path), FileType::File)); + } + + #[test] + fn test_identify_unknown_file() { + let handler = FileHandler::default(); + let path = PathBuf::from("tests/fixtures"); + assert!(matches!(handler.identify(&path), FileType::Unknown)); + } + + #[test] + fn test_get_layout_success() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("test.gmi", FileType::Gemini), + create_test_file("tests/fixtures/_layout.html", FileType::Layout), + create_test_file("regular.html", FileType::File), + ]; + + assert!(handler.get_layout_or_panic(&files).is_ok()); + } + + #[test] + fn test_get_layout_failure() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("test.gmi", FileType::Gemini), + create_test_file("regular.html", FileType::File), + ]; + + assert!(handler.get_layout_or_panic(&files).is_err()); + } + + // Mock strategy for testing + struct MockStrategy { + is_match: bool, + file_type: FileType, + } + + impl FileHandlerStrategy for MockStrategy { + fn is(&self, _path: &PathBuf) -> bool { + self.is_match + } + + fn identify(&self) -> FileType { + self.file_type.clone() + } + + fn can_handle(&self, file_type: &FileType) -> bool { + &self.file_type == file_type + } + + fn handle_html(&self, _source: &PathBuf, _destination: &PathBuf, _file: &File, _layout: &str) {} + fn handle_gemini(&self, _source: &PathBuf, _destination: &PathBuf, _file: &File) {} + } + + #[test] + fn test_custom_strategy() { + let mock_strategy = MockStrategy { + is_match: true, + file_type: FileType::Gemini, + }; + + let handler = FileHandler { + strategies: vec![Box::new(mock_strategy)], + layout: None, + }; + + let path = PathBuf::from("test.whatever"); + assert!(matches!(handler.identify(&path), FileType::Gemini)); + } + + #[test] + fn test_handle_all_empty_files() { + let handler = FileHandler::default(); + let files: Vec = vec![]; + + // Should not panic with empty vector + handler.handle_all( + &PathBuf::from("source"), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &files + ); + } + + #[test] + fn test_handle_with_layout() { + let mut handler = FileHandler::default(); + handler.layout = Some("test layout".to_string()); + + let file = create_test_file("tests/fixtures/test1.gmi", FileType::Gemini); + + // Should not panic with valid layout + handler.handle( + &PathBuf::from(""), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &file + ); + } + + #[test] + #[should_panic(expected = "Layout should be initialized before handling files")] + fn test_handle_without_layout() { + let handler = FileHandler::default(); + let file = create_test_file("test.gmi", FileType::Gemini); + + handler.handle( + &PathBuf::from("source"), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &file + ); + } + + #[test] + fn test_slice_handling() { + let mut handler = FileHandler::default(); + let files = vec![ + create_test_file("tests/fixtures/test1.gmi", FileType::Gemini), + create_test_file("tests/fixtures/_layout.html", FileType::Layout), + create_test_file("tests/fixtures/test2.gmi", FileType::Gemini), + create_test_file("tests/fixtures/test3.gmi", FileType::Gemini), + ]; + + let _ = handler.get_layout_or_panic(&files[1..]); + + // Test with slice + handler.handle_all( + &PathBuf::from(""), + &PathBuf::from("tests/fixtures/output"), + &PathBuf::from("tests/fixtures/output"), + &files[1..] // Test with slice of last three elements + ); + } +} diff --git a/src/gemini_parser.rs b/src/gemini_parser.rs index 496dc0d..418b758 100644 --- a/src/gemini_parser.rs +++ b/src/gemini_parser.rs @@ -1,224 +1,222 @@ - pub fn parse(source: &str) -> String { - - let lines = source.split("\n"); - let mut is_preformatted = false; - - let mut block_label: Option = None; - let mut html: String = "".to_owned(); - let mut current_line_type: Option = None; - - let mut heading_stack: Vec = Vec::new(); - for line in lines { - let mut line_type = LineType::Blank; - if line.char_indices().count() > 2 { - let mut end = line.len(); - if line.char_indices().count() > 3 { - end = line.char_indices().map(|(i, _)| i).nth(3).unwrap(); - } - line_type = identify_line(&line[..end], is_preformatted); - } - match line_type { - LineType::PreformattedToggle => { - is_preformatted = !is_preformatted; - if is_preformatted && line.char_indices().count() > 3 { - block_label = Some(get_partial_line_content(&line_type, line)); - } else { - block_label = None; - } - }, - _ => { - // Close previous block if needed - if let Some(line) = ¤t_line_type { - if line != &line_type && is_block(line) { - html.push_str(get_line_closer(line)); - } - } - - // Blocks - if is_block(&line_type) { - if let Some(line) = ¤t_line_type { - if line != &line_type { - html.push_str(&get_line_opener(&line_type, block_label.as_ref())); - } - } else { - html.push_str(&get_line_opener(&line_type, None)); - } - - let line_content = get_partial_line_content(&line_type, line); - html.push_str(&line_content); - } else { - html.push_str(&get_heading_wrapper(&mut heading_stack, &line_type)); - html.push_str(&get_full_line_content(&line_type, line)); - } - current_line_type = Some(line_type); - }, - } - } - if let Some(line) = ¤t_line_type { - if is_block(line) { - html.push_str(get_line_closer(line)); - } - } - html.push_str(&close_heading_wrapper(&mut heading_stack)); - html -} - -fn is_block(line_type: &LineType) -> bool { - return match line_type { - LineType::PreformattedText | LineType::ListItem | LineType::Quote => true, - _ => false, - } -} - -fn get_partial_line_content(line_type: &LineType, line: &str) -> String { - let encoded_line = line.replace("<", "<").replace(">", ">"); - return match line_type { - LineType::ListItem => format!("
  • {}
  • ", encoded_line[2..].trim()), - LineType::Quote => encoded_line[1..].trim().to_string(), - LineType::PreformattedText => format!("{}\n", encoded_line), - LineType::PreformattedToggle => encoded_line[3..].trim().to_string(), - _ => "".to_string(), - } -} - -fn get_full_line_content(line_type: &LineType, line: &str) -> String { - let encoded_line = line.replace("<", "<").replace(">", ">"); - match line_type { - LineType::Text => format!("

    {}

    \n", encoded_line.trim()), - LineType::Blank => "
    \n".to_string(), - LineType::Link => { - let url = get_link_address(line); - if url.starts_with("gemini:") { - format!("

    {}

    \n", url, get_link_content(line)) - } else { - format!("

    {}

    \n", url.replace(".gmi", ".html"), get_link_content(line)) - } - }, - LineType::Heading1 => format!("

    {}

    \n", encoded_line[1..].trim()), - LineType::Heading2 => format!("

    {}

    \n", encoded_line[2..].trim()), - LineType::Heading3 => format!("

    {}

    \n", encoded_line[3..].trim()), - _ => "".to_string(), - } +#[derive(PartialEq, Eq, Debug)] +pub enum GeminiLine { + Text(String, bool), + PreformattedToggle(bool, String), + Heading(u8, String), + Link(String, String), + Quote(String), + ListItem(String) } -fn get_heading_wrapper(heading_stack: &mut Vec, line_type: &LineType) -> String { - let mut string = String::new(); - let current_heading: u8 = match line_type { - LineType::Heading1 => 1, - LineType::Heading2 => 2, - LineType::Heading3 => 3, - _ => 255 - }; - - if current_heading < 255 { - while let Some(open_heading) = heading_stack.pop() { - // You just encountered a more important heading. - // Put it back. Desist. - if open_heading < current_heading { - heading_stack.push(open_heading); - break; - } +/// Parses gemtext source code into a vector of GeminiLine elements. +/// +/// # Arguments +/// * `source` - A string slice that contains the gemtext +/// +/// # Returns +/// A `Vec` containing the rendered HTML. +pub fn parse(source: &str) -> Vec { + source.lines() + .fold( + (Vec::new(), false), + |(mut lines, is_preformatted), line| { + let parsed = if is_preformatted { + parse_preformatted_line(line) + } else { + parse_line(line) + }; - string.push_str(""); + let new_is_preformatted = match parsed { + GeminiLine::PreformattedToggle(x, _) => x, + _ => is_preformatted + }; - if open_heading == current_heading { - break; + lines.push(parsed); + (lines, new_is_preformatted) } - } - heading_stack.push(current_heading); - string.push_str(&format!("
    ", current_heading)); - } - - return string; + ) + .0 } -fn close_heading_wrapper(heading_stack: &mut Vec) -> String { - let mut string = String::new(); - while let Some(_open_heading) = heading_stack.pop() { - string.push_str("
    "); +fn parse_preformatted_line(line: &str) -> GeminiLine { + match line { + s if s.starts_with("```") => GeminiLine::PreformattedToggle(false, String::new()), + _ => GeminiLine::Text(line.to_string(), true), } - return string; } -fn get_line_opener(line_type: &LineType, block_label: Option<&String>) -> String { - match line_type { - LineType::ListItem => "
      ".to_string(), - LineType::Quote => "
      ".to_string(), - LineType::PreformattedText => { - if let Some(label) = &block_label { - return format!("
      ", label);
      -            } else {
      -                return "
      ".to_string();
      +fn parse_line(line: &str) -> GeminiLine {
      +    match line {
      +        s if s.starts_with("###") => GeminiLine::Heading(3, s[3..].to_string()),
      +        s if s.starts_with("##") => GeminiLine::Heading(2, s[2..].to_string()),
      +        s if s.starts_with("#") => GeminiLine::Heading(1, s[1..].to_string()),
      +        s if s.starts_with("=>") => {
      +            let content = s[2..].trim();
      +            match content.split_once(char::is_whitespace) {
      +                Some((url, text)) => GeminiLine::Link(url.trim().to_string(), text.trim().to_string()),
      +                None => GeminiLine::Link(content.trim().to_string(), String::new()),
                   }
               },
      -        _ => "".to_string(),
      +        s if s.starts_with("* ") => GeminiLine::ListItem(s[2..].to_string()),
      +        s if s.starts_with(">") => GeminiLine::Quote(s[1..].to_string()),
      +        s if s.starts_with("```") => GeminiLine::PreformattedToggle(true, s[3..].to_string()),
      +        _ => GeminiLine::Text(line.to_string(), false),
           }
       }
       
      -fn get_line_closer(line_type: &LineType) -> &'static str {
      -    match line_type {
      -        LineType::ListItem => "
    \n", - LineType::Quote => "\n", - LineType::PreformattedText => "\n", - _ => "", +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_headings() { + assert_eq!(parse_line("### Heading"), GeminiLine::Heading(3, " Heading".to_string())); + assert_eq!(parse_line("## Heading"), GeminiLine::Heading(2, " Heading".to_string())); + assert_eq!(parse_line("# Heading"), GeminiLine::Heading(1, " Heading".to_string())); + assert_eq!(parse_line("###"), GeminiLine::Heading(3, "".to_string())); + assert_eq!(parse_line("#####"), GeminiLine::Heading(3, "##".to_string())); + assert_eq!(parse_line("# "), GeminiLine::Heading(1, " ".to_string())); + + assert_eq!(parse_preformatted_line("### Heading"), GeminiLine::Text("### Heading".to_string(), true)); + assert_eq!(parse_preformatted_line("## Heading"), GeminiLine::Text("## Heading".to_string(), true)); + assert_eq!(parse_preformatted_line("# Heading"), GeminiLine::Text("# Heading".to_string(), true)); + } + + #[test] + fn test_links() { + assert_eq!( + parse_line("=> https://example.com Link text"), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()) + ); + assert_eq!( + parse_line("=> /local/path"), + GeminiLine::Link("/local/path".to_string(), "".to_string()) + ); + + assert_eq!( + parse_line("=>"), + GeminiLine::Link("".to_string(), "".to_string()) + ); + assert_eq!( + parse_line("=> "), + GeminiLine::Link("".to_string(), "".to_string()) + ); + assert_eq!( + parse_line("=> multiple spaces in text"), + GeminiLine::Link("multiple".to_string(), "spaces in text".to_string()) + ); + + assert_eq!( + parse_preformatted_line("=> https://example.com Link text"), + GeminiLine::Text("=> https://example.com Link text".to_string(), true) + ); + } + + #[test] + fn test_list_items() { + assert_eq!( + parse_line("* List item"), + GeminiLine::ListItem("List item".to_string()) + ); + + assert_eq!(parse_line("* "), GeminiLine::ListItem("".to_string())); + assert_eq!(parse_line("*"), GeminiLine::Text("*".to_string(), false)); + assert_eq!(parse_line("*WithText"), GeminiLine::Text("*WithText".to_string(), false)); + assert_eq!(parse_line("* Multiple spaces"), GeminiLine::ListItem(" Multiple spaces".to_string())); + } + + #[test] + fn test_quotes() { + assert_eq!( + parse_line(">Quote text"), + GeminiLine::Quote("Quote text".to_string()) + ); + + assert_eq!(parse_line(">"), GeminiLine::Quote("".to_string())); + assert_eq!(parse_line("> "), GeminiLine::Quote(" ".to_string())); + assert_eq!(parse_line(">>Nested"), GeminiLine::Quote(">Nested".to_string())); + } + + #[test] + fn test_preformatted() { + assert_eq!( + parse_line("```alt-text"), + GeminiLine::PreformattedToggle(true, "alt-text".to_string()) + ); + + assert_eq!(parse_line("```"), GeminiLine::PreformattedToggle(true, "".to_string())); + assert_eq!(parse_line("``` "), GeminiLine::PreformattedToggle(true, " ".to_string())); + assert_eq!(parse_line("````"), GeminiLine::PreformattedToggle(true, "`".to_string())); + + assert_eq!( + parse_preformatted_line("```alt-text"), + GeminiLine::PreformattedToggle(false, "".to_string()) + ); + assert_eq!( + parse_preformatted_line("```"), + GeminiLine::PreformattedToggle(false, "".to_string()) + ); + + } + + #[test] + fn test_text() { + // Normal case + assert_eq!( + parse_line("Regular text"), + GeminiLine::Text("Regular text".to_string(), false) + ); + + // Edge cases + assert_eq!(parse_line(""), GeminiLine::Text("".to_string(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); + assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false)); + } + + #[test] + fn test_malformed_input() { + assert_eq!(parse_line("= >Not a link"), GeminiLine::Text("= >Not a link".to_string(), false)); + assert_eq!(parse_line("``Not preformatted"), GeminiLine::Text("``Not preformatted".to_string(), false)); + assert_eq!(parse_line("** Not a list"), GeminiLine::Text("** Not a list".to_string(), false)); + } + + #[test] + fn test_full_document() { + let input = "\ +# Heading 1 +## Heading 2 +### Heading 3 +Regular text +=> https://example.com Link text +* List item +>Quote +```alt +code +# Heading 1 +## Heading 2 +### Heading 3 +=> https://example.com Link text +* List item +>Quote +```trailing alt"; + let result = parse(input); + assert_eq!(result, vec![ + GeminiLine::Heading(1, " Heading 1".to_string()), + GeminiLine::Heading(2, " Heading 2".to_string()), + GeminiLine::Heading(3, " Heading 3".to_string()), + GeminiLine::Text("Regular text".to_string(), false), + GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()), + GeminiLine::ListItem("List item".to_string()), + GeminiLine::Quote("Quote".to_string()), + GeminiLine::PreformattedToggle(true, "alt".to_string()), + GeminiLine::Text("code".to_string(), true), + GeminiLine::Text("# Heading 1".to_string(), true), + GeminiLine::Text("## Heading 2".to_string(), true), + GeminiLine::Text("### Heading 3".to_string(), true), + GeminiLine::Text("=> https://example.com Link text".to_string(), true), + GeminiLine::Text("* List item".to_string(), true), + GeminiLine::Text(">Quote".to_string(), true), + GeminiLine::PreformattedToggle(false, "".to_string()), + ]); } } - -fn get_link_content(line: &str) -> &str { - let components: Vec<&str> = line[2..].trim().splitn(2, " ").collect(); - if components.len() > 1 { - return components[1].trim() - } - components[0].trim() -} - -fn get_link_address(line: &str) -> &str { - let components: Vec<&str> = line[2..].trim().splitn(2, " ").collect(); - components[0].trim() -} - -fn identify_line(line: &str, is_preformatted: bool) -> LineType { - if line.starts_with("```") { - return LineType::PreformattedToggle; - } - if is_preformatted { - return LineType::PreformattedText; - } - if line.is_empty() { - return LineType::Blank; - } - if line.starts_with("=>") { - return LineType::Link; - } - if line.starts_with("* ") { - return LineType::ListItem; - } - if line.starts_with(">") { - return LineType::Quote; - } - if line.starts_with("###") { - return LineType::Heading3; - } - if line.starts_with("##") { - return LineType::Heading2; - } - if line.starts_with("#") { - return LineType::Heading1; - } - - LineType::Text -} - -#[derive(PartialEq, Eq)] -enum LineType { - Text, - Blank, - Link, - PreformattedToggle, - PreformattedText, - Heading1, - Heading2, - Heading3, - ListItem, - Quote -} diff --git a/src/html_renderer.rs b/src/html_renderer.rs new file mode 100644 index 0000000..47d36bb --- /dev/null +++ b/src/html_renderer.rs @@ -0,0 +1,267 @@ +use crate::gemini_parser::GeminiLine; + +/// Renders HTML from a vector of GeminiLine elements. +/// +/// # Arguments +/// * `lines` - Vector of GeminiLine elements to render +/// +/// # Returns +/// A String containing the rendered HTML. +pub fn render_html(lines: Vec) -> String { + let mut heading_stack = Vec::new(); + let mut last_line: Option<&GeminiLine> = None; + let mut result = String::new(); + + for line in &lines { + result.push_str(&line_preamble(line, last_line, &mut heading_stack)); + result.push_str(&line_content(line)); + result.push('\n'); + last_line = Some(line); + } + + result.push_str(&finalize_html(&heading_stack, last_line)); + result +} + +fn line_preamble( + line: &GeminiLine, + last_line: Option<&GeminiLine>, + heading_stack: &mut Vec +) -> String { + let mut html = String::new(); + + if let Some(last_line) = last_line { + match last_line { + GeminiLine::ListItem(_) => { + match line { + GeminiLine::ListItem(_) => {}, + _ => html.push_str("\n"), + } + }, + GeminiLine::Quote(_) => { + match line { + GeminiLine::Quote(_) => {}, + _ => html.push_str("\n"), + } + }, + _ => {} + } + } + + match line { + GeminiLine::Heading(level, _) => { + while let Some(open_heading) = heading_stack.pop() { + // You just encountered a more important heading. + // Put it back. Desist. + if open_heading < *level { + heading_stack.push(open_heading); + break; + } + + html.push_str("\n"); + + if open_heading == *level { + break; + } + } + heading_stack.push(*level); + html.push_str(&format!("
    \n", level)); + }, + GeminiLine::ListItem(_) => { + match last_line { + Some(GeminiLine::ListItem(_)) => {}, + _ => html.push_str("
      \n"), + } + }, + GeminiLine::Quote(_) => { + match last_line { + Some(GeminiLine::Quote(_)) => {}, + _ => html.push_str("
      \n"), + } + }, + _ => {} + } + + html +} + +fn line_content(line: &GeminiLine) -> String { + match line { + GeminiLine::Text(content, false) => format!("

      {}

      ", content), + GeminiLine::Link(url, text) => { + let display = if text.is_empty() { url } else { text }; + format!("

      {}

      ", url, display) + }, + GeminiLine::Heading(level, content) => format!("{}", level, content, level), + GeminiLine::ListItem(content) => format!("
    • {}
    • ", content), + GeminiLine::PreformattedToggle(true, alt_text) => { format!("
      ", alt_text) }
      +        GeminiLine::PreformattedToggle(false, _) => { "
      ".to_string() } + GeminiLine::Text(content, true) | GeminiLine::Quote(content) => format!("{}", content) + } +} + +fn finalize_html(heading_stack: &[u8], last_line: Option<&GeminiLine>) -> String { + let mut html = String::new(); + + match last_line { + Some(GeminiLine::ListItem(_)) => html.push_str("
    \n"), + Some(GeminiLine::Quote(_)) => html.push_str("\n"), + _ => {} + } + + for _ in heading_stack { + html.push_str("
    \n"); + } + + html +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_text() { + let input = vec![ + GeminiLine::Text("Hello world".to_string(), false), + ]; + assert_eq!( + render_html(input), + "

    Hello world

    \n" + ); + } + + #[test] + fn test_heading_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Top".to_string()), + GeminiLine::Heading(2, "Sub".to_string()), + GeminiLine::Heading(3, "SubSub".to_string()), + GeminiLine::Heading(2, "Another Sub".to_string()), + ]; + assert_eq!( + render_html(input), + "
    \n\ +

    Top

    \n\ +
    \n\ +

    Sub

    \n\ +
    \n\ +

    SubSub

    \n\ +
    \n\ +
    \n\ +
    \n\ +

    Another Sub

    \n\ +
    \n\ +
    \n" + ); + } + + #[test] + fn test_list_transitions() { + let input = vec![ + GeminiLine::ListItem("First".to_string()), + GeminiLine::ListItem("Second".to_string()), + GeminiLine::Text("Break".to_string(), false), + GeminiLine::ListItem("New list".to_string()), + ]; + assert_eq!( + render_html(input), + "
      \n\ +
    • First
    • \n\ +
    • Second
    • \n\ +
    \n\ +

    Break

    \n\ +
      \n\ +
    • New list
    • \n\ +
    \n" + ); + } + + #[test] + fn test_quote_transitions() { + let input = vec![ + GeminiLine::Quote("First quote".to_string()), + GeminiLine::Quote("Still quoting".to_string()), + GeminiLine::Text("Normal text".to_string(), false), + GeminiLine::Quote("New quote".to_string()), + ]; + assert_eq!( + render_html(input), + "
    \n\ + First quote\n\ + Still quoting\n\ +
    \n\ +

    Normal text

    \n\ +
    \n\ + New quote\n\ +
    \n" + ); + } + + #[test] + fn test_preformatted() { + let input = vec![ + GeminiLine::PreformattedToggle(true, "code".to_string()), + GeminiLine::Text("let x = 42;".to_string(), true), + GeminiLine::PreformattedToggle(false, String::new()), + ]; + assert_eq!( + render_html(input), + "
    \n\
    +             let x = 42;\n\
    +             
    \n" + ); + } + + #[test] + fn test_links() { + let input = vec![ + GeminiLine::Link("https://example.com".to_string(), "Example".to_string()), + GeminiLine::Link("https://rust-lang.org".to_string(), "".to_string()), + ]; + assert_eq!( + render_html(input), + "

    Example

    \n\ +

    https://rust-lang.org

    \n" + ); + } + + #[test] + fn test_complex_nesting() { + let input = vec![ + GeminiLine::Heading(1, "Title".to_string()), + GeminiLine::Text("Intro".to_string(), false), + GeminiLine::Heading(2, "Section".to_string()), + GeminiLine::ListItem("Point 1".to_string()), + GeminiLine::ListItem("Point 2".to_string()), + GeminiLine::Quote("Important quote".to_string()), + GeminiLine::Heading(2, "Another Section".to_string()), + ]; + assert_eq!( + render_html(input), + "
    \n\ +

    Title

    \n\ +

    Intro

    \n\ +
    \n\ +

    Section

    \n\ +
      \n\ +
    • Point 1
    • \n\ +
    • Point 2
    • \n\ +
    \n\ +
    \n\ + Important quote\n\ +
    \n\ +
    \n\ +
    \n\ +

    Another Section

    \n\ +
    \n\ +
    \n" + ); + } + + #[test] + fn test_empty_input() { + let input = Vec::new(); + assert_eq!(render_html(input), ""); + } +} diff --git a/src/main.rs b/src/main.rs index fc085d8..533a6bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod gemini_parser; +mod html_renderer; mod file_finder; mod file_handler; diff --git a/tests/fixtures/_layout.html b/tests/fixtures/_layout.html new file mode 100644 index 0000000..2b57c5d --- /dev/null +++ b/tests/fixtures/_layout.html @@ -0,0 +1,6 @@ + +{{ title }} + +{{ content }} + + diff --git a/tests/fixtures/assets/style.css b/tests/fixtures/assets/style.css new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/image.png b/tests/fixtures/image.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/nested/nested.gmi b/tests/fixtures/nested/nested.gmi new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/regular.html b/tests/fixtures/regular.html new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/test1.gmi b/tests/fixtures/test1.gmi new file mode 100644 index 0000000..65fd302 --- /dev/null +++ b/tests/fixtures/test1.gmi @@ -0,0 +1,4 @@ +--- title: Test Title +--- description: Test Description +# Heading +Some content here diff --git a/tests/fixtures/test2.gmi b/tests/fixtures/test2.gmi new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/test3.gmi b/tests/fixtures/test3.gmi new file mode 100644 index 0000000..e69de29