adrscope/infrastructure/renderer/
wiki.rs

1//! Wiki-style markdown generation.
2//!
3//! Generates markdown files suitable for GitHub Wiki.
4
5use std::collections::HashMap;
6use std::fmt::Write;
7
8use crate::domain::{Adr, AdrStatistics, Status};
9use crate::error::Result;
10
11/// Renderer for wiki-style markdown output.
12#[derive(Debug, Clone, Default)]
13pub struct WikiRenderer;
14
15impl WikiRenderer {
16    /// Creates a new wiki renderer.
17    #[must_use]
18    pub const fn new() -> Self {
19        Self
20    }
21
22    /// Generates the main ADR index page.
23    #[must_use]
24    pub fn render_index(&self, adrs: &[Adr], pages_url: Option<&str>) -> String {
25        let mut output = String::new();
26
27        let _ = writeln!(output, "# ADR Index");
28        let _ = writeln!(output);
29
30        if let Some(url) = pages_url {
31            let _ = writeln!(output, "> [View Interactive ADRScope Viewer]({url})");
32            let _ = writeln!(output);
33        }
34
35        let _ = writeln!(output, "| ID | Title | Status | Category | Created |");
36        let _ = writeln!(output, "|:---|:------|:------:|:---------|:--------|");
37
38        for adr in adrs {
39            let created = adr
40                .created()
41                .map_or_else(|| "-".to_string(), |d| d.to_string());
42
43            let status_badge = status_badge(adr.status());
44
45            let _ = writeln!(
46                output,
47                "| {} | [{}]({}) | {} | {} | {} |",
48                adr.id(),
49                adr.title(),
50                adr.filename(),
51                status_badge,
52                adr.category(),
53                created
54            );
55        }
56
57        output
58    }
59
60    /// Generates an ADR listing grouped by status.
61    #[must_use]
62    pub fn render_by_status(&self, adrs: &[Adr]) -> String {
63        let mut output = String::new();
64
65        let _ = writeln!(output, "# ADRs by Status");
66        let _ = writeln!(output);
67
68        // Group ADRs by status
69        let mut by_status: HashMap<Status, Vec<&Adr>> = HashMap::new();
70        for adr in adrs {
71            by_status.entry(adr.status()).or_default().push(adr);
72        }
73
74        // Output in a fixed order
75        for status in Status::all() {
76            if let Some(group) = by_status.get(status) {
77                if !group.is_empty() {
78                    let _ = writeln!(output, "## {} {}", status_emoji(*status), status);
79                    let _ = writeln!(output);
80
81                    for adr in group {
82                        let _ = writeln!(
83                            output,
84                            "- [{}]({}) - {}",
85                            adr.title(),
86                            adr.filename(),
87                            adr.description()
88                        );
89                    }
90                    let _ = writeln!(output);
91                }
92            }
93        }
94
95        output
96    }
97
98    /// Generates an ADR listing grouped by category.
99    #[must_use]
100    pub fn render_by_category(&self, adrs: &[Adr]) -> String {
101        let mut output = String::new();
102
103        let _ = writeln!(output, "# ADRs by Category");
104        let _ = writeln!(output);
105
106        // Group ADRs by category
107        let mut by_category: HashMap<&str, Vec<&Adr>> = HashMap::new();
108        for adr in adrs {
109            let category = if adr.category().is_empty() {
110                "Uncategorized"
111            } else {
112                adr.category()
113            };
114            by_category.entry(category).or_default().push(adr);
115        }
116
117        // Sort categories alphabetically
118        let mut categories: Vec<_> = by_category.keys().collect();
119        categories.sort();
120
121        for category in categories {
122            if let Some(group) = by_category.get(category) {
123                let _ = writeln!(output, "## {category}");
124                let _ = writeln!(output);
125
126                for adr in group {
127                    let status = status_badge(adr.status());
128                    let _ = writeln!(
129                        output,
130                        "- [{}]({}) {} - {}",
131                        adr.title(),
132                        adr.filename(),
133                        status,
134                        truncate(adr.description(), 80)
135                    );
136                }
137                let _ = writeln!(output);
138            }
139        }
140
141        output
142    }
143
144    /// Generates a chronological timeline of ADRs.
145    #[must_use]
146    pub fn render_timeline(&self, adrs: &[Adr]) -> String {
147        let mut output = String::new();
148
149        let _ = writeln!(output, "# ADR Timeline");
150        let _ = writeln!(output);
151
152        // Sort ADRs by created date (newest first)
153        let mut sorted: Vec<&Adr> = adrs.iter().collect();
154        sorted.sort_by(|a, b| b.created().cmp(&a.created()));
155
156        // Group by year-month
157        let mut current_month: Option<String> = None;
158
159        for adr in &sorted {
160            if let Some(date) = adr.created() {
161                let month_key = format!("{}-{:02}", date.year(), date.month() as u8);
162
163                if current_month.as_ref() != Some(&month_key) {
164                    current_month = Some(month_key);
165                    let _ = writeln!(output, "\n## {} {}", date.month(), date.year());
166                    let _ = writeln!(output);
167                }
168
169                let status = status_badge(adr.status());
170                let _ = writeln!(
171                    output,
172                    "- **{}** [{}]({}) {}",
173                    date,
174                    adr.title(),
175                    adr.filename(),
176                    status
177                );
178            }
179        }
180
181        // ADRs without dates
182        let undated: Vec<_> = sorted.iter().filter(|a| a.created().is_none()).collect();
183        if !undated.is_empty() {
184            let _ = writeln!(output, "\n## Undated");
185            let _ = writeln!(output);
186            for adr in undated {
187                let status = status_badge(adr.status());
188                let _ = writeln!(output, "- [{}]({}) {}", adr.title(), adr.filename(), status);
189            }
190        }
191
192        output
193    }
194
195    /// Generates a statistics summary page.
196    #[must_use]
197    pub fn render_statistics(&self, stats: &AdrStatistics) -> String {
198        let mut output = String::new();
199
200        let _ = writeln!(output, "# ADR Statistics");
201        let _ = writeln!(output);
202        let _ = writeln!(output, "**Total ADRs:** {}", stats.total_count);
203        let _ = writeln!(output);
204
205        // Status breakdown
206        let _ = writeln!(output, "## By Status");
207        let _ = writeln!(output);
208        for status in Status::all() {
209            let count = stats.by_status.get(status.as_str()).copied().unwrap_or(0);
210            let _ = writeln!(output, "- {} {}: {}", status_emoji(*status), status, count);
211        }
212        let _ = writeln!(output);
213
214        // Category breakdown
215        if !stats.by_category.is_empty() {
216            let _ = writeln!(output, "## By Category");
217            let _ = writeln!(output);
218            let mut categories: Vec<_> = stats.by_category.iter().collect();
219            categories.sort_by(|a, b| b.1.cmp(a.1));
220            for (category, count) in categories {
221                let _ = writeln!(output, "- {category}: {count}");
222            }
223            let _ = writeln!(output);
224        }
225
226        // Author breakdown
227        if !stats.by_author.is_empty() {
228            let _ = writeln!(output, "## By Author");
229            let _ = writeln!(output);
230            let mut authors: Vec<_> = stats.by_author.iter().collect();
231            authors.sort_by(|a, b| b.1.cmp(a.1));
232            for (author, count) in authors.iter().take(10) {
233                let _ = writeln!(output, "- {author}: {count}");
234            }
235            let _ = writeln!(output);
236        }
237
238        // Date range
239        if let (Some(earliest), Some(latest)) = (&stats.earliest_date, &stats.latest_date) {
240            let _ = writeln!(output, "## Date Range");
241            let _ = writeln!(output);
242            let _ = writeln!(output, "- **Earliest:** {earliest}");
243            let _ = writeln!(output, "- **Latest:** {latest}");
244        }
245
246        output
247    }
248
249    /// Generates all wiki files.
250    pub fn render_all(
251        &self,
252        adrs: &[Adr],
253        pages_url: Option<&str>,
254    ) -> Result<Vec<(String, String)>> {
255        let stats = AdrStatistics::from_adrs(adrs);
256
257        Ok(vec![
258            (
259                "ADR-Index.md".to_string(),
260                self.render_index(adrs, pages_url),
261            ),
262            ("ADR-By-Status.md".to_string(), self.render_by_status(adrs)),
263            (
264                "ADR-By-Category.md".to_string(),
265                self.render_by_category(adrs),
266            ),
267            ("ADR-Timeline.md".to_string(), self.render_timeline(adrs)),
268            (
269                "ADR-Statistics.md".to_string(),
270                self.render_statistics(&stats),
271            ),
272        ])
273    }
274}
275
276/// Returns an emoji for the given status.
277fn status_emoji(status: Status) -> &'static str {
278    match status {
279        Status::Proposed => "\u{1F7E1}",   // yellow circle
280        Status::Accepted => "\u{2705}",    // green check
281        Status::Deprecated => "\u{1F534}", // red circle
282        Status::Superseded => "\u{26AA}",  // white circle
283    }
284}
285
286/// Returns a markdown badge for the given status.
287fn status_badge(status: Status) -> String {
288    format!("`{}`", status.as_str())
289}
290
291/// Truncates a string to the given length, adding ellipsis if needed.
292fn truncate(s: &str, max_len: usize) -> String {
293    if s.len() <= max_len {
294        s.to_string()
295    } else {
296        format!("{}...", &s[..max_len.saturating_sub(3)])
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::domain::{AdrId, Frontmatter};
304    use std::path::PathBuf;
305    use time::macros::date;
306
307    fn create_test_adr(id: &str, title: &str, status: Status, category: &str) -> Adr {
308        let frontmatter = Frontmatter::new(title)
309            .with_status(status)
310            .with_category(category)
311            .with_description(format!("Description for {title}"))
312            .with_created(date!(2025 - 01 - 15));
313
314        Adr::new(
315            AdrId::new(id),
316            format!("{id}.md"),
317            PathBuf::from(format!("{id}.md")),
318            frontmatter,
319            String::new(),
320            String::new(),
321            String::new(),
322        )
323    }
324
325    #[test]
326    fn test_render_index() {
327        let adrs = vec![
328            create_test_adr("adr_0001", "Use PostgreSQL", Status::Accepted, "database"),
329            create_test_adr("adr_0002", "Use Rust", Status::Proposed, "language"),
330        ];
331
332        let renderer = WikiRenderer::new();
333        let output = renderer.render_index(&adrs, Some("https://example.com/adrs"));
334
335        assert!(output.contains("# ADR Index"));
336        assert!(output.contains("[View Interactive ADRScope Viewer]"));
337        assert!(output.contains("Use PostgreSQL"));
338        assert!(output.contains("adr_0001.md"));
339    }
340
341    #[test]
342    fn test_render_by_status() {
343        let adrs = vec![
344            create_test_adr("adr_0001", "ADR 1", Status::Accepted, "cat"),
345            create_test_adr("adr_0002", "ADR 2", Status::Accepted, "cat"),
346            create_test_adr("adr_0003", "ADR 3", Status::Proposed, "cat"),
347        ];
348
349        let renderer = WikiRenderer::new();
350        let output = renderer.render_by_status(&adrs);
351
352        assert!(output.contains("# ADRs by Status"));
353        assert!(output.contains("## ")); // Status headers
354    }
355
356    #[test]
357    fn test_render_by_category() {
358        let adrs = vec![
359            create_test_adr("adr_0001", "ADR 1", Status::Accepted, "architecture"),
360            create_test_adr("adr_0002", "ADR 2", Status::Accepted, "api"),
361        ];
362
363        let renderer = WikiRenderer::new();
364        let output = renderer.render_by_category(&adrs);
365
366        assert!(output.contains("# ADRs by Category"));
367        assert!(output.contains("## api"));
368        assert!(output.contains("## architecture"));
369    }
370
371    #[test]
372    fn test_truncate() {
373        assert_eq!(truncate("short", 10), "short");
374        assert_eq!(truncate("this is a long string", 10), "this is...");
375    }
376
377    #[test]
378    fn test_status_badge() {
379        assert_eq!(status_badge(Status::Accepted), "`accepted`");
380        assert_eq!(status_badge(Status::Proposed), "`proposed`");
381    }
382
383    #[test]
384    fn test_status_emoji() {
385        assert_eq!(status_emoji(Status::Proposed), "\u{1F7E1}");
386        assert_eq!(status_emoji(Status::Accepted), "\u{2705}");
387        assert_eq!(status_emoji(Status::Deprecated), "\u{1F534}");
388        assert_eq!(status_emoji(Status::Superseded), "\u{26AA}");
389    }
390
391    #[test]
392    fn test_render_timeline() {
393        let adrs = vec![
394            create_test_adr("adr_0001", "First ADR", Status::Accepted, "arch"),
395            create_test_adr("adr_0002", "Second ADR", Status::Proposed, "api"),
396        ];
397
398        let renderer = WikiRenderer::new();
399        let output = renderer.render_timeline(&adrs);
400
401        assert!(output.contains("# ADR Timeline"));
402        assert!(output.contains("2025"));
403        assert!(output.contains("First ADR"));
404        assert!(output.contains("Second ADR"));
405    }
406
407    #[test]
408    fn test_render_timeline_with_undated() {
409        // Create an ADR without a date
410        let frontmatter = Frontmatter::new("Undated ADR")
411            .with_status(Status::Proposed)
412            .with_category("test");
413
414        let undated_adr = Adr::new(
415            AdrId::new("adr_undated"),
416            "adr_undated.md".to_string(),
417            PathBuf::from("adr_undated.md"),
418            frontmatter,
419            String::new(),
420            String::new(),
421            String::new(),
422        );
423
424        let adrs = vec![
425            create_test_adr("adr_0001", "Dated ADR", Status::Accepted, "arch"),
426            undated_adr,
427        ];
428
429        let renderer = WikiRenderer::new();
430        let output = renderer.render_timeline(&adrs);
431
432        assert!(output.contains("# ADR Timeline"));
433        assert!(output.contains("## Undated"));
434        assert!(output.contains("Undated ADR"));
435    }
436
437    #[test]
438    fn test_render_statistics() {
439        let adrs = vec![
440            create_test_adr("adr_0001", "ADR 1", Status::Accepted, "arch"),
441            create_test_adr("adr_0002", "ADR 2", Status::Accepted, "api"),
442            create_test_adr("adr_0003", "ADR 3", Status::Proposed, "arch"),
443        ];
444
445        let stats = AdrStatistics::from_adrs(&adrs);
446        let renderer = WikiRenderer::new();
447        let output = renderer.render_statistics(&stats);
448
449        assert!(output.contains("# ADR Statistics"));
450        assert!(output.contains("**Total ADRs:** 3"));
451        assert!(output.contains("## By Status"));
452        assert!(output.contains("## By Category"));
453    }
454
455    #[test]
456    fn test_render_statistics_with_authors() {
457        let frontmatter = Frontmatter::new("ADR with Author")
458            .with_status(Status::Accepted)
459            .with_category("arch")
460            .with_author("Test Author")
461            .with_created(date!(2025 - 01 - 15));
462
463        let adr = Adr::new(
464            AdrId::new("adr_0001"),
465            "adr_0001.md".to_string(),
466            PathBuf::from("adr_0001.md"),
467            frontmatter,
468            String::new(),
469            String::new(),
470            String::new(),
471        );
472
473        let stats = AdrStatistics::from_adrs(&[adr]);
474        let renderer = WikiRenderer::new();
475        let output = renderer.render_statistics(&stats);
476
477        assert!(output.contains("## By Author"));
478        assert!(output.contains("Test Author"));
479    }
480
481    #[test]
482    fn test_render_all() {
483        let adrs = vec![
484            create_test_adr("adr_0001", "ADR 1", Status::Accepted, "arch"),
485            create_test_adr("adr_0002", "ADR 2", Status::Proposed, "api"),
486        ];
487
488        let renderer = WikiRenderer::new();
489        let files = renderer
490            .render_all(&adrs, Some("https://example.com"))
491            .expect("should render all");
492
493        assert_eq!(files.len(), 5);
494
495        let filenames: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect();
496        assert!(filenames.contains(&"ADR-Index.md"));
497        assert!(filenames.contains(&"ADR-By-Status.md"));
498        assert!(filenames.contains(&"ADR-By-Category.md"));
499        assert!(filenames.contains(&"ADR-Timeline.md"));
500        assert!(filenames.contains(&"ADR-Statistics.md"));
501    }
502
503    #[test]
504    fn test_render_index_without_url() {
505        let adrs = vec![create_test_adr(
506            "adr_0001",
507            "Test ADR",
508            Status::Accepted,
509            "test",
510        )];
511
512        let renderer = WikiRenderer::new();
513        let output = renderer.render_index(&adrs, None);
514
515        assert!(output.contains("# ADR Index"));
516        assert!(!output.contains("[View Interactive ADRScope Viewer]"));
517    }
518
519    #[test]
520    fn test_render_by_category_uncategorized() {
521        // Create an ADR without a category
522        let frontmatter = Frontmatter::new("Uncategorized ADR")
523            .with_status(Status::Proposed)
524            .with_created(date!(2025 - 01 - 15));
525
526        let uncategorized_adr = Adr::new(
527            AdrId::new("adr_uncat"),
528            "adr_uncat.md".to_string(),
529            PathBuf::from("adr_uncat.md"),
530            frontmatter,
531            String::new(),
532            String::new(),
533            String::new(),
534        );
535
536        let adrs = vec![uncategorized_adr];
537
538        let renderer = WikiRenderer::new();
539        let output = renderer.render_by_category(&adrs);
540
541        assert!(output.contains("## Uncategorized"));
542    }
543
544    #[test]
545    fn test_truncate_edge_cases() {
546        // Exactly at max length
547        assert_eq!(truncate("12345678", 8), "12345678");
548        // Just over max length
549        assert_eq!(truncate("123456789", 8), "12345...");
550        // Empty string
551        assert_eq!(truncate("", 10), "");
552        // Very short max
553        assert_eq!(truncate("hello", 3), "...");
554    }
555}