1use glob::glob;
2use pulldown_cmark::{Options, Parser, html};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use tera::{Function, Value};
6
7fn 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
14pub 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
37pub 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
55pub 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
79pub 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 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
93
94 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 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 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
148pub 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 if path.is_dir() && !name.starts_with('.') {
167 dirs.push(Value::String(name.to_string()));
168 }
169 }
170 }
171 }
172
173 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
184pub 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
198pub 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 let paragraphs: Vec<String> = text
207 .split("\n\n")
208 .map(|p| {
209 let p = p.replace('\n', "<br>");
210 let p = convert_bold(&p);
212 let p = convert_label(&p);
214 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#[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(); 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(); 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#[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 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#[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 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
318pub 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 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 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); assert!(dirs.contains(&Value::String("subdir1".to_string())));
461 assert!(dirs.contains(&Value::String("subdir2".to_string())));
462 }
463}