mdvault_core/macros/
discovery.rs1use 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#[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#[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
48pub 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 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
97pub struct MacroRepository {
99 pub root: PathBuf,
100 pub macros: Vec<MacroInfo>,
101}
102
103impl MacroRepository {
104 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 pub fn list_all(&self) -> &[MacroInfo] {
112 &self.macros
113 }
114
115 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(¯os_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 fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
149 fs::write(macros_dir.join("old.yaml"), "name: old\nsteps: []").unwrap(); let macros = discover_macros(¯os_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(¯os_dir).unwrap();
171
172 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(¯os_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(¯os_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(¯os_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(¯os_dir).unwrap();
219
220 let repo = MacroRepository::new(¯os_dir).unwrap();
221 let result = repo.get_by_name("nonexistent");
222
223 assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
224 }
225}