1use std::collections::BTreeMap;
2use std::path::Path;
3
4use chrono::NaiveDate;
5use dm_meta::{Category, Document};
6use dm_scan::DocTree;
7
8fn today() -> NaiveDate {
13 chrono::Local::now().date_naive()
14}
15
16fn 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
37fn subgroup(doc: &Document, root: &Path) -> String {
40 let rp = rel_path(doc, root);
41 let parts: Vec<&str> = rp.split('/').collect();
42 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
58pub 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 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 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 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 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
165pub 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 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 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 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
242pub 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 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 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 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
320pub 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#[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 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 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 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}