1use anyhow::Result;
4use chrono::{Datelike, Utc};
5use clap::Args as ClapArgs;
6use colored::Colorize;
7use std::collections::HashMap;
8use std::fmt::Write as _;
9use std::fs;
10use std::path::Path;
11
12use crate::core::{AdrStatus, ConfigManager, Git, NotesManager};
13
14#[derive(ClapArgs, Debug)]
16pub struct Args {
17 #[arg(long, short, default_value = "markdown")]
19 pub format: String,
20
21 #[arg(long, short)]
23 pub output: Option<String>,
24
25 #[arg(long)]
27 pub detailed: bool,
28
29 #[arg(long)]
31 pub timeline: bool,
32}
33
34pub fn run(args: Args) -> Result<()> {
40 let git = Git::new();
41 git.check_repository()?;
42
43 let config = ConfigManager::new(git.clone()).load()?;
44 let notes = NotesManager::new(git, config);
45
46 let adrs = notes.list()?;
47
48 if adrs.is_empty() {
49 eprintln!(
50 "{} No ADRs found. Create your first ADR with: git adr new <title>",
51 "→".yellow()
52 );
53 return Ok(());
54 }
55
56 let mut status_counts: HashMap<AdrStatus, usize> = HashMap::new();
58 let mut tag_counts: HashMap<String, usize> = HashMap::new();
59 let mut monthly_counts: HashMap<String, usize> = HashMap::new();
60
61 for adr in &adrs {
62 *status_counts
64 .entry(adr.frontmatter.status.clone())
65 .or_insert(0) += 1;
66
67 for tag in &adr.frontmatter.tags {
69 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
70 }
71
72 if let Some(date) = &adr.frontmatter.date {
74 let month_key = format!("{}-{:02}", date.0.year(), date.0.month());
75 *monthly_counts.entry(month_key).or_insert(0) += 1;
76 }
77 }
78
79 let report = match args.format.as_str() {
80 "json" => generate_json_report(&adrs, &status_counts, &tag_counts, &monthly_counts)?,
81 "html" => generate_html_report(
82 &adrs,
83 &status_counts,
84 &tag_counts,
85 &monthly_counts,
86 args.detailed,
87 args.timeline,
88 ),
89 _ => generate_markdown_report(
90 &adrs,
91 &status_counts,
92 &tag_counts,
93 &monthly_counts,
94 args.detailed,
95 args.timeline,
96 ),
97 };
98
99 if let Some(output_path) = args.output {
100 let path = Path::new(&output_path);
101 if let Some(parent) = path.parent() {
102 if !parent.exists() {
103 fs::create_dir_all(parent)?;
104 }
105 }
106 fs::write(&output_path, &report)?;
107 eprintln!("{} Report saved to: {}", "✓".green(), output_path.cyan());
108 } else {
109 println!("{report}");
110 }
111
112 Ok(())
113}
114
115fn generate_json_report(
117 adrs: &[crate::core::Adr],
118 status_counts: &HashMap<AdrStatus, usize>,
119 tag_counts: &HashMap<String, usize>,
120 monthly_counts: &HashMap<String, usize>,
121) -> Result<String> {
122 let mut status_map: HashMap<String, usize> = HashMap::new();
123 for (status, count) in status_counts {
124 status_map.insert(status.to_string(), *count);
125 }
126
127 let report = serde_json::json!({
128 "generated_at": Utc::now().to_rfc3339(),
129 "total_adrs": adrs.len(),
130 "status_breakdown": status_map,
131 "tag_breakdown": tag_counts,
132 "monthly_breakdown": monthly_counts,
133 "acceptance_rate": calculate_acceptance_rate(status_counts),
134 });
135
136 Ok(serde_json::to_string_pretty(&report)?)
137}
138
139#[allow(clippy::cast_precision_loss)]
141fn generate_markdown_report(
142 adrs: &[crate::core::Adr],
143 status_counts: &HashMap<AdrStatus, usize>,
144 tag_counts: &HashMap<String, usize>,
145 monthly_counts: &HashMap<String, usize>,
146 detailed: bool,
147 timeline: bool,
148) -> String {
149 let mut report = String::new();
150
151 report.push_str("# Architecture Decision Records Report\n\n");
152 let _ = writeln!(
153 report,
154 "*Generated: {}*\n",
155 Utc::now().format("%Y-%m-%d %H:%M UTC")
156 );
157
158 report.push_str("## Summary\n\n");
160 let _ = writeln!(report, "- **Total ADRs**: {}", adrs.len());
161 let _ = writeln!(
162 report,
163 "- **Acceptance Rate**: {:.1}%\n",
164 calculate_acceptance_rate(status_counts)
165 );
166
167 report.push_str("## Status Breakdown\n\n");
169 report.push_str("| Status | Count | Percentage |\n");
170 report.push_str("|--------|-------|------------|\n");
171
172 let statuses = [
173 AdrStatus::Proposed,
174 AdrStatus::Accepted,
175 AdrStatus::Deprecated,
176 AdrStatus::Superseded,
177 AdrStatus::Rejected,
178 ];
179
180 for status in &statuses {
181 let count = status_counts.get(status).unwrap_or(&0);
182 let percentage = if adrs.is_empty() {
183 0.0
184 } else {
185 (*count as f64 / adrs.len() as f64) * 100.0
186 };
187 let _ = writeln!(report, "| {} | {} | {:.1}% |", status, count, percentage);
188 }
189 report.push('\n');
190
191 if !tag_counts.is_empty() {
193 report.push_str("## Top Tags\n\n");
194 let mut tags: Vec<_> = tag_counts.iter().collect();
195 tags.sort_by(|a, b| b.1.cmp(a.1));
196
197 for (tag, count) in tags.iter().take(10) {
198 let _ = writeln!(report, "- `{}`: {} ADRs", tag, count);
199 }
200 report.push('\n');
201 }
202
203 if timeline && !monthly_counts.is_empty() {
205 report.push_str("## Timeline\n\n");
206 let mut months: Vec<_> = monthly_counts.iter().collect();
207 months.sort_by(|a, b| a.0.cmp(b.0));
208
209 report.push_str("| Month | ADRs Created |\n");
210 report.push_str("|-------|-------------|\n");
211 for (month, count) in months {
212 let _ = writeln!(report, "| {} | {} |", month, count);
213 }
214 report.push('\n');
215 }
216
217 if detailed {
219 report.push_str("## ADR List\n\n");
220 report.push_str("| ID | Title | Status | Date |\n");
221 report.push_str("|-----|-------|--------|------|\n");
222
223 for adr in adrs {
224 let date = adr
225 .frontmatter
226 .date
227 .as_ref()
228 .map_or_else(|| "-".to_string(), |d| d.0.format("%Y-%m-%d").to_string());
229 let _ = writeln!(
230 report,
231 "| {} | {} | {} | {} |",
232 adr.id, adr.frontmatter.title, adr.frontmatter.status, date
233 );
234 }
235 }
236
237 report
238}
239
240#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
242fn generate_html_report(
243 adrs: &[crate::core::Adr],
244 status_counts: &HashMap<AdrStatus, usize>,
245 tag_counts: &HashMap<String, usize>,
246 monthly_counts: &HashMap<String, usize>,
247 detailed: bool,
248 timeline: bool,
249) -> String {
250 let mut html = String::new();
251
252 html.push_str(r#"<!DOCTYPE html>
253<html lang="en">
254<head>
255 <meta charset="UTF-8">
256 <meta name="viewport" content="width=device-width, initial-scale=1.0">
257 <title>ADR Report</title>
258 <style>
259 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
260 h1 { color: #333; border-bottom: 2px solid #0066cc; padding-bottom: 10px; }
261 h2 { color: #555; margin-top: 30px; }
262 table { border-collapse: collapse; width: 100%; margin: 15px 0; }
263 th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
264 th { background: #f5f5f5; }
265 tr:hover { background: #fafafa; }
266 .stat { display: inline-block; padding: 15px 25px; margin: 5px; background: #f0f7ff; border-radius: 8px; }
267 .stat-value { font-size: 28px; font-weight: bold; color: #0066cc; }
268 .stat-label { font-size: 12px; color: #666; text-transform: uppercase; }
269 .tag { display: inline-block; padding: 2px 8px; margin: 2px; background: #e0e0e0; border-radius: 4px; font-size: 12px; }
270 .status-proposed { color: #f59e0b; }
271 .status-accepted { color: #10b981; }
272 .status-deprecated { color: #6b7280; }
273 .status-superseded { color: #8b5cf6; }
274 .status-rejected { color: #ef4444; }
275 </style>
276</head>
277<body>
278"#);
279
280 html.push_str("<h1>Architecture Decision Records Report</h1>\n");
281 let _ = writeln!(
282 html,
283 "<p><em>Generated: {}</em></p>",
284 Utc::now().format("%Y-%m-%d %H:%M UTC")
285 );
286
287 html.push_str("<div>\n");
289 let _ = write!(
290 html,
291 r#"<div class="stat"><div class="stat-value">{}</div><div class="stat-label">Total ADRs</div></div>"#,
292 adrs.len()
293 );
294 let _ = write!(
295 html,
296 r#"<div class="stat"><div class="stat-value">{:.0}%</div><div class="stat-label">Acceptance Rate</div></div>"#,
297 calculate_acceptance_rate(status_counts)
298 );
299 html.push_str("</div>\n\n");
300
301 html.push_str("<h2>Status Breakdown</h2>\n<table>\n<tr><th>Status</th><th>Count</th><th>Percentage</th></tr>\n");
303 for status in &[
304 AdrStatus::Proposed,
305 AdrStatus::Accepted,
306 AdrStatus::Deprecated,
307 AdrStatus::Superseded,
308 AdrStatus::Rejected,
309 ] {
310 let count = status_counts.get(status).unwrap_or(&0);
311 let pct = if adrs.is_empty() {
312 0.0
313 } else {
314 (*count as f64 / adrs.len() as f64) * 100.0
315 };
316 let class = format!("status-{}", status.to_string().to_lowercase());
317 let _ = writeln!(
318 html,
319 r#"<tr><td class="{}">{}</td><td>{}</td><td>{:.1}%</td></tr>"#,
320 class, status, count, pct
321 );
322 }
323 html.push_str("</table>\n\n");
324
325 if !tag_counts.is_empty() {
327 html.push_str("<h2>Top Tags</h2>\n<p>");
328 let mut tags: Vec<_> = tag_counts.iter().collect();
329 tags.sort_by(|a, b| b.1.cmp(a.1));
330 for (tag, count) in tags.iter().take(15) {
331 let _ = write!(html, r#"<span class="tag">{} ({})</span> "#, tag, count);
332 }
333 html.push_str("</p>\n\n");
334 }
335
336 if timeline && !monthly_counts.is_empty() {
338 html.push_str("<h2>Timeline</h2>\n<table>\n<tr><th>Month</th><th>ADRs Created</th></tr>\n");
339 let mut months: Vec<_> = monthly_counts.iter().collect();
340 months.sort_by(|a, b| a.0.cmp(b.0));
341 for (month, count) in months {
342 let _ = writeln!(html, "<tr><td>{}</td><td>{}</td></tr>", month, count);
343 }
344 html.push_str("</table>\n\n");
345 }
346
347 if detailed {
349 html.push_str("<h2>ADR List</h2>\n<table>\n<tr><th>ID</th><th>Title</th><th>Status</th><th>Date</th></tr>\n");
350 for adr in adrs {
351 let date = adr
352 .frontmatter
353 .date
354 .as_ref()
355 .map_or_else(|| "-".to_string(), |d| d.0.format("%Y-%m-%d").to_string());
356 let class = format!(
357 "status-{}",
358 adr.frontmatter.status.to_string().to_lowercase()
359 );
360 let _ = writeln!(
361 html,
362 r#"<tr><td>{}</td><td>{}</td><td class="{}">{}</td><td>{}</td></tr>"#,
363 adr.id, adr.frontmatter.title, class, adr.frontmatter.status, date
364 );
365 }
366 html.push_str("</table>\n");
367 }
368
369 html.push_str("</body>\n</html>");
370 html
371}
372
373#[allow(clippy::cast_precision_loss)]
375fn calculate_acceptance_rate(status_counts: &HashMap<AdrStatus, usize>) -> f64 {
376 let accepted = *status_counts.get(&AdrStatus::Accepted).unwrap_or(&0);
377 let total_decided = accepted
378 + status_counts.get(&AdrStatus::Rejected).unwrap_or(&0)
379 + status_counts.get(&AdrStatus::Deprecated).unwrap_or(&0)
380 + status_counts.get(&AdrStatus::Superseded).unwrap_or(&0);
381
382 if total_decided == 0 {
383 100.0
384 } else {
385 (accepted as f64 / total_decided as f64) * 100.0
386 }
387}