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::SharedTracker;
10use crate::text::html_to_text;
11
12fn 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
19pub 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
47pub 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
71pub 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
100pub 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 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("*");
114
115 let glob_pattern = base_path.join(pattern);
117 let glob_str = glob_pattern.to_string_lossy();
118
119 let mut files: Vec<Value> = Vec::new();
123
124 if let Ok(entries) = glob(&glob_str) {
125 for entry in entries.flatten() {
126 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 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
172pub 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 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 if path.is_dir() && !name.starts_with('.') {
194 dirs.push(Value::String(name.to_string()));
195 }
196 }
197 }
198 }
199
200 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
211pub 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
225pub 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
234pub 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 let paragraphs: Vec<String> = text
243 .split("\n\n")
244 .map(|p| {
245 let p = p.replace('\n', "<br>");
246 let p = convert_bold(&p);
248 let p = convert_label(&p);
250 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#[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(); 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(); 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#[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 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#[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 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
354pub 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 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 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); assert!(dirs.contains(&Value::String("subdir1".to_string())));
498 assert!(dirs.contains(&Value::String("subdir2".to_string())));
499 }
500}