1use glob::glob;
4use pulldown_cmark::{Options, Parser, html};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use tera::{Function, Value};
8
9use crate::lua::{AssetManifest, highlight_code_sync};
10use crate::text::html_to_text;
11use crate::tracker::SharedTracker;
12
13fn expand_tilde(path: &str) -> PathBuf {
15 path.strip_prefix("~/")
16 .and_then(|stripped| dirs::home_dir().map(|home| home.join(stripped)))
17 .unwrap_or_else(|| PathBuf::from(path))
18}
19
20pub fn make_load_json(tracker: Option<SharedTracker>) -> impl Function {
23 move |args: &HashMap<String, Value>| -> tera::Result<Value> {
24 let path = args
25 .get("path")
26 .and_then(|v| v.as_str())
27 .ok_or_else(|| tera::Error::msg("load_json requires 'path' argument"))?;
28
29 let path = expand_tilde(path);
30
31 let content = match std::fs::read_to_string(&path) {
32 Ok(c) => c,
33 Err(_) => return Ok(Value::Null),
34 };
35
36 if let Some(ref tracker) = tracker {
37 let canonical = path.canonicalize().unwrap_or(path);
38 tracker.record_read(canonical, content.as_bytes());
39 }
40
41 match serde_json::from_str(&content) {
42 Ok(v) => Ok(v),
43 Err(_) => Ok(Value::Null),
44 }
45 }
46}
47
48pub fn make_read_file(tracker: Option<SharedTracker>) -> impl Function {
51 move |args: &HashMap<String, Value>| -> tera::Result<Value> {
52 let path = args
53 .get("path")
54 .and_then(|v| v.as_str())
55 .ok_or_else(|| tera::Error::msg("read_file requires 'path' argument"))?;
56
57 let path = expand_tilde(path);
58
59 match std::fs::read_to_string(&path) {
60 Ok(content) => {
61 if let Some(ref tracker) = tracker {
62 let canonical = path.canonicalize().unwrap_or(path);
63 tracker.record_read(canonical, content.as_bytes());
64 }
65 Ok(Value::String(content))
66 }
67 Err(_) => Ok(Value::Null),
68 }
69 }
70}
71
72pub fn make_read_markdown(tracker: Option<SharedTracker>) -> impl Function {
75 move |args: &HashMap<String, Value>| -> tera::Result<Value> {
76 let path = args
77 .get("path")
78 .and_then(|v| v.as_str())
79 .ok_or_else(|| tera::Error::msg("read_markdown requires 'path' argument"))?;
80
81 let path = expand_tilde(path);
82
83 match std::fs::read_to_string(&path) {
84 Ok(content) => {
85 if let Some(ref tracker) = tracker {
86 let canonical = path.canonicalize().unwrap_or(path);
87 tracker.record_read(canonical, content.as_bytes());
88 }
89
90 let options = Options::all();
91 let parser = Parser::new_ext(&content, options);
92 let mut html_output = String::new();
93 html::push_html(&mut html_output, parser);
94 Ok(Value::String(html_output))
95 }
96 Err(_) => Ok(Value::Null),
97 }
98 }
99}
100
101pub fn make_list_files(_tracker: Option<SharedTracker>) -> impl Function {
105 move |args: &HashMap<String, Value>| -> tera::Result<Value> {
106 let path = args
107 .get("path")
108 .and_then(|v| v.as_str())
109 .ok_or_else(|| tera::Error::msg("list_files requires 'path' argument"))?;
110
111 let base_path = expand_tilde(path);
112
113 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
115
116 let glob_pattern = base_path.join(pattern);
118 let glob_str = glob_pattern.to_string_lossy();
119
120 let mut files: Vec<Value> = Vec::new();
124
125 if let Ok(entries) = glob(&glob_str) {
126 for entry in entries.flatten() {
127 if entry.is_dir() {
129 continue;
130 }
131
132 let name = entry
133 .file_name()
134 .and_then(|n| n.to_str())
135 .unwrap_or("")
136 .to_string();
137
138 let stem = entry
139 .file_stem()
140 .and_then(|s| s.to_str())
141 .unwrap_or("")
142 .to_string();
143
144 let ext = entry
145 .extension()
146 .and_then(|e| e.to_str())
147 .unwrap_or("")
148 .to_string();
149
150 let file_path = entry.to_string_lossy().to_string();
151
152 let mut file_obj = serde_json::Map::new();
153 file_obj.insert("path".to_string(), Value::String(file_path));
154 file_obj.insert("name".to_string(), Value::String(name));
155 file_obj.insert("stem".to_string(), Value::String(stem));
156 file_obj.insert("ext".to_string(), Value::String(ext));
157
158 files.push(Value::Object(file_obj));
159 }
160 }
161
162 files.sort_by(|a, b| {
164 let name_a = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
165 let name_b = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
166 name_a.cmp(name_b)
167 });
168
169 Ok(Value::Array(files))
170 }
171}
172
173pub fn make_list_dirs(_tracker: Option<SharedTracker>) -> impl Function {
176 move |args: &HashMap<String, Value>| -> tera::Result<Value> {
177 let path = args
178 .get("path")
179 .and_then(|v| v.as_str())
180 .ok_or_else(|| tera::Error::msg("list_dirs requires 'path' argument"))?;
181
182 let base_path = expand_tilde(path);
183
184 let mut dirs: Vec<Value> = Vec::new();
188
189 if let Ok(entries) = std::fs::read_dir(&base_path) {
190 for entry in entries.flatten() {
191 let path = entry.path();
192 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
193 if path.is_dir() && !name.starts_with('.') {
195 dirs.push(Value::String(name.to_string()));
196 }
197 }
198 }
199 }
200
201 dirs.sort_by(|a, b| {
203 let name_a = a.as_str().unwrap_or("");
204 let name_b = b.as_str().unwrap_or("");
205 name_a.cmp(name_b)
206 });
207
208 Ok(Value::Array(dirs))
209 }
210}
211
212pub fn markdown_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
214 let text = value
215 .as_str()
216 .ok_or_else(|| tera::Error::msg("markdown filter requires a string"))?;
217
218 let options = Options::all();
219 let parser = Parser::new_ext(text, options);
220 let mut html_output = String::new();
221 html::push_html(&mut html_output, parser);
222
223 Ok(Value::String(html_output))
224}
225
226pub fn html_to_text_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
228 let html = value
229 .as_str()
230 .ok_or_else(|| tera::Error::msg("html_to_text filter requires a string"))?;
231
232 Ok(Value::String(html_to_text(html)))
233}
234
235pub fn linebreaks_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
238 let text = value
239 .as_str()
240 .ok_or_else(|| tera::Error::msg("linebreaks filter requires a string"))?;
241
242 let paragraphs: Vec<String> = text
244 .split("\n\n")
245 .map(|p| {
246 let p = p.replace('\n', "<br>");
247 let p = convert_bold(&p);
249 let p = convert_label(&p);
251 convert_code(&p)
253 })
254 .collect();
255
256 let html = format!("<p>{}</p>", paragraphs.join("</p><p>"));
257 Ok(Value::String(html))
258}
259
260#[allow(clippy::while_let_on_iterator)]
262fn convert_bold(text: &str) -> String {
263 let mut result = String::new();
264 let mut chars = text.chars().peekable();
265
266 while let Some(c) = chars.next() {
267 if c == '*' && chars.peek() == Some(&'*') {
268 chars.next(); let mut bold_text = String::new();
271 let mut found_close = false;
272 while let Some(bc) = chars.next() {
273 if bc == '*' && chars.peek() == Some(&'*') {
274 chars.next(); found_close = true;
276 break;
277 }
278 bold_text.push(bc);
279 }
280 if found_close {
281 result.push_str(&format!("<strong>{}</strong>", bold_text));
282 } else {
283 result.push_str("**");
284 result.push_str(&bold_text);
285 }
286 } else {
287 result.push(c);
288 }
289 }
290 result
291}
292
293#[allow(clippy::while_let_on_iterator)]
295fn convert_label(text: &str) -> String {
296 let mut result = String::new();
297 let mut chars = text.chars().peekable();
298
299 while let Some(c) = chars.next() {
300 if c == '*' {
301 let mut label_text = String::new();
303 let mut found_close = false;
304 while let Some(lc) = chars.next() {
305 if lc == '*' {
306 found_close = true;
307 break;
308 }
309 label_text.push(lc);
310 }
311 if found_close {
312 result.push_str(&format!("<span class=\"label\">{}</span>", label_text));
313 } else {
314 result.push('*');
315 result.push_str(&label_text);
316 }
317 } else {
318 result.push(c);
319 }
320 }
321 result
322}
323
324#[allow(clippy::while_let_on_iterator)]
326fn convert_code(text: &str) -> String {
327 let mut result = String::new();
328 let mut chars = text.chars().peekable();
329
330 while let Some(c) = chars.next() {
331 if c == '`' {
332 let mut code_text = String::new();
334 let mut found_close = false;
335 while let Some(cc) = chars.next() {
336 if cc == '`' {
337 found_close = true;
338 break;
339 }
340 code_text.push(cc);
341 }
342 if found_close {
343 result.push_str(&format!("<code>{}</code>", code_text));
344 } else {
345 result.push('`');
346 result.push_str(&code_text);
347 }
348 } else {
349 result.push(c);
350 }
351 }
352 result
353}
354
355pub fn highlight_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
358 let code = value
359 .as_str()
360 .ok_or_else(|| tera::Error::msg("highlight filter requires a string"))?;
361
362 let lang = args.get("lang").and_then(|v| v.as_str()).unwrap_or("text");
363
364 let highlighted = highlight_code_sync(code, lang);
365 Ok(Value::String(highlighted))
366}
367
368pub fn make_asset_filter(
372 manifest: AssetManifest,
373) -> impl Fn(&Value, &HashMap<String, Value>) -> tera::Result<Value> {
374 move |value: &Value, _args: &HashMap<String, Value>| {
375 let path = value
376 .as_str()
377 .ok_or_else(|| tera::Error::msg("asset filter requires a string path"))?;
378
379 let normalized = if path.starts_with('/') {
381 path.to_string()
382 } else {
383 format!("/{}", path)
384 };
385
386 let result = manifest
388 .get(&normalized)
389 .map(|v| v.clone())
390 .unwrap_or_else(|| path.to_string());
391
392 Ok(Value::String(result))
393 }
394}
395
396pub fn register_data_functions(
398 tera: &mut tera::Tera,
399 tracker: Option<SharedTracker>,
400 asset_manifest: Option<AssetManifest>,
401) {
402 tera.register_function("load_json", make_load_json(tracker.clone()));
403 tera.register_function("read_file", make_read_file(tracker.clone()));
404 tera.register_function("read_markdown", make_read_markdown(tracker.clone()));
405 tera.register_function("list_files", make_list_files(tracker.clone()));
406 tera.register_function("list_dirs", make_list_dirs(tracker));
407 tera.register_filter("markdown", markdown_filter);
408 tera.register_filter("linebreaks", linebreaks_filter);
409 tera.register_filter("html_to_text", html_to_text_filter);
410 tera.register_filter("highlight", highlight_filter);
411
412 if let Some(manifest) = asset_manifest {
414 tera.register_filter("asset", make_asset_filter(manifest));
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use std::fs;
422 use tempfile::tempdir;
423 use tera::Function;
424
425 #[test]
426 fn test_expand_tilde() {
427 let path = expand_tilde("~/test");
428 assert!(path.to_string_lossy().contains("test"));
429 assert!(!path.to_string_lossy().starts_with("~"));
430
431 let path = expand_tilde("/absolute/path");
432 assert_eq!(path, PathBuf::from("/absolute/path"));
433 }
434
435 #[test]
436 fn test_load_json() {
437 let dir = tempdir().unwrap();
438 let json_path = dir.path().join("test.json");
439 fs::write(&json_path, r#"{"name": "test", "value": 42}"#).unwrap();
440
441 let func = make_load_json(None);
442 let mut args = HashMap::new();
443 args.insert(
444 "path".to_string(),
445 Value::String(json_path.to_string_lossy().to_string()),
446 );
447
448 let result = func.call(&args).unwrap();
449 assert_eq!(result.get("name").unwrap().as_str().unwrap(), "test");
450 assert_eq!(result.get("value").unwrap().as_i64().unwrap(), 42);
451 }
452
453 #[test]
454 fn test_load_json_missing_file() {
455 let func = make_load_json(None);
456 let mut args = HashMap::new();
457 args.insert(
458 "path".to_string(),
459 Value::String("/nonexistent/path.json".to_string()),
460 );
461
462 let result = func.call(&args).unwrap();
463 assert!(result.is_null());
464 }
465
466 #[test]
467 fn test_read_file() {
468 let dir = tempdir().unwrap();
469 let file_path = dir.path().join("test.txt");
470 fs::write(&file_path, "Hello, World!").unwrap();
471
472 let func = make_read_file(None);
473 let mut args = HashMap::new();
474 args.insert(
475 "path".to_string(),
476 Value::String(file_path.to_string_lossy().to_string()),
477 );
478
479 let result = func.call(&args).unwrap();
480 assert_eq!(result.as_str().unwrap(), "Hello, World!");
481 }
482
483 #[test]
484 fn test_read_file_missing() {
485 let func = make_read_file(None);
486 let mut args = HashMap::new();
487 args.insert(
488 "path".to_string(),
489 Value::String("/nonexistent/file.txt".to_string()),
490 );
491
492 let result = func.call(&args).unwrap();
493 assert!(result.is_null());
494 }
495
496 #[test]
497 fn test_list_files() {
498 let dir = tempdir().unwrap();
499 fs::write(dir.path().join("solution.py"), "print('hello')").unwrap();
500 fs::write(dir.path().join("solution.cpp"), "int main(){}").unwrap();
501 fs::write(dir.path().join("README.md"), "# Hello").unwrap();
502
503 let func = make_list_files(None);
504 let mut args = HashMap::new();
505 args.insert(
506 "path".to_string(),
507 Value::String(dir.path().to_string_lossy().to_string()),
508 );
509
510 let result = func.call(&args).unwrap();
511 let files = result.as_array().unwrap();
512 assert_eq!(files.len(), 3);
513
514 args.insert(
516 "pattern".to_string(),
517 Value::String("solution.*".to_string()),
518 );
519 let result = func.call(&args).unwrap();
520 let files = result.as_array().unwrap();
521 assert_eq!(files.len(), 2);
522
523 let file = &files[0];
525 assert!(file.get("path").is_some());
526 assert!(file.get("name").is_some());
527 assert!(file.get("stem").is_some());
528 assert!(file.get("ext").is_some());
529 }
530
531 #[test]
532 fn test_list_dirs() {
533 let dir = tempdir().unwrap();
534 fs::create_dir(dir.path().join("subdir1")).unwrap();
535 fs::create_dir(dir.path().join("subdir2")).unwrap();
536 fs::create_dir(dir.path().join(".hidden")).unwrap();
537 fs::write(dir.path().join("file.txt"), "not a dir").unwrap();
538
539 let func = make_list_dirs(None);
540 let mut args = HashMap::new();
541 args.insert(
542 "path".to_string(),
543 Value::String(dir.path().to_string_lossy().to_string()),
544 );
545
546 let result = func.call(&args).unwrap();
547 let dirs = result.as_array().unwrap();
548 assert_eq!(dirs.len(), 2); assert!(dirs.contains(&Value::String("subdir1".to_string())));
550 assert!(dirs.contains(&Value::String("subdir2".to_string())));
551 }
552}