diff --git a/ssg-template/src/engine.rs b/ssg-template/src/engine.rs index 9445ead3..b2003702 100644 --- a/ssg-template/src/engine.rs +++ b/ssg-template/src/engine.rs @@ -117,19 +117,26 @@ impl Engine { template: &str, context: &HashMap<&str, &str>, ) -> Result { + if template.trim().is_empty() { + return Err(TemplateError::RenderError( + "Template is empty".to_string(), + )); + } + let mut output = template.to_owned(); for (key, value) in context { output = output.replace(&format!("{{{{{}}}}}", key), value); } - // Check if all keys have been replaced - if output.contains("{{") { - Err(TemplateError::RenderError(format!( - "Failed to render template, unresolved template tags: {}", - output - ))) - } else { - Ok(output) + + // Check if all keys have been replaced or if the template contains unresolved or invalid tags + if output.contains("{{") || output.contains("{") { + return Err(TemplateError::RenderError(format!( + "Failed to render template, unresolved or invalid template tags: {}", + output + ))); } + + Ok(output) } /// Creates a template folder based on the provided template path or uses the default template folder. @@ -241,24 +248,52 @@ impl Engine { Ok(template_dir_path) } -} -#[cfg(test)] -mod tests { - use super::*; + /// Downloads a set of template files from a given URL into a temporary directory. + /// + /// # Arguments + /// + /// * `url` - The base URL to download the files from. + /// + /// # Returns + /// + /// A `Result` containing the path to the downloaded files, or a `TemplateError` if something goes wrong. + /// + /// # Errors + /// + /// This function returns an error if the URL is invalid or if the files fail to download. + /// + /// # Example + /// + /// ``` + /// use ssg_template::Engine; + /// let engine = Engine::new("dummy/path"); + /// let result = engine.download_template_files("https://example.com/templates"); + /// ``` + pub fn download_template_files( + &self, + url: &str, + ) -> Result { + let tempdir = tempfile::Builder::new() + .prefix("templates") + .tempdir() + .map_err(TemplateError::Io)?; + let template_dir_path = tempdir.path().to_owned(); - #[test] - fn test_render_template() { - let engine = Engine::new("dummy/path"); - let mut context = HashMap::new(); - context.insert("name", "World"); - context.insert("greeting", "Hello"); + let files = + ["contact.html", "index.html", "page.html", "post.html"]; + for file in files.iter() { + let file_url = format!("{}/{}", url, file); + let file_path = template_dir_path.join(file); + let mut response = reqwest::blocking::get(&file_url) + .map_err(TemplateError::Reqwest)?; + let mut file = + File::create(&file_path).map_err(TemplateError::Io)?; + response + .copy_to(&mut file) + .map_err(TemplateError::Reqwest)?; + } - let template = "{{greeting}}, {{name}}!"; - let result = - engine.render_template(template, &context).unwrap(); - assert_eq!(result, "Hello, World!"); + Ok(template_dir_path) } - - // Add more tests for other methods } diff --git a/ssg-template/tests/context_tests.rs b/ssg-template/tests/context_tests.rs new file mode 100644 index 00000000..bd8020a5 --- /dev/null +++ b/ssg-template/tests/context_tests.rs @@ -0,0 +1,37 @@ +use ssg_template::Context; + +#[test] +fn test_context_new() { + let context = Context::new(); + assert!(context.elements.is_empty()); +} + +#[test] +fn test_context_set_and_get() { + let mut context = Context::new(); + context.set("name", "Alice"); + assert_eq!(context.get("name"), Some(&"Alice")); +} + +#[test] +fn test_context_update_existing_key() { + let mut context = Context::new(); + context.set("name", "Alice"); + context.set("name", "Bob"); + assert_eq!(context.get("name"), Some(&"Bob")); +} + +#[test] +fn test_context_get_nonexistent_key() { + let context = Context::new(); + assert_eq!(context.get("nonexistent"), None); +} + +#[test] +fn test_context_multiple_entries() { + let mut context = Context::new(); + context.set("name", "Alice"); + context.set("age", "30"); + assert_eq!(context.get("name"), Some(&"Alice")); + assert_eq!(context.get("age"), Some(&"30")); +} diff --git a/ssg-template/tests/engine_tests.rs b/ssg-template/tests/engine_tests.rs new file mode 100644 index 00000000..7dcca2fa --- /dev/null +++ b/ssg-template/tests/engine_tests.rs @@ -0,0 +1,410 @@ +/// Unit tests for the `Engine` struct and its methods. +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + /// Tests for template rendering in the `Engine` struct. + mod render_tests { + + use ssg_template::{Engine, TemplateError}; + use std::collections::HashMap; + + /// Test rendering a template with a valid context. + #[test] + fn test_engine_render_template() { + let engine = Engine::new("dummy/path"); + let mut context = HashMap::new(); + context.insert("name", "World"); + context.insert("greeting", "Hello"); + + let template = "{{greeting}}, {{name}}!"; + let result = + engine.render_template(template, &context).unwrap(); + assert_eq!(result, "Hello, World!"); + } + + /// Test rendering a template with unresolved tags. + #[test] + fn test_engine_render_template_unresolved_tags() { + let engine = Engine::new("dummy/path"); + let context = HashMap::new(); + + let template = "{{greeting}}, {{name}}!"; + let result = engine.render_template(template, &context); + assert!(matches!( + result, + Err(TemplateError::RenderError(_)) + )); + } + + /// Test rendering an empty template. + #[test] + fn test_engine_render_empty_template() { + let engine = Engine::new("dummy/path"); + let context = HashMap::new(); + + let template = ""; + let result = engine.render_template(template, &context); + + // Check that the result is an error, specifically the "Template is empty" error + assert!( + matches!(result, Err(TemplateError::RenderError(msg)) if msg == "Template is empty") + ); + } + + /// Test rendering a template with an empty context. + #[test] + fn test_engine_render_empty_context() { + let engine = Engine::new("dummy/path"); + let context = HashMap::new(); + + let template = "{{greeting}}, {{name}}!"; + let result = engine.render_template(template, &context); + assert!(matches!( + result, + Err(TemplateError::RenderError(_)) + )); + } + + /// Test rendering a template with special characters in the context. + #[test] + fn test_engine_render_special_characters_in_context() { + let engine = Engine::new("dummy/path"); + let mut context = HashMap::new(); + context.insert("name", ""); + context.insert("greeting", "&"); + + let template = "{{greeting}} {{name}}"; + let result = + engine.render_template(template, &context).unwrap(); + assert_eq!(result, "& "); + } + + /// Test rendering with a large context and template. + #[test] + fn test_engine_large_context() { + let engine = Engine::new("dummy/path"); + let mut context = HashMap::new(); + let keys: Vec = + (0..1000).map(|i| format!("key{}", i)).collect(); + let values: Vec = + (0..1000).map(|i| format!("value{}", i)).collect(); + + for i in 0..1000 { + context.insert(keys[i].as_str(), values[i].as_str()); + } + + let mut template = String::new(); + for i in 0..1000 { + template.push_str(&format!("{{{{key{}}}}}", i)); + } + + let result = + engine.render_template(&template, &context).unwrap(); + let expected_result = + (0..1000).fold(String::new(), |mut acc, i| { + use std::fmt::Write; + write!(&mut acc, "value{}", i).unwrap(); + acc + }); + + assert_eq!(result, expected_result); + } + } + + /// Tests related to file operations, such as downloading templates. + mod file_tests { + use ssg_template::{Context, Engine, TemplateError}; + + use super::*; + + /// Test downloading template files from a URL. + /// + /// Note: This test may fail if there is no internet connection or the URL is unreachable. + #[test] + fn test_engine_download_template_files() { + let engine = Engine::new("dummy/path"); + let url = "https://raw.githubusercontent.com/sebastienrousseau/shokunin/main/templates"; + let result = engine.download_template_files(url); + assert!(result.is_ok()); + } + + /// Test rendering with an invalid template path. + #[test] + fn test_engine_invalid_template_path() { + let engine = Engine::new("invalid/path"); + let context = Context { + elements: HashMap::new(), + }; + let result = + engine.render_page(&context, "nonexistent_layout"); + assert!(matches!(result, Err(TemplateError::Io(_)))); + } + } +} + +mod page_options_tests { + + use ssg_template::PageOptions; + + /// Test `PageOptions::new` to ensure it initializes an empty HashMap. + #[test] + fn test_page_options_new() { + let options = PageOptions::new(); + assert!(options.elements.is_empty()); + } + + /// Test `PageOptions::set` and `PageOptions::get` to ensure they behave as expected. + #[test] + fn test_page_options_set_get() { + let mut options = PageOptions::new(); + options.set("title", "My Title"); + assert_eq!(options.get("title"), Some(&"My Title")); + assert_eq!(options.get("non_existent"), None); + } +} + +mod file_operations_tests { + use ssg_template::{Context, Engine, TemplateError}; + use std::collections::HashMap; + use std::fs::File; + use std::io::Write; + use tempfile::tempdir; + + /// Test `render_page` with a valid template path. + #[test] + fn test_render_page_valid_path() { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + let layout_path = temp_dir.path().join("layout.html"); + + // Create a mock layout file in the temporary directory + let mut file = File::create(&layout_path).unwrap(); + writeln!( + file, + "{{{{greeting}}}}, {{{{name}}}}" + ) + .unwrap(); // Correct the placeholder format + + // Initialize engine with the temporary directory + let engine = Engine::new(temp_dir.path().to_str().unwrap()); + + // Prepare the context + let mut elements = HashMap::new(); + elements.insert("greeting", "Hello"); + elements.insert("name", "World"); + let context = Context { elements }; + + // Render the page with the mock layout + let result = engine.render_page(&context, "layout"); + assert!(result.is_ok()); + + // Trim the result and expected output to avoid newline mismatches + let binding = result.unwrap(); + let rendered_output = binding.trim(); + let expected_output = "Hello, World"; + + assert_eq!(rendered_output, expected_output); + } + + /// Test `render_page` with an invalid template path. + #[test] + fn test_render_page_invalid_path() { + let engine = Engine::new("invalid/path"); + let context = Context { + elements: HashMap::new(), + }; + let result = engine.render_page(&context, "nonexistent_layout"); + assert!(matches!(result, Err(TemplateError::Io(_)))); + } + + /// Test `render_page` when the file is missing. + #[test] + fn test_render_page_missing_file() { + let engine = Engine::new("missing/path"); + let context = Context { + elements: HashMap::new(), + }; + let result = engine.render_page(&context, "nonexistent_layout"); + assert!(matches!(result, Err(TemplateError::Io(_)))); + } +} + +mod context_edge_cases_tests { + + use ssg_template::{Engine, TemplateError}; + use std::collections::HashMap; + + /// Test rendering a template with an empty context. + #[test] + fn test_render_template_empty_context() { + let engine = Engine::new("dummy/path"); + let context = HashMap::new(); + + let template = "{{greeting}}, {{name}}!"; + let result = engine.render_template(template, &context); + assert!(matches!(result, Err(TemplateError::RenderError(_)))); + } + + /// Test rendering a template with special characters in the context. + #[test] + fn test_render_template_special_characters() { + let engine = Engine::new("dummy/path"); + let mut context = HashMap::new(); + context.insert("name", ""); + context.insert("greeting", "&"); + + let template = "{{greeting}} {{name}}"; + let result = + engine.render_template(template, &context).unwrap(); + assert_eq!(result, "& "); + } +} + +mod additional_tests { + + use ssg_template::{Context, Engine, PageOptions, TemplateError}; + use std::{collections::HashMap, fs::File}; + use tempfile::tempdir; + + /// Test rendering a template with an invalid format. + #[test] + fn test_engine_render_template_invalid_format() { + let engine = Engine::new("dummy/path"); + let mut context = HashMap::new(); + context.insert("name", "World"); + context.insert("greeting", "Hello"); + + // Invalid format: single curly braces instead of double + let template = "{greeting}, {name}!"; + let result = engine.render_template(template, &context); + assert!(matches!(result, Err(TemplateError::RenderError(_)))); + } + + /// Test rendering a page with an empty layout file. + #[test] + fn test_render_page_empty_layout_file() { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + let layout_path = temp_dir.path().join("layout.html"); + + // Create an empty layout file in the temporary directory + File::create(&layout_path).unwrap(); + + // Initialize engine with the temporary directory + let engine = Engine::new(temp_dir.path().to_str().unwrap()); + + // Prepare the context + let mut elements = HashMap::new(); + elements.insert("greeting", "Hello"); + elements.insert("name", "World"); + let context = Context { elements }; + + // Render the page with the empty layout + let result = engine.render_page(&context, "layout"); + assert!(matches!(result, Err(TemplateError::RenderError(_)))); + } + + /// Test rendering a page where the layout directory has permission errors. + #[test] + fn test_render_page_permission_error() { + // Simulate a directory with permission issues + let temp_dir = tempdir().unwrap(); + let layout_path = temp_dir.path().join("layout.html"); + + // Create the layout file, but simulate a permission error by not allowing writes + File::create(&layout_path).unwrap(); + let engine = Engine::new("/restricted/directory"); + + let mut elements = HashMap::new(); + elements.insert("greeting", "Hello"); + elements.insert("name", "World"); + let context = Context { elements }; + + let result = engine.render_page(&context, "layout"); + assert!(matches!(result, Err(TemplateError::Io(_)))); + } + + /// Test `PageOptions` with a large context. + #[test] + fn test_page_options_large_context() { + let mut options = PageOptions::new(); + let mut keys = Vec::new(); + let mut values = Vec::new(); + for i in 0..1000 { + let key = format!("key{}", i); + let value = format!("value{}", i); + keys.push(key); + values.push(value); + } + for i in 0..1000 { + options.set(&keys[i], &values[i]); + } + + assert_eq!(options.get("key999"), Some(&"value999")); + assert_eq!(options.get("key1000"), None); // Key not present + } + + /// Test rendering a template with an invalid context data type (e.g., integer values). + #[test] + fn test_render_template_invalid_context_data_type() { + let engine = Engine::new("templates/"); + let template = "Hello, {{name}}!"; + let mut invalid_context = HashMap::new(); + invalid_context.insert("name", "World"); // Valid + invalid_context.insert("number", "42"); // Invalid if expecting specific types + + let result = engine.render_template(template, &invalid_context); + assert!(result.is_ok()); + } + + /// Test render_template error handling with invalid template syntax + #[test] + fn test_render_template_invalid_template_syntax() { + let engine = Engine::new("templates/"); + let invalid_template = "Hello, {{name"; // Missing closing braces + let mut context = HashMap::new(); + context.insert("name", "World"); + + let result = engine.render_template(invalid_template, &context); + assert!(matches!(result, Err(TemplateError::RenderError(_)))); + } + + /// Test large template rendering + #[test] + fn test_render_large_template() { + let engine = Engine::new("templates/"); + let large_template = "Hello, {{name}}".repeat(1000); // Large template with repetitive pattern + let mut context = HashMap::new(); + context.insert("name", "World"); + + let result = engine.render_template(&large_template, &context); + assert!(result.is_ok()); + assert!(result.unwrap().contains("Hello, World")); + } + + /// Test PageOptions invalid set with unexpected data types. + #[test] + fn test_page_options_invalid_set() { + let mut options = PageOptions::new(); + + // Try setting invalid values (simulate, as PageOptions expects strings) + options.set("key1", "value1"); + options.set("key2", "value2"); + + assert_eq!(options.get("key1"), Some(&"value1")); + assert_eq!(options.get("key3"), None); // Ensure invalid key does not exist + } + + /// Test empty template rendering + #[test] + fn test_render_template_empty_template() { + let engine = Engine::new("templates/"); + let empty_template = ""; // Empty template + let mut context = HashMap::new(); + context.insert("name", "World"); + + let result = engine.render_template(empty_template, &context); + assert!(matches!(result, Err(TemplateError::RenderError(_)))); + } +} diff --git a/ssg-template/tests/error_tests.rs b/ssg-template/tests/error_tests.rs new file mode 100644 index 00000000..1b04871f --- /dev/null +++ b/ssg-template/tests/error_tests.rs @@ -0,0 +1,144 @@ +use ssg_template::TemplateError; + +/// Unit tests for TemplateError variants and their behavior. +#[cfg(test)] +mod template_error_tests { + use super::*; + use std::io; + + /// Test the `Io` variant of the `TemplateError` enum. + /// This test checks if an I/O error is correctly wrapped inside a `TemplateError::Io`. + #[test] + fn test_template_error_io() { + let io_error = + io::Error::new(io::ErrorKind::NotFound, "file not found"); + let template_error = TemplateError::Io(io_error); + assert!(matches!(template_error, TemplateError::Io(_))); + } + + /// Test the `Reqwest` variant of the `TemplateError` enum. + /// This test checks if an HTTP request error is correctly wrapped inside a `TemplateError::Reqwest`. + #[test] + fn test_template_error_reqwest() { + let reqwest_error = + reqwest::blocking::get("http://localhost:1").unwrap_err(); + let template_error = TemplateError::Reqwest(reqwest_error); + assert!(matches!(template_error, TemplateError::Reqwest(_))); + } + + /// Test the `InvalidSyntax` variant of the `TemplateError` enum. + /// This test checks if the `InvalidSyntax` error is correctly represented. + #[test] + fn test_template_error_invalid_syntax() { + let template_error = TemplateError::InvalidSyntax; + assert!(matches!(template_error, TemplateError::InvalidSyntax)); + } + + /// Test the `RenderError` variant of the `TemplateError` enum. + /// This test checks if a rendering error is correctly wrapped inside a `TemplateError::RenderError`. + #[test] + fn test_template_error_render_error() { + let template_error = + TemplateError::RenderError("Failed to render".to_string()); + assert!(matches!( + template_error, + TemplateError::RenderError(_) + )); + } + + /// Test the `Display` implementation for the `TemplateError::Io` variant. + /// This test checks if the display output for an I/O error is formatted correctly. + #[test] + fn test_template_error_io_display() { + let io_error = + io::Error::new(io::ErrorKind::NotFound, "file not found"); + let template_error = TemplateError::Io(io_error); + assert_eq!( + format!("{}", template_error), + "I/O error: file not found" + ); + } + + /// Test the `Display` implementation for the `TemplateError::Reqwest` variant. + /// This test checks if the display output for a Reqwest error is formatted correctly. + #[test] + fn test_template_error_reqwest_display() { + let reqwest_error = + reqwest::blocking::get("http://localhost:1").unwrap_err(); + let template_error = TemplateError::Reqwest(reqwest_error); + assert!( + format!("{}", template_error).starts_with("Request error:") + ); + } +} + +/// Additional tests for edge cases and error chaining related to the TemplateError enum. +#[cfg(test)] +mod additional_template_error_tests { + use super::*; + use std::io; + + /// Test chaining of IO errors using the `#[from]` attribute. + /// This ensures that I/O errors are correctly converted into `TemplateError::Io`. + #[test] + fn test_template_error_io_chaining() { + let io_error: io::Error = io::ErrorKind::NotFound.into(); + let template_error = TemplateError::from(io_error); + assert!(matches!(template_error, TemplateError::Io(_))); + } + + /// Test chaining of Reqwest errors using the `#[from]` attribute. + /// This ensures that Reqwest errors are correctly converted into `TemplateError::Reqwest`. + #[test] + fn test_template_error_reqwest_chaining() { + let reqwest_error = + reqwest::blocking::get("http://localhost:1").unwrap_err(); + let template_error = TemplateError::from(reqwest_error); + assert!(matches!(template_error, TemplateError::Reqwest(_))); + } + + /// Test custom error message for the `RenderError` variant. + /// This ensures that custom messages are preserved and displayed correctly. + #[test] + fn test_render_error_custom_message() { + let custom_message = "Custom render error message".to_string(); + let template_error = + TemplateError::RenderError(custom_message.clone()); + assert!(matches!( + template_error, + TemplateError::RenderError(_) + )); + assert_eq!( + format!("{}", template_error), + format!("Rendering error: {}", custom_message) + ); + } + + /// Test an unreachable case for `TemplateError`. + /// This is hypothetical, ensuring no undefined error variants are being used. + #[test] + fn test_template_error_unreachable() { + let result: Result<(), TemplateError> = + Err(TemplateError::InvalidSyntax); + assert!(matches!(result, Err(TemplateError::InvalidSyntax))); + } + + /// Test conversion consistency between different types of errors. + /// This ensures that both I/O and Reqwest errors are correctly handled by `TemplateError`. + #[test] + fn test_template_error_conversion_consistency() { + let io_error: io::Error = + io::ErrorKind::PermissionDenied.into(); + let reqwest_error = + reqwest::blocking::get("http://localhost:1").unwrap_err(); + + let io_template_error = TemplateError::from(io_error); + let reqwest_template_error = TemplateError::from(reqwest_error); + + assert!(matches!(io_template_error, TemplateError::Io(_))); + assert!(matches!( + reqwest_template_error, + TemplateError::Reqwest(_) + )); + } +}