Skip to main content

mdvault_core/scripting/
vault_bindings.rs

1//! Vault operation bindings for Lua.
2//!
3//! This module provides Lua bindings for vault operations:
4//! - `mdv.template(name, vars?)` - Render a template by name
5//! - `mdv.capture(name, vars?)` - Execute a capture workflow
6//! - `mdv.macro(name, vars?)` - Execute a macro workflow
7//! - `mdv.read_note(path)` - Read a note's content and frontmatter
8
9use 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
28/// Register vault operation bindings on an existing mdv table.
29///
30/// This adds `mdv.template()`, `mdv.capture()`, and `mdv.macro()` functions
31/// that have access to the vault context for executing operations.
32pub fn register_vault_bindings(lua: &Lua, ctx: VaultContext) -> LuaResult<()> {
33    // Store context in Lua app data
34    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
47/// Create the `mdv.template(name, vars?)` function.
48///
49/// Returns: `(content, nil)` on success, `(nil, error)` on failure.
50///
51/// # Examples (in Lua)
52///
53/// ```lua
54/// local content, err = mdv.template("meeting", { title = "Standup" })
55/// if err then
56///     print("Error: " .. err)
57/// else
58///     print(content)
59/// end
60/// ```
61fn 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        // Load template
70        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        // Build render context
84        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        // Render template body
94        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
109/// Create the `mdv.capture(name, vars?)` function.
110///
111/// Returns: `(true, nil)` on success, `(false, error)` on failure.
112///
113/// # Examples (in Lua)
114///
115/// ```lua
116/// local ok, err = mdv.capture("log-to-daily", { text = "Created note" })
117/// if not ok then
118///     print("Error: " .. err)
119/// end
120/// ```
121fn 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        // Load capture
130        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        // Build context
144        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        // Execute capture
154        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
164/// Create the `mdv.macro(name, vars?)` function.
165///
166/// Returns: `(true, nil)` on success, `(false, error)` on failure.
167///
168/// Note: Shell steps in macros are NOT executed from hooks (no --trust context).
169///
170/// # Examples (in Lua)
171///
172/// ```lua
173/// local ok, err = mdv.macro("on-task-created", { task_path = note.path })
174/// if not ok then
175///     print("Error: " .. err)
176/// end
177/// ```
178fn 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        // Load macro
187        let loaded = match ctx.macro_repo.get_by_name(&macro_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        // Build context
201        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        // Create a hook step executor (no shell support)
211        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        // Run macro with shell disabled (no --trust in hooks)
218        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
236/// Create the `mdv.read_note(path)` function.
237///
238/// Reads a note from the vault and returns its content and frontmatter.
239///
240/// Returns: `(note_table, nil)` on success, `(nil, error)` on failure.
241///
242/// The note table contains:
243/// - `path`: The resolved path to the note
244/// - `content`: The full file content including frontmatter
245/// - `body`: The note body without frontmatter
246/// - `frontmatter`: A table with frontmatter fields (if present)
247/// - `title`: The title from frontmatter (if present)
248/// - `type`: The note type from frontmatter (if present)
249///
250/// # Examples (in Lua)
251///
252/// ```lua
253/// local note, err = mdv.read_note("projects/my-project.md")
254/// if err then
255///     print("Error: " .. err)
256/// else
257///     print("Title: " .. (note.title or "untitled"))
258///     if note.frontmatter then
259///         print("Status: " .. (note.frontmatter.status or "unknown"))
260///     end
261/// end
262/// ```
263fn 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        // Resolve path relative to vault root
270        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        // Read file content
280        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        // Parse frontmatter
295        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        // Build note table
308        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        // Add frontmatter if present
314        if let Some(ref fm) = parsed.frontmatter {
315            // Convert frontmatter to serde_yaml::Value for yaml_to_lua_table
316            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            // Extract common fields for convenience
324            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
336/// Create the `mdv.selector(opts)` function.
337///
338/// Shows an interactive selector for notes of a given type.
339/// Requires a selector callback to be set in the VaultContext.
340///
341/// Options:
342/// - `type`: Note type to filter (required, e.g., "project", "task")
343/// - `prompt`: Prompt text to display (optional, defaults to "Select {type}")
344/// - `fuzzy`: Enable fuzzy search (optional, defaults to true)
345///
346/// Returns: selected note's path as string, or nil if cancelled/unavailable.
347///
348/// # Examples (in Lua)
349///
350/// ```lua
351/// local project = mdv.selector({ type = "project" })
352/// if project then
353///     print("Selected: " .. project)
354/// end
355///
356/// -- With custom prompt
357/// local task = mdv.selector({
358///     type = "task",
359///     prompt = "Choose a task to link"
360/// })
361/// ```
362fn 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        // Check if selector callback is available
369        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        // Check if index is available
379        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        // Parse options
389        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        // Query for notes of the given type
401        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        // Build selector items
415        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        // Build selector options
425        let selector_opts = SelectorOptions::new(prompt).with_fuzzy(fuzzy);
426
427        // Call the selector
428        match selector(&items, &selector_opts) {
429            Some(selected) => Ok(Value::String(lua.create_string(&selected)?)),
430            None => Ok(Value::Nil),
431        }
432    })
433}
434
435/// Build base context with date/time and config paths.
436fn build_base_context(config: &ResolvedConfig) -> HashMap<String, String> {
437    let mut ctx = HashMap::new();
438    let now = Local::now();
439
440    // Date/time
441    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    // Config paths
448    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
459/// Convert a Lua value to a string.
460fn 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
474/// Execute a capture operation.
475fn execute_capture(
476    config: &ResolvedConfig,
477    spec: &CaptureSpec,
478    vars: &HashMap<String, String>,
479) -> Result<(), String> {
480    // Render target file path
481    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    // Read existing file or create if missing
486    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            // Create the file with minimal structure
493            let content = create_minimal_note(vars, spec.target.section.as_deref());
494
495            // Ensure parent directory exists
496            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            // Write the new file
503            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    // Execute capture operations
519    let (result_content, _section_info) =
520        execute_capture_operations(&existing_content, spec, vars)?;
521
522    // Write back to file
523    fs::write(&target_file, &result_content)
524        .map_err(|e| format!("failed to write to {}: {}", target_file.display(), e))?;
525
526    Ok(())
527}
528
529/// Create a minimal note structure for auto-created files.
530fn 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    // Add the target section if specified
537    if let Some(section_name) = section {
538        content.push_str(&format!("\n## {}\n", section_name));
539    }
540
541    content
542}
543
544/// Execute capture operations: frontmatter modification and/or content insertion.
545fn execute_capture_operations(
546    existing_content: &str,
547    spec: &CaptureSpec,
548    ctx: &HashMap<String, String>,
549) -> Result<(String, Option<(String, u8)>), String> {
550    // Parse frontmatter from existing content
551    let mut parsed = parse(existing_content)
552        .map_err(|e| format!("failed to parse frontmatter: {}", e))?;
553    let mut section_info = None;
554
555    // Apply frontmatter operations if specified
556    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    // Insert content if specified
562    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            &section_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    // Serialize the document
587    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
596/// Step executor for hooks (no shell support).
597struct 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        // Load template
610        let loaded = self
611            .template_repo
612            .get_by_name(&step.template)
613            .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
614
615        // Merge step vars
616        let vars = ctx.with_step_vars(&step.vars_with);
617
618        // Resolve output path
619        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        // Render template
640        let rendered = render_string(&loaded.body, &vars)
641            .map_err(|e| MacroRunError::TemplateError(e.to_string()))?;
642
643        // Create parent directories if needed
644        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        // Write file
651        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        // Load capture
673        let loaded = self
674            .capture_repo
675            .get_by_name(&step.capture)
676            .map_err(|e| MacroRunError::CaptureError(e.to_string()))?;
677
678        // Merge step vars
679        let vars = ctx.with_step_vars(&step.vars_with);
680
681        // Execute capture
682        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        // Shell steps are not supported in hooks
699        Err(MacroRunError::TrustRequired)
700    }
701}