From d8a99f725b9db8c602b4b98b097111e84ee47b19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:54:47 +0000 Subject: [PATCH 1/4] Initial plan From 72d08b1dcf5c854291062fa7ee7228a02a3dd46f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:16:04 +0000 Subject: [PATCH 2/4] feat: [#40] implement infrastructure templates module - Add TemplateProvider for generating configuration templates - Add EmbeddedTemplates with JSON template containing placeholders - Add TemplateError with comprehensive help messages - Add TemplateType enum for extensible template format support - Implement async file generation with directory creation - Add path validation and error handling - Add comprehensive unit tests (32 tests, all passing) - Update infrastructure module exports Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/infrastructure/mod.rs | 2 + src/infrastructure/templates/embedded.rs | 166 ++++++++ src/infrastructure/templates/errors.rs | 233 +++++++++++ src/infrastructure/templates/mod.rs | 51 +++ src/infrastructure/templates/provider.rs | 498 +++++++++++++++++++++++ 5 files changed, 950 insertions(+) create mode 100644 src/infrastructure/templates/embedded.rs create mode 100644 src/infrastructure/templates/errors.rs create mode 100644 src/infrastructure/templates/mod.rs create mode 100644 src/infrastructure/templates/provider.rs diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 64fca4c3..0e11090f 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -13,9 +13,11 @@ //! - `template` - Template rendering delivery mechanisms (wrappers) //! - `remote_actions` - Repository-like implementations for remote system operations //! - `persistence` - Persistence infrastructure (repositories, file locking, storage) +//! - `templates` - Configuration template generation for user-facing configuration files //! - `trace` - Trace file generation for error analysis pub mod external_tools; pub mod persistence; pub mod remote_actions; +pub mod templates; pub mod trace; diff --git a/src/infrastructure/templates/embedded.rs b/src/infrastructure/templates/embedded.rs new file mode 100644 index 00000000..45a6501d --- /dev/null +++ b/src/infrastructure/templates/embedded.rs @@ -0,0 +1,166 @@ +//! Embedded Template Resources +//! +//! This module provides embedded configuration templates that are compiled +//! into the binary at build time. Templates use simple placeholders that +//! users can easily find and replace. + +use super::provider::TemplateType; + +/// Container for embedded template resources +/// +/// Templates are embedded in the binary at compile time to ensure +/// they're always available without external dependencies. +pub struct EmbeddedTemplates; + +impl EmbeddedTemplates { + /// Create a new embedded templates container + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Get template content for the specified type + /// + /// Returns the template as a static string if the template type is supported, + /// or `None` if the template type is not found. + #[must_use] + pub const fn get_template(&self, template_type: TemplateType) -> Option<&'static str> { + match template_type { + TemplateType::Json => Some(JSON_TEMPLATE), + } + } + + /// Get list of all available template types + #[must_use] + pub fn available_templates(&self) -> Vec { + vec![TemplateType::Json] + } +} + +impl Default for EmbeddedTemplates { + fn default() -> Self { + Self::new() + } +} + +/// JSON configuration template +/// +/// This template provides a complete example of the configuration format +/// with placeholder values that users can replace with their actual values. +/// +/// The template uses simple, easily searchable placeholders like +/// `REPLACE_WITH_*` to make it clear what needs to be filled in. +const JSON_TEMPLATE: &str = r#"{ + "environment": { + "name": "REPLACE_WITH_ENVIRONMENT_NAME" + }, + "ssh_credentials": { + "private_key_path": "REPLACE_WITH_SSH_PRIVATE_KEY_PATH", + "public_key_path": "REPLACE_WITH_SSH_PUBLIC_KEY_PATH", + "username": "torrust", + "port": 22 + } +}"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_provide_valid_json_template() { + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json).unwrap(); + + // Verify the template is valid JSON + let _value: serde_json::Value = + serde_json::from_str(template).expect("JSON template should be valid JSON"); + } + + #[test] + fn it_should_contain_required_placeholder_fields() { + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json).unwrap(); + + // Verify required placeholders are present + assert!(template.contains("REPLACE_WITH_ENVIRONMENT_NAME")); + assert!(template.contains("REPLACE_WITH_SSH_PRIVATE_KEY_PATH")); + assert!(template.contains("REPLACE_WITH_SSH_PUBLIC_KEY_PATH")); + + // Verify default values are present + assert!(template.contains(r#""username": "torrust""#)); + assert!(template.contains(r#""port": 22"#)); + } + + #[test] + fn it_should_list_available_templates() { + let embedded = EmbeddedTemplates::new(); + let templates = embedded.available_templates(); + + assert_eq!(templates.len(), 1); + assert!(templates.contains(&TemplateType::Json)); + } + + #[test] + fn it_should_create_via_default_trait() { + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json); + assert!(template.is_some()); + } + + #[test] + fn it_should_return_none_for_unsupported_template() { + let embedded = EmbeddedTemplates::new(); + // We can't test with an actual unsupported type since the enum only has Json + // But we verify the pattern by checking that Json works + assert!(embedded.get_template(TemplateType::Json).is_some()); + } + + #[test] + fn it_should_have_well_formatted_json() { + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json).unwrap(); + + // Parse and re-serialize to verify formatting + let parsed: serde_json::Value = serde_json::from_str(template).unwrap(); + let reformatted = serde_json::to_string_pretty(&parsed).unwrap(); + + // Both should parse to the same value + let parsed_again: serde_json::Value = serde_json::from_str(&reformatted).unwrap(); + assert_eq!(parsed, parsed_again); + } + + #[test] + fn it_should_match_environment_creation_config_structure() { + use crate::domain::config::EnvironmentCreationConfig; + + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json).unwrap(); + + // Verify template can be parsed as EnvironmentCreationConfig + // (even with placeholder values) + let result: Result = serde_json::from_str(template); + + // Should succeed because placeholders are valid strings + assert!(result.is_ok(), "Template should match config structure"); + } + + #[test] + fn it_should_have_consistent_placeholder_naming() { + let embedded = EmbeddedTemplates::new(); + let template = embedded.get_template(TemplateType::Json).unwrap(); + + // All placeholders should follow the REPLACE_WITH_* pattern + let placeholders = vec![ + "REPLACE_WITH_ENVIRONMENT_NAME", + "REPLACE_WITH_SSH_PRIVATE_KEY_PATH", + "REPLACE_WITH_SSH_PUBLIC_KEY_PATH", + ]; + + for placeholder in placeholders { + assert!( + template.contains(placeholder), + "Missing placeholder: {placeholder}" + ); + } + } +} diff --git a/src/infrastructure/templates/errors.rs b/src/infrastructure/templates/errors.rs new file mode 100644 index 00000000..b04ccd01 --- /dev/null +++ b/src/infrastructure/templates/errors.rs @@ -0,0 +1,233 @@ +//! Template Error Types +//! +//! This module provides structured error types for template operations with +//! comprehensive error context and actionable troubleshooting guidance. + +use std::path::PathBuf; +use thiserror::Error; + +/// Errors that can occur during template operations +/// +/// These errors represent infrastructure-level failures in template +/// handling and provide structured context for troubleshooting. +#[derive(Debug, Error)] +pub enum TemplateError { + /// Template was not found in embedded resources + #[error("Template not found: {template_type}")] + TemplateNotFound { template_type: String }, + + /// Requested template type is not supported + #[error("Unsupported template type: {requested_type}")] + UnsupportedTemplateType { + requested_type: String, + supported_types: Vec, + }, + + /// Output path is invalid or unsuitable for template generation + #[error("Invalid output path: {path} - {reason}")] + InvalidOutputPath { path: PathBuf, reason: String }, + + /// Failed to create directory for template file + #[error("Failed to create directory: {path}")] + DirectoryCreationFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Failed to write template file + #[error("Failed to write template file: {path}")] + FileWriteFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Template validation failed (indicates a bug in embedded templates) + #[error("Template validation failed: {template_type}")] + TemplateValidationFailed { + template_type: String, + #[source] + source: serde_json::Error, + }, +} + +impl TemplateError { + /// Get detailed troubleshooting guidance for this error + /// + /// Returns multi-line help text with specific steps to resolve the error. + /// This follows the project's tiered help system pattern. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::TemplateNotFound { .. } => { + "Template Not Found - Detailed Troubleshooting: + +1. Check if the template type is supported +2. Verify the application binary includes embedded templates +3. Try regenerating templates if they should be available +4. Report issue if template should be available but is missing + +For more information, see the template documentation." + } + + Self::UnsupportedTemplateType { .. } => { + "Unsupported Template Type - Detailed Troubleshooting: + +1. Use 'json' for JSON templates (currently supported) +2. TOML support will be added in a future release +3. Verify you are using the correct template type format + +For more information, see the template format documentation." + } + + Self::InvalidOutputPath { .. } => { + "Invalid Output Path - Detailed Troubleshooting: + +1. Ensure the path points to a file (not directory) +2. Use correct file extension (.json for JSON templates) +3. Verify parent directory exists or can be created +4. Check write permissions for the target location + +For more information, see the file system documentation." + } + + Self::DirectoryCreationFailed { .. } => { + "Directory Creation Failed - Detailed Troubleshooting: + +1. Check write permissions for the parent directory +2. Verify disk space is available: df -h +3. Ensure no file exists with the same name as the directory +4. Check path length limits on your system + +For more information, see the filesystem troubleshooting guide." + } + + Self::FileWriteFailed { .. } => { + "Template File Write Failed - Detailed Troubleshooting: + +1. Check write permissions for the target file and directory +2. Verify disk space is available: df -h +3. Ensure the file is not open in another application +4. Check if antivirus software is blocking file creation + +For more information, see the file operations documentation." + } + + Self::TemplateValidationFailed { .. } => { + "Template Validation Failed - Detailed Troubleshooting: + +1. This indicates a bug in the embedded templates +2. Report this issue with full error details +3. Use --generate-template to create a fresh template +4. Check for application updates + +This is likely a software bug that needs to be reported." + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_provide_help_for_template_not_found() { + let error = TemplateError::TemplateNotFound { + template_type: "YAML".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Template Not Found")); + assert!(help.contains("Check if the template type is supported")); + } + + #[test] + fn it_should_provide_help_for_unsupported_template_type() { + let error = TemplateError::UnsupportedTemplateType { + requested_type: "xml".to_string(), + supported_types: vec!["json".to_string()], + }; + + let help = error.help(); + assert!(help.contains("Unsupported Template Type")); + assert!(help.contains("Use 'json' for JSON templates")); + } + + #[test] + fn it_should_provide_help_for_invalid_output_path() { + let error = TemplateError::InvalidOutputPath { + path: PathBuf::from("/tmp/test"), + reason: "Path is a directory".to_string(), + }; + + let help = error.help(); + assert!(help.contains("Invalid Output Path")); + assert!(help.contains("Ensure the path points to a file")); + } + + #[test] + fn it_should_provide_help_for_directory_creation_failed() { + let error = TemplateError::DirectoryCreationFailed { + path: PathBuf::from("/tmp/test"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + + let help = error.help(); + assert!(help.contains("Directory Creation Failed")); + assert!(help.contains("Check write permissions")); + } + + #[test] + fn it_should_provide_help_for_file_write_failed() { + let error = TemplateError::FileWriteFailed { + path: PathBuf::from("/tmp/test.json"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + + let help = error.help(); + assert!(help.contains("Template File Write Failed")); + assert!(help.contains("Check write permissions")); + } + + #[test] + fn it_should_provide_help_for_template_validation_failed() { + let error = TemplateError::TemplateValidationFailed { + template_type: "JSON".to_string(), + source: serde_json::from_str::("invalid").unwrap_err(), + }; + + let help = error.help(); + assert!(help.contains("Template Validation Failed")); + assert!(help.contains("This indicates a bug")); + } + + #[test] + fn it_should_format_error_messages_correctly() { + let error = TemplateError::TemplateNotFound { + template_type: "YAML".to_string(), + }; + assert_eq!(error.to_string(), "Template not found: YAML"); + + let error = TemplateError::UnsupportedTemplateType { + requested_type: "xml".to_string(), + supported_types: vec!["json".to_string()], + }; + assert_eq!(error.to_string(), "Unsupported template type: xml"); + } + + #[test] + fn it_should_preserve_source_errors() { + let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test error"); + let error = TemplateError::FileWriteFailed { + path: PathBuf::from("/tmp/test.json"), + source: io_error, + }; + + // Verify source error is accessible + let source = std::error::Error::source(&error); + assert!(source.is_some()); + assert_eq!(source.unwrap().to_string(), "test error"); + } +} diff --git a/src/infrastructure/templates/mod.rs b/src/infrastructure/templates/mod.rs new file mode 100644 index 00000000..af9cd3b0 --- /dev/null +++ b/src/infrastructure/templates/mod.rs @@ -0,0 +1,51 @@ +//! Template System for Configuration File Generation +//! +//! This module provides infrastructure for generating configuration file templates +//! that users can fill out to create deployment environments. It handles embedded +//! template resources and file system operations for template generation. +//! +//! ## Key Features +//! +//! - Embedded JSON configuration templates +//! - Async file generation with proper directory creation +//! - Comprehensive error handling with actionable guidance +//! - Extensible architecture for future template formats +//! +//! ## Usage Example +//! +//! ```rust +//! use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; +//! use std::path::Path; +//! +//! # async fn example() -> Result<(), Box> { +//! let provider = TemplateProvider::new(); +//! +//! // Generate template at specific path +//! provider.generate_template( +//! TemplateType::Json, +//! Path::new("./environment-config.json") +//! ).await?; +//! +//! // Or generate with default filename in a directory +//! let path = provider.generate_template_in_directory( +//! TemplateType::Json, +//! Path::new("./configs") +//! ).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Architecture +//! +//! - `provider` - High-level template generation API +//! - `embedded` - Embedded template resources (compile-time) +//! - `errors` - Template-specific error types with detailed help + +pub mod embedded; +pub mod errors; +pub mod provider; + +// Re-export commonly used types +pub use embedded::EmbeddedTemplates; +pub use errors::TemplateError; +pub use provider::{TemplateProvider, TemplateType}; diff --git a/src/infrastructure/templates/provider.rs b/src/infrastructure/templates/provider.rs new file mode 100644 index 00000000..d1f6e4c8 --- /dev/null +++ b/src/infrastructure/templates/provider.rs @@ -0,0 +1,498 @@ +//! Template Provider Implementation +//! +//! This module provides the high-level API for template generation with +//! async file operations and comprehensive error handling. + +use std::path::{Path, PathBuf}; + +use super::embedded::EmbeddedTemplates; +use super::errors::TemplateError; + +/// Provider for configuration templates +/// +/// Handles template retrieval from embedded resources and generation +/// of template files on the filesystem. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; +/// use std::path::Path; +/// +/// # async fn example() -> Result<(), Box> { +/// let provider = TemplateProvider::new(); +/// +/// // Generate template at specific path +/// provider.generate_template( +/// TemplateType::Json, +/// Path::new("./config.json") +/// ).await?; +/// # Ok(()) +/// # } +/// ``` +pub struct TemplateProvider { + embedded: EmbeddedTemplates, +} + +impl TemplateProvider { + /// Create a new template provider + #[must_use] + pub fn new() -> Self { + Self { + embedded: EmbeddedTemplates::new(), + } + } + + /// Generate a template file at the specified path + /// + /// This method creates a configuration template file with placeholder values + /// that users can fill in. It handles directory creation and validates the + /// output path. + /// + /// # Arguments + /// + /// * `template_type` - Type of template to generate (currently only JSON) + /// * `output_path` - Path where the template file should be created + /// + /// # Returns + /// + /// * `Ok(())` - Template generated successfully + /// * `Err(TemplateError)` - Template generation failed + /// + /// # Errors + /// + /// Returns an error if: + /// - Template type is not supported + /// - Output path is invalid (wrong extension, is a directory, etc.) + /// - Parent directory cannot be created + /// - File cannot be written due to permissions or I/O errors + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; + /// use std::path::Path; + /// + /// # async fn example() -> Result<(), Box> { + /// let provider = TemplateProvider::new(); + /// provider.generate_template( + /// TemplateType::Json, + /// Path::new("./environment-template.json") + /// ).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn generate_template( + &self, + template_type: TemplateType, + output_path: &Path, + ) -> Result<(), TemplateError> { + // Get template content from embedded resources + let template_content = self.embedded.get_template(template_type).ok_or_else(|| { + TemplateError::TemplateNotFound { + template_type: template_type.to_string(), + } + })?; + + // Validate output path + Self::validate_output_path(output_path)?; + + // Create parent directories if they don't exist + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|source| { + TemplateError::DirectoryCreationFailed { + path: parent.to_path_buf(), + source, + } + })?; + } + + // Write template to file + tokio::fs::write(output_path, template_content) + .await + .map_err(|source| TemplateError::FileWriteFailed { + path: output_path.to_path_buf(), + source, + })?; + + Ok(()) + } + + /// Generate template with default filename in specified directory + /// + /// This is a convenience method that generates a template using the default + /// filename for the template type in the specified directory. + /// + /// # Arguments + /// + /// * `template_type` - Type of template to generate + /// * `directory` - Directory where template should be created + /// + /// # Returns + /// + /// * `Ok(PathBuf)` - Path to the generated template file + /// * `Err(TemplateError)` - Template generation failed + /// + /// # Errors + /// + /// Returns an error if: + /// - Template type is not supported + /// - Directory cannot be created + /// - File cannot be written due to permissions or I/O errors + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; + /// use std::path::Path; + /// + /// # async fn example() -> Result<(), Box> { + /// let provider = TemplateProvider::new(); + /// let path = provider.generate_template_in_directory( + /// TemplateType::Json, + /// Path::new("./configs") + /// ).await?; + /// println!("Template generated at: {}", path.display()); + /// # Ok(()) + /// # } + /// ``` + pub async fn generate_template_in_directory( + &self, + template_type: TemplateType, + directory: &Path, + ) -> Result { + let filename = template_type.default_filename(); + let output_path = directory.join(filename); + + self.generate_template(template_type, &output_path).await?; + + Ok(output_path) + } + + /// Get template content as string without writing to file + /// + /// Useful for testing and programmatic access to templates. + /// + /// # Errors + /// + /// Returns `TemplateError::TemplateNotFound` if the template type is not supported. + pub fn get_template_content(&self, template_type: TemplateType) -> Result<&str, TemplateError> { + self.embedded + .get_template(template_type) + .ok_or_else(|| TemplateError::TemplateNotFound { + template_type: template_type.to_string(), + }) + } + + /// List all available template types + #[must_use] + pub fn available_templates(&self) -> Vec { + self.embedded.available_templates() + } + + /// Validate that the output path is suitable for template generation + fn validate_output_path(path: &Path) -> Result<(), TemplateError> { + // Check if path already exists and is not a file + if path.exists() && !path.is_file() { + return Err(TemplateError::InvalidOutputPath { + path: path.to_path_buf(), + reason: "Path exists but is not a file".to_string(), + }); + } + + // Validate file extension matches template type (JSON) + if let Some(extension) = path.extension() { + if extension != "json" { + return Err(TemplateError::InvalidOutputPath { + path: path.to_path_buf(), + reason: format!( + "File extension '{}' does not match JSON template type", + extension.to_string_lossy() + ), + }); + } + } else { + return Err(TemplateError::InvalidOutputPath { + path: path.to_path_buf(), + reason: "No file extension specified".to_string(), + }); + } + + Ok(()) + } +} + +impl Default for TemplateProvider { + fn default() -> Self { + Self::new() + } +} + +/// Supported template types +/// +/// Currently only JSON is supported, with plans to add TOML and YAML in the future. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateType { + /// JSON configuration template + Json, + // Future: Toml, Yaml +} + +impl TemplateType { + /// Get the default filename for this template type + #[must_use] + pub const fn default_filename(&self) -> &'static str { + match self { + Self::Json => "environment-template.json", + } + } + + /// Get the file extension for this template type + #[must_use] + pub const fn file_extension(&self) -> &'static str { + match self { + Self::Json => "json", + } + } +} + +impl std::fmt::Display for TemplateType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Json => write!(f, "JSON"), + } + } +} + +impl std::str::FromStr for TemplateType { + type Err = TemplateError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(Self::Json), + _ => Err(TemplateError::UnsupportedTemplateType { + requested_type: s.to_string(), + supported_types: vec!["json".to_string()], + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn it_should_generate_template_at_specified_path() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.path().join("config.json"); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &output_path) + .await; + + assert!(result.is_ok()); + assert!(output_path.exists()); + + // Verify content is valid JSON + let content = std::fs::read_to_string(&output_path).unwrap(); + let _value: serde_json::Value = serde_json::from_str(&content).unwrap(); + } + + #[tokio::test] + async fn it_should_generate_template_in_directory() { + let temp_dir = TempDir::new().unwrap(); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template_in_directory(TemplateType::Json, temp_dir.path()) + .await; + + assert!(result.is_ok()); + let output_path = result.unwrap(); + assert!(output_path.exists()); + assert_eq!( + output_path.file_name().unwrap(), + "environment-template.json" + ); + } + + #[tokio::test] + async fn it_should_create_parent_directories() { + let temp_dir = TempDir::new().unwrap(); + let nested_path = temp_dir + .path() + .join("configs") + .join("env") + .join("test.json"); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &nested_path) + .await; + + assert!(result.is_ok()); + assert!(nested_path.exists()); + assert!(nested_path.parent().unwrap().exists()); + } + + #[tokio::test] + async fn it_should_fail_with_invalid_extension() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.path().join("config.txt"); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &output_path) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TemplateError::InvalidOutputPath { reason, .. } => { + assert!(reason.contains("does not match JSON")); + } + other => panic!("Expected InvalidOutputPath error, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_fail_with_no_extension() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.path().join("config"); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &output_path) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TemplateError::InvalidOutputPath { reason, .. } => { + assert!(reason.contains("No file extension")); + } + other => panic!("Expected InvalidOutputPath error, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_fail_if_path_is_directory() { + let temp_dir = TempDir::new().unwrap(); + let dir_path = temp_dir.path().join("subdir"); + std::fs::create_dir(&dir_path).unwrap(); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &dir_path) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + TemplateError::InvalidOutputPath { reason, .. } => { + assert!(reason.contains("not a file")); + } + other => panic!("Expected InvalidOutputPath error, got: {other:?}"), + } + } + + #[tokio::test] + async fn it_should_overwrite_existing_file() { + let temp_dir = TempDir::new().unwrap(); + let output_path = temp_dir.path().join("config.json"); + + // Create initial file + std::fs::write(&output_path, "old content").unwrap(); + + let provider = TemplateProvider::new(); + let result = provider + .generate_template(TemplateType::Json, &output_path) + .await; + + assert!(result.is_ok()); + + // Verify content was replaced + let content = std::fs::read_to_string(&output_path).unwrap(); + assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME")); + assert!(!content.contains("old content")); + } + + #[test] + fn it_should_get_template_content() { + let provider = TemplateProvider::new(); + let result = provider.get_template_content(TemplateType::Json); + + assert!(result.is_ok()); + let content = result.unwrap(); + assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME")); + } + + #[test] + fn it_should_list_available_templates() { + let provider = TemplateProvider::new(); + let templates = provider.available_templates(); + + assert_eq!(templates.len(), 1); + assert!(templates.contains(&TemplateType::Json)); + } + + #[test] + fn it_should_create_via_default_trait() { + let provider = TemplateProvider::default(); + let templates = provider.available_templates(); + assert!(!templates.is_empty()); + } + + // TemplateType tests + #[test] + fn it_should_have_correct_default_filename() { + assert_eq!( + TemplateType::Json.default_filename(), + "environment-template.json" + ); + } + + #[test] + fn it_should_have_correct_file_extension() { + assert_eq!(TemplateType::Json.file_extension(), "json"); + } + + #[test] + fn it_should_display_template_type() { + assert_eq!(TemplateType::Json.to_string(), "JSON"); + } + + #[test] + fn it_should_parse_from_string() { + assert_eq!("json".parse::().unwrap(), TemplateType::Json); + assert_eq!("JSON".parse::().unwrap(), TemplateType::Json); + assert_eq!("Json".parse::().unwrap(), TemplateType::Json); + } + + #[test] + fn it_should_fail_parsing_unsupported_type() { + let result = "yaml".parse::(); + assert!(result.is_err()); + + match result.unwrap_err() { + TemplateError::UnsupportedTemplateType { + requested_type, + supported_types, + } => { + assert_eq!(requested_type, "yaml"); + assert_eq!(supported_types, vec!["json"]); + } + other => panic!("Expected UnsupportedTemplateType error, got: {other:?}"), + } + } + + #[test] + fn it_should_be_copy_and_clone() { + let t1 = TemplateType::Json; + let t2 = t1; // Copy + let t3 = t1; // Also copy (not clone) + + assert_eq!(t1, t2); + assert_eq!(t1, t3); + } +} From 913e9be5251c552e10f8516e1164b3b780dd67d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 12:25:30 +0000 Subject: [PATCH 3/4] fix: [#40] make template validation extensible for future formats - Add template_type parameter to validate_output_path - Use template_type.file_extension() instead of hardcoded 'json' - Improve error messages to include template type name - Make code ready for TOML/YAML support without changes Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/infrastructure/templates/provider.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/templates/provider.rs b/src/infrastructure/templates/provider.rs index d1f6e4c8..c0f65e62 100644 --- a/src/infrastructure/templates/provider.rs +++ b/src/infrastructure/templates/provider.rs @@ -95,7 +95,7 @@ impl TemplateProvider { })?; // Validate output path - Self::validate_output_path(output_path)?; + Self::validate_output_path(output_path, template_type)?; // Create parent directories if they don't exist if let Some(parent) = output_path.parent() { @@ -191,7 +191,7 @@ impl TemplateProvider { } /// Validate that the output path is suitable for template generation - fn validate_output_path(path: &Path) -> Result<(), TemplateError> { + fn validate_output_path(path: &Path, template_type: TemplateType) -> Result<(), TemplateError> { // Check if path already exists and is not a file if path.exists() && !path.is_file() { return Err(TemplateError::InvalidOutputPath { @@ -200,14 +200,17 @@ impl TemplateProvider { }); } - // Validate file extension matches template type (JSON) + // Validate file extension matches template type + let expected_extension = template_type.file_extension(); if let Some(extension) = path.extension() { - if extension != "json" { + if extension != expected_extension { return Err(TemplateError::InvalidOutputPath { path: path.to_path_buf(), reason: format!( - "File extension '{}' does not match JSON template type", - extension.to_string_lossy() + "File extension '{}' does not match {} template type (expected '{}')", + extension.to_string_lossy(), + template_type, + expected_extension ), }); } From 691a1c7639074dc353555c54637212af27acd23b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 07:55:01 +0000 Subject: [PATCH 4/4] refactor: [#40] use struct-based template generation instead of hardcoded strings Replace infrastructure/templates module with struct-based approach: - Add template() method to EnvironmentCreationConfig - Add generate_template_file() for async file generation - Add template generation error types to CreateConfigError - Remove src/infrastructure/templates/ module (no longer needed) - Generate template from struct = type-safe, auto-synced, zero duplication Benefits: - Compiler guarantees template matches struct - Add/remove fields automatically updates template - No manual synchronization needed - Template IS the struct definition Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/domain/config/environment_config.rs | 218 ++++++++++ src/domain/config/errors.rs | 108 +++++ src/infrastructure/mod.rs | 2 - src/infrastructure/templates/embedded.rs | 166 -------- src/infrastructure/templates/errors.rs | 233 ----------- src/infrastructure/templates/mod.rs | 51 --- src/infrastructure/templates/provider.rs | 501 ----------------------- 7 files changed, 326 insertions(+), 953 deletions(-) delete mode 100644 src/infrastructure/templates/embedded.rs delete mode 100644 src/infrastructure/templates/errors.rs delete mode 100644 src/infrastructure/templates/mod.rs delete mode 100644 src/infrastructure/templates/provider.rs diff --git a/src/domain/config/environment_config.rs b/src/domain/config/environment_config.rs index 98ada66a..aea4304f 100644 --- a/src/domain/config/environment_config.rs +++ b/src/domain/config/environment_config.rs @@ -152,6 +152,100 @@ impl EnvironmentCreationConfig { Ok((environment_name, ssh_credentials, ssh_port)) } + + /// Creates a template instance with placeholder values + /// + /// This method generates a configuration template with placeholder values + /// that users can replace with their actual configuration. The template + /// structure matches the `EnvironmentCreationConfig` exactly, ensuring + /// type safety and automatic synchronization with struct changes. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::config::EnvironmentCreationConfig; + /// + /// let template = EnvironmentCreationConfig::template(); + /// assert_eq!(template.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME"); + /// ``` + #[must_use] + pub fn template() -> Self { + Self { + environment: EnvironmentSection { + name: "REPLACE_WITH_ENVIRONMENT_NAME".to_string(), + }, + ssh_credentials: SshCredentialsConfig { + private_key_path: "REPLACE_WITH_SSH_PRIVATE_KEY_PATH".to_string(), + public_key_path: "REPLACE_WITH_SSH_PUBLIC_KEY_PATH".to_string(), + username: "torrust".to_string(), // default value + port: 22, // default value + }, + } + } + + /// Generates a configuration template file at the specified path + /// + /// This method creates a JSON configuration file with placeholder values + /// that users can edit. The file is formatted with pretty-printing for + /// better readability. + /// + /// # Arguments + /// + /// * `path` - Path where the template file should be created + /// + /// # Returns + /// + /// * `Ok(())` - Template file created successfully + /// * `Err(CreateConfigError)` - File creation or serialization failed + /// + /// # Errors + /// + /// Returns an error if: + /// - Parent directory cannot be created + /// - Template serialization fails (unlikely - indicates a bug) + /// - File cannot be written due to permissions or I/O errors + /// + /// # Examples + /// + /// ```rust,no_run + /// use torrust_tracker_deployer_lib::domain::config::EnvironmentCreationConfig; + /// use std::path::Path; + /// + /// # async fn example() -> Result<(), Box> { + /// EnvironmentCreationConfig::generate_template_file( + /// Path::new("./environment-config.json") + /// ).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn generate_template_file(path: &std::path::Path) -> Result<(), CreateConfigError> { + // Create template instance with placeholders + let template = Self::template(); + + // Serialize to pretty-printed JSON + let json = serde_json::to_string_pretty(&template) + .map_err(|source| CreateConfigError::TemplateSerializationFailed { source })?; + + // Create parent directories if they don't exist + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|source| { + CreateConfigError::TemplateDirectoryCreationFailed { + path: parent.to_path_buf(), + source, + } + })?; + } + + // Write template to file + tokio::fs::write(path, json).await.map_err(|source| { + CreateConfigError::TemplateFileWriteFailed { + path: path.to_path_buf(), + source, + } + })?; + + Ok(()) + } } #[cfg(test)] @@ -419,4 +513,128 @@ mod tests { assert_eq!(original, deserialized); } + + #[test] + fn test_template_has_placeholder_values() { + let template = EnvironmentCreationConfig::template(); + + assert_eq!(template.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME"); + assert_eq!( + template.ssh_credentials.private_key_path, + "REPLACE_WITH_SSH_PRIVATE_KEY_PATH" + ); + assert_eq!( + template.ssh_credentials.public_key_path, + "REPLACE_WITH_SSH_PUBLIC_KEY_PATH" + ); + assert_eq!(template.ssh_credentials.username, "torrust"); + assert_eq!(template.ssh_credentials.port, 22); + } + + #[test] + fn test_template_serializes_to_valid_json() { + let template = EnvironmentCreationConfig::template(); + let json = serde_json::to_string_pretty(&template).unwrap(); + + // Verify it can be deserialized back + let deserialized: EnvironmentCreationConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(template, deserialized); + } + + #[test] + fn test_template_structure_matches_config() { + let template = EnvironmentCreationConfig::template(); + + // Verify template has same structure as regular config + let regular_config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "test".to_string(), + }, + SshCredentialsConfig::new( + "path1".to_string(), + "path2".to_string(), + "user".to_string(), + 22, + ), + ); + + // Both should serialize to same structure (different values) + let template_json = serde_json::to_value(&template).unwrap(); + let config_json = serde_json::to_value(®ular_config).unwrap(); + + // Check structure matches + assert!(template_json.is_object()); + assert!(config_json.is_object()); + + let template_obj = template_json.as_object().unwrap(); + let config_obj = config_json.as_object().unwrap(); + + assert_eq!(template_obj.keys().len(), config_obj.keys().len()); + assert!(template_obj.contains_key("environment")); + assert!(template_obj.contains_key("ssh_credentials")); + } + + #[tokio::test] + async fn test_generate_template_file() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let template_path = temp_dir.path().join("config.json"); + + let result = EnvironmentCreationConfig::generate_template_file(&template_path).await; + assert!(result.is_ok()); + + // Verify file exists + assert!(template_path.exists()); + + // Verify content is valid JSON + let content = std::fs::read_to_string(&template_path).unwrap(); + let parsed: EnvironmentCreationConfig = serde_json::from_str(&content).unwrap(); + + // Verify placeholders are present + assert_eq!(parsed.environment.name, "REPLACE_WITH_ENVIRONMENT_NAME"); + assert_eq!( + parsed.ssh_credentials.private_key_path, + "REPLACE_WITH_SSH_PRIVATE_KEY_PATH" + ); + } + + #[tokio::test] + async fn test_generate_template_file_creates_parent_directories() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let nested_path = temp_dir + .path() + .join("configs") + .join("env") + .join("test.json"); + + let result = EnvironmentCreationConfig::generate_template_file(&nested_path).await; + assert!(result.is_ok()); + + // Verify nested directories were created + assert!(nested_path.exists()); + assert!(nested_path.parent().unwrap().exists()); + } + + #[tokio::test] + async fn test_generate_template_file_overwrites_existing() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let template_path = temp_dir.path().join("config.json"); + + // Create initial file + std::fs::write(&template_path, "old content").unwrap(); + + // Generate template should overwrite + let result = EnvironmentCreationConfig::generate_template_file(&template_path).await; + assert!(result.is_ok()); + + // Verify content was replaced + let content = std::fs::read_to_string(&template_path).unwrap(); + assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME")); + assert!(!content.contains("old content")); + } } diff --git a/src/domain/config/errors.rs b/src/domain/config/errors.rs index 9efb8229..759a5cfc 100644 --- a/src/domain/config/errors.rs +++ b/src/domain/config/errors.rs @@ -35,6 +35,29 @@ pub enum CreateConfigError { /// Invalid SSH port (must be 1-65535) #[error("Invalid SSH port: {port} (must be between 1 and 65535)")] InvalidPort { port: u16 }, + + /// Failed to serialize configuration template to JSON + #[error("Failed to serialize configuration template to JSON")] + TemplateSerializationFailed { + #[source] + source: serde_json::Error, + }, + + /// Failed to create parent directory for template file + #[error("Failed to create directory: {path}")] + TemplateDirectoryCreationFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Failed to write template file + #[error("Failed to write template file: {path}")] + TemplateFileWriteFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, } impl CreateConfigError { @@ -59,6 +82,7 @@ impl CreateConfigError { /// assert!(help.contains("Check that the file path is correct")); /// ``` #[must_use] + #[allow(clippy::too_many_lines)] pub fn help(&self) -> &'static str { match self { Self::InvalidEnvironmentName(_) => { @@ -129,6 +153,51 @@ impl CreateConfigError { \n\ Fix: Update the SSH port in your configuration to a valid port number (1-65535)." } + Self::TemplateSerializationFailed { .. } => { + "Template serialization failed.\n\ + \n\ + This indicates an internal error in template generation.\n\ + \n\ + Common causes:\n\ + - Software bug in template generation logic\n\ + - Invalid data structure for JSON serialization\n\ + \n\ + Fix:\n\ + 1. Report this issue with full error details\n\ + 2. Check for application updates\n\ + \n\ + This is likely a software bug that needs to be reported." + } + Self::TemplateDirectoryCreationFailed { .. } => { + "Failed to create directory for template file.\n\ + \n\ + Common causes:\n\ + - Insufficient permissions to create directory\n\ + - No disk space available\n\ + - A file exists with the same name as the directory\n\ + - Path length exceeds system limits\n\ + \n\ + Fix:\n\ + 1. Check write permissions for the parent directory\n\ + 2. Verify disk space is available: df -h\n\ + 3. Ensure no file exists with the same name as the directory\n\ + 4. Try using a shorter path" + } + Self::TemplateFileWriteFailed { .. } => { + "Failed to write template file.\n\ + \n\ + Common causes:\n\ + - Insufficient permissions to write file\n\ + - No disk space available\n\ + - File is open in another application\n\ + - Antivirus software blocking file creation\n\ + \n\ + Fix:\n\ + 1. Check write permissions for the target file and directory\n\ + 2. Verify disk space is available: df -h\n\ + 3. Ensure the file is not open in another application\n\ + 4. Check if antivirus software is blocking file creation" + } } } } @@ -214,4 +283,43 @@ mod tests { ); } } + + #[test] + fn test_template_serialization_failed_error() { + // Simulate serialization error (hard to create naturally) + let json_error = serde_json::from_str::("invalid").unwrap_err(); + let error = CreateConfigError::TemplateSerializationFailed { source: json_error }; + + assert!(error + .to_string() + .contains("serialize configuration template")); + assert!(error.help().contains("internal error")); + assert!(error.help().contains("Report this issue")); + } + + #[test] + fn test_template_directory_creation_failed_error() { + let error = CreateConfigError::TemplateDirectoryCreationFailed { + path: PathBuf::from("/test/path"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + + assert!(error.to_string().contains("Failed to create directory")); + assert!(error.to_string().contains("/test/path")); + assert!(error.help().contains("permissions")); + assert!(error.help().contains("df -h")); + } + + #[test] + fn test_template_file_write_failed_error() { + let error = CreateConfigError::TemplateFileWriteFailed { + path: PathBuf::from("/test/file.json"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), + }; + + assert!(error.to_string().contains("Failed to write template file")); + assert!(error.to_string().contains("/test/file.json")); + assert!(error.help().contains("permissions")); + assert!(error.help().contains("disk space")); + } } diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 0e11090f..64fca4c3 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -13,11 +13,9 @@ //! - `template` - Template rendering delivery mechanisms (wrappers) //! - `remote_actions` - Repository-like implementations for remote system operations //! - `persistence` - Persistence infrastructure (repositories, file locking, storage) -//! - `templates` - Configuration template generation for user-facing configuration files //! - `trace` - Trace file generation for error analysis pub mod external_tools; pub mod persistence; pub mod remote_actions; -pub mod templates; pub mod trace; diff --git a/src/infrastructure/templates/embedded.rs b/src/infrastructure/templates/embedded.rs deleted file mode 100644 index 45a6501d..00000000 --- a/src/infrastructure/templates/embedded.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Embedded Template Resources -//! -//! This module provides embedded configuration templates that are compiled -//! into the binary at build time. Templates use simple placeholders that -//! users can easily find and replace. - -use super::provider::TemplateType; - -/// Container for embedded template resources -/// -/// Templates are embedded in the binary at compile time to ensure -/// they're always available without external dependencies. -pub struct EmbeddedTemplates; - -impl EmbeddedTemplates { - /// Create a new embedded templates container - #[must_use] - pub const fn new() -> Self { - Self - } - - /// Get template content for the specified type - /// - /// Returns the template as a static string if the template type is supported, - /// or `None` if the template type is not found. - #[must_use] - pub const fn get_template(&self, template_type: TemplateType) -> Option<&'static str> { - match template_type { - TemplateType::Json => Some(JSON_TEMPLATE), - } - } - - /// Get list of all available template types - #[must_use] - pub fn available_templates(&self) -> Vec { - vec![TemplateType::Json] - } -} - -impl Default for EmbeddedTemplates { - fn default() -> Self { - Self::new() - } -} - -/// JSON configuration template -/// -/// This template provides a complete example of the configuration format -/// with placeholder values that users can replace with their actual values. -/// -/// The template uses simple, easily searchable placeholders like -/// `REPLACE_WITH_*` to make it clear what needs to be filled in. -const JSON_TEMPLATE: &str = r#"{ - "environment": { - "name": "REPLACE_WITH_ENVIRONMENT_NAME" - }, - "ssh_credentials": { - "private_key_path": "REPLACE_WITH_SSH_PRIVATE_KEY_PATH", - "public_key_path": "REPLACE_WITH_SSH_PUBLIC_KEY_PATH", - "username": "torrust", - "port": 22 - } -}"#; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_provide_valid_json_template() { - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json).unwrap(); - - // Verify the template is valid JSON - let _value: serde_json::Value = - serde_json::from_str(template).expect("JSON template should be valid JSON"); - } - - #[test] - fn it_should_contain_required_placeholder_fields() { - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json).unwrap(); - - // Verify required placeholders are present - assert!(template.contains("REPLACE_WITH_ENVIRONMENT_NAME")); - assert!(template.contains("REPLACE_WITH_SSH_PRIVATE_KEY_PATH")); - assert!(template.contains("REPLACE_WITH_SSH_PUBLIC_KEY_PATH")); - - // Verify default values are present - assert!(template.contains(r#""username": "torrust""#)); - assert!(template.contains(r#""port": 22"#)); - } - - #[test] - fn it_should_list_available_templates() { - let embedded = EmbeddedTemplates::new(); - let templates = embedded.available_templates(); - - assert_eq!(templates.len(), 1); - assert!(templates.contains(&TemplateType::Json)); - } - - #[test] - fn it_should_create_via_default_trait() { - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json); - assert!(template.is_some()); - } - - #[test] - fn it_should_return_none_for_unsupported_template() { - let embedded = EmbeddedTemplates::new(); - // We can't test with an actual unsupported type since the enum only has Json - // But we verify the pattern by checking that Json works - assert!(embedded.get_template(TemplateType::Json).is_some()); - } - - #[test] - fn it_should_have_well_formatted_json() { - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json).unwrap(); - - // Parse and re-serialize to verify formatting - let parsed: serde_json::Value = serde_json::from_str(template).unwrap(); - let reformatted = serde_json::to_string_pretty(&parsed).unwrap(); - - // Both should parse to the same value - let parsed_again: serde_json::Value = serde_json::from_str(&reformatted).unwrap(); - assert_eq!(parsed, parsed_again); - } - - #[test] - fn it_should_match_environment_creation_config_structure() { - use crate::domain::config::EnvironmentCreationConfig; - - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json).unwrap(); - - // Verify template can be parsed as EnvironmentCreationConfig - // (even with placeholder values) - let result: Result = serde_json::from_str(template); - - // Should succeed because placeholders are valid strings - assert!(result.is_ok(), "Template should match config structure"); - } - - #[test] - fn it_should_have_consistent_placeholder_naming() { - let embedded = EmbeddedTemplates::new(); - let template = embedded.get_template(TemplateType::Json).unwrap(); - - // All placeholders should follow the REPLACE_WITH_* pattern - let placeholders = vec![ - "REPLACE_WITH_ENVIRONMENT_NAME", - "REPLACE_WITH_SSH_PRIVATE_KEY_PATH", - "REPLACE_WITH_SSH_PUBLIC_KEY_PATH", - ]; - - for placeholder in placeholders { - assert!( - template.contains(placeholder), - "Missing placeholder: {placeholder}" - ); - } - } -} diff --git a/src/infrastructure/templates/errors.rs b/src/infrastructure/templates/errors.rs deleted file mode 100644 index b04ccd01..00000000 --- a/src/infrastructure/templates/errors.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Template Error Types -//! -//! This module provides structured error types for template operations with -//! comprehensive error context and actionable troubleshooting guidance. - -use std::path::PathBuf; -use thiserror::Error; - -/// Errors that can occur during template operations -/// -/// These errors represent infrastructure-level failures in template -/// handling and provide structured context for troubleshooting. -#[derive(Debug, Error)] -pub enum TemplateError { - /// Template was not found in embedded resources - #[error("Template not found: {template_type}")] - TemplateNotFound { template_type: String }, - - /// Requested template type is not supported - #[error("Unsupported template type: {requested_type}")] - UnsupportedTemplateType { - requested_type: String, - supported_types: Vec, - }, - - /// Output path is invalid or unsuitable for template generation - #[error("Invalid output path: {path} - {reason}")] - InvalidOutputPath { path: PathBuf, reason: String }, - - /// Failed to create directory for template file - #[error("Failed to create directory: {path}")] - DirectoryCreationFailed { - path: PathBuf, - #[source] - source: std::io::Error, - }, - - /// Failed to write template file - #[error("Failed to write template file: {path}")] - FileWriteFailed { - path: PathBuf, - #[source] - source: std::io::Error, - }, - - /// Template validation failed (indicates a bug in embedded templates) - #[error("Template validation failed: {template_type}")] - TemplateValidationFailed { - template_type: String, - #[source] - source: serde_json::Error, - }, -} - -impl TemplateError { - /// Get detailed troubleshooting guidance for this error - /// - /// Returns multi-line help text with specific steps to resolve the error. - /// This follows the project's tiered help system pattern. - #[must_use] - pub fn help(&self) -> &'static str { - match self { - Self::TemplateNotFound { .. } => { - "Template Not Found - Detailed Troubleshooting: - -1. Check if the template type is supported -2. Verify the application binary includes embedded templates -3. Try regenerating templates if they should be available -4. Report issue if template should be available but is missing - -For more information, see the template documentation." - } - - Self::UnsupportedTemplateType { .. } => { - "Unsupported Template Type - Detailed Troubleshooting: - -1. Use 'json' for JSON templates (currently supported) -2. TOML support will be added in a future release -3. Verify you are using the correct template type format - -For more information, see the template format documentation." - } - - Self::InvalidOutputPath { .. } => { - "Invalid Output Path - Detailed Troubleshooting: - -1. Ensure the path points to a file (not directory) -2. Use correct file extension (.json for JSON templates) -3. Verify parent directory exists or can be created -4. Check write permissions for the target location - -For more information, see the file system documentation." - } - - Self::DirectoryCreationFailed { .. } => { - "Directory Creation Failed - Detailed Troubleshooting: - -1. Check write permissions for the parent directory -2. Verify disk space is available: df -h -3. Ensure no file exists with the same name as the directory -4. Check path length limits on your system - -For more information, see the filesystem troubleshooting guide." - } - - Self::FileWriteFailed { .. } => { - "Template File Write Failed - Detailed Troubleshooting: - -1. Check write permissions for the target file and directory -2. Verify disk space is available: df -h -3. Ensure the file is not open in another application -4. Check if antivirus software is blocking file creation - -For more information, see the file operations documentation." - } - - Self::TemplateValidationFailed { .. } => { - "Template Validation Failed - Detailed Troubleshooting: - -1. This indicates a bug in the embedded templates -2. Report this issue with full error details -3. Use --generate-template to create a fresh template -4. Check for application updates - -This is likely a software bug that needs to be reported." - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_provide_help_for_template_not_found() { - let error = TemplateError::TemplateNotFound { - template_type: "YAML".to_string(), - }; - - let help = error.help(); - assert!(help.contains("Template Not Found")); - assert!(help.contains("Check if the template type is supported")); - } - - #[test] - fn it_should_provide_help_for_unsupported_template_type() { - let error = TemplateError::UnsupportedTemplateType { - requested_type: "xml".to_string(), - supported_types: vec!["json".to_string()], - }; - - let help = error.help(); - assert!(help.contains("Unsupported Template Type")); - assert!(help.contains("Use 'json' for JSON templates")); - } - - #[test] - fn it_should_provide_help_for_invalid_output_path() { - let error = TemplateError::InvalidOutputPath { - path: PathBuf::from("/tmp/test"), - reason: "Path is a directory".to_string(), - }; - - let help = error.help(); - assert!(help.contains("Invalid Output Path")); - assert!(help.contains("Ensure the path points to a file")); - } - - #[test] - fn it_should_provide_help_for_directory_creation_failed() { - let error = TemplateError::DirectoryCreationFailed { - path: PathBuf::from("/tmp/test"), - source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), - }; - - let help = error.help(); - assert!(help.contains("Directory Creation Failed")); - assert!(help.contains("Check write permissions")); - } - - #[test] - fn it_should_provide_help_for_file_write_failed() { - let error = TemplateError::FileWriteFailed { - path: PathBuf::from("/tmp/test.json"), - source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test"), - }; - - let help = error.help(); - assert!(help.contains("Template File Write Failed")); - assert!(help.contains("Check write permissions")); - } - - #[test] - fn it_should_provide_help_for_template_validation_failed() { - let error = TemplateError::TemplateValidationFailed { - template_type: "JSON".to_string(), - source: serde_json::from_str::("invalid").unwrap_err(), - }; - - let help = error.help(); - assert!(help.contains("Template Validation Failed")); - assert!(help.contains("This indicates a bug")); - } - - #[test] - fn it_should_format_error_messages_correctly() { - let error = TemplateError::TemplateNotFound { - template_type: "YAML".to_string(), - }; - assert_eq!(error.to_string(), "Template not found: YAML"); - - let error = TemplateError::UnsupportedTemplateType { - requested_type: "xml".to_string(), - supported_types: vec!["json".to_string()], - }; - assert_eq!(error.to_string(), "Unsupported template type: xml"); - } - - #[test] - fn it_should_preserve_source_errors() { - let io_error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "test error"); - let error = TemplateError::FileWriteFailed { - path: PathBuf::from("/tmp/test.json"), - source: io_error, - }; - - // Verify source error is accessible - let source = std::error::Error::source(&error); - assert!(source.is_some()); - assert_eq!(source.unwrap().to_string(), "test error"); - } -} diff --git a/src/infrastructure/templates/mod.rs b/src/infrastructure/templates/mod.rs deleted file mode 100644 index af9cd3b0..00000000 --- a/src/infrastructure/templates/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Template System for Configuration File Generation -//! -//! This module provides infrastructure for generating configuration file templates -//! that users can fill out to create deployment environments. It handles embedded -//! template resources and file system operations for template generation. -//! -//! ## Key Features -//! -//! - Embedded JSON configuration templates -//! - Async file generation with proper directory creation -//! - Comprehensive error handling with actionable guidance -//! - Extensible architecture for future template formats -//! -//! ## Usage Example -//! -//! ```rust -//! use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; -//! use std::path::Path; -//! -//! # async fn example() -> Result<(), Box> { -//! let provider = TemplateProvider::new(); -//! -//! // Generate template at specific path -//! provider.generate_template( -//! TemplateType::Json, -//! Path::new("./environment-config.json") -//! ).await?; -//! -//! // Or generate with default filename in a directory -//! let path = provider.generate_template_in_directory( -//! TemplateType::Json, -//! Path::new("./configs") -//! ).await?; -//! # Ok(()) -//! # } -//! ``` -//! -//! ## Architecture -//! -//! - `provider` - High-level template generation API -//! - `embedded` - Embedded template resources (compile-time) -//! - `errors` - Template-specific error types with detailed help - -pub mod embedded; -pub mod errors; -pub mod provider; - -// Re-export commonly used types -pub use embedded::EmbeddedTemplates; -pub use errors::TemplateError; -pub use provider::{TemplateProvider, TemplateType}; diff --git a/src/infrastructure/templates/provider.rs b/src/infrastructure/templates/provider.rs deleted file mode 100644 index c0f65e62..00000000 --- a/src/infrastructure/templates/provider.rs +++ /dev/null @@ -1,501 +0,0 @@ -//! Template Provider Implementation -//! -//! This module provides the high-level API for template generation with -//! async file operations and comprehensive error handling. - -use std::path::{Path, PathBuf}; - -use super::embedded::EmbeddedTemplates; -use super::errors::TemplateError; - -/// Provider for configuration templates -/// -/// Handles template retrieval from embedded resources and generation -/// of template files on the filesystem. -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; -/// use std::path::Path; -/// -/// # async fn example() -> Result<(), Box> { -/// let provider = TemplateProvider::new(); -/// -/// // Generate template at specific path -/// provider.generate_template( -/// TemplateType::Json, -/// Path::new("./config.json") -/// ).await?; -/// # Ok(()) -/// # } -/// ``` -pub struct TemplateProvider { - embedded: EmbeddedTemplates, -} - -impl TemplateProvider { - /// Create a new template provider - #[must_use] - pub fn new() -> Self { - Self { - embedded: EmbeddedTemplates::new(), - } - } - - /// Generate a template file at the specified path - /// - /// This method creates a configuration template file with placeholder values - /// that users can fill in. It handles directory creation and validates the - /// output path. - /// - /// # Arguments - /// - /// * `template_type` - Type of template to generate (currently only JSON) - /// * `output_path` - Path where the template file should be created - /// - /// # Returns - /// - /// * `Ok(())` - Template generated successfully - /// * `Err(TemplateError)` - Template generation failed - /// - /// # Errors - /// - /// Returns an error if: - /// - Template type is not supported - /// - Output path is invalid (wrong extension, is a directory, etc.) - /// - Parent directory cannot be created - /// - File cannot be written due to permissions or I/O errors - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; - /// use std::path::Path; - /// - /// # async fn example() -> Result<(), Box> { - /// let provider = TemplateProvider::new(); - /// provider.generate_template( - /// TemplateType::Json, - /// Path::new("./environment-template.json") - /// ).await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn generate_template( - &self, - template_type: TemplateType, - output_path: &Path, - ) -> Result<(), TemplateError> { - // Get template content from embedded resources - let template_content = self.embedded.get_template(template_type).ok_or_else(|| { - TemplateError::TemplateNotFound { - template_type: template_type.to_string(), - } - })?; - - // Validate output path - Self::validate_output_path(output_path, template_type)?; - - // Create parent directories if they don't exist - if let Some(parent) = output_path.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|source| { - TemplateError::DirectoryCreationFailed { - path: parent.to_path_buf(), - source, - } - })?; - } - - // Write template to file - tokio::fs::write(output_path, template_content) - .await - .map_err(|source| TemplateError::FileWriteFailed { - path: output_path.to_path_buf(), - source, - })?; - - Ok(()) - } - - /// Generate template with default filename in specified directory - /// - /// This is a convenience method that generates a template using the default - /// filename for the template type in the specified directory. - /// - /// # Arguments - /// - /// * `template_type` - Type of template to generate - /// * `directory` - Directory where template should be created - /// - /// # Returns - /// - /// * `Ok(PathBuf)` - Path to the generated template file - /// * `Err(TemplateError)` - Template generation failed - /// - /// # Errors - /// - /// Returns an error if: - /// - Template type is not supported - /// - Directory cannot be created - /// - File cannot be written due to permissions or I/O errors - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::infrastructure::templates::{TemplateProvider, TemplateType}; - /// use std::path::Path; - /// - /// # async fn example() -> Result<(), Box> { - /// let provider = TemplateProvider::new(); - /// let path = provider.generate_template_in_directory( - /// TemplateType::Json, - /// Path::new("./configs") - /// ).await?; - /// println!("Template generated at: {}", path.display()); - /// # Ok(()) - /// # } - /// ``` - pub async fn generate_template_in_directory( - &self, - template_type: TemplateType, - directory: &Path, - ) -> Result { - let filename = template_type.default_filename(); - let output_path = directory.join(filename); - - self.generate_template(template_type, &output_path).await?; - - Ok(output_path) - } - - /// Get template content as string without writing to file - /// - /// Useful for testing and programmatic access to templates. - /// - /// # Errors - /// - /// Returns `TemplateError::TemplateNotFound` if the template type is not supported. - pub fn get_template_content(&self, template_type: TemplateType) -> Result<&str, TemplateError> { - self.embedded - .get_template(template_type) - .ok_or_else(|| TemplateError::TemplateNotFound { - template_type: template_type.to_string(), - }) - } - - /// List all available template types - #[must_use] - pub fn available_templates(&self) -> Vec { - self.embedded.available_templates() - } - - /// Validate that the output path is suitable for template generation - fn validate_output_path(path: &Path, template_type: TemplateType) -> Result<(), TemplateError> { - // Check if path already exists and is not a file - if path.exists() && !path.is_file() { - return Err(TemplateError::InvalidOutputPath { - path: path.to_path_buf(), - reason: "Path exists but is not a file".to_string(), - }); - } - - // Validate file extension matches template type - let expected_extension = template_type.file_extension(); - if let Some(extension) = path.extension() { - if extension != expected_extension { - return Err(TemplateError::InvalidOutputPath { - path: path.to_path_buf(), - reason: format!( - "File extension '{}' does not match {} template type (expected '{}')", - extension.to_string_lossy(), - template_type, - expected_extension - ), - }); - } - } else { - return Err(TemplateError::InvalidOutputPath { - path: path.to_path_buf(), - reason: "No file extension specified".to_string(), - }); - } - - Ok(()) - } -} - -impl Default for TemplateProvider { - fn default() -> Self { - Self::new() - } -} - -/// Supported template types -/// -/// Currently only JSON is supported, with plans to add TOML and YAML in the future. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TemplateType { - /// JSON configuration template - Json, - // Future: Toml, Yaml -} - -impl TemplateType { - /// Get the default filename for this template type - #[must_use] - pub const fn default_filename(&self) -> &'static str { - match self { - Self::Json => "environment-template.json", - } - } - - /// Get the file extension for this template type - #[must_use] - pub const fn file_extension(&self) -> &'static str { - match self { - Self::Json => "json", - } - } -} - -impl std::fmt::Display for TemplateType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Json => write!(f, "JSON"), - } - } -} - -impl std::str::FromStr for TemplateType { - type Err = TemplateError; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "json" => Ok(Self::Json), - _ => Err(TemplateError::UnsupportedTemplateType { - requested_type: s.to_string(), - supported_types: vec!["json".to_string()], - }), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn it_should_generate_template_at_specified_path() { - let temp_dir = TempDir::new().unwrap(); - let output_path = temp_dir.path().join("config.json"); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &output_path) - .await; - - assert!(result.is_ok()); - assert!(output_path.exists()); - - // Verify content is valid JSON - let content = std::fs::read_to_string(&output_path).unwrap(); - let _value: serde_json::Value = serde_json::from_str(&content).unwrap(); - } - - #[tokio::test] - async fn it_should_generate_template_in_directory() { - let temp_dir = TempDir::new().unwrap(); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template_in_directory(TemplateType::Json, temp_dir.path()) - .await; - - assert!(result.is_ok()); - let output_path = result.unwrap(); - assert!(output_path.exists()); - assert_eq!( - output_path.file_name().unwrap(), - "environment-template.json" - ); - } - - #[tokio::test] - async fn it_should_create_parent_directories() { - let temp_dir = TempDir::new().unwrap(); - let nested_path = temp_dir - .path() - .join("configs") - .join("env") - .join("test.json"); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &nested_path) - .await; - - assert!(result.is_ok()); - assert!(nested_path.exists()); - assert!(nested_path.parent().unwrap().exists()); - } - - #[tokio::test] - async fn it_should_fail_with_invalid_extension() { - let temp_dir = TempDir::new().unwrap(); - let output_path = temp_dir.path().join("config.txt"); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &output_path) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - TemplateError::InvalidOutputPath { reason, .. } => { - assert!(reason.contains("does not match JSON")); - } - other => panic!("Expected InvalidOutputPath error, got: {other:?}"), - } - } - - #[tokio::test] - async fn it_should_fail_with_no_extension() { - let temp_dir = TempDir::new().unwrap(); - let output_path = temp_dir.path().join("config"); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &output_path) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - TemplateError::InvalidOutputPath { reason, .. } => { - assert!(reason.contains("No file extension")); - } - other => panic!("Expected InvalidOutputPath error, got: {other:?}"), - } - } - - #[tokio::test] - async fn it_should_fail_if_path_is_directory() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path().join("subdir"); - std::fs::create_dir(&dir_path).unwrap(); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &dir_path) - .await; - - assert!(result.is_err()); - match result.unwrap_err() { - TemplateError::InvalidOutputPath { reason, .. } => { - assert!(reason.contains("not a file")); - } - other => panic!("Expected InvalidOutputPath error, got: {other:?}"), - } - } - - #[tokio::test] - async fn it_should_overwrite_existing_file() { - let temp_dir = TempDir::new().unwrap(); - let output_path = temp_dir.path().join("config.json"); - - // Create initial file - std::fs::write(&output_path, "old content").unwrap(); - - let provider = TemplateProvider::new(); - let result = provider - .generate_template(TemplateType::Json, &output_path) - .await; - - assert!(result.is_ok()); - - // Verify content was replaced - let content = std::fs::read_to_string(&output_path).unwrap(); - assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME")); - assert!(!content.contains("old content")); - } - - #[test] - fn it_should_get_template_content() { - let provider = TemplateProvider::new(); - let result = provider.get_template_content(TemplateType::Json); - - assert!(result.is_ok()); - let content = result.unwrap(); - assert!(content.contains("REPLACE_WITH_ENVIRONMENT_NAME")); - } - - #[test] - fn it_should_list_available_templates() { - let provider = TemplateProvider::new(); - let templates = provider.available_templates(); - - assert_eq!(templates.len(), 1); - assert!(templates.contains(&TemplateType::Json)); - } - - #[test] - fn it_should_create_via_default_trait() { - let provider = TemplateProvider::default(); - let templates = provider.available_templates(); - assert!(!templates.is_empty()); - } - - // TemplateType tests - #[test] - fn it_should_have_correct_default_filename() { - assert_eq!( - TemplateType::Json.default_filename(), - "environment-template.json" - ); - } - - #[test] - fn it_should_have_correct_file_extension() { - assert_eq!(TemplateType::Json.file_extension(), "json"); - } - - #[test] - fn it_should_display_template_type() { - assert_eq!(TemplateType::Json.to_string(), "JSON"); - } - - #[test] - fn it_should_parse_from_string() { - assert_eq!("json".parse::().unwrap(), TemplateType::Json); - assert_eq!("JSON".parse::().unwrap(), TemplateType::Json); - assert_eq!("Json".parse::().unwrap(), TemplateType::Json); - } - - #[test] - fn it_should_fail_parsing_unsupported_type() { - let result = "yaml".parse::(); - assert!(result.is_err()); - - match result.unwrap_err() { - TemplateError::UnsupportedTemplateType { - requested_type, - supported_types, - } => { - assert_eq!(requested_type, "yaml"); - assert_eq!(supported_types, vec!["json"]); - } - other => panic!("Expected UnsupportedTemplateType error, got: {other:?}"), - } - } - - #[test] - fn it_should_be_copy_and_clone() { - let t1 = TemplateType::Json; - let t2 = t1; // Copy - let t3 = t1; // Also copy (not clone) - - assert_eq!(t1, t2); - assert_eq!(t1, t3); - } -}