Skip to main content

mdvault_core/macros/
discovery.rs

1//! Macro discovery and repository.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8use walkdir::WalkDir;
9
10use super::lua_loader::load_macro_from_lua;
11use super::types::{LoadedMacro, MacroFormat, MacroInfo, MacroSpec};
12
13/// Error type for macro discovery.
14#[derive(Debug, Error)]
15pub enum MacroDiscoveryError {
16    #[error("macros directory does not exist: {0}")]
17    MissingDir(String),
18
19    #[error("failed to read macros directory {0}: {1}")]
20    WalkError(String, #[source] walkdir::Error),
21}
22
23/// Error type for macro repository operations.
24#[derive(Debug, Error)]
25pub enum MacroRepoError {
26    #[error(transparent)]
27    Discovery(#[from] MacroDiscoveryError),
28
29    #[error("macro not found: {0}")]
30    NotFound(String),
31
32    #[error("failed to read macro file {path}: {source}")]
33    Io {
34        path: PathBuf,
35        #[source]
36        source: std::io::Error,
37    },
38
39    #[error("failed to parse macro YAML {path}: {source}")]
40    Parse {
41        path: PathBuf,
42        #[source]
43        source: serde_yaml::Error,
44    },
45
46    #[error("failed to parse macro Lua {path}: {source}")]
47    LuaParse {
48        path: PathBuf,
49        #[source]
50        source: crate::scripting::ScriptingError,
51    },
52
53    #[error("invalid macro definition in {path}: {message}")]
54    LuaInvalid { path: PathBuf, message: String },
55}
56
57/// Discover macro files in a directory.
58///
59/// Finds all `.lua` and `.yaml` files in the given directory and its subdirectories.
60/// Lua files take precedence over YAML files with the same name.
61pub fn discover_macros(root: &Path) -> Result<Vec<MacroInfo>, MacroDiscoveryError> {
62    let root = root
63        .canonicalize()
64        .map_err(|_| MacroDiscoveryError::MissingDir(root.display().to_string()))?;
65
66    if !root.exists() {
67        return Err(MacroDiscoveryError::MissingDir(root.display().to_string()));
68    }
69
70    // Use a map to handle Lua/YAML precedence (Lua wins)
71    let mut macros: HashMap<String, MacroInfo> = HashMap::new();
72
73    for entry in WalkDir::new(&root) {
74        let entry = entry
75            .map_err(|e| MacroDiscoveryError::WalkError(root.display().to_string(), e))?;
76
77        let path = entry.path();
78        if !path.is_file() {
79            continue;
80        }
81
82        let (logical, format) = match get_macro_format(path) {
83            Some((l, f)) => (l, f),
84            None => continue,
85        };
86
87        let rel = path.strip_prefix(&root).unwrap_or(path);
88        let full_logical =
89            if rel.parent().map(|p| p.as_os_str().is_empty()).unwrap_or(true) {
90                logical.clone()
91            } else {
92                let parent = rel.parent().unwrap().to_string_lossy();
93                format!("{}/{}", parent, logical)
94            };
95
96        // Lua takes precedence over YAML
97        match macros.get(&full_logical) {
98            Some(existing) if existing.format == MacroFormat::Lua => {
99                // Keep existing Lua file
100                continue;
101            }
102            _ => {
103                macros.insert(
104                    full_logical.clone(),
105                    MacroInfo {
106                        logical_name: full_logical,
107                        path: path.to_path_buf(),
108                        format,
109                    },
110                );
111            }
112        }
113    }
114
115    let mut out: Vec<MacroInfo> = macros.into_values().collect();
116    out.sort_by(|a, b| a.logical_name.cmp(&b.logical_name));
117    Ok(out)
118}
119
120/// Get the macro format and logical name from a file path.
121/// Returns None if the file is not a macro file.
122fn get_macro_format(path: &Path) -> Option<(String, MacroFormat)> {
123    let name = path.file_name().and_then(|s| s.to_str())?;
124
125    if name.ends_with(".lua") {
126        let logical = name.strip_suffix(".lua")?.to_string();
127        Some((logical, MacroFormat::Lua))
128    } else if name.ends_with(".yaml") {
129        let logical = name.strip_suffix(".yaml")?.to_string();
130        Some((logical, MacroFormat::Yaml))
131    } else if name.ends_with(".yml") {
132        let logical = name.strip_suffix(".yml")?.to_string();
133        Some((logical, MacroFormat::Yaml))
134    } else {
135        None
136    }
137}
138
139/// Repository for discovering and loading macros.
140pub struct MacroRepository {
141    pub root: PathBuf,
142    pub macros: Vec<MacroInfo>,
143}
144
145impl MacroRepository {
146    /// Create a new macro repository from a directory.
147    pub fn new(root: &Path) -> Result<Self, MacroDiscoveryError> {
148        let macros = discover_macros(root)?;
149        Ok(Self { root: root.to_path_buf(), macros })
150    }
151
152    /// List all discovered macros.
153    pub fn list_all(&self) -> &[MacroInfo] {
154        &self.macros
155    }
156
157    /// Get a macro by its logical name.
158    pub fn get_by_name(&self, name: &str) -> Result<LoadedMacro, MacroRepoError> {
159        let info = self
160            .macros
161            .iter()
162            .find(|m| m.logical_name == name)
163            .ok_or_else(|| MacroRepoError::NotFound(name.to_string()))?;
164
165        let spec = match info.format {
166            MacroFormat::Lua => load_macro_from_lua(&info.path)?,
167            MacroFormat::Yaml => {
168                // Emit deprecation warning for YAML macros
169                eprintln!(
170                    "warning: YAML macros are deprecated. Please migrate '{}' to Lua format.",
171                    info.path.display()
172                );
173
174                let content = fs::read_to_string(&info.path).map_err(|e| {
175                    MacroRepoError::Io { path: info.path.clone(), source: e }
176                })?;
177
178                serde_yaml::from_str::<MacroSpec>(&content).map_err(|e| {
179                    MacroRepoError::Parse { path: info.path.clone(), source: e }
180                })?
181            }
182        };
183
184        Ok(LoadedMacro {
185            logical_name: info.logical_name.clone(),
186            path: info.path.clone(),
187            spec,
188        })
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::fs;
196    use tempfile::TempDir;
197
198    #[test]
199    fn test_discover_macros_yaml() {
200        let temp = TempDir::new().unwrap();
201        let macros_dir = temp.path().join("macros");
202        fs::create_dir_all(&macros_dir).unwrap();
203
204        // Create some macro files
205        fs::write(
206            macros_dir.join("weekly-review.yaml"),
207            "name: weekly-review\nsteps: []",
208        )
209        .unwrap();
210        fs::write(macros_dir.join("daily-note.yml"), "name: daily-note\nsteps: []")
211            .unwrap();
212
213        // Create a subdirectory with another macro
214        let sub_dir = macros_dir.join("project");
215        fs::create_dir_all(&sub_dir).unwrap();
216        fs::write(sub_dir.join("setup.yaml"), "name: project-setup\nsteps: []").unwrap();
217
218        // Create a non-yaml file (should be ignored)
219        fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
220
221        let macros = discover_macros(&macros_dir).unwrap();
222
223        assert_eq!(macros.len(), 3);
224        assert!(
225            macros
226                .iter()
227                .any(|m| m.logical_name == "daily-note" && m.format == MacroFormat::Yaml)
228        );
229        assert!(
230            macros
231                .iter()
232                .any(|m| m.logical_name == "weekly-review"
233                    && m.format == MacroFormat::Yaml)
234        );
235        assert!(
236            macros
237                .iter()
238                .any(|m| m.logical_name == "project/setup"
239                    && m.format == MacroFormat::Yaml)
240        );
241    }
242
243    #[test]
244    fn test_discover_macros_lua() {
245        let temp = TempDir::new().unwrap();
246        let macros_dir = temp.path().join("macros");
247        fs::create_dir_all(&macros_dir).unwrap();
248
249        fs::write(macros_dir.join("weekly-review.lua"), "return {}").unwrap();
250        fs::write(macros_dir.join("daily-note.lua"), "return {}").unwrap();
251
252        let macros = discover_macros(&macros_dir).unwrap();
253
254        assert_eq!(macros.len(), 2);
255        assert!(
256            macros
257                .iter()
258                .any(|m| m.logical_name == "daily-note" && m.format == MacroFormat::Lua)
259        );
260        assert!(
261            macros.iter().any(
262                |m| m.logical_name == "weekly-review" && m.format == MacroFormat::Lua
263            )
264        );
265    }
266
267    #[test]
268    fn test_discover_macros_lua_precedence() {
269        let temp = TempDir::new().unwrap();
270        let macros_dir = temp.path().join("macros");
271        fs::create_dir_all(&macros_dir).unwrap();
272
273        // Both Lua and YAML with same name - Lua should win
274        fs::write(macros_dir.join("test.lua"), "return {}").unwrap();
275        fs::write(macros_dir.join("test.yaml"), "name: test\nsteps: []").unwrap();
276        fs::write(macros_dir.join("yaml-only.yaml"), "name: yaml-only\nsteps: []")
277            .unwrap();
278
279        let macros = discover_macros(&macros_dir).unwrap();
280
281        assert_eq!(macros.len(), 2);
282
283        let test_macro = macros.iter().find(|m| m.logical_name == "test").unwrap();
284        assert_eq!(test_macro.format, MacroFormat::Lua);
285
286        let yaml_only = macros.iter().find(|m| m.logical_name == "yaml-only").unwrap();
287        assert_eq!(yaml_only.format, MacroFormat::Yaml);
288    }
289
290    #[test]
291    fn test_macro_repository() {
292        let temp = TempDir::new().unwrap();
293        let macros_dir = temp.path().join("macros");
294        fs::create_dir_all(&macros_dir).unwrap();
295
296        fs::write(
297            macros_dir.join("test.yaml"),
298            r#"
299name: test
300description: A test macro
301steps:
302  - template: meeting-note
303    with:
304      title: "Test"
305"#,
306        )
307        .unwrap();
308
309        let repo = MacroRepository::new(&macros_dir).unwrap();
310        assert_eq!(repo.list_all().len(), 1);
311
312        let loaded = repo.get_by_name("test").unwrap();
313        assert_eq!(loaded.spec.name, "test");
314        assert_eq!(loaded.spec.description, "A test macro");
315        assert_eq!(loaded.spec.steps.len(), 1);
316    }
317
318    #[test]
319    fn test_macro_not_found() {
320        let temp = TempDir::new().unwrap();
321        let macros_dir = temp.path().join("macros");
322        fs::create_dir_all(&macros_dir).unwrap();
323
324        let repo = MacroRepository::new(&macros_dir).unwrap();
325        let result = repo.get_by_name("nonexistent");
326
327        assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
328    }
329}