mdvault_core/macros/
discovery.rs1use 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#[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#[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
57pub 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 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 match macros.get(&full_logical) {
98 Some(existing) if existing.format == MacroFormat::Lua => {
99 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
120fn 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
139pub struct MacroRepository {
141 pub root: PathBuf,
142 pub macros: Vec<MacroInfo>,
143}
144
145impl MacroRepository {
146 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 pub fn list_all(&self) -> &[MacroInfo] {
154 &self.macros
155 }
156
157 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 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(¯os_dir).unwrap();
203
204 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 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 fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
220
221 let macros = discover_macros(¯os_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(¯os_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(¯os_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(¯os_dir).unwrap();
272
273 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(¯os_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(¯os_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(¯os_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(¯os_dir).unwrap();
323
324 let repo = MacroRepository::new(¯os_dir).unwrap();
325 let result = repo.get_by_name("nonexistent");
326
327 assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
328 }
329}