Skip to main content

dm_scan/
lib.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use dm_meta::{Category, Document};
5
6// ---------------------------------------------------------------------------
7// Error
8// ---------------------------------------------------------------------------
9
10/// An error encountered during directory scanning.
11#[derive(Debug, Clone)]
12pub struct ScanError {
13    pub path: PathBuf,
14    pub message: String,
15}
16
17impl std::fmt::Display for ScanError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        write!(f, "{}: {}", self.path.display(), self.message)
20    }
21}
22
23// ---------------------------------------------------------------------------
24// ScanFilter
25// ---------------------------------------------------------------------------
26
27/// Filter criteria for scanning documents. All specified fields must match (AND logic).
28#[derive(Debug, Clone, Default)]
29pub struct ScanFilter {
30    pub categories: Option<Vec<Category>>,
31    pub tags: Option<Vec<String>>,
32    pub status: Option<String>,
33    pub author: Option<String>,
34}
35
36impl ScanFilter {
37    /// Check whether a document matches all active filter criteria.
38    pub fn matches(&self, doc: &Document) -> bool {
39        if let Some(ref cats) = self.categories {
40            if !cats.contains(&doc.category) {
41                return false;
42            }
43        }
44        if let Some(ref tags) = self.tags {
45            let doc_tags = doc.frontmatter.tags.as_deref().unwrap_or(&[]);
46            if !tags.iter().any(|t| doc_tags.iter().any(|dt| dt.eq_ignore_ascii_case(t))) {
47                return false;
48            }
49        }
50        if let Some(ref status) = self.status {
51            let doc_status = doc.frontmatter.status.as_deref().unwrap_or("");
52            if !doc_status.eq_ignore_ascii_case(status) {
53                return false;
54            }
55        }
56        if let Some(ref author) = self.author {
57            let doc_author = doc.frontmatter.author.as_deref().unwrap_or("");
58            if !doc_author.eq_ignore_ascii_case(author) {
59                return false;
60            }
61        }
62        true
63    }
64}
65
66// ---------------------------------------------------------------------------
67// DocTree
68// ---------------------------------------------------------------------------
69
70/// A scanned documentation tree containing parsed documents and any scan errors.
71pub struct DocTree {
72    pub docs: Vec<Document>,
73    pub errors: Vec<ScanError>,
74    pub root: PathBuf,
75}
76
77impl DocTree {
78    /// Scan a directory for all markdown files and parse them.
79    pub fn scan(root: &Path) -> Self {
80        Self::scan_filtered(root, &ScanFilter::default())
81    }
82
83    /// Scan with a filter applied.
84    pub fn scan_filtered(root: &Path, filter: &ScanFilter) -> Self {
85        let mut docs = Vec::new();
86        let mut errors = Vec::new();
87
88        let pattern = format!("{}/**/*.md", root.display());
89        let entries = match glob::glob(&pattern) {
90            Ok(paths) => paths,
91            Err(e) => {
92                errors.push(ScanError {
93                    path: root.to_path_buf(),
94                    message: format!("glob error: {e}"),
95                });
96                return DocTree { docs, errors, root: root.to_path_buf() };
97            }
98        };
99
100        for entry in entries {
101            match entry {
102                Ok(path) => {
103                    match dm_meta::parse_document(&path) {
104                        Ok(doc) => {
105                            if filter.matches(&doc) {
106                                docs.push(doc);
107                            }
108                        }
109                        Err(e) => {
110                            errors.push(ScanError {
111                                path,
112                                message: e.to_string(),
113                            });
114                        }
115                    }
116                }
117                Err(e) => {
118                    errors.push(ScanError {
119                        path: PathBuf::from(e.path().display().to_string()),
120                        message: e.error().to_string(),
121                    });
122                }
123            }
124        }
125
126        docs.sort_by(|a, b| a.path.cmp(&b.path));
127
128        DocTree { docs, errors, root: root.to_path_buf() }
129    }
130
131    /// Get all documents.
132    pub fn all(&self) -> &[Document] {
133        &self.docs
134    }
135
136    /// Get documents by category.
137    pub fn by_category(&self, category: Category) -> Vec<&Document> {
138        self.docs.iter().filter(|d| d.category == category).collect()
139    }
140
141    /// Get documents matching a tag.
142    pub fn by_tag(&self, tag: &str) -> Vec<&Document> {
143        self.docs.iter().filter(|d| {
144            d.frontmatter.tags.as_deref().unwrap_or(&[])
145                .iter()
146                .any(|t| t.eq_ignore_ascii_case(tag))
147        }).collect()
148    }
149
150    /// Search documents by title or body content (case-insensitive substring match).
151    pub fn search(&self, query: &str) -> Vec<&Document> {
152        let q = query.to_lowercase();
153        self.docs.iter().filter(|d| {
154            let title_match = d.frontmatter.title.as_deref()
155                .map(|t| t.to_lowercase().contains(&q))
156                .unwrap_or(false);
157            let body_match = d.body.to_lowercase().contains(&q);
158            title_match || body_match
159        }).collect()
160    }
161
162    /// Get a document by its path (relative to root).
163    pub fn get(&self, rel_path: &str) -> Option<&Document> {
164        let target = self.root.join(rel_path);
165        self.docs.iter().find(|d| d.path == target)
166    }
167
168    /// Count documents by category.
169    pub fn counts(&self) -> HashMap<Category, usize> {
170        let mut map = HashMap::new();
171        for doc in &self.docs {
172            *map.entry(doc.category).or_insert(0) += 1;
173        }
174        map
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Tests
180// ---------------------------------------------------------------------------
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn fixtures_root() -> PathBuf {
187        // CARGO_MANIFEST_DIR points to the crate dir; fixtures are at workspace root.
188        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
189        let workspace = manifest.parent().unwrap().parent().unwrap();
190        let root = workspace.join("tests/fixtures/docs");
191        assert!(root.exists(), "fixtures dir not found at {}", root.display());
192        root
193    }
194
195    #[test]
196    fn scan_finds_all_markdown_files() {
197        let tree = DocTree::scan(&fixtures_root());
198        // 9 files with valid frontmatter + no_frontmatter.md (parsed with default fm)
199        assert!(tree.docs.len() >= 9, "expected >= 9 docs, got {}", tree.docs.len());
200    }
201
202    #[test]
203    fn scan_continues_on_errors() {
204        let tree = DocTree::scan(&fixtures_root());
205        // no_frontmatter.md parses successfully (with default RawFrontmatter),
206        // so it shows up in docs not errors. Verify scan didn't abort.
207        let total = tree.docs.len() + tree.errors.len();
208        assert!(total >= 10, "expected >= 10 total entries, got {total}");
209    }
210
211    #[test]
212    fn by_category_active() {
213        let tree = DocTree::scan(&fixtures_root());
214        let active = tree.by_category(Category::Active);
215        assert!(active.len() >= 4, "expected >= 4 active docs, got {}", active.len());
216        for doc in &active {
217            assert_eq!(doc.category, Category::Active);
218        }
219    }
220
221    #[test]
222    fn by_category_design() {
223        let tree = DocTree::scan(&fixtures_root());
224        let design = tree.by_category(Category::Design);
225        assert_eq!(design.len(), 2, "expected 2 design docs, got {}", design.len());
226        for doc in &design {
227            assert_eq!(doc.category, Category::Design);
228        }
229    }
230
231    #[test]
232    fn by_tag_architecture() {
233        let tree = DocTree::scan(&fixtures_root());
234        let arch = tree.by_tag("architecture");
235        assert!(arch.len() >= 2, "expected >= 2 docs with 'architecture' tag, got {}", arch.len());
236        for doc in &arch {
237            let tags = doc.frontmatter.tags.as_deref().unwrap();
238            assert!(tags.iter().any(|t| t == "architecture"));
239        }
240    }
241
242    #[test]
243    fn search_finds_execution_engine() {
244        let tree = DocTree::scan(&fixtures_root());
245        let results = tree.search("execution");
246        assert!(!results.is_empty(), "search for 'execution' should find results");
247        assert!(results.iter().any(|d| {
248            d.frontmatter.title.as_deref()
249                .map(|t| t.contains("Execution Engine"))
250                .unwrap_or(false)
251        }));
252    }
253
254    #[test]
255    fn search_is_case_insensitive() {
256        let tree = DocTree::scan(&fixtures_root());
257        let lower = tree.search("core concepts");
258        let upper = tree.search("CORE CONCEPTS");
259        assert_eq!(lower.len(), upper.len());
260        assert!(!lower.is_empty());
261    }
262
263    #[test]
264    fn scan_filter_by_category() {
265        let filter = ScanFilter {
266            categories: Some(vec![Category::Research]),
267            ..Default::default()
268        };
269        let tree = DocTree::scan_filtered(&fixtures_root(), &filter);
270        assert_eq!(tree.docs.len(), 2, "expected 2 research docs, got {}", tree.docs.len());
271        for doc in &tree.docs {
272            assert_eq!(doc.category, Category::Research);
273        }
274    }
275
276    #[test]
277    fn counts_returns_correct_values() {
278        let tree = DocTree::scan(&fixtures_root());
279        let counts = tree.counts();
280        assert!(counts.get(&Category::Active).copied().unwrap_or(0) >= 4);
281        assert_eq!(counts.get(&Category::Design).copied().unwrap_or(0), 2);
282        assert_eq!(counts.get(&Category::Research).copied().unwrap_or(0), 2);
283        assert_eq!(counts.get(&Category::Archive).copied().unwrap_or(0), 1);
284    }
285}