1use std::collections::HashMap;
6use std::fmt::Write;
7
8use crate::domain::{Adr, AdrStatistics, Status};
9use crate::error::Result;
10
11#[derive(Debug, Clone, Default)]
13pub struct WikiRenderer;
14
15impl WikiRenderer {
16 #[must_use]
18 pub const fn new() -> Self {
19 Self
20 }
21
22 #[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 #[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 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 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 #[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 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 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 #[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 let mut sorted: Vec<&Adr> = adrs.iter().collect();
154 sorted.sort_by(|a, b| b.created().cmp(&a.created()));
155
156 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 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 #[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 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 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 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 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 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
276fn status_emoji(status: Status) -> &'static str {
278 match status {
279 Status::Proposed => "\u{1F7E1}", Status::Accepted => "\u{2705}", Status::Deprecated => "\u{1F534}", Status::Superseded => "\u{26AA}", }
284}
285
286fn status_badge(status: Status) -> String {
288 format!("`{}`", status.as_str())
289}
290
291fn 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("## ")); }
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 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 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 assert_eq!(truncate("12345678", 8), "12345678");
548 assert_eq!(truncate("123456789", 8), "12345...");
550 assert_eq!(truncate("", 10), "");
552 assert_eq!(truncate("hello", 3), "...");
554 }
555}