Skip to main content

mdvault_core/macros/
discovery.rs

1//! Macro discovery and repository.
2
3use std::path::{Path, PathBuf};
4
5use thiserror::Error;
6use walkdir::WalkDir;
7
8use super::lua_loader::load_macro_from_lua;
9use super::types::{LoadedMacro, MacroFormat, MacroInfo};
10
11/// Error type for macro discovery.
12#[derive(Debug, Error)]
13pub enum MacroDiscoveryError {
14    #[error("macros directory does not exist: {0}")]
15    MissingDir(String),
16
17    #[error("failed to read macros directory {0}: {1}")]
18    WalkError(String, #[source] walkdir::Error),
19}
20
21/// Error type for macro repository operations.
22#[derive(Debug, Error)]
23pub enum MacroRepoError {
24    #[error(transparent)]
25    Discovery(#[from] MacroDiscoveryError),
26
27    #[error("macro not found: {0}")]
28    NotFound(String),
29
30    #[error("failed to read macro file {path}: {source}")]
31    Io {
32        path: PathBuf,
33        #[source]
34        source: std::io::Error,
35    },
36
37    #[error("failed to parse macro Lua {path}: {source}")]
38    LuaParse {
39        path: PathBuf,
40        #[source]
41        source: crate::scripting::ScriptingError,
42    },
43
44    #[error("invalid macro definition in {path}: {message}")]
45    LuaInvalid { path: PathBuf, message: String },
46}
47
48/// Discover Lua macro files in a directory.
49pub fn discover_macros(root: &Path) -> Result<Vec<MacroInfo>, MacroDiscoveryError> {
50    let root = root
51        .canonicalize()
52        .map_err(|_| MacroDiscoveryError::MissingDir(root.display().to_string()))?;
53
54    if !root.exists() {
55        return Err(MacroDiscoveryError::MissingDir(root.display().to_string()));
56    }
57
58    let mut macros: Vec<MacroInfo> = Vec::new();
59
60    for entry in WalkDir::new(&root) {
61        let entry = entry
62            .map_err(|e| MacroDiscoveryError::WalkError(root.display().to_string(), e))?;
63
64        let path = entry.path();
65        if !path.is_file() {
66            continue;
67        }
68
69        // Only process .lua files
70        let name = match path.file_name().and_then(|s| s.to_str()) {
71            Some(n) if n.ends_with(".lua") => n,
72            _ => continue,
73        };
74
75        let logical = name.strip_suffix(".lua").unwrap().to_string();
76
77        let rel = path.strip_prefix(&root).unwrap_or(path);
78        let full_logical =
79            if rel.parent().map(|p| p.as_os_str().is_empty()).unwrap_or(true) {
80                logical
81            } else {
82                let parent = rel.parent().unwrap().to_string_lossy();
83                format!("{}/{}", parent, logical)
84            };
85
86        macros.push(MacroInfo {
87            logical_name: full_logical,
88            path: path.to_path_buf(),
89            format: MacroFormat::Lua,
90        });
91    }
92
93    macros.sort_by(|a, b| a.logical_name.cmp(&b.logical_name));
94    Ok(macros)
95}
96
97/// Repository for discovering and loading macros.
98pub struct MacroRepository {
99    pub root: PathBuf,
100    pub macros: Vec<MacroInfo>,
101}
102
103impl MacroRepository {
104    /// Create a new macro repository from a directory.
105    pub fn new(root: &Path) -> Result<Self, MacroDiscoveryError> {
106        let macros = discover_macros(root)?;
107        Ok(Self { root: root.to_path_buf(), macros })
108    }
109
110    /// List all discovered macros.
111    pub fn list_all(&self) -> &[MacroInfo] {
112        &self.macros
113    }
114
115    /// Get a macro by its logical name.
116    pub fn get_by_name(&self, name: &str) -> Result<LoadedMacro, MacroRepoError> {
117        let info = self
118            .macros
119            .iter()
120            .find(|m| m.logical_name == name)
121            .ok_or_else(|| MacroRepoError::NotFound(name.to_string()))?;
122
123        let spec = load_macro_from_lua(&info.path)?;
124
125        Ok(LoadedMacro {
126            logical_name: info.logical_name.clone(),
127            path: info.path.clone(),
128            spec,
129        })
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::fs;
137    use tempfile::TempDir;
138
139    #[test]
140    fn test_discover_macros_lua() {
141        let temp = TempDir::new().unwrap();
142        let macros_dir = temp.path().join("macros");
143        fs::create_dir_all(&macros_dir).unwrap();
144
145        fs::write(macros_dir.join("weekly-review.lua"), "return {}").unwrap();
146        fs::write(macros_dir.join("daily-note.lua"), "return {}").unwrap();
147        // Non-lua files should be ignored
148        fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
149        fs::write(macros_dir.join("old.yaml"), "name: old\nsteps: []").unwrap(); // YAML no longer supported
150
151        let macros = discover_macros(&macros_dir).unwrap();
152
153        assert_eq!(macros.len(), 2);
154        assert!(
155            macros
156                .iter()
157                .any(|m| m.logical_name == "daily-note" && m.format == MacroFormat::Lua)
158        );
159        assert!(
160            macros.iter().any(
161                |m| m.logical_name == "weekly-review" && m.format == MacroFormat::Lua
162            )
163        );
164    }
165
166    #[test]
167    fn test_discover_macros_nested() {
168        let temp = TempDir::new().unwrap();
169        let macros_dir = temp.path().join("macros");
170        fs::create_dir_all(&macros_dir).unwrap();
171
172        // Create a subdirectory with macros
173        let sub_dir = macros_dir.join("project");
174        fs::create_dir_all(&sub_dir).unwrap();
175        fs::write(sub_dir.join("setup.lua"), "return {}").unwrap();
176        fs::write(sub_dir.join("teardown.lua"), "return {}").unwrap();
177
178        let macros = discover_macros(&macros_dir).unwrap();
179
180        assert_eq!(macros.len(), 2);
181        assert!(macros.iter().any(|m| m.logical_name == "project/setup"));
182        assert!(macros.iter().any(|m| m.logical_name == "project/teardown"));
183    }
184
185    #[test]
186    fn test_macro_repository() {
187        let temp = TempDir::new().unwrap();
188        let macros_dir = temp.path().join("macros");
189        fs::create_dir_all(&macros_dir).unwrap();
190
191        fs::write(
192            macros_dir.join("test.lua"),
193            r#"
194return {
195    name = "test",
196    description = "A test macro",
197    steps = {
198        { template = "meeting-note", ["with"] = { title = "Test" } }
199    }
200}
201"#,
202        )
203        .unwrap();
204
205        let repo = MacroRepository::new(&macros_dir).unwrap();
206        assert_eq!(repo.list_all().len(), 1);
207
208        let loaded = repo.get_by_name("test").unwrap();
209        assert_eq!(loaded.spec.name, "test");
210        assert_eq!(loaded.spec.description, "A test macro");
211        assert_eq!(loaded.spec.steps.len(), 1);
212    }
213
214    #[test]
215    fn test_macro_not_found() {
216        let temp = TempDir::new().unwrap();
217        let macros_dir = temp.path().join("macros");
218        fs::create_dir_all(&macros_dir).unwrap();
219
220        let repo = MacroRepository::new(&macros_dir).unwrap();
221        let result = repo.get_by_name("nonexistent");
222
223        assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
224    }
225}