1use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use chrono::Local;
14use mlua::{Function, Lua, MultiValue, Result as LuaResult, Table, Value};
15
16use super::selector::{SelectorItem, SelectorOptions};
17use super::vault_context::VaultContext;
18use crate::captures::CaptureSpec;
19use crate::config::types::ResolvedConfig;
20use crate::frontmatter::{apply_ops, parse, serialize};
21use crate::index::NoteQuery;
22use crate::macros::runner::{MacroRunError, RunContext, RunOptions, StepExecutor};
23use crate::macros::types::{CaptureStep, ShellStep, StepResult, TemplateStep};
24use crate::markdown_ast::{MarkdownEditor, SectionMatch};
25use crate::templates::engine::render_string;
26use crate::types::validation::yaml_to_lua_table;
27
28pub fn register_vault_bindings(lua: &Lua, ctx: VaultContext) -> LuaResult<()> {
33 lua.set_app_data(ctx);
35
36 let mdv: Table = lua.globals().get("mdv")?;
37
38 mdv.set("template", create_template_fn(lua)?)?;
39 mdv.set("capture", create_capture_fn(lua)?)?;
40 mdv.set("macro", create_macro_fn(lua)?)?;
41 mdv.set("read_note", create_read_note_fn(lua)?)?;
42 mdv.set("selector", create_selector_fn(lua)?)?;
43
44 Ok(())
45}
46
47fn create_template_fn(lua: &Lua) -> LuaResult<Function> {
62 lua.create_function(|lua, args: (String, Option<Table>)| {
63 let (template_name, vars_table) = args;
64
65 let ctx = lua
66 .app_data_ref::<VaultContext>()
67 .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
68
69 let loaded = match ctx.template_repo.get_by_name(&template_name) {
71 Ok(t) => t,
72 Err(e) => {
73 return Ok(MultiValue::from_vec(vec![
74 Value::Nil,
75 Value::String(lua.create_string(format!(
76 "template '{}' not found: {}",
77 template_name, e
78 ))?),
79 ]));
80 }
81 };
82
83 let mut render_ctx = build_base_context(&ctx.config);
85 if let Some(table) = vars_table {
86 for pair in table.pairs::<String, Value>() {
87 let (key, value) = pair?;
88 let str_value = lua_value_to_string(&key, value)?;
89 render_ctx.insert(key, str_value);
90 }
91 }
92
93 match render_string(&loaded.body, &render_ctx) {
95 Ok(rendered) => Ok(MultiValue::from_vec(vec![
96 Value::String(lua.create_string(&rendered)?),
97 Value::Nil,
98 ])),
99 Err(e) => Ok(MultiValue::from_vec(vec![
100 Value::Nil,
101 Value::String(
102 lua.create_string(format!("template render error: {}", e))?,
103 ),
104 ])),
105 }
106 })
107}
108
109fn create_capture_fn(lua: &Lua) -> LuaResult<Function> {
122 lua.create_function(|lua, args: (String, Option<Table>)| {
123 let (capture_name, vars_table) = args;
124
125 let ctx = lua
126 .app_data_ref::<VaultContext>()
127 .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
128
129 let loaded = match ctx.capture_repo.get_by_name(&capture_name) {
131 Ok(c) => c,
132 Err(e) => {
133 return Ok(MultiValue::from_vec(vec![
134 Value::Boolean(false),
135 Value::String(lua.create_string(format!(
136 "capture '{}' not found: {}",
137 capture_name, e
138 ))?),
139 ]));
140 }
141 };
142
143 let mut vars = build_base_context(&ctx.config);
145 if let Some(table) = vars_table {
146 for pair in table.pairs::<String, Value>() {
147 let (key, value) = pair?;
148 let str_value = lua_value_to_string(&key, value)?;
149 vars.insert(key, str_value);
150 }
151 }
152
153 match execute_capture(&ctx.config, &loaded.spec, &vars) {
155 Ok(_) => Ok(MultiValue::from_vec(vec![Value::Boolean(true), Value::Nil])),
156 Err(e) => Ok(MultiValue::from_vec(vec![
157 Value::Boolean(false),
158 Value::String(lua.create_string(&e)?),
159 ])),
160 }
161 })
162}
163
164fn create_macro_fn(lua: &Lua) -> LuaResult<Function> {
179 lua.create_function(|lua, args: (String, Option<Table>)| {
180 let (macro_name, vars_table) = args;
181
182 let ctx = lua
183 .app_data_ref::<VaultContext>()
184 .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
185
186 let loaded = match ctx.macro_repo.get_by_name(¯o_name) {
188 Ok(m) => m,
189 Err(e) => {
190 return Ok(MultiValue::from_vec(vec![
191 Value::Boolean(false),
192 Value::String(lua.create_string(format!(
193 "macro '{}' not found: {}",
194 macro_name, e
195 ))?),
196 ]));
197 }
198 };
199
200 let mut vars = build_base_context(&ctx.config);
202 if let Some(table) = vars_table {
203 for pair in table.pairs::<String, Value>() {
204 let (key, value) = pair?;
205 let str_value = lua_value_to_string(&key, value)?;
206 vars.insert(key, str_value);
207 }
208 }
209
210 let executor = HookStepExecutor {
212 config: ctx.config.clone(),
213 template_repo: ctx.template_repo.clone(),
214 capture_repo: ctx.capture_repo.clone(),
215 };
216
217 let run_ctx = RunContext::new(
219 vars,
220 RunOptions { trust: false, allow_shell: false, dry_run: false },
221 );
222
223 let result = crate::macros::runner::run_macro(&loaded, &executor, run_ctx);
224
225 if result.success {
226 Ok(MultiValue::from_vec(vec![Value::Boolean(true), Value::Nil]))
227 } else {
228 Ok(MultiValue::from_vec(vec![
229 Value::Boolean(false),
230 Value::String(lua.create_string(&result.message)?),
231 ]))
232 }
233 })
234}
235
236fn create_read_note_fn(lua: &Lua) -> LuaResult<Function> {
264 lua.create_function(|lua, path: String| {
265 let ctx = lua
266 .app_data_ref::<VaultContext>()
267 .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
268
269 let resolved_path =
271 if path.ends_with(".md") { path.clone() } else { format!("{}.md", path) };
272
273 let full_path = if Path::new(&resolved_path).is_absolute() {
274 std::path::PathBuf::from(&resolved_path)
275 } else {
276 ctx.vault_root.join(&resolved_path)
277 };
278
279 let content = match fs::read_to_string(&full_path) {
281 Ok(c) => c,
282 Err(e) => {
283 return Ok(MultiValue::from_vec(vec![
284 Value::Nil,
285 Value::String(lua.create_string(format!(
286 "failed to read '{}': {}",
287 full_path.display(),
288 e
289 ))?),
290 ]));
291 }
292 };
293
294 let parsed = match parse(&content) {
296 Ok(p) => p,
297 Err(e) => {
298 return Ok(MultiValue::from_vec(vec![
299 Value::Nil,
300 Value::String(
301 lua.create_string(format!("failed to parse frontmatter: {}", e))?,
302 ),
303 ]));
304 }
305 };
306
307 let note_table = lua.create_table()?;
309 note_table.set("path", resolved_path)?;
310 note_table.set("content", content)?;
311 note_table.set("body", parsed.body.clone())?;
312
313 if let Some(ref fm) = parsed.frontmatter {
315 let fm_yaml = serde_yaml::to_value(fm).map_err(|e| {
317 mlua::Error::runtime(format!("failed to serialize frontmatter: {}", e))
318 })?;
319
320 let fm_table = yaml_to_lua_table(lua, &fm_yaml)?;
321 note_table.set("frontmatter", fm_table)?;
322
323 if let Some(title) = fm.fields.get("title").and_then(|v| v.as_str()) {
325 note_table.set("title", title)?;
326 }
327 if let Some(note_type) = fm.fields.get("type").and_then(|v| v.as_str()) {
328 note_table.set("type", note_type)?;
329 }
330 }
331
332 Ok(MultiValue::from_vec(vec![Value::Table(note_table), Value::Nil]))
333 })
334}
335
336fn create_selector_fn(lua: &Lua) -> LuaResult<Function> {
363 lua.create_function(|lua, opts: Table| {
364 let ctx = lua
365 .app_data_ref::<VaultContext>()
366 .ok_or_else(|| mlua::Error::runtime("VaultContext not available"))?;
367
368 let selector = match &ctx.selector_callback {
370 Some(cb) => cb.clone(),
371 None => {
372 return Err(mlua::Error::runtime(
373 "Selector not available (no interactive context)",
374 ));
375 }
376 };
377
378 let db = match &ctx.index_db {
380 Some(db) => db,
381 None => {
382 return Err(mlua::Error::runtime(
383 "Index database not available. Run 'mdv reindex' first.",
384 ));
385 }
386 };
387
388 let note_type: String = opts.get("type").map_err(|_| {
390 mlua::Error::runtime(
391 "selector requires 'type' option (e.g., { type = \"project\" })",
392 )
393 })?;
394
395 let prompt: String =
396 opts.get("prompt").unwrap_or_else(|_| format!("Select {}", note_type));
397
398 let fuzzy: bool = opts.get("fuzzy").unwrap_or(true);
399
400 let query = NoteQuery {
402 note_type: Some(note_type.parse().unwrap_or_default()),
403 ..Default::default()
404 };
405
406 let notes = db
407 .query_notes(&query)
408 .map_err(|e| mlua::Error::runtime(format!("Query error: {}", e)))?;
409
410 if notes.is_empty() {
411 return Ok(Value::Nil);
412 }
413
414 let items: Vec<SelectorItem> = notes
416 .iter()
417 .map(|note| {
418 let label = note.title.clone();
419 let value = note.path.to_string_lossy().to_string();
420 SelectorItem::new(label, value)
421 })
422 .collect();
423
424 let selector_opts = SelectorOptions::new(prompt).with_fuzzy(fuzzy);
426
427 match selector(&items, &selector_opts) {
429 Some(selected) => Ok(Value::String(lua.create_string(&selected)?)),
430 None => Ok(Value::Nil),
431 }
432 })
433}
434
435fn build_base_context(config: &ResolvedConfig) -> HashMap<String, String> {
437 let mut ctx = HashMap::new();
438 let now = Local::now();
439
440 ctx.insert("date".into(), now.format("%Y-%m-%d").to_string());
442 ctx.insert("time".into(), now.format("%H:%M").to_string());
443 ctx.insert("datetime".into(), now.to_rfc3339());
444 ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
445 ctx.insert("now".into(), now.format("%Y-%m-%dT%H:%M:%S").to_string());
446
447 ctx.insert("vault_root".into(), config.vault_root.to_string_lossy().to_string());
449 ctx.insert(
450 "templates_dir".into(),
451 config.templates_dir.to_string_lossy().to_string(),
452 );
453 ctx.insert("captures_dir".into(), config.captures_dir.to_string_lossy().to_string());
454 ctx.insert("macros_dir".into(), config.macros_dir.to_string_lossy().to_string());
455
456 ctx
457}
458
459fn lua_value_to_string(key: &str, value: Value) -> LuaResult<String> {
461 match value {
462 Value::String(s) => Ok(s.to_str()?.to_string()),
463 Value::Integer(i) => Ok(i.to_string()),
464 Value::Number(n) => Ok(n.to_string()),
465 Value::Boolean(b) => Ok(b.to_string()),
466 Value::Nil => Ok(String::new()),
467 _ => Err(mlua::Error::runtime(format!(
468 "context value for '{}' must be string, number, boolean, or nil",
469 key
470 ))),
471 }
472}
473
474fn execute_capture(
476 config: &ResolvedConfig,
477 spec: &CaptureSpec,
478 vars: &HashMap<String, String>,
479) -> Result<(), String> {
480 let target_file_raw =
482 render_string(&spec.target.file, vars).map_err(|e| e.to_string())?;
483 let target_file = resolve_target_path(&config.vault_root, &target_file_raw);
484
485 let existing_content = match fs::read_to_string(&target_file) {
487 Ok(content) => content,
488 Err(e)
489 if e.kind() == std::io::ErrorKind::NotFound
490 && spec.target.create_if_missing =>
491 {
492 let content = create_minimal_note(vars, spec.target.section.as_deref());
494
495 if let Some(parent) = target_file.parent() {
497 fs::create_dir_all(parent).map_err(|e| {
498 format!("failed to create directory {}: {}", parent.display(), e)
499 })?;
500 }
501
502 fs::write(&target_file, &content).map_err(|e| {
504 format!("failed to create target file {}: {}", target_file.display(), e)
505 })?;
506
507 content
508 }
509 Err(e) => {
510 return Err(format!(
511 "failed to read target file {}: {}",
512 target_file.display(),
513 e
514 ));
515 }
516 };
517
518 let (result_content, _section_info) =
520 execute_capture_operations(&existing_content, spec, vars)?;
521
522 fs::write(&target_file, &result_content)
524 .map_err(|e| format!("failed to write to {}: {}", target_file.display(), e))?;
525
526 Ok(())
527}
528
529fn create_minimal_note(vars: &HashMap<String, String>, section: Option<&str>) -> String {
531 let date = vars.get("date").map(|s| s.as_str()).unwrap_or("unknown");
532 let title = vars.get("title").map(|s| s.as_str()).unwrap_or(date);
533
534 let mut content = format!("---\ntype: daily\ndate: {}\n---\n\n# {}\n", date, title);
535
536 if let Some(section_name) = section {
538 content.push_str(&format!("\n## {}\n", section_name));
539 }
540
541 content
542}
543
544fn execute_capture_operations(
546 existing_content: &str,
547 spec: &CaptureSpec,
548 ctx: &HashMap<String, String>,
549) -> Result<(String, Option<(String, u8)>), String> {
550 let mut parsed = parse(existing_content)
552 .map_err(|e| format!("failed to parse frontmatter: {}", e))?;
553 let mut section_info = None;
554
555 if let Some(fm_ops) = &spec.frontmatter {
557 parsed = apply_ops(parsed, fm_ops, ctx)
558 .map_err(|e| format!("failed to apply frontmatter ops: {}", e))?;
559 }
560
561 if let Some(content_template) = &spec.content {
563 let section = spec.target.section.as_ref().ok_or_else(|| {
564 "capture has content but no target section specified".to_string()
565 })?;
566
567 let rendered_section = render_string(section, ctx).map_err(|e| e.to_string())?;
568 let rendered_content =
569 render_string(content_template, ctx).map_err(|e| e.to_string())?;
570
571 let section_match = SectionMatch::new(&rendered_section);
572 let position = spec.target.position.clone().into();
573
574 let result = MarkdownEditor::insert_into_section(
575 &parsed.body,
576 §ion_match,
577 &rendered_content,
578 position,
579 )
580 .map_err(|e| format!("section insertion failed: {}", e))?;
581
582 section_info = Some((result.matched_heading.title, result.matched_heading.level));
583 parsed.body = result.content;
584 }
585
586 let final_content = serialize(&parsed);
588 Ok((final_content, section_info))
589}
590
591fn resolve_target_path(vault_root: &Path, target: &str) -> std::path::PathBuf {
592 let path = Path::new(target);
593 if path.is_absolute() { path.to_path_buf() } else { vault_root.join(path) }
594}
595
596struct HookStepExecutor {
598 config: std::sync::Arc<ResolvedConfig>,
599 template_repo: std::sync::Arc<crate::templates::repository::TemplateRepository>,
600 capture_repo: std::sync::Arc<crate::captures::CaptureRepository>,
601}
602
603impl StepExecutor for HookStepExecutor {
604 fn execute_template(
605 &self,
606 step: &TemplateStep,
607 ctx: &RunContext,
608 ) -> Result<StepResult, MacroRunError> {
609 let loaded = self
611 .template_repo
612 .get_by_name(&step.template)
613 .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
614
615 let vars = ctx.with_step_vars(&step.vars_with);
617
618 let output_path = if let Some(output) = step.output.as_ref() {
620 let rendered = render_string(output, &vars)
621 .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
622 resolve_target_path(&self.config.vault_root, &rendered)
623 } else if let Some(fm) = loaded.frontmatter.as_ref() {
624 if let Some(output) = fm.output.as_ref() {
625 let rendered = render_string(output, &vars)
626 .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
627 resolve_target_path(&self.config.vault_root, &rendered)
628 } else {
629 return Err(MacroRunError::TemplateError(
630 "template has no output path and none specified in step".to_string(),
631 ));
632 }
633 } else {
634 return Err(MacroRunError::TemplateError(
635 "template has no output path and none specified in step".to_string(),
636 ));
637 };
638
639 let rendered = render_string(&loaded.body, &vars)
641 .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
642
643 if let Some(parent) = output_path.parent() {
645 fs::create_dir_all(parent).map_err(|e| {
646 MacroRunError::TemplateError(format!("failed to create directory: {}", e))
647 })?;
648 }
649
650 fs::write(&output_path, &rendered).map_err(|e| {
652 MacroRunError::TemplateError(format!(
653 "failed to write {}: {}",
654 output_path.display(),
655 e
656 ))
657 })?;
658
659 Ok(StepResult {
660 step_index: 0,
661 success: true,
662 message: format!("Created {}", output_path.display()),
663 output_path: Some(output_path),
664 })
665 }
666
667 fn execute_capture(
668 &self,
669 step: &CaptureStep,
670 ctx: &RunContext,
671 ) -> Result<StepResult, MacroRunError> {
672 let loaded = self
674 .capture_repo
675 .get_by_name(&step.capture)
676 .map_err(|e| MacroRunError::CaptureError(e.to_string()))?;
677
678 let vars = ctx.with_step_vars(&step.vars_with);
680
681 execute_capture(&self.config, &loaded.spec, &vars)
683 .map_err(MacroRunError::CaptureError)?;
684
685 Ok(StepResult {
686 step_index: 0,
687 success: true,
688 message: format!("Executed capture: {}", step.capture),
689 output_path: None,
690 })
691 }
692
693 fn execute_shell(
694 &self,
695 _step: &ShellStep,
696 _ctx: &RunContext,
697 ) -> Result<StepResult, MacroRunError> {
698 Err(MacroRunError::TrustRequired)
700 }
701}