1use 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; 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#[derive(Debug)]
174pub struct File {
175 engine: Engine,
176
177 path: String,
179
180 filename: String,
182
183 format: Format,
185
186 extension: Extension,
188
189 inner_format: Option<Format>,
191
192 inner_extension: Option<Extension>,
194
195 content: String,
197}
198
199impl File {
200 pub fn new(path: &str, content: String) -> Result<File, Error> {
225 if path.is_empty() {
227 return Err(Error::EmptyPath);
228 }
229
230 let filename = Self::extract_filename(path);
231
232 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 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 return Err(Error::MissingInnerExtension {
267 path: path.to_string(),
268 });
269 }
270 } else {
271 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 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 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 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) .collect();
377
378 if extensions.is_empty() {
379 return Err(Error::MissingExtension {
380 path: path.to_string(),
381 });
382 }
383
384 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 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 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 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 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 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 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 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 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 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 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 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 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 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); }
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 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}