Skip to main content

dm_index/
lib.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use chrono::NaiveDate;
5use dm_meta::{Category, Document};
6use dm_scan::DocTree;
7
8// ---------------------------------------------------------------------------
9// Helpers
10// ---------------------------------------------------------------------------
11
12fn today() -> NaiveDate {
13    chrono::Local::now().date_naive()
14}
15
16/// Compute a relative path from the doc root for display in generated markdown.
17fn rel_path(doc: &Document, root: &Path) -> String {
18    doc.path
19        .strip_prefix(root)
20        .unwrap_or(&doc.path)
21        .to_string_lossy()
22        .replace('\\', "/")
23}
24
25fn title_or_filename(doc: &Document) -> String {
26    doc.frontmatter
27        .title
28        .clone()
29        .unwrap_or_else(|| {
30            doc.path
31                .file_stem()
32                .map(|s| s.to_string_lossy().into_owned())
33                .unwrap_or_else(|| "Untitled".into())
34        })
35}
36
37/// Extract the first path component after the category directory.
38/// e.g. `active/architecture/FOO.md` -> `architecture`
39fn subgroup(doc: &Document, root: &Path) -> String {
40    let rp = rel_path(doc, root);
41    let parts: Vec<&str> = rp.split('/').collect();
42    // parts[0] = category dir (active/design/...), parts[1] = subgroup
43    if parts.len() >= 3 {
44        parts[1].to_string()
45    } else {
46        "other".to_string()
47    }
48}
49
50fn capitalize(s: &str) -> String {
51    let mut c = s.chars();
52    match c.next() {
53        None => String::new(),
54        Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
55    }
56}
57
58// ---------------------------------------------------------------------------
59// INDEX.md
60// ---------------------------------------------------------------------------
61
62/// Generate an INDEX.md table of contents grouped by category.
63pub fn generate_index(tree: &DocTree) -> String {
64    generate_index_with_date(tree, today())
65}
66
67fn generate_index_with_date(tree: &DocTree, date: NaiveDate) -> String {
68    let mut out = String::new();
69    out.push_str(&format!("# Documentation Index\n\n*Auto-generated: {date}*\n"));
70
71    // Active docs grouped by subdirectory
72    let active = tree.by_category(Category::Active);
73    if !active.is_empty() {
74        out.push_str("\n## Active Documentation\n");
75        let mut groups: BTreeMap<String, Vec<&Document>> = BTreeMap::new();
76        for doc in &active {
77            let sg = subgroup(doc, &tree.root);
78            groups.entry(sg).or_default().push(doc);
79        }
80        for (group, mut docs) in groups {
81            out.push_str(&format!("\n### {}\n\n", capitalize(&group)));
82            docs.sort_by_key(|d| title_or_filename(d).to_lowercase());
83            for doc in docs {
84                let title = title_or_filename(doc);
85                let rp = rel_path(doc, &tree.root);
86                let updated = doc.frontmatter.last_updated
87                    .map(|d| format!(" *(updated {d})*"))
88                    .unwrap_or_default();
89                out.push_str(&format!("- [{title}]({rp}){updated}\n"));
90            }
91        }
92    }
93
94    // Design docs grouped by status
95    let design = tree.by_category(Category::Design);
96    if !design.is_empty() {
97        out.push_str("\n## Design Documents\n");
98        let mut groups: BTreeMap<String, Vec<&Document>> = BTreeMap::new();
99        for doc in &design {
100            let status = doc.frontmatter.status.as_deref().unwrap_or("proposed").to_lowercase();
101            groups.entry(status).or_default().push(doc);
102        }
103        for (status, mut docs) in groups {
104            out.push_str(&format!("\n### {}\n\n", capitalize(&status)));
105            docs.sort_by_key(|d| d.frontmatter.doc_id.unwrap_or(u32::MAX));
106            for doc in docs {
107                let title = title_or_filename(doc);
108                let rp = rel_path(doc, &tree.root);
109                let prefix = doc.frontmatter.doc_id
110                    .map(|id| format!("{id:03}: "))
111                    .unwrap_or_default();
112                let meta = match status.as_str() {
113                    "accepted" => doc.frontmatter.decision_date
114                        .map(|d| format!(" *accepted {d}*"))
115                        .unwrap_or_default(),
116                    _ => {
117                        let author = doc.frontmatter.author.as_deref();
118                        let created = doc.frontmatter.created;
119                        match (author, created) {
120                            (Some(a), Some(d)) => format!(" *by {a}, {d}*"),
121                            (Some(a), None) => format!(" *by {a}*"),
122                            (None, Some(d)) => format!(" *{d}*"),
123                            (None, None) => String::new(),
124                        }
125                    }
126                };
127                out.push_str(&format!("- [{prefix}{title}]({rp}){meta}\n"));
128            }
129        }
130    }
131
132    // Research docs
133    let research = tree.by_category(Category::Research);
134    if !research.is_empty() {
135        out.push_str("\n## Research\n\n");
136        let mut docs = research.to_vec();
137        docs.sort_by_key(|d| title_or_filename(d).to_lowercase());
138        for doc in docs {
139            let title = title_or_filename(doc);
140            let rp = rel_path(doc, &tree.root);
141            let status = doc.frontmatter.status.as_deref().unwrap_or("draft");
142            out.push_str(&format!("- [{title}]({rp}) *({status})*\n"));
143        }
144    }
145
146    // Archive docs
147    let archive = tree.by_category(Category::Archive);
148    if !archive.is_empty() {
149        out.push_str("\n## Archive\n\n");
150        let mut docs = archive.to_vec();
151        docs.sort_by_key(|d| title_or_filename(d).to_lowercase());
152        for doc in docs {
153            let title = title_or_filename(doc);
154            let rp = rel_path(doc, &tree.root);
155            let reason = doc.frontmatter.archived_reason.as_ref()
156                .map(|r| format!(" *{r}*"))
157                .unwrap_or_default();
158            out.push_str(&format!("- [{title}]({rp}){reason}\n"));
159        }
160    }
161
162    out
163}
164
165// ---------------------------------------------------------------------------
166// CHANGELOG.md
167// ---------------------------------------------------------------------------
168
169/// Generate a CHANGELOG.md listing recently updated, created, and archived documents.
170pub fn generate_changelog(tree: &DocTree, since_days: u32) -> String {
171    generate_changelog_with_date(tree, since_days, today())
172}
173
174fn generate_changelog_with_date(tree: &DocTree, since_days: u32, date: NaiveDate) -> String {
175    let cutoff = date - chrono::Days::new(since_days as u64);
176    let mut out = String::new();
177    out.push_str(&format!(
178        "# Documentation Changelog\n\n*Auto-generated: {date}*\n*Showing changes from the last {since_days} days.*\n"
179    ));
180
181    // Recently Updated
182    out.push_str("\n## Recently Updated\n\n");
183    let mut updated: Vec<&Document> = tree.all().iter()
184        .filter(|d| d.frontmatter.last_updated.map(|u| u >= cutoff).unwrap_or(false))
185        .collect();
186    updated.sort_by(|a, b| b.frontmatter.last_updated.cmp(&a.frontmatter.last_updated));
187    if updated.is_empty() {
188        out.push_str("- No changes.\n");
189    } else {
190        for doc in updated {
191            let date_str = doc.frontmatter.last_updated.unwrap();
192            let title = title_or_filename(doc);
193            let rp = rel_path(doc, &tree.root);
194            let version_info = doc.frontmatter.version
195                .map(|v| format!(" — updated to v{v}"))
196                .unwrap_or_default();
197            out.push_str(&format!("- **{date_str}** [{title}]({rp}){version_info}\n"));
198        }
199    }
200
201    // Recently Created
202    out.push_str("\n## Recently Created\n\n");
203    let mut created: Vec<&Document> = tree.all().iter()
204        .filter(|d| d.frontmatter.created.map(|c| c >= cutoff).unwrap_or(false))
205        .collect();
206    created.sort_by(|a, b| b.frontmatter.created.cmp(&a.frontmatter.created));
207    if created.is_empty() {
208        out.push_str("- No changes.\n");
209    } else {
210        for doc in created {
211            let date_str = doc.frontmatter.created.unwrap();
212            let title = title_or_filename(doc);
213            let rp = rel_path(doc, &tree.root);
214            out.push_str(&format!("- **{date_str}** [{title}]({rp})\n"));
215        }
216    }
217
218    // Recently Archived
219    out.push_str("\n## Recently Archived\n\n");
220    let archive = tree.by_category(Category::Archive);
221    let mut archived: Vec<&&Document> = archive.iter()
222        .filter(|d| d.frontmatter.archived_date.map(|a| a >= cutoff).unwrap_or(false))
223        .collect();
224    archived.sort_by(|a, b| b.frontmatter.archived_date.cmp(&a.frontmatter.archived_date));
225    if archived.is_empty() {
226        out.push_str("- No changes.\n");
227    } else {
228        for doc in archived {
229            let date_str = doc.frontmatter.archived_date.unwrap();
230            let title = title_or_filename(doc);
231            let rp = rel_path(doc, &tree.root);
232            let reason = doc.frontmatter.archived_reason.as_ref()
233                .map(|r| format!(" — {r}"))
234                .unwrap_or_default();
235            out.push_str(&format!("- **{date_str}** [{title}]({rp}){reason}\n"));
236        }
237    }
238
239    out
240}
241
242// ---------------------------------------------------------------------------
243// ROADMAP.md
244// ---------------------------------------------------------------------------
245
246/// Generate a ROADMAP.md from proposed/accepted design docs and promising research.
247pub fn generate_roadmap(tree: &DocTree) -> String {
248    generate_roadmap_with_date(tree, today())
249}
250
251fn generate_roadmap_with_date(tree: &DocTree, date: NaiveDate) -> String {
252    let mut out = String::new();
253    out.push_str(&format!("# Documentation Roadmap\n\n*Auto-generated: {date}*\n"));
254
255    // Under Review (Proposed)
256    out.push_str("\n## Under Review (Proposed)\n\n");
257    let design = tree.by_category(Category::Design);
258    let mut proposed: Vec<&&Document> = design.iter()
259        .filter(|d| d.frontmatter.status.as_deref().map(|s| s.eq_ignore_ascii_case("proposed")).unwrap_or(false))
260        .collect();
261    proposed.sort_by_key(|d| d.frontmatter.doc_id.unwrap_or(u32::MAX));
262    if proposed.is_empty() {
263        out.push_str("- None.\n");
264    } else {
265        for doc in proposed {
266            let title = title_or_filename(doc);
267            let rp = rel_path(doc, &tree.root);
268            let prefix = doc.frontmatter.doc_id
269                .map(|id| format!("{id:03}: "))
270                .unwrap_or_default();
271            let author = doc.frontmatter.author.as_ref()
272                .map(|a| format!(" *by {a}*"))
273                .unwrap_or_default();
274            out.push_str(&format!("- [{prefix}{title}]({rp}){author}\n"));
275        }
276    }
277
278    // Ready for Implementation (Accepted)
279    out.push_str("\n## Ready for Implementation (Accepted)\n\n");
280    let mut accepted: Vec<&&Document> = design.iter()
281        .filter(|d| d.frontmatter.status.as_deref().map(|s| s.eq_ignore_ascii_case("accepted")).unwrap_or(false))
282        .collect();
283    accepted.sort_by_key(|d| d.frontmatter.doc_id.unwrap_or(u32::MAX));
284    if accepted.is_empty() {
285        out.push_str("- None.\n");
286    } else {
287        for doc in accepted {
288            let title = title_or_filename(doc);
289            let rp = rel_path(doc, &tree.root);
290            let prefix = doc.frontmatter.doc_id
291                .map(|id| format!("{id:03}: "))
292                .unwrap_or_default();
293            let decision = doc.frontmatter.decision_date
294                .map(|d| format!(" *accepted {d}*"))
295                .unwrap_or_default();
296            out.push_str(&format!("- [{prefix}{title}]({rp}){decision}\n"));
297        }
298    }
299
300    // Potential Future Work (Research)
301    out.push_str("\n## Potential Future Work (Research)\n\n");
302    let research = tree.by_category(Category::Research);
303    let mut future: Vec<&&Document> = research.iter()
304        .filter(|d| d.frontmatter.may_become_design_doc == Some(true))
305        .collect();
306    future.sort_by_key(|d| title_or_filename(d).to_lowercase());
307    if future.is_empty() {
308        out.push_str("- None.\n");
309    } else {
310        for doc in future {
311            let title = title_or_filename(doc);
312            let rp = rel_path(doc, &tree.root);
313            out.push_str(&format!("- [{title}]({rp}) *(may become design doc)*\n"));
314        }
315    }
316
317    out
318}
319
320// ---------------------------------------------------------------------------
321// Write all
322// ---------------------------------------------------------------------------
323
324/// Generate and write INDEX.md, CHANGELOG.md, and ROADMAP.md to the output directory.
325pub fn write_all(tree: &DocTree, output_dir: &Path, changelog_days: u32) -> Result<(), std::io::Error> {
326    std::fs::create_dir_all(output_dir)?;
327    std::fs::write(output_dir.join("INDEX.md"), generate_index(tree))?;
328    std::fs::write(output_dir.join("CHANGELOG.md"), generate_changelog(tree, changelog_days))?;
329    std::fs::write(output_dir.join("ROADMAP.md"), generate_roadmap(tree))?;
330    Ok(())
331}
332
333// ---------------------------------------------------------------------------
334// Tests
335// ---------------------------------------------------------------------------
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::path::PathBuf;
341
342    fn fixtures_root() -> PathBuf {
343        let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
344        let workspace = manifest.parent().unwrap().parent().unwrap();
345        let root = workspace.join("tests/fixtures/docs");
346        assert!(root.exists(), "fixtures dir not found at {}", root.display());
347        root
348    }
349
350    fn scan_fixtures() -> DocTree {
351        DocTree::scan(&fixtures_root())
352    }
353
354    #[test]
355    fn index_groups_active_by_subdirectory() {
356        let tree = scan_fixtures();
357        let idx = generate_index(&tree);
358        assert!(idx.contains("## Active Documentation"));
359        assert!(idx.contains("### Architecture"));
360        assert!(idx.contains("### Api") || idx.contains("### api"));
361        assert!(idx.contains("### Guides") || idx.contains("### guides"));
362        assert!(idx.contains("Core Concepts"));
363        assert!(idx.contains("Execution Engine"));
364    }
365
366    #[test]
367    fn index_lists_design_by_status() {
368        let tree = scan_fixtures();
369        let idx = generate_index(&tree);
370        assert!(idx.contains("## Design Documents"));
371        assert!(idx.contains("### Proposed"));
372        assert!(idx.contains("### Accepted"));
373        assert!(idx.contains("Recursive Self-Optimization"));
374        assert!(idx.contains("Context Fidelity"));
375    }
376
377    #[test]
378    fn index_includes_research_and_archive() {
379        let tree = scan_fixtures();
380        let idx = generate_index(&tree);
381        assert!(idx.contains("## Research"));
382        assert!(idx.contains("AI Optimization Techniques Survey"));
383        assert!(idx.contains("*(draft)*"));
384        assert!(idx.contains("## Archive"));
385        assert!(idx.contains("Original Execution Engine Design"));
386    }
387
388    #[test]
389    fn changelog_shows_recently_updated() {
390        let tree = scan_fixtures();
391        // Use a date just after the most recent update so all fixture docs are "recent"
392        let date = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap();
393        let cl = generate_changelog_with_date(&tree, 365, date);
394        assert!(cl.contains("## Recently Updated"));
395        assert!(cl.contains("Execution Engine"));
396    }
397
398    #[test]
399    fn changelog_shows_no_changes_when_too_old() {
400        let tree = scan_fixtures();
401        // Use a date far in the future with a tiny window — nothing should match
402        let date = NaiveDate::from_ymd_opt(2030, 1, 1).unwrap();
403        let cl = generate_changelog_with_date(&tree, 1, date);
404        assert!(cl.contains("- No changes."));
405    }
406
407    #[test]
408    fn roadmap_includes_proposed() {
409        let tree = scan_fixtures();
410        let rm = generate_roadmap(&tree);
411        assert!(rm.contains("## Under Review (Proposed)"));
412        assert!(rm.contains("Recursive Self-Optimization"));
413    }
414
415    #[test]
416    fn roadmap_includes_accepted() {
417        let tree = scan_fixtures();
418        let rm = generate_roadmap(&tree);
419        assert!(rm.contains("## Ready for Implementation (Accepted)"));
420        assert!(rm.contains("Context Fidelity"));
421        assert!(rm.contains("accepted 2026-01-25"));
422    }
423
424    #[test]
425    fn roadmap_includes_research_may_become_design() {
426        let tree = scan_fixtures();
427        let rm = generate_roadmap(&tree);
428        assert!(rm.contains("## Potential Future Work (Research)"));
429        assert!(rm.contains("AI Optimization Techniques Survey"));
430        assert!(rm.contains("may become design doc"));
431    }
432
433    #[test]
434    fn roadmap_excludes_research_not_becoming_design() {
435        let tree = scan_fixtures();
436        let rm = generate_roadmap(&tree);
437        // Competitor Analysis has may_become_design_doc=false
438        let future_section_start = rm.find("## Potential Future Work").unwrap();
439        let future_section = &rm[future_section_start..];
440        assert!(!future_section.contains("Competitor Analysis"));
441    }
442
443    #[test]
444    fn write_all_creates_files() {
445        let tree = scan_fixtures();
446        let dir = tempfile::tempdir().unwrap();
447        write_all(&tree, dir.path(), 30).unwrap();
448        assert!(dir.path().join("INDEX.md").exists());
449        assert!(dir.path().join("CHANGELOG.md").exists());
450        assert!(dir.path().join("ROADMAP.md").exists());
451    }
452}