rs_web/
data.rs

1//! Tera template functions for data loading and content rendering
2
3use glob::glob;
4use pulldown_cmark::{Options, Parser, html};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use tera::{Function, Value};
8
9use crate::lua::SharedTracker;
10use crate::text::html_to_text;
11
12/// Expand ~ to home directory
13fn expand_tilde(path: &str) -> PathBuf {
14    path.strip_prefix("~/")
15        .and_then(|stripped| dirs::home_dir().map(|home| home.join(stripped)))
16        .unwrap_or_else(|| PathBuf::from(path))
17}
18
19/// load_json(path) - Load and parse a JSON file
20/// Returns the parsed JSON value, or Null if file doesn't exist or is invalid
21pub fn make_load_json(tracker: Option<SharedTracker>) -> impl Function {
22    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
23        let path = args
24            .get("path")
25            .and_then(|v| v.as_str())
26            .ok_or_else(|| tera::Error::msg("load_json requires 'path' argument"))?;
27
28        let path = expand_tilde(path);
29
30        let content = match std::fs::read_to_string(&path) {
31            Ok(c) => c,
32            Err(_) => return Ok(Value::Null),
33        };
34
35        if let Some(ref tracker) = tracker {
36            let canonical = path.canonicalize().unwrap_or(path);
37            tracker.record_read(canonical, content.as_bytes());
38        }
39
40        match serde_json::from_str(&content) {
41            Ok(v) => Ok(v),
42            Err(_) => Ok(Value::Null),
43        }
44    }
45}
46
47/// read_file(path) - Read a file as text
48/// Returns the file content as string, or Null if file doesn't exist
49pub fn make_read_file(tracker: Option<SharedTracker>) -> impl Function {
50    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
51        let path = args
52            .get("path")
53            .and_then(|v| v.as_str())
54            .ok_or_else(|| tera::Error::msg("read_file requires 'path' argument"))?;
55
56        let path = expand_tilde(path);
57
58        match std::fs::read_to_string(&path) {
59            Ok(content) => {
60                if let Some(ref tracker) = tracker {
61                    let canonical = path.canonicalize().unwrap_or(path);
62                    tracker.record_read(canonical, content.as_bytes());
63                }
64                Ok(Value::String(content))
65            }
66            Err(_) => Ok(Value::Null),
67        }
68    }
69}
70
71/// read_markdown(path) - Read a Markdown file and return as HTML
72/// Returns the rendered HTML as string, or Null if file doesn't exist
73pub fn make_read_markdown(tracker: Option<SharedTracker>) -> impl Function {
74    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
75        let path = args
76            .get("path")
77            .and_then(|v| v.as_str())
78            .ok_or_else(|| tera::Error::msg("read_markdown requires 'path' argument"))?;
79
80        let path = expand_tilde(path);
81
82        match std::fs::read_to_string(&path) {
83            Ok(content) => {
84                if let Some(ref tracker) = tracker {
85                    let canonical = path.canonicalize().unwrap_or(path);
86                    tracker.record_read(canonical, content.as_bytes());
87                }
88
89                let options = Options::all();
90                let parser = Parser::new_ext(&content, options);
91                let mut html_output = String::new();
92                html::push_html(&mut html_output, parser);
93                Ok(Value::String(html_output))
94            }
95            Err(_) => Ok(Value::Null),
96        }
97    }
98}
99
100/// list_files(path, pattern?) - List files with metadata
101/// Returns array of objects: [{path, name, stem, ext}, ...]
102/// Optional pattern argument supports glob syntax (e.g., "solution.*", "*.py")
103pub fn make_list_files(_tracker: Option<SharedTracker>) -> impl Function {
104    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
105        let path = args
106            .get("path")
107            .and_then(|v| v.as_str())
108            .ok_or_else(|| tera::Error::msg("list_files requires 'path' argument"))?;
109
110        let base_path = expand_tilde(path);
111
112        // Get optional pattern, default to "*" (all files)
113        let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
114
115        // Build glob pattern
116        let glob_pattern = base_path.join(pattern);
117        let glob_str = glob_pattern.to_string_lossy();
118
119        // Note: Directory listings are not tracked - changes to directory contents
120        // will trigger full rebuilds via the file watcher
121
122        let mut files: Vec<Value> = Vec::new();
123
124        if let Ok(entries) = glob(&glob_str) {
125            for entry in entries.flatten() {
126                // Skip directories
127                if entry.is_dir() {
128                    continue;
129                }
130
131                let name = entry
132                    .file_name()
133                    .and_then(|n| n.to_str())
134                    .unwrap_or("")
135                    .to_string();
136
137                let stem = entry
138                    .file_stem()
139                    .and_then(|s| s.to_str())
140                    .unwrap_or("")
141                    .to_string();
142
143                let ext = entry
144                    .extension()
145                    .and_then(|e| e.to_str())
146                    .unwrap_or("")
147                    .to_string();
148
149                let file_path = entry.to_string_lossy().to_string();
150
151                let mut file_obj = serde_json::Map::new();
152                file_obj.insert("path".to_string(), Value::String(file_path));
153                file_obj.insert("name".to_string(), Value::String(name));
154                file_obj.insert("stem".to_string(), Value::String(stem));
155                file_obj.insert("ext".to_string(), Value::String(ext));
156
157                files.push(Value::Object(file_obj));
158            }
159        }
160
161        // Sort by name for consistent ordering
162        files.sort_by(|a, b| {
163            let name_a = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
164            let name_b = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
165            name_a.cmp(name_b)
166        });
167
168        Ok(Value::Array(files))
169    }
170}
171
172/// list_dirs(path) - List subdirectories
173/// Returns array of directory names (strings)
174pub fn make_list_dirs(_tracker: Option<SharedTracker>) -> impl Function {
175    move |args: &HashMap<String, Value>| -> tera::Result<Value> {
176        let path = args
177            .get("path")
178            .and_then(|v| v.as_str())
179            .ok_or_else(|| tera::Error::msg("list_dirs requires 'path' argument"))?;
180
181        let base_path = expand_tilde(path);
182
183        // Note: Directory listings are not tracked - changes to directory contents
184        // will trigger full rebuilds via the file watcher
185
186        let mut dirs: Vec<Value> = Vec::new();
187
188        if let Ok(entries) = std::fs::read_dir(&base_path) {
189            for entry in entries.flatten() {
190                let path = entry.path();
191                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
192                    // Skip non-directories and hidden directories
193                    if path.is_dir() && !name.starts_with('.') {
194                        dirs.push(Value::String(name.to_string()));
195                    }
196                }
197            }
198        }
199
200        // Sort for consistent ordering
201        dirs.sort_by(|a, b| {
202            let name_a = a.as_str().unwrap_or("");
203            let name_b = b.as_str().unwrap_or("");
204            name_a.cmp(name_b)
205        });
206
207        Ok(Value::Array(dirs))
208    }
209}
210
211/// markdown filter - Convert markdown text to HTML
212pub fn markdown_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
213    let text = value
214        .as_str()
215        .ok_or_else(|| tera::Error::msg("markdown filter requires a string"))?;
216
217    let options = Options::all();
218    let parser = Parser::new_ext(text, options);
219    let mut html_output = String::new();
220    html::push_html(&mut html_output, parser);
221
222    Ok(Value::String(html_output))
223}
224
225/// html_to_text filter - Convert HTML to beautifully formatted plain text
226pub fn html_to_text_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
227    let html = value
228        .as_str()
229        .ok_or_else(|| tera::Error::msg("html_to_text filter requires a string"))?;
230
231    Ok(Value::String(html_to_text(html)))
232}
233
234/// linebreaks filter - Convert newlines to <br> tags and double newlines to paragraphs
235/// Also supports basic markdown: **bold**, *label*, `code`
236pub fn linebreaks_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
237    let text = value
238        .as_str()
239        .ok_or_else(|| tera::Error::msg("linebreaks filter requires a string"))?;
240
241    // Split by double newlines for paragraphs
242    let paragraphs: Vec<String> = text
243        .split("\n\n")
244        .map(|p| {
245            let p = p.replace('\n', "<br>");
246            // Convert **bold** to <strong> (must be before single *)
247            let p = convert_bold(&p);
248            // Convert *label* to <span class="label">
249            let p = convert_label(&p);
250            // Convert `code` to <code>
251            convert_code(&p)
252        })
253        .collect();
254
255    let html = format!("<p>{}</p>", paragraphs.join("</p><p>"));
256    Ok(Value::String(html))
257}
258
259/// Convert **text** to <strong>text</strong>
260#[allow(clippy::while_let_on_iterator)]
261fn convert_bold(text: &str) -> String {
262    let mut result = String::new();
263    let mut chars = text.chars().peekable();
264
265    while let Some(c) = chars.next() {
266        if c == '*' && chars.peek() == Some(&'*') {
267            chars.next(); // consume second *
268            // Find closing **
269            let mut bold_text = String::new();
270            let mut found_close = false;
271            while let Some(bc) = chars.next() {
272                if bc == '*' && chars.peek() == Some(&'*') {
273                    chars.next(); // consume second *
274                    found_close = true;
275                    break;
276                }
277                bold_text.push(bc);
278            }
279            if found_close {
280                result.push_str(&format!("<strong>{}</strong>", bold_text));
281            } else {
282                result.push_str("**");
283                result.push_str(&bold_text);
284            }
285        } else {
286            result.push(c);
287        }
288    }
289    result
290}
291
292/// Convert *text* to <span class="label">text</span>
293#[allow(clippy::while_let_on_iterator)]
294fn convert_label(text: &str) -> String {
295    let mut result = String::new();
296    let mut chars = text.chars().peekable();
297
298    while let Some(c) = chars.next() {
299        if c == '*' {
300            // Find closing *
301            let mut label_text = String::new();
302            let mut found_close = false;
303            while let Some(lc) = chars.next() {
304                if lc == '*' {
305                    found_close = true;
306                    break;
307                }
308                label_text.push(lc);
309            }
310            if found_close {
311                result.push_str(&format!("<span class=\"label\">{}</span>", label_text));
312            } else {
313                result.push('*');
314                result.push_str(&label_text);
315            }
316        } else {
317            result.push(c);
318        }
319    }
320    result
321}
322
323/// Convert `text` to <code>text</code>
324#[allow(clippy::while_let_on_iterator)]
325fn convert_code(text: &str) -> String {
326    let mut result = String::new();
327    let mut chars = text.chars().peekable();
328
329    while let Some(c) = chars.next() {
330        if c == '`' {
331            // Find closing `
332            let mut code_text = String::new();
333            let mut found_close = false;
334            while let Some(cc) = chars.next() {
335                if cc == '`' {
336                    found_close = true;
337                    break;
338                }
339                code_text.push(cc);
340            }
341            if found_close {
342                result.push_str(&format!("<code>{}</code>", code_text));
343            } else {
344                result.push('`');
345                result.push_str(&code_text);
346            }
347        } else {
348            result.push(c);
349        }
350    }
351    result
352}
353
354/// Register all data functions with Tera
355pub fn register_data_functions(tera: &mut tera::Tera, tracker: Option<SharedTracker>) {
356    tera.register_function("load_json", make_load_json(tracker.clone()));
357    tera.register_function("read_file", make_read_file(tracker.clone()));
358    tera.register_function("read_markdown", make_read_markdown(tracker.clone()));
359    tera.register_function("list_files", make_list_files(tracker.clone()));
360    tera.register_function("list_dirs", make_list_dirs(tracker));
361    tera.register_filter("markdown", markdown_filter);
362    tera.register_filter("linebreaks", linebreaks_filter);
363    tera.register_filter("html_to_text", html_to_text_filter);
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use std::fs;
370    use tempfile::tempdir;
371    use tera::Function;
372
373    #[test]
374    fn test_expand_tilde() {
375        let path = expand_tilde("~/test");
376        assert!(path.to_string_lossy().contains("test"));
377        assert!(!path.to_string_lossy().starts_with("~"));
378
379        let path = expand_tilde("/absolute/path");
380        assert_eq!(path, PathBuf::from("/absolute/path"));
381    }
382
383    #[test]
384    fn test_load_json() {
385        let dir = tempdir().unwrap();
386        let json_path = dir.path().join("test.json");
387        fs::write(&json_path, r#"{"name": "test", "value": 42}"#).unwrap();
388
389        let func = make_load_json(None);
390        let mut args = HashMap::new();
391        args.insert(
392            "path".to_string(),
393            Value::String(json_path.to_string_lossy().to_string()),
394        );
395
396        let result = func.call(&args).unwrap();
397        assert_eq!(result.get("name").unwrap().as_str().unwrap(), "test");
398        assert_eq!(result.get("value").unwrap().as_i64().unwrap(), 42);
399    }
400
401    #[test]
402    fn test_load_json_missing_file() {
403        let func = make_load_json(None);
404        let mut args = HashMap::new();
405        args.insert(
406            "path".to_string(),
407            Value::String("/nonexistent/path.json".to_string()),
408        );
409
410        let result = func.call(&args).unwrap();
411        assert!(result.is_null());
412    }
413
414    #[test]
415    fn test_read_file() {
416        let dir = tempdir().unwrap();
417        let file_path = dir.path().join("test.txt");
418        fs::write(&file_path, "Hello, World!").unwrap();
419
420        let func = make_read_file(None);
421        let mut args = HashMap::new();
422        args.insert(
423            "path".to_string(),
424            Value::String(file_path.to_string_lossy().to_string()),
425        );
426
427        let result = func.call(&args).unwrap();
428        assert_eq!(result.as_str().unwrap(), "Hello, World!");
429    }
430
431    #[test]
432    fn test_read_file_missing() {
433        let func = make_read_file(None);
434        let mut args = HashMap::new();
435        args.insert(
436            "path".to_string(),
437            Value::String("/nonexistent/file.txt".to_string()),
438        );
439
440        let result = func.call(&args).unwrap();
441        assert!(result.is_null());
442    }
443
444    #[test]
445    fn test_list_files() {
446        let dir = tempdir().unwrap();
447        fs::write(dir.path().join("solution.py"), "print('hello')").unwrap();
448        fs::write(dir.path().join("solution.cpp"), "int main(){}").unwrap();
449        fs::write(dir.path().join("README.md"), "# Hello").unwrap();
450
451        let func = make_list_files(None);
452        let mut args = HashMap::new();
453        args.insert(
454            "path".to_string(),
455            Value::String(dir.path().to_string_lossy().to_string()),
456        );
457
458        let result = func.call(&args).unwrap();
459        let files = result.as_array().unwrap();
460        assert_eq!(files.len(), 3);
461
462        // Test with pattern
463        args.insert(
464            "pattern".to_string(),
465            Value::String("solution.*".to_string()),
466        );
467        let result = func.call(&args).unwrap();
468        let files = result.as_array().unwrap();
469        assert_eq!(files.len(), 2);
470
471        // Check file object structure
472        let file = &files[0];
473        assert!(file.get("path").is_some());
474        assert!(file.get("name").is_some());
475        assert!(file.get("stem").is_some());
476        assert!(file.get("ext").is_some());
477    }
478
479    #[test]
480    fn test_list_dirs() {
481        let dir = tempdir().unwrap();
482        fs::create_dir(dir.path().join("subdir1")).unwrap();
483        fs::create_dir(dir.path().join("subdir2")).unwrap();
484        fs::create_dir(dir.path().join(".hidden")).unwrap();
485        fs::write(dir.path().join("file.txt"), "not a dir").unwrap();
486
487        let func = make_list_dirs(None);
488        let mut args = HashMap::new();
489        args.insert(
490            "path".to_string(),
491            Value::String(dir.path().to_string_lossy().to_string()),
492        );
493
494        let result = func.call(&args).unwrap();
495        let dirs = result.as_array().unwrap();
496        assert_eq!(dirs.len(), 2); // Should exclude .hidden
497        assert!(dirs.contains(&Value::String("subdir1".to_string())));
498        assert!(dirs.contains(&Value::String("subdir2".to_string())));
499    }
500}