mdvault_core/macros/
discovery.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6use thiserror::Error;
7use walkdir::WalkDir;
8
9use super::types::{LoadedMacro, MacroInfo, MacroSpec};
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 YAML {path}: {source}")]
38 Parse {
39 path: PathBuf,
40 #[source]
41 source: serde_yaml::Error,
42 },
43}
44
45pub fn discover_macros(root: &Path) -> Result<Vec<MacroInfo>, MacroDiscoveryError> {
49 let root = root
50 .canonicalize()
51 .map_err(|_| MacroDiscoveryError::MissingDir(root.display().to_string()))?;
52
53 if !root.exists() {
54 return Err(MacroDiscoveryError::MissingDir(root.display().to_string()));
55 }
56
57 let mut out = Vec::new();
58
59 for entry in WalkDir::new(&root) {
60 let entry = entry
61 .map_err(|e| MacroDiscoveryError::WalkError(root.display().to_string(), e))?;
62
63 let path = entry.path();
64 if !path.is_file() {
65 continue;
66 }
67 if !is_yaml_file(path) {
68 continue;
69 }
70
71 let rel = path.strip_prefix(&root).unwrap_or(path);
72 let logical = logical_name_from_relative(rel);
73
74 out.push(MacroInfo { logical_name: logical, path: path.to_path_buf() });
75 }
76
77 out.sort_by(|a, b| a.logical_name.cmp(&b.logical_name));
78 Ok(out)
79}
80
81fn is_yaml_file(path: &Path) -> bool {
82 path.extension().and_then(|e| e.to_str()).is_some_and(|e| e == "yaml" || e == "yml")
83}
84
85fn logical_name_from_relative(rel: &Path) -> String {
86 let s = rel.to_string_lossy();
87 if let Some(stripped) = s.strip_suffix(".yaml") {
89 return stripped.to_string();
90 }
91 if let Some(stripped) = s.strip_suffix(".yml") {
92 return stripped.to_string();
93 }
94 s.to_string()
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 content = fs::read_to_string(&info.path)
124 .map_err(|e| MacroRepoError::Io { path: info.path.clone(), source: e })?;
125
126 let spec: MacroSpec = serde_yaml::from_str(&content)
127 .map_err(|e| MacroRepoError::Parse { path: info.path.clone(), source: e })?;
128
129 Ok(LoadedMacro {
130 logical_name: info.logical_name.clone(),
131 path: info.path.clone(),
132 spec,
133 })
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use std::fs;
141 use tempfile::TempDir;
142
143 #[test]
144 fn test_discover_macros() {
145 let temp = TempDir::new().unwrap();
146 let macros_dir = temp.path().join("macros");
147 fs::create_dir_all(¯os_dir).unwrap();
148
149 fs::write(
151 macros_dir.join("weekly-review.yaml"),
152 "name: weekly-review\nsteps: []",
153 )
154 .unwrap();
155 fs::write(macros_dir.join("daily-note.yml"), "name: daily-note\nsteps: []")
156 .unwrap();
157
158 let sub_dir = macros_dir.join("project");
160 fs::create_dir_all(&sub_dir).unwrap();
161 fs::write(sub_dir.join("setup.yaml"), "name: project-setup\nsteps: []").unwrap();
162
163 fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
165
166 let macros = discover_macros(¯os_dir).unwrap();
167
168 assert_eq!(macros.len(), 3);
169 assert!(macros.iter().any(|m| m.logical_name == "daily-note"));
170 assert!(macros.iter().any(|m| m.logical_name == "weekly-review"));
171 assert!(macros.iter().any(|m| m.logical_name == "project/setup"));
172 }
173
174 #[test]
175 fn test_macro_repository() {
176 let temp = TempDir::new().unwrap();
177 let macros_dir = temp.path().join("macros");
178 fs::create_dir_all(¯os_dir).unwrap();
179
180 fs::write(
181 macros_dir.join("test.yaml"),
182 r#"
183name: test
184description: A test macro
185steps:
186 - template: meeting-note
187 with:
188 title: "Test"
189"#,
190 )
191 .unwrap();
192
193 let repo = MacroRepository::new(¯os_dir).unwrap();
194 assert_eq!(repo.list_all().len(), 1);
195
196 let loaded = repo.get_by_name("test").unwrap();
197 assert_eq!(loaded.spec.name, "test");
198 assert_eq!(loaded.spec.description, "A test macro");
199 assert_eq!(loaded.spec.steps.len(), 1);
200 }
201
202 #[test]
203 fn test_macro_not_found() {
204 let temp = TempDir::new().unwrap();
205 let macros_dir = temp.path().join("macros");
206 fs::create_dir_all(¯os_dir).unwrap();
207
208 let repo = MacroRepository::new(¯os_dir).unwrap();
209 let result = repo.get_by_name("nonexistent");
210
211 assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
212 }
213}