sherpack_engine/
files_object.rs

1//! MiniJinja integration for the Files API
2//!
3//! This module provides a MiniJinja Object implementation that exposes
4//! the Files API to templates.
5//!
6//! # Usage in Templates
7//!
8//! ```jinja2
9//! {# Read a file as string #}
10//! {{ files.get("config/nginx.conf") }}
11//!
12//! {# Read and encode as base64 #}
13//! {{ files.get("config/nginx.conf") | b64encode }}
14//!
15//! {# Check if file exists #}
16//! {% if files.exists("config/custom.yaml") %}
17//!   {{ files.get("config/custom.yaml") }}
18//! {% endif %}
19//!
20//! {# Iterate over files matching pattern #}
21//! {% for file in files.glob("config/*.yaml") %}
22//!   {{ file.name }}: {{ file.content | b64encode }}
23//! {% endfor %}
24//!
25//! {# Read file lines #}
26//! {% for line in files.lines("hosts.txt") %}
27//!   - {{ line }}
28//! {% endfor %}
29//! ```
30
31use std::sync::Arc;
32
33use minijinja::value::{Object, ObjectRepr, Value};
34use minijinja::{Error, ErrorKind};
35use sherpack_core::files::{FileProvider, Files};
36
37/// MiniJinja Object wrapper for the Files API
38///
39/// This struct implements the `Object` trait to expose file operations
40/// to templates via method calls.
41#[derive(Debug)]
42pub struct FilesObject {
43    files: Files,
44}
45
46impl FilesObject {
47    /// Create a new FilesObject from a Files instance
48    pub fn new(files: Files) -> Self {
49        Self { files }
50    }
51
52    /// Create a new FilesObject from a FileProvider
53    pub fn from_provider(provider: impl FileProvider + 'static) -> Self {
54        Self {
55            files: Files::new(provider),
56        }
57    }
58
59    /// Create a new FilesObject from an Arc'd FileProvider
60    pub fn from_arc_provider(provider: Arc<dyn FileProvider>) -> Self {
61        Self {
62            files: Files::from_arc(provider),
63        }
64    }
65}
66
67impl Object for FilesObject {
68    fn repr(self: &Arc<Self>) -> ObjectRepr {
69        ObjectRepr::Plain
70    }
71
72    fn call_method(
73        self: &Arc<Self>,
74        _state: &minijinja::State,
75        method: &str,
76        args: &[Value],
77    ) -> Result<Value, Error> {
78        match method {
79            "get" => {
80                let path = get_path_arg(args, "get")?;
81                match self.files.get(&path) {
82                    Ok(content) => Ok(Value::from(content)),
83                    Err(e) => Err(Error::new(ErrorKind::InvalidOperation, e.to_string())),
84                }
85            }
86
87            "get_bytes" => {
88                let path = get_path_arg(args, "get_bytes")?;
89                match self.files.get_bytes(&path) {
90                    Ok(bytes) => {
91                        // Return as a sequence of integers for b64encode compatibility
92                        Ok(Value::from(bytes))
93                    }
94                    Err(e) => Err(Error::new(ErrorKind::InvalidOperation, e.to_string())),
95                }
96            }
97
98            "exists" => {
99                let path = get_path_arg(args, "exists")?;
100                Ok(Value::from(self.files.exists(&path)))
101            }
102
103            "glob" => {
104                let pattern = get_path_arg(args, "glob")?;
105                match self.files.glob(&pattern) {
106                    Ok(entries) => {
107                        // Convert to MiniJinja-friendly format
108                        let values: Vec<Value> = entries
109                            .into_iter()
110                            .map(|entry| {
111                                Value::from_object(FileEntryObject {
112                                    path: entry.path,
113                                    name: entry.name,
114                                    content: entry.content,
115                                    size: entry.size,
116                                })
117                            })
118                            .collect();
119                        Ok(Value::from(values))
120                    }
121                    Err(e) => Err(Error::new(ErrorKind::InvalidOperation, e.to_string())),
122                }
123            }
124
125            "lines" => {
126                let path = get_path_arg(args, "lines")?;
127                match self.files.lines(&path) {
128                    Ok(lines) => Ok(Value::from(lines)),
129                    Err(e) => Err(Error::new(ErrorKind::InvalidOperation, e.to_string())),
130                }
131            }
132
133            _ => Err(Error::new(
134                ErrorKind::UnknownMethod,
135                format!(
136                    "files object has no method '{}'. Available methods: get, get_bytes, exists, glob, lines",
137                    method
138                ),
139            )),
140        }
141    }
142}
143
144/// Helper to extract path argument from method call
145fn get_path_arg(args: &[Value], method_name: &str) -> Result<String, Error> {
146    args.first()
147        .and_then(|v| v.as_str())
148        .map(|s| s.to_string())
149        .ok_or_else(|| {
150            Error::new(
151                ErrorKind::InvalidOperation,
152                format!("files.{}() requires a path string argument", method_name),
153            )
154        })
155}
156
157/// MiniJinja Object for file entries returned by glob
158#[derive(Debug)]
159struct FileEntryObject {
160    path: String,
161    name: String,
162    content: String,
163    size: usize,
164}
165
166impl Object for FileEntryObject {
167    fn repr(self: &Arc<Self>) -> ObjectRepr {
168        ObjectRepr::Map
169    }
170
171    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
172        let key_str = key.as_str()?;
173        match key_str {
174            "path" => Some(Value::from(self.path.clone())),
175            "name" => Some(Value::from(self.name.clone())),
176            "content" => Some(Value::from(self.content.clone())),
177            "size" => Some(Value::from(self.size)),
178            _ => None,
179        }
180    }
181
182    fn enumerate(self: &Arc<Self>) -> minijinja::value::Enumerator {
183        minijinja::value::Enumerator::Str(&["path", "name", "content", "size"])
184    }
185}
186
187/// Create a MiniJinja Value containing a FilesObject
188///
189/// This is the main entry point for injecting the files API into templates.
190pub fn create_files_value(files: Files) -> Value {
191    Value::from_object(FilesObject::new(files))
192}
193
194/// Create a MiniJinja Value from a FileProvider
195pub fn create_files_value_from_provider(provider: impl FileProvider + 'static) -> Value {
196    Value::from_object(FilesObject::from_provider(provider))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use minijinja::Environment;
203    use sherpack_core::files::MockFileProvider;
204
205    fn create_test_env() -> (Environment<'static>, Value) {
206        let provider = MockFileProvider::new()
207            .with_text_file("config/app.yaml", "key: value\nother: data")
208            .with_text_file("config/db.yaml", "host: localhost")
209            .with_text_file("scripts/init.sh", "#!/bin/bash\necho hello");
210
211        let files_value = create_files_value_from_provider(provider);
212
213        let mut env = Environment::new();
214        env.add_global("files", files_value.clone());
215
216        (env, files_value)
217    }
218
219    #[test]
220    fn test_files_get() {
221        let (env, _) = create_test_env();
222
223        let result = env
224            .render_str(r#"{{ files.get("config/app.yaml") }}"#, ())
225            .unwrap();
226        assert_eq!(result, "key: value\nother: data");
227    }
228
229    #[test]
230    fn test_files_exists_true() {
231        let (env, _) = create_test_env();
232
233        let result = env
234            .render_str(r#"{{ files.exists("config/app.yaml") }}"#, ())
235            .unwrap();
236        assert_eq!(result, "true");
237    }
238
239    #[test]
240    fn test_files_exists_false() {
241        let (env, _) = create_test_env();
242
243        let result = env
244            .render_str(r#"{{ files.exists("nonexistent.txt") }}"#, ())
245            .unwrap();
246        assert_eq!(result, "false");
247    }
248
249    #[test]
250    fn test_files_glob() {
251        let (env, _) = create_test_env();
252
253        let template = r#"{% for f in files.glob("config/*.yaml") %}{{ f.name }}:{{ f.content | length }},{% endfor %}"#;
254        let result = env.render_str(template, ()).unwrap();
255
256        // Files should be in sorted order
257        assert!(result.contains("app.yaml:"));
258        assert!(result.contains("db.yaml:"));
259    }
260
261    #[test]
262    fn test_files_glob_attributes() {
263        let (env, _) = create_test_env();
264
265        let template = r#"{% for f in files.glob("config/app.yaml") %}path={{ f.path }},name={{ f.name }},size={{ f.size }}{% endfor %}"#;
266        let result = env.render_str(template, ()).unwrap();
267
268        assert!(result.contains("path=config/app.yaml"));
269        assert!(result.contains("name=app.yaml"));
270        assert!(result.contains("size="));
271    }
272
273    #[test]
274    fn test_files_lines() {
275        let (env, _) = create_test_env();
276
277        let template =
278            r#"{% for line in files.lines("scripts/init.sh") %}[{{ line }}]{% endfor %}"#;
279        let result = env.render_str(template, ()).unwrap();
280
281        assert_eq!(result, "[#!/bin/bash][echo hello]");
282    }
283
284    #[test]
285    fn test_files_conditional() {
286        let (env, _) = create_test_env();
287
288        let template =
289            r#"{% if files.exists("config/app.yaml") %}found{% else %}not found{% endif %}"#;
290        let result = env.render_str(template, ()).unwrap();
291        assert_eq!(result, "found");
292
293        let template2 =
294            r#"{% if files.exists("missing.yaml") %}found{% else %}not found{% endif %}"#;
295        let result2 = env.render_str(template2, ()).unwrap();
296        assert_eq!(result2, "not found");
297    }
298
299    #[test]
300    fn test_files_get_not_found() {
301        let (env, _) = create_test_env();
302
303        let result = env.render_str(r#"{{ files.get("nonexistent.txt") }}"#, ());
304        assert!(result.is_err());
305        assert!(result.unwrap_err().to_string().contains("not found"));
306    }
307
308    #[test]
309    fn test_files_unknown_method() {
310        let (env, _) = create_test_env();
311
312        let result = env.render_str(r#"{{ files.unknown() }}"#, ());
313        assert!(result.is_err());
314        assert!(result.unwrap_err().to_string().contains("unknown"));
315    }
316
317    #[test]
318    fn test_files_with_filters() {
319        let (env, _) = create_test_env();
320
321        // Test with trim
322        let template = r#"{{ files.get("config/app.yaml") | trim }}"#;
323        let result = env.render_str(template, ()).unwrap();
324        assert_eq!(result, "key: value\nother: data");
325    }
326}