Skip to main content

torrust_tracker_deployer_lib/domain/template/
file.rs

1//! - Identifying file formats based on extensions
2//! - Validating template file structure
3//!
4//! This module is **NOT** responsible for:
5//! - Template rendering or variable substitution
6//! - File I/O operations
7//! - Template resolution or compilation
8//!
9//! # Supported File Types
10//!
11//! - **Static files**: Direct configuration files (`.yml`, `.yaml`, `.toml`, `.tf`)
12//! - **Tera templates**: Dynamic templates with variable substitution (`.yml.tera`, `.toml.tera`, `.tf.tera`)
13//!
14//! # Examples
15//!
16//! ```rust
17//! use torrust_tracker_deployer_lib::domain::template::file::File;
18//!
19//! // Create DTO for static YAML file
20//! let static_file = File::new("config/app.yml", "key: value".to_string())?;
21//!
22//! // Create DTO for Tera template with YAML output
23//! let template_file = File::new("templates/inventory.yml.tera", "host: {{ vm_ip }}".to_string())?;
24//! # Ok::<(), Box<dyn std::error::Error>>(())
25//! ```
26
27use std::fmt::Display;
28use std::path::Path;
29
30#[derive(Debug, Clone, PartialEq)]
31pub enum Engine {
32    Static,
33    Tera,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub enum Format {
38    Tera,
39    Yml,
40    Toml,
41    Tf,
42    Tfvars,
43    Env,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub enum Extension {
48    Tera,
49    Yaml,
50    Yml,
51    Toml,
52    Tf,
53    Tfvars,
54    Env,
55}
56
57#[derive(thiserror::Error, Debug, Clone, PartialEq)]
58pub enum Error {
59    #[error("Template file path cannot be empty")]
60    EmptyPath,
61
62    #[error("Template file path must contain a filename: {path}")]
63    MissingFilename { path: String },
64
65    #[error("Template file must have an extension: {path}")]
66    MissingExtension { path: String },
67
68    #[error("Unknown file extension '{extension}' in file: {path}")]
69    UnknownExtension { path: String, extension: String },
70
71    #[error("Tera template file must have an inner extension to determine output format: {path}")]
72    MissingInnerExtension { path: String },
73
74    #[error("Unknown inner extension '{extension}' in Tera template file: {path}")]
75    UnknownInnerExtension { path: String, extension: String },
76
77    #[error("Invalid inner extension '{extension}' for Tera template file: {path}. Tera templates cannot have 'tera' as inner extension")]
78    InvalidInnerExtension { path: String, extension: String },
79}
80
81impl TryFrom<&str> for Format {
82    type Error = String; // Use simple string error for Format conversion
83
84    fn try_from(extension: &str) -> Result<Self, Self::Error> {
85        match extension.to_lowercase().as_str() {
86            "tera" => Ok(Format::Tera),
87            "yml" | "yaml" => Ok(Format::Yml),
88            "toml" => Ok(Format::Toml),
89            "tf" => Ok(Format::Tf),
90            "env" => Ok(Format::Env),
91            _ => Err(extension.to_string()),
92        }
93    }
94}
95
96impl TryFrom<&str> for Extension {
97    type Error = String;
98
99    fn try_from(extension: &str) -> Result<Self, Self::Error> {
100        match extension.to_lowercase().as_str() {
101            "tera" => Ok(Extension::Tera),
102            "yaml" => Ok(Extension::Yaml),
103            "yml" => Ok(Extension::Yml),
104            "toml" => Ok(Extension::Toml),
105            "tf" => Ok(Extension::Tf),
106            "tfvars" => Ok(Extension::Tfvars),
107            "env" => Ok(Extension::Env),
108            _ => Err(extension.to_string()),
109        }
110    }
111}
112
113impl Display for Extension {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Extension::Tera => write!(f, "tera"),
117            Extension::Yaml => write!(f, "yaml"),
118            Extension::Yml => write!(f, "yml"),
119            Extension::Toml => write!(f, "toml"),
120            Extension::Tf => write!(f, "tf"),
121            Extension::Tfvars => write!(f, "tfvars"),
122            Extension::Env => write!(f, "env"),
123        }
124    }
125}
126
127/// A Data Transfer Object (DTO) representing template file metadata for the deployment system.
128///
129/// The `File` struct encapsulates metadata about template files without being responsible
130/// for template resolution or rendering. It serves as a structured representation of
131/// template information that can be passed between components in the deployment pipeline.
132///
133/// # Purpose
134///
135/// This DTO is designed to:
136/// - Parse and store template file metadata (path, format, engine type)
137/// - Provide a standardized representation of template information
138/// - Enable validation of template file structure and format
139/// - Facilitate template processing by other components
140///
141/// **Note**: This struct does NOT handle template rendering or variable resolution.
142/// Those responsibilities belong to dedicated template engine components.
143///
144/// # Template Engine Detection
145///
146/// The engine is determined by the file extension pattern:
147/// - Files ending with `.tera` are processed as Tera templates
148/// - All other files are treated as static files
149///
150/// # Format Detection
151///
152/// For Tera templates (`.ext.tera`), the inner extension determines the output format.
153/// For static files, the extension directly determines the format.
154///
155/// Supported formats: `yml`/`yaml`, `toml`, `tf`, `tera`
156///
157/// # Examples
158///
159/// ```rust
160/// # use torrust_tracker_deployer_lib::domain::template::file::File;
161/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
162/// // Create DTO for a static YAML file
163/// let static_file = File::new("config/app.yml", "key: value".to_string())?;
164/// assert_eq!(static_file.engine(), &torrust_tracker_deployer_lib::domain::template::file::Engine::Static);
165///
166/// // Create DTO for a Tera template that outputs YAML
167/// let template_file = File::new("templates/inventory.yml.tera", "host: {{ vm_ip }}".to_string())?;
168/// assert_eq!(template_file.engine(), &torrust_tracker_deployer_lib::domain::template::file::Engine::Tera);
169/// assert_eq!(template_file.inner_format(), Some(&torrust_tracker_deployer_lib::domain::template::file::Format::Yml));
170/// # Ok(())
171/// # }
172/// ```
173#[derive(Debug)]
174pub struct File {
175    engine: Engine,
176
177    /// The original full path of the template file, e.g., `templates/ansible/inventory.yml.tera`
178    path: String,
179
180    /// The filename without the directory path, e.g., `inventory.yml.tera`
181    filename: String,
182
183    /// The file format based on the extension, e.g., `yml`, `toml`, `tf`, `tera`
184    format: Format,
185
186    /// The file extension, e.g., `yml`, `yaml`, `toml`, `tf`, `tera`
187    extension: Extension,
188
189    /// When the file is a template, the inner format (e.g., `yml`, `toml`, `tf`)
190    inner_format: Option<Format>,
191
192    /// When the file is a template, the inner extension (e.g., `yml`, `yaml`, `toml`, `tf`)
193    inner_extension: Option<Extension>,
194
195    /// The content of the template file as a string
196    content: String,
197}
198
199impl File {
200    /// Creates a new template file with metadata extracted from the path
201    ///
202    /// # Arguments
203    /// * `path` - Full path to the template file (e.g., "templates/ansible/inventory.yml.tera")
204    /// * `content` - The content of the template file as a string
205    ///
206    /// # Errors
207    /// Returns an error if:
208    /// - The file path does not contain a file extension
209    /// - The file extension is not recognized
210    /// - A Tera template file (.tera) does not have a valid inner extension to determine output format
211    ///
212    /// # Examples
213    /// ```
214    /// # use torrust_tracker_deployer_lib::domain::template::file::File;
215    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
216    /// let file = File::new("templates/ansible/inventory.yml.tera", "content here".to_string())?;
217    /// // This creates a Tera template with yml inner format
218    ///
219    /// let static_file = File::new("templates/ansible/wait-cloud-init.yml", "---\nkey: value".to_string())?;
220    /// // This creates a static yml file
221    /// # Ok(())
222    /// # }
223    /// ```
224    pub fn new(path: &str, content: String) -> Result<File, Error> {
225        // Check for empty path first
226        if path.is_empty() {
227            return Err(Error::EmptyPath);
228        }
229
230        let filename = Self::extract_filename(path);
231
232        // Check if we actually got a filename from the path
233        if filename.is_empty() {
234            return Err(Error::MissingFilename {
235                path: path.to_string(),
236            });
237        }
238
239        let (extension, inner_extension) = Self::extract_extensions(&filename, path)?;
240
241        let (engine, format, inner_format, final_inner_extension) = if extension == Extension::Tera
242        {
243            // This is a Tera template file (e.g., inventory.yml.tera)
244            if let Some(ref inner_ext) = inner_extension {
245                let inner_format = match inner_ext {
246                    Extension::Yml | Extension::Yaml => Format::Yml,
247                    Extension::Toml => Format::Toml,
248                    Extension::Tf => Format::Tf,
249                    Extension::Tfvars => Format::Tfvars,
250                    Extension::Env => Format::Env,
251                    Extension::Tera => {
252                        return Err(Error::InvalidInnerExtension {
253                            path: path.to_string(),
254                            extension: "tera".to_string(),
255                        });
256                    }
257                };
258                (
259                    Engine::Tera,
260                    Format::Tera,
261                    Some(inner_format),
262                    inner_extension,
263                )
264            } else {
265                // This is a .tera file with no inner extension - not allowed
266                return Err(Error::MissingInnerExtension {
267                    path: path.to_string(),
268                });
269            }
270        } else {
271            // This is a static file with a single extension
272            let format = match extension {
273                Extension::Yml | Extension::Yaml => Format::Yml,
274                Extension::Toml => Format::Toml,
275                Extension::Tf => Format::Tf,
276                Extension::Tfvars => Format::Tfvars,
277                Extension::Env => Format::Env,
278                Extension::Tera => {
279                    // Single .tera extension without inner extension - not allowed
280                    return Err(Error::MissingInnerExtension {
281                        path: path.to_string(),
282                    });
283                }
284            };
285            (Engine::Static, format, None, None)
286        };
287
288        Ok(File {
289            engine,
290            path: path.to_string(),
291            filename,
292            format,
293            extension,
294            inner_format,
295            inner_extension: final_inner_extension,
296            content,
297        })
298    }
299
300    #[must_use]
301    pub fn engine(&self) -> &Engine {
302        &self.engine
303    }
304
305    #[must_use]
306    pub fn path(&self) -> &str {
307        &self.path
308    }
309
310    #[must_use]
311    pub fn filename(&self) -> &str {
312        &self.filename
313    }
314
315    #[must_use]
316    pub fn format(&self) -> &Format {
317        &self.format
318    }
319
320    #[must_use]
321    pub fn extension(&self) -> &Extension {
322        &self.extension
323    }
324
325    #[must_use]
326    pub fn inner_format(&self) -> Option<&Format> {
327        self.inner_format.as_ref()
328    }
329
330    #[must_use]
331    pub fn inner_extension(&self) -> Option<&Extension> {
332        self.inner_extension.as_ref()
333    }
334
335    #[must_use]
336    pub fn content(&self) -> &str {
337        &self.content
338    }
339
340    /// Extracts the filename from a file path
341    ///
342    /// # Arguments
343    ///
344    /// * `path` - The file path to extract the filename from
345    ///
346    /// # Returns
347    ///
348    /// The filename as a String, or an empty string if the path is invalid
349    fn extract_filename(path: &str) -> String {
350        Path::new(path)
351            .file_name()
352            .and_then(|name| name.to_str())
353            .unwrap_or("")
354            .to_string()
355    }
356
357    /// Extracts file extensions from a filename
358    ///
359    /// # Arguments
360    ///
361    /// * `filename` - The filename to extract extensions from (e.g., "inventory.yml.tera")
362    /// * `path` - The full file path (for error reporting)
363    ///
364    /// # Returns
365    ///
366    /// A tuple containing the last extension and optionally the previous extension
367    /// For example: "inventory.yml.tera" returns (`Extension::Tera`, `Some(Extension::Yml)`)
368    /// For example: "config.yml" returns (`Extension::Yml`, `None`)
369    fn extract_extensions(
370        filename: &str,
371        path: &str,
372    ) -> Result<(Extension, Option<Extension>), Error> {
373        let extensions: Vec<&str> = filename
374            .split('.')
375            .skip(1) // Skip the base name
376            .collect();
377
378        if extensions.is_empty() {
379            return Err(Error::MissingExtension {
380                path: path.to_string(),
381            });
382        }
383
384        // Get the last extension (required)
385        let last_extension = extensions.last().unwrap();
386        let extension = Extension::try_from(*last_extension).map_err(|unknown_ext| {
387            Error::UnknownExtension {
388                path: path.to_string(),
389                extension: unknown_ext,
390            }
391        })?;
392
393        // Get the previous extension if it exists
394        let inner_extension = if extensions.len() >= 2 {
395            let inner_ext = extensions[extensions.len() - 2];
396            Some(Extension::try_from(inner_ext).map_err(|unknown_ext| {
397                Error::UnknownInnerExtension {
398                    path: path.to_string(),
399                    extension: unknown_ext,
400                }
401            })?)
402        } else {
403            None
404        };
405
406        Ok((extension, inner_extension))
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn it_should_create_a_static_yml_template() {
416        let path = "templates/ansible/wait-cloud-init.yml";
417        let content = "---
418# Ansible Playbook: Wait for cloud-init completion
419- name: Wait for cloud-init completion
420  hosts: all
421  gather_facts: false
422  become: true
423  tasks:
424    - name: Wait for cloud-init to complete
425      command: cloud-init status --wait
426"
427        .to_string();
428
429        let file = File::new(path, content.clone()).expect("Failed to create file");
430
431        assert_eq!(file.engine(), &Engine::Static);
432        assert_eq!(file.path(), path);
433        assert_eq!(file.filename(), "wait-cloud-init.yml");
434        assert_eq!(file.format(), &Format::Yml);
435        assert_eq!(file.inner_format(), None);
436        assert_eq!(file.content(), &content);
437    }
438
439    #[test]
440    fn it_should_create_a_tera_template_with_yml_inner_format() {
441        let path = "templates/ansible/inventory.yml.tera";
442        let content = "# Ansible Inventory File (YAML format)
443all:
444  hosts:
445    torrust-tracker-vm:
446      ansible_host: {{ vm_ip }}
447      ansible_user: {{ vm_user }}
448      ansible_ssh_private_key_file: {{ ssh_private_key_path }}
449      ansible_ssh_common_args: '-o StrictHostKeyChecking=no'
450"
451        .to_string();
452
453        let file = File::new(path, content.clone()).expect("Failed to create file");
454
455        assert_eq!(file.engine(), &Engine::Tera);
456        assert_eq!(file.path(), path);
457        assert_eq!(file.filename(), "inventory.yml.tera");
458        assert_eq!(file.format(), &Format::Tera);
459        assert_eq!(file.inner_format(), Some(&Format::Yml));
460        assert_eq!(file.content(), &content);
461    }
462
463    #[test]
464    fn it_should_create_a_tera_template_with_toml_inner_format() {
465        let path = "templates/config/app.toml.tera";
466        let content = "[server]
467host = \"{{ server_host }}\"
468port = {{ server_port }}
469
470[database]
471url = \"{{ db_url }}\"
472"
473        .to_string();
474
475        let file = File::new(path, content.clone()).expect("Failed to create file");
476
477        assert_eq!(file.engine(), &Engine::Tera);
478        assert_eq!(file.path(), path);
479        assert_eq!(file.filename(), "app.toml.tera");
480        assert_eq!(file.format(), &Format::Tera);
481        assert_eq!(file.inner_format(), Some(&Format::Toml));
482        assert_eq!(file.content(), &content);
483    }
484
485    #[test]
486    fn it_should_create_a_static_toml_template() {
487        let path = "config/app.toml";
488        let content = "[server]
489host = \"localhost\"
490port = 8080
491
492[database]
493url = \"sqlite://db.sqlite\"
494"
495        .to_string();
496
497        let file = File::new(path, content.clone()).expect("Failed to create file");
498
499        assert_eq!(file.engine(), &Engine::Static);
500        assert_eq!(file.path(), path);
501        assert_eq!(file.filename(), "app.toml");
502        assert_eq!(file.format(), &Format::Toml);
503        assert_eq!(file.inner_format(), None);
504        assert_eq!(file.content(), &content);
505    }
506
507    #[test]
508    fn it_should_create_a_terraform_tera_template() {
509        let path = "templates/tofu/main.tf.tera";
510        let content = "resource \"lxd_container\" \"{{ container_name }}\" {
511  name  = \"{{ container_name }}\"
512  image = \"ubuntu:{{ ubuntu_version }}\"
513  
514  config = {
515    \"user.user-data\" = file(\"{{ cloud_init_path }}\")
516  }
517}
518"
519        .to_string();
520
521        let file = File::new(path, content.clone()).expect("Failed to create file");
522
523        assert_eq!(file.engine(), &Engine::Tera);
524        assert_eq!(file.path(), path);
525        assert_eq!(file.filename(), "main.tf.tera");
526        assert_eq!(file.format(), &Format::Tera);
527        assert_eq!(file.inner_format(), Some(&Format::Tf));
528        assert_eq!(file.content(), &content);
529    }
530
531    #[test]
532    fn it_should_convert_format_from_extension() {
533        assert_eq!(Format::try_from("yml"), Ok(Format::Yml));
534        assert_eq!(Format::try_from("yaml"), Ok(Format::Yml));
535        assert_eq!(Format::try_from("toml"), Ok(Format::Toml));
536        assert_eq!(Format::try_from("tf"), Ok(Format::Tf));
537        assert_eq!(Format::try_from("tera"), Ok(Format::Tera));
538        assert!(Format::try_from("unknown").is_err());
539        assert_eq!(Format::try_from("unknown").unwrap_err(), "unknown");
540    }
541
542    #[test]
543    fn it_should_fail_when_file_has_no_extension() {
544        let path = "templates/ansible/hosts";
545        let content = "localhost".to_string();
546
547        let result = File::new(path, content);
548
549        assert!(result.is_err());
550        assert_eq!(
551            result.unwrap_err(),
552            Error::MissingExtension {
553                path: path.to_string()
554            }
555        );
556    }
557
558    #[test]
559    fn it_should_fail_when_file_has_unknown_extension() {
560        let path = "templates/config/app.unknown";
561        let content = "content".to_string();
562
563        let result = File::new(path, content);
564
565        assert!(result.is_err());
566        assert_eq!(
567            result.unwrap_err(),
568            Error::UnknownExtension {
569                path: path.to_string(),
570                extension: "unknown".to_string()
571            }
572        );
573    }
574
575    #[test]
576    fn it_should_handle_complex_tera_template_paths() {
577        let path = "templates/deeply/nested/config.production.yml.tera";
578        let content = "production: {{ is_production }}".to_string();
579
580        let file = File::new(path, content.clone()).expect("Failed to create file");
581
582        assert_eq!(file.engine(), &Engine::Tera);
583        assert_eq!(file.path(), path);
584        assert_eq!(file.filename(), "config.production.yml.tera");
585        assert_eq!(file.format(), &Format::Tera);
586        assert_eq!(file.inner_format(), Some(&Format::Yml));
587        assert_eq!(file.content(), &content);
588    }
589
590    #[test]
591    fn it_should_fail_when_a_tera_template_does_not_have_an_inner_extension() {
592        let path = "templates/config/template.tera";
593        let content = "{{ some_variable }}".to_string();
594
595        let result = File::new(path, content);
596
597        // This should fail because we don't know what format the resolved template should be
598        assert!(result.is_err());
599        assert_eq!(
600            result.unwrap_err(),
601            Error::MissingInnerExtension {
602                path: path.to_string()
603            }
604        );
605    }
606
607    #[test]
608    fn it_should_fail_when_tera_template_has_unknown_inner_extension() {
609        let path = "templates/config/app.unknown.tera";
610        let content = "{{ some_variable }}".to_string();
611
612        let result = File::new(path, content);
613
614        // This should fail because the inner extension is not recognized
615        assert!(result.is_err());
616        assert_eq!(
617            result.unwrap_err(),
618            Error::UnknownInnerExtension {
619                path: path.to_string(),
620                extension: "unknown".to_string()
621            }
622        );
623    }
624
625    #[test]
626    fn it_should_fail_when_tera_template_has_tera_as_inner_extension() {
627        let path = "templates/config/app.tera.tera";
628        let content = "{{ some_variable }}".to_string();
629
630        let result = File::new(path, content);
631
632        // This should fail because "tera" is not allowed as an inner extension
633        // It doesn't make sense to have a .tera.tera file
634        assert!(result.is_err());
635        assert_eq!(
636            result.unwrap_err(),
637            Error::InvalidInnerExtension {
638                path: path.to_string(),
639                extension: "tera".to_string()
640            }
641        );
642    }
643
644    #[test]
645    fn it_should_fail_when_path_is_empty() {
646        let path = "";
647        let content = "content".to_string();
648
649        let result = File::new(path, content);
650
651        assert!(result.is_err());
652        assert_eq!(result.unwrap_err(), Error::EmptyPath);
653    }
654
655    #[test]
656    fn it_should_fail_when_directory_path_has_no_filename() {
657        let path = "templates/ansible/";
658        let content = "content".to_string();
659
660        let result = File::new(path, content);
661
662        assert!(result.is_err());
663        assert_eq!(
664            result.unwrap_err(),
665            Error::MissingExtension {
666                path: path.to_string()
667            }
668        );
669    }
670
671    #[test]
672    fn it_should_fail_when_path_resolves_to_no_filename() {
673        let path = ".";
674        let content = "content".to_string();
675
676        let result = File::new(path, content);
677
678        assert!(result.is_err());
679        assert_eq!(
680            result.unwrap_err(),
681            Error::MissingFilename {
682                path: path.to_string()
683            }
684        );
685    }
686
687    #[test]
688    fn it_should_handle_hidden_files_with_tera_template() {
689        let path = "templates/.hidden.yml.tera";
690        let content = "key: {{ value }}".to_string();
691
692        let file = File::new(path, content.clone()).expect("Failed to create file");
693
694        assert_eq!(file.engine(), &Engine::Tera);
695        assert_eq!(file.path(), path);
696        assert_eq!(file.filename(), ".hidden.yml.tera");
697        assert_eq!(file.format(), &Format::Tera);
698        assert_eq!(file.inner_format(), Some(&Format::Yml));
699        assert_eq!(file.content(), &content);
700    }
701
702    #[test]
703    fn it_should_handle_case_insensitive_extensions() {
704        let path_upper = "templates/config/app.YML.TERA";
705        let path_mixed = "templates/config/app.Yml.Tera";
706        let content = "key: {{ value }}".to_string();
707
708        let file_upper = File::new(path_upper, content.clone())
709            .expect("Failed to create file with uppercase extensions");
710        let file_mixed = File::new(path_mixed, content.clone())
711            .expect("Failed to create file with mixed case extensions");
712
713        // Both should work due to case-insensitive matching
714        assert_eq!(file_upper.engine(), &Engine::Tera);
715        assert_eq!(file_upper.format(), &Format::Tera);
716        assert_eq!(file_upper.inner_format(), Some(&Format::Yml));
717
718        assert_eq!(file_mixed.engine(), &Engine::Tera);
719        assert_eq!(file_mixed.format(), &Format::Tera);
720        assert_eq!(file_mixed.inner_format(), Some(&Format::Yml));
721    }
722
723    #[test]
724    fn it_should_handle_special_characters_in_filename() {
725        let path = "templates/config@2024/app-v1.2.yml.tera";
726        let content = "version: {{ app_version }}".to_string();
727
728        let file = File::new(path, content.clone())
729            .expect("Failed to create file with special characters");
730
731        assert_eq!(file.engine(), &Engine::Tera);
732        assert_eq!(file.filename(), "app-v1.2.yml.tera");
733        assert_eq!(file.format(), &Format::Tera);
734        assert_eq!(file.inner_format(), Some(&Format::Yml));
735    }
736
737    #[test]
738    fn it_should_handle_multiple_intermediate_extensions() {
739        let path = "templates/config.production.staging.deployment.yml.tera";
740        let content = "env: {{ environment }}".to_string();
741
742        let file = File::new(path, content.clone())
743            .expect("Failed to create file with multiple extensions");
744
745        assert_eq!(file.engine(), &Engine::Tera);
746        assert_eq!(
747            file.filename(),
748            "config.production.staging.deployment.yml.tera"
749        );
750        assert_eq!(file.format(), &Format::Tera);
751        // Should still correctly identify yml as the inner format (second-to-last extension)
752        assert_eq!(file.inner_format(), Some(&Format::Yml));
753    }
754
755    #[test]
756    fn it_should_handle_filename_starting_with_dot() {
757        let path = ".yml";
758        let content = "key: value".to_string();
759
760        let file = File::new(path, content.clone()).expect("Should create file for .yml");
761
762        // ".yml" is treated as a filename with extension "yml"
763        assert_eq!(file.engine(), &Engine::Static);
764        assert_eq!(file.filename(), ".yml");
765        assert_eq!(file.format(), &Format::Yml);
766        assert_eq!(file.inner_format(), None);
767    }
768
769    #[test]
770    fn it_should_fail_when_filename_has_only_dots() {
771        let path = "templates/...";
772        let content = "content".to_string();
773
774        let result = File::new(path, content);
775
776        // "..." results in empty extensions, leading to UnknownExtension with empty string
777        assert!(result.is_err());
778        assert_eq!(
779            result.unwrap_err(),
780            Error::UnknownExtension {
781                path: path.to_string(),
782                extension: String::new()
783            }
784        );
785    }
786
787    #[test]
788    fn it_should_treat_yaml_and_yml_extensions_the_same() {
789        let path_yaml = "templates/config.yaml";
790        let path_yml = "templates/config.yml";
791        let content = "key: value".to_string();
792
793        let file_yaml = File::new(path_yaml, content.clone()).expect("Failed to create .yaml file");
794        let file_yml_format =
795            File::new(path_yml, content.clone()).expect("Failed to create .yml file");
796
797        // Both yaml and yml should be treated as Format::Yml
798        assert_eq!(file_yaml.format(), &Format::Yml);
799        assert_eq!(file_yml_format.format(), &Format::Yml);
800        assert_eq!(file_yaml.engine(), &Engine::Static);
801        assert_eq!(file_yml_format.engine(), &Engine::Static);
802    }
803
804    #[test]
805    fn it_should_handle_duplicate_extensions_in_tera_template() {
806        let path = "templates/config.yml.yml.tera";
807        let content = "key: {{ value }}".to_string();
808
809        let file = File::new(path, content.clone())
810            .expect("Failed to create file with duplicate extensions");
811
812        assert_eq!(file.engine(), &Engine::Tera);
813        assert_eq!(file.format(), &Format::Tera);
814        // Should use the second-to-last extension (yml) as inner format
815        assert_eq!(file.inner_format(), Some(&Format::Yml));
816    }
817
818    #[test]
819    fn it_should_fail_when_filename_ends_with_dot() {
820        let path = "templates/config.yml.";
821        let content = "key: value".to_string();
822
823        let result = File::new(path, content);
824
825        // Filename ending with dot results in empty last extension
826        assert!(result.is_err());
827        assert_eq!(
828            result.unwrap_err(),
829            Error::UnknownExtension {
830                path: path.to_string(),
831                extension: String::new()
832            }
833        );
834    }
835
836    #[test]
837    fn it_should_handle_very_long_extension_chains() {
838        let path = "config.a.b.c.d.e.f.g.h.i.j.yml.tera";
839        let content = "key: {{ value }}".to_string();
840
841        let file = File::new(path, content.clone()).expect("Should handle long extension chains");
842
843        assert_eq!(file.engine(), &Engine::Tera);
844        assert_eq!(file.format(), &Format::Tera);
845        // Should still correctly identify yml as inner format
846        assert_eq!(file.inner_format(), Some(&Format::Yml));
847        assert_eq!(file.filename(), "config.a.b.c.d.e.f.g.h.i.j.yml.tera");
848    }
849
850    #[test]
851    fn it_should_handle_unicode_filenames() {
852        let path = "templates/конфиг.yml.tera";
853        let content = "ключ: {{ значение }}".to_string();
854
855        let file = File::new(path, content.clone()).expect("Should handle unicode filenames");
856
857        assert_eq!(file.engine(), &Engine::Tera);
858        assert_eq!(file.filename(), "конфиг.yml.tera");
859        assert_eq!(file.format(), &Format::Tera);
860        assert_eq!(file.inner_format(), Some(&Format::Yml));
861    }
862
863    #[test]
864    fn it_should_handle_relative_path_traversal() {
865        let path = "../../../dangerous.yml.tera";
866        let content = "malicious: {{ code }}".to_string();
867
868        let file = File::new(path, content.clone())
869            .expect("Should handle path traversal (path parsing only)");
870
871        // The file parsing should work regardless of path traversal
872        assert_eq!(file.engine(), &Engine::Tera);
873        assert_eq!(file.filename(), "dangerous.yml.tera");
874        assert_eq!(file.format(), &Format::Tera);
875        assert_eq!(file.inner_format(), Some(&Format::Yml));
876        assert_eq!(file.path(), path); // Original path preserved
877    }
878
879    #[test]
880    fn it_should_fail_when_extensions_are_single_characters() {
881        let path = "templates/config.a.b";
882        let content = "content".to_string();
883
884        let result = File::new(path, content);
885
886        // Single character extensions should fail as unknown
887        assert!(result.is_err());
888        assert_eq!(
889            result.unwrap_err(),
890            Error::UnknownExtension {
891                path: path.to_string(),
892                extension: "b".to_string()
893            }
894        );
895    }
896}