1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use dm_meta::{Category, Document};
5
6#[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#[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 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
66pub struct DocTree {
72 pub docs: Vec<Document>,
73 pub errors: Vec<ScanError>,
74 pub root: PathBuf,
75}
76
77impl DocTree {
78 pub fn scan(root: &Path) -> Self {
80 Self::scan_filtered(root, &ScanFilter::default())
81 }
82
83 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 pub fn all(&self) -> &[Document] {
133 &self.docs
134 }
135
136 pub fn by_category(&self, category: Category) -> Vec<&Document> {
138 self.docs.iter().filter(|d| d.category == category).collect()
139 }
140
141 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 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 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 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#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn fixtures_root() -> PathBuf {
187 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 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 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}