Skip to main content

pipi/generator/
template.rs

1//! This module defines a `Template` struct for handling template files.
2
3use std::{
4    collections::HashMap,
5    path::{Path, PathBuf},
6    sync::{Arc, Mutex},
7};
8
9use rand::{distributions::Alphanumeric, rngs::StdRng, Rng, SeedableRng};
10use tera::{Context, Tera};
11
12use crate::settings::Settings;
13
14const TEMPLATE_EXTENSION: &str = "t";
15
16fn generate_random_string<R: Rng>(rng: &mut R, length: u64) -> String {
17    (0..length)
18        .map(|_| rng.sample(Alphanumeric) as char)
19        .collect()
20}
21
22/// Represents a template that can be rendered with injected settings.
23#[derive(Debug, Clone)]
24pub struct Template {
25    rng: Arc<Mutex<StdRng>>,
26}
27
28impl Default for Template {
29    fn default() -> Self {
30        #[cfg(test)]
31        let rng = StdRng::seed_from_u64(42);
32        #[cfg(not(test))]
33        let rng = StdRng::from_entropy();
34        Self {
35            rng: Arc::new(Mutex::new(rng)),
36        }
37    }
38}
39
40impl Template {
41    #[must_use]
42    pub fn new(rng: StdRng) -> Self {
43        Self {
44            rng: Arc::new(Mutex::new(rng)),
45        }
46    }
47    /// Checks if the provided file path has a ".t" extension, marking it as a
48    /// template.
49    ///
50    /// Returns `true` if the file has a ".t" extension, otherwise `false`.
51    #[must_use]
52    pub fn is_template(&self, path: &Path) -> bool {
53        path.extension()
54            .and_then(|ext| ext.to_str())
55            .filter(|&ext| ext == TEMPLATE_EXTENSION)
56            .is_some()
57    }
58
59    // Method to register filters in the Tera instance.
60    fn register_filters(&self, tera_instance: &mut tera::Tera) {
61        // Clone the Arc to move it into the closure.
62        let rng_clone = Arc::clone(&self.rng);
63
64        tera_instance.register_filter(
65            "random_string",
66            move |value: &tera::Value, _args: &HashMap<String, tera::Value>| {
67                if let tera::Value::Number(length) = value {
68                    if let Some(length) = length.as_u64() {
69                        let rand_str: String = rng_clone.lock().map_or_else(
70                            |_| {
71                                let mut r = StdRng::from_entropy();
72                                generate_random_string(&mut r, length)
73                            },
74                            |mut rng| generate_random_string(&mut *rng, length),
75                        );
76                        return Ok(tera::Value::String(rand_str));
77                    }
78                }
79                // Ok(tera::Value::String(String::new()))
80                Err(tera::Error::msg("arg must be a number"))
81            },
82        );
83    }
84
85    /// Renders a template with the provided content and settings.
86    ///
87    /// # Errors
88    /// when could not render the template
89    pub fn render(&self, template_content: &str, settings: &Settings) -> tera::Result<String> {
90        tracing::trace!(
91            template_content,
92            settings = format!("{settings:#?}"),
93            "render template"
94        );
95
96        let mut tera_instance = Tera::default();
97        self.register_filters(&mut tera_instance);
98
99        let mut context = Context::new();
100        context.insert("settings", &settings);
101
102        let rendered_output = tera_instance.render_str(template_content, &context)?;
103
104        Ok(rendered_output)
105    }
106
107    /// Removes the ".t" extension from a template file path, if present.
108    ///
109    /// # Errors
110    /// if the given path is not contains template extension
111    pub fn strip_template_extension(&self, path: &Path) -> std::io::Result<PathBuf> {
112        path.file_stem().map_or_else(
113            || {
114                Err(std::io::Error::new(
115                    std::io::ErrorKind::InvalidInput,
116                    "Failed to retrieve file stem",
117                ))
118            },
119            |stem| {
120                let mut path_without_extension = path.to_path_buf();
121                path_without_extension.set_file_name(stem);
122                if let Some(parent_dir) = path.parent() {
123                    path_without_extension = parent_dir.join(stem.to_string_lossy().to_string());
124                }
125                Ok(path_without_extension)
126            },
127        )
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_is_template() {
137        let template = Template::default();
138
139        let path = Path::new("example.t");
140        assert!(template.is_template(path));
141
142        let path = Path::new("example.txt");
143        assert!(!template.is_template(path));
144
145        let path = Path::new("directory/");
146        assert!(!template.is_template(path));
147    }
148
149    #[test]
150    fn test_render_template() {
151        let template = Template::default();
152        let template_content = "crate: {{ settings.package_name }}";
153
154        let mock_settings = Settings {
155            package_name: "pipi-app".to_string(),
156            ..Default::default()
157        };
158
159        let result = template.render(template_content, &mock_settings);
160        assert!(result.is_ok());
161        assert_eq!(result.unwrap(), "crate: pipi-app");
162    }
163
164    #[test]
165    fn test_strip_template_extension() {
166        let template = Template::default();
167
168        let path = Path::new("example.t");
169        let result = template.strip_template_extension(path);
170        assert!(result.is_ok());
171        assert_eq!(result.unwrap(), Path::new("example"));
172
173        let path = Path::new("example");
174        let result = template.strip_template_extension(path);
175        assert!(result.is_ok());
176        assert_eq!(result.unwrap(), Path::new("example"));
177
178        let path = Path::new("");
179        let result = template.strip_template_extension(path);
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn can_create_random_string() {
185        let template = Template::default();
186        let template_content = "rand: {{20 | random_string }}";
187
188        let mock_settings = Settings {
189            package_name: "pipi-app".to_string(),
190            ..Default::default()
191        };
192
193        let result = template.render(template_content, &mock_settings);
194        assert!(result.is_ok());
195        assert_eq!(result.unwrap(), "rand: IhPi3oZCnaWvL2oIeA07");
196        let result = template.render(template_content, &mock_settings);
197        assert!(result.is_ok());
198        assert_eq!(result.unwrap(), "rand: mg3ZtJzh0NoAKhdDqpQ2");
199    }
200}