mdvault_core/macros/
discovery.rs

1//! Macro discovery and repository.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use thiserror::Error;
7use walkdir::WalkDir;
8
9use super::types::{LoadedMacro, MacroInfo, MacroSpec};
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 YAML {path}: {source}")]
38    Parse {
39        path: PathBuf,
40        #[source]
41        source: serde_yaml::Error,
42    },
43}
44
45/// Discover macro files in a directory.
46///
47/// Finds all `.yaml` files in the given directory and its subdirectories.
48pub 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    // Remove .yaml or .yml extension
88    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
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 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(&macros_dir).unwrap();
148
149        // Create some macro files
150        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        // Create a subdirectory with another macro
159        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        // Create a non-yaml file (should be ignored)
164        fs::write(macros_dir.join("notes.md"), "# Notes").unwrap();
165
166        let macros = discover_macros(&macros_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(&macros_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(&macros_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(&macros_dir).unwrap();
207
208        let repo = MacroRepository::new(&macros_dir).unwrap();
209        let result = repo.get_by_name("nonexistent");
210
211        assert!(matches!(result, Err(MacroRepoError::NotFound(_))));
212    }
213}