Skip to main content

torrust_tracker_deployer_lib/domain/template/
embedded.rs

1//! Embedded template management system
2//!
3//! This module provides the `TemplateManager` which handles embedded template
4//! resources and their extraction to the filesystem for use by the deployment
5//! system. It uses `rust-embed` to bundle templates within the binary.
6//!
7//! ## Key Features
8//!
9//! - Embedded template resource management using `rust-embed`
10//! - Template extraction to filesystem for processing
11//! - Directory structure creation and management
12//! - Template cleanup and reset functionality for testing
13//! - Comprehensive error handling with detailed context
14//!
15//! ## Template Organization
16//!
17//! Templates are organized in the source tree under `templates/` and embedded
18//! into the binary at compile time. The manager can extract these templates
19//! to a working directory for use by template engines and deployment tools.
20//!
21//! ## Usage Scenarios
22//!
23//! - Initial template setup for deployment operations
24//! - Template refresh for testing environments
25//! - Development and debugging support with accessible template files
26
27use rust_embed::RustEmbed;
28use std::fs;
29use std::path::{Path, PathBuf};
30use thiserror::Error;
31
32/// Errors that can occur during template manager operations
33#[derive(Debug, Error)]
34pub enum TemplateManagerError {
35    #[error("Failed to create templates directory: {path}")]
36    DirectoryCreation {
37        path: String,
38        #[source]
39        source: std::io::Error,
40    },
41
42    #[error("Template file not found in embedded resources: {relative_path}")]
43    TemplateNotFound { relative_path: String },
44
45    #[error("Invalid UTF-8 in embedded template: {relative_path}")]
46    InvalidUtf8 {
47        relative_path: String,
48        #[source]
49        source: std::str::Utf8Error,
50    },
51
52    #[error("Failed to create parent directory for template: {path}")]
53    ParentDirectoryCreation {
54        path: String,
55        #[source]
56        source: std::io::Error,
57    },
58
59    #[error("Failed to write template file: {path}")]
60    TemplateWrite {
61        path: String,
62        #[source]
63        source: std::io::Error,
64    },
65
66    #[error("Template file already exists: {path}")]
67    TemplateAlreadyExists { path: String },
68}
69
70/// Embedded template files from the ./templates directory
71#[derive(RustEmbed)]
72#[folder = "templates/"]
73pub struct EmbeddedTemplates;
74
75/// Template manager that handles on-demand creation of templates from embedded resources
76pub struct TemplateManager {
77    templates_dir: PathBuf,
78}
79
80impl TemplateManager {
81    /// Create a new template manager with a custom templates directory
82    pub fn new<P: Into<PathBuf>>(templates_dir: P) -> Self {
83        Self {
84            templates_dir: templates_dir.into(),
85        }
86    }
87
88    /// Create the templates directory if it doesn't exist
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the directory creation fails due to permissions or filesystem issues.
93    pub fn ensure_templates_dir(&self) -> Result<(), TemplateManagerError> {
94        if !self.templates_dir.exists() {
95            fs::create_dir_all(&self.templates_dir).map_err(|source| {
96                TemplateManagerError::DirectoryCreation {
97                    path: self.templates_dir.display().to_string(),
98                    source,
99                }
100            })?;
101        }
102        Ok(())
103    }
104
105    /// Clean and prepare the templates directory to ensure fresh embedded templates
106    ///
107    /// This method combines `clean_templates_dir()` and `ensure_templates_dir()` to provide
108    /// a clean state for template operations. It's particularly useful in testing and
109    /// development environments where you want to ensure fresh templates from embedded resources.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if either directory cleaning or creation fails due to permissions
114    /// or filesystem issues.
115    pub fn reset_templates_dir(&self) -> Result<(), TemplateManagerError> {
116        self.clean_templates_dir()?;
117        self.ensure_templates_dir()?;
118        Ok(())
119    }
120
121    /// Get the path to a template file, creating it from embedded resources if it doesn't exist
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if:
126    /// - The template is not found in embedded resources
127    /// - The embedded template contains invalid UTF-8
128    /// - File system operations fail (directory creation or file writing)
129    pub fn get_template_path(&self, relative_path: &str) -> Result<PathBuf, TemplateManagerError> {
130        let template_path = self.templates_dir.join(relative_path);
131
132        // If the template file already exists, return its path
133        if template_path.exists() {
134            return Ok(template_path);
135        }
136
137        // Create the template from embedded resources
138        self.create_template_from_embedded(relative_path)?;
139
140        Ok(template_path)
141    }
142
143    /// Create a template file from embedded resources
144    fn create_template_from_embedded(
145        &self,
146        relative_path: &str,
147    ) -> Result<(), TemplateManagerError> {
148        let template_path = self.templates_dir.join(relative_path);
149
150        // Check if template already exists - don't overwrite
151        if template_path.exists() {
152            return Err(TemplateManagerError::TemplateAlreadyExists {
153                path: template_path.display().to_string(),
154            });
155        }
156
157        // Get the embedded file content
158        let embedded_file = EmbeddedTemplates::get(relative_path).ok_or_else(|| {
159            TemplateManagerError::TemplateNotFound {
160                relative_path: relative_path.to_string(),
161            }
162        })?;
163
164        let content = std::str::from_utf8(&embedded_file.data).map_err(|source| {
165            TemplateManagerError::InvalidUtf8 {
166                relative_path: relative_path.to_string(),
167                source,
168            }
169        })?;
170
171        // Ensure parent directory exists
172        if let Some(parent) = template_path.parent() {
173            fs::create_dir_all(parent).map_err(|source| {
174                TemplateManagerError::ParentDirectoryCreation {
175                    path: template_path.display().to_string(),
176                    source,
177                }
178            })?;
179        }
180
181        // Write the content to the file
182        fs::write(&template_path, content).map_err(|source| {
183            TemplateManagerError::TemplateWrite {
184                path: template_path.display().to_string(),
185                source,
186            }
187        })?;
188
189        tracing::debug!("Created template from embedded resources: {relative_path}");
190
191        Ok(())
192    }
193
194    /// Get the templates directory path
195    #[must_use]
196    pub fn templates_dir(&self) -> &Path {
197        &self.templates_dir
198    }
199
200    /// Clean the templates directory by removing all files and subdirectories
201    ///
202    /// This is useful for development/testing to ensure fresh templates are used
203    /// from embedded resources.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the directory removal fails due to permissions or filesystem issues.
208    pub fn clean_templates_dir(&self) -> Result<(), TemplateManagerError> {
209        if self.templates_dir.exists() {
210            fs::remove_dir_all(&self.templates_dir).map_err(|source| {
211                TemplateManagerError::DirectoryCreation {
212                    path: self.templates_dir.display().to_string(),
213                    source,
214                }
215            })?;
216            tracing::debug!(
217                "Cleaned templates directory: {}",
218                self.templates_dir.display()
219            );
220        }
221        Ok(())
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    #[cfg(unix)]
229    use std::os::unix::fs::PermissionsExt;
230    use tempfile::TempDir;
231
232    #[test]
233    fn it_should_create_templates_directory() {
234        let temp_dir = TempDir::new().unwrap();
235        let templates_path = temp_dir.path().join("test_templates");
236
237        let manager = TemplateManager::new(&templates_path);
238
239        // Directory should not exist initially
240        assert!(!templates_path.exists());
241
242        // After ensuring directory, it should exist
243        manager.ensure_templates_dir().unwrap();
244        assert!(templates_path.exists());
245        assert!(templates_path.is_dir());
246    }
247
248    #[test]
249    fn it_should_create_template_from_embedded_resources() {
250        let temp_dir = TempDir::new().unwrap();
251        let templates_path = temp_dir.path().join("test_templates");
252
253        let manager = TemplateManager::new(&templates_path);
254        manager.ensure_templates_dir().unwrap();
255
256        // Try to get a template path - this should create the file from embedded resources
257        let template_path = manager.get_template_path("ansible/ansible.cfg").unwrap();
258
259        // The file should now exist
260        assert!(template_path.exists());
261        assert!(template_path.is_file());
262
263        // The content should match what we expect (basic verification)
264        let content = fs::read_to_string(&template_path).unwrap();
265        assert!(!content.is_empty());
266    }
267
268    #[test]
269    fn it_should_fail_when_template_not_found_in_embedded_resources() {
270        let temp_dir = TempDir::new().unwrap();
271        let templates_path = temp_dir.path().join("test_templates");
272
273        let manager = TemplateManager::new(&templates_path);
274        manager.ensure_templates_dir().unwrap();
275
276        // Try to get a non-existent template path
277        let result = manager.get_template_path("non-existent/template.txt");
278
279        // This should fail with TemplateNotFound error
280        assert!(result.is_err());
281        let error = result.unwrap_err();
282        match error {
283            TemplateManagerError::TemplateNotFound { relative_path } => {
284                assert_eq!(relative_path, "non-existent/template.txt");
285            }
286            _ => panic!("Expected TemplateNotFound error, got: {error:?}"),
287        }
288    }
289
290    #[cfg(unix)]
291    #[test]
292    fn it_should_fail_when_directory_creation_is_denied() {
293        let temp_dir = TempDir::new().unwrap();
294        let read_only_dir = temp_dir.path().join("read_only");
295        fs::create_dir(&read_only_dir).unwrap();
296
297        // Make directory read-only
298        let mut perms = fs::metadata(&read_only_dir).unwrap().permissions();
299        perms.set_mode(0o444); // Read-only
300        fs::set_permissions(&read_only_dir, perms).unwrap();
301
302        let templates_path = read_only_dir.join("should_fail");
303        let manager = TemplateManager::new(&templates_path);
304
305        // This should fail with DirectoryCreation error
306        let result = manager.ensure_templates_dir();
307
308        assert!(result.is_err());
309        let error = result.unwrap_err();
310        match error {
311            TemplateManagerError::DirectoryCreation { path, source } => {
312                assert!(path.contains("should_fail"));
313                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
314            }
315            _ => panic!("Expected DirectoryCreation error, got: {error:?}"),
316        }
317
318        // Restore permissions for cleanup
319        let mut perms = fs::metadata(&read_only_dir).unwrap().permissions();
320        perms.set_mode(0o755);
321        fs::set_permissions(&read_only_dir, perms).unwrap();
322    }
323
324    #[cfg(unix)]
325    #[test]
326    fn it_should_fail_when_parent_directory_creation_is_denied() {
327        let temp_dir = TempDir::new().unwrap();
328        let read_only_dir = temp_dir.path().join("read_only");
329        fs::create_dir(&read_only_dir).unwrap();
330
331        // Make directory read-only
332        let mut perms = fs::metadata(&read_only_dir).unwrap().permissions();
333        perms.set_mode(0o444); // Read-only
334        fs::set_permissions(&read_only_dir, perms).unwrap();
335
336        let templates_path = temp_dir.path().join("templates");
337        let manager = TemplateManager::new(&templates_path);
338        manager.ensure_templates_dir().unwrap();
339
340        // Create a template path that requires parent directory creation inside read-only dir
341        // We'll need to temporarily modify the manager to use a path that will fail
342        let failing_manager = TemplateManager::new(&read_only_dir);
343
344        let result = failing_manager.get_template_path("some/nested/template.txt");
345
346        assert!(result.is_err());
347        let error = result.unwrap_err();
348        match error {
349            TemplateManagerError::TemplateNotFound { .. } => {
350                // This is expected since the template doesn't exist in embedded resources
351                // But let's test with a real template that exists
352            }
353            _ => panic!("Unexpected error: {error:?}"),
354        }
355
356        // Test with a real template that exists
357        let result = failing_manager.get_template_path("ansible/ansible.cfg");
358
359        assert!(result.is_err());
360        let error = result.unwrap_err();
361        match error {
362            TemplateManagerError::ParentDirectoryCreation { path, source } => {
363                assert!(path.contains("ansible.cfg"));
364                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
365            }
366            _ => panic!("Expected ParentDirectoryCreation error, got: {error:?}"),
367        }
368
369        // Restore permissions for cleanup
370        let mut perms = fs::metadata(&read_only_dir).unwrap().permissions();
371        perms.set_mode(0o755);
372        fs::set_permissions(&read_only_dir, perms).unwrap();
373    }
374
375    #[cfg(unix)]
376    #[test]
377    fn it_should_fail_when_template_write_is_denied() {
378        let temp_dir = TempDir::new().unwrap();
379        let templates_path = temp_dir.path().join("templates");
380        let manager = TemplateManager::new(&templates_path);
381        manager.ensure_templates_dir().unwrap();
382
383        // Create the ansible subdirectory
384        let ansible_dir = templates_path.join("ansible");
385        fs::create_dir(&ansible_dir).unwrap();
386
387        // Make the ansible directory read-only so file creation will fail
388        let mut perms = fs::metadata(&ansible_dir).unwrap().permissions();
389        perms.set_mode(0o444); // Read-only
390        fs::set_permissions(&ansible_dir, perms).unwrap();
391
392        // Try to get a template path - this should fail when trying to write the file
393        let result = manager.get_template_path("ansible/ansible.cfg");
394
395        assert!(result.is_err());
396        let error = result.unwrap_err();
397        match error {
398            TemplateManagerError::TemplateWrite { path, source } => {
399                assert!(path.contains("ansible.cfg"));
400                assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
401            }
402            _ => panic!("Expected TemplateWrite error, got: {error:?}"),
403        }
404
405        // Restore permissions for cleanup
406        let mut perms = fs::metadata(&ansible_dir).unwrap().permissions();
407        perms.set_mode(0o755);
408        fs::set_permissions(&ansible_dir, perms).unwrap();
409    }
410
411    #[test]
412    fn it_should_return_existing_template_path_without_recreating() {
413        let temp_dir = TempDir::new().unwrap();
414        let templates_path = temp_dir.path().join("test_templates");
415
416        let manager = TemplateManager::new(&templates_path);
417        manager.ensure_templates_dir().unwrap();
418
419        // First call should create the template
420        let template_path1 = manager.get_template_path("ansible/ansible.cfg").unwrap();
421        assert!(template_path1.exists());
422
423        // Modify the file content to verify it's not recreated
424        let original_content = fs::read_to_string(&template_path1).unwrap();
425        fs::write(&template_path1, "modified content").unwrap();
426
427        // Second call should return the same path without recreating
428        let template_path2 = manager.get_template_path("ansible/ansible.cfg").unwrap();
429        assert_eq!(template_path1, template_path2);
430
431        // Content should still be modified, proving it wasn't recreated
432        let current_content = fs::read_to_string(&template_path2).unwrap();
433        assert_eq!(current_content, "modified content");
434        assert_ne!(current_content, original_content);
435    }
436
437    #[test]
438    fn it_should_provide_correct_templates_dir_path() {
439        let test_path = PathBuf::from("/test/path");
440        let manager = TemplateManager::new(&test_path);
441
442        assert_eq!(manager.templates_dir(), Path::new("/test/path"));
443    }
444
445    #[test]
446    fn it_should_handle_nested_template_paths() {
447        let temp_dir = TempDir::new().unwrap();
448        let templates_path = temp_dir.path().join("test_templates");
449
450        let manager = TemplateManager::new(&templates_path);
451        manager.ensure_templates_dir().unwrap();
452
453        // Test deeply nested template path
454        let template_path = manager.get_template_path("tofu/lxd/main.tf").unwrap();
455
456        assert!(template_path.exists());
457        assert!(template_path.is_file());
458
459        // Verify parent directories were created
460        assert!(template_path.parent().unwrap().exists());
461        assert!(template_path.parent().unwrap().parent().unwrap().exists());
462    }
463
464    #[test]
465    fn it_should_fail_when_trying_to_create_existing_template() {
466        let temp_dir = TempDir::new().unwrap();
467        let templates_path = temp_dir.path().join("test_templates");
468
469        let manager = TemplateManager::new(&templates_path);
470        manager.ensure_templates_dir().unwrap();
471
472        // Create a template first time
473        let first_path = manager.get_template_path("ansible/ansible.cfg").unwrap();
474
475        // Try to create the same template again through get_template_path - should succeed since it returns existing
476        let second_path = manager.get_template_path("ansible/ansible.cfg").unwrap();
477
478        // Both paths should be the same and the file should exist
479        assert_eq!(first_path, second_path);
480        assert!(second_path.exists());
481    }
482    #[test]
483    fn it_should_clean_templates_directory() {
484        let temp_dir = TempDir::new().unwrap();
485        let templates_path = temp_dir.path().join("test_templates");
486
487        let manager = TemplateManager::new(&templates_path);
488        manager.ensure_templates_dir().unwrap();
489
490        // Create some templates
491        let template_path1 = manager.get_template_path("ansible/ansible.cfg").unwrap();
492        let template_path2 = manager.get_template_path("tofu/lxd/main.tf").unwrap();
493
494        // Verify templates exist
495        assert!(template_path1.exists());
496        assert!(template_path2.exists());
497        assert!(templates_path.exists());
498
499        // Clean the directory
500        manager.clean_templates_dir().unwrap();
501
502        // Templates directory should be gone
503        assert!(!templates_path.exists());
504        assert!(!template_path1.exists());
505        assert!(!template_path2.exists());
506    }
507
508    #[test]
509    fn it_should_handle_clean_on_nonexistent_directory() {
510        let temp_dir = TempDir::new().unwrap();
511        let templates_path = temp_dir.path().join("nonexistent_templates");
512
513        let manager = TemplateManager::new(&templates_path);
514
515        // Clean should not fail on non-existent directory
516        let result = manager.clean_templates_dir();
517        assert!(result.is_ok());
518    }
519
520    #[test]
521    fn it_should_reset_templates_directory() {
522        let temp_dir = TempDir::new().unwrap();
523        let templates_path = temp_dir.path().join("test_templates");
524
525        let manager = TemplateManager::new(&templates_path);
526
527        // Initially the directory should not exist
528        assert!(!templates_path.exists());
529
530        // First, create the directory and some templates
531        manager.ensure_templates_dir().unwrap();
532        let template_path = manager.get_template_path("ansible/ansible.cfg").unwrap();
533        assert!(template_path.exists());
534        assert!(templates_path.exists());
535
536        // Now use the combined method
537        manager.reset_templates_dir().unwrap();
538
539        // Directory should exist but templates should be gone
540        assert!(templates_path.exists());
541        assert!(templates_path.is_dir());
542        // Old template file should be gone (directory was cleaned)
543        assert!(!template_path.exists());
544    }
545
546    #[test]
547    fn it_should_reset_templates_directory_on_nonexistent_directory() {
548        let temp_dir = TempDir::new().unwrap();
549        let templates_path = temp_dir.path().join("nonexistent_templates");
550
551        let manager = TemplateManager::new(&templates_path);
552
553        // Directory should not exist initially
554        assert!(!templates_path.exists());
555
556        // Combined method should work on non-existent directory
557        let result = manager.reset_templates_dir();
558        assert!(result.is_ok());
559
560        // Directory should now exist
561        assert!(templates_path.exists());
562        assert!(templates_path.is_dir());
563    }
564}