rs_web/
data.rs

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