git_adr/cli/
report.rs

1//! Generate ADR analytics reports.
2
3use 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/// Arguments for the report command.
15#[derive(ClapArgs, Debug)]
16pub struct Args {
17    /// Output format (markdown, html, json).
18    #[arg(long, short, default_value = "markdown")]
19    pub format: String,
20
21    /// Output file (stdout if not specified).
22    #[arg(long, short)]
23    pub output: Option<String>,
24
25    /// Include detailed status breakdown.
26    #[arg(long)]
27    pub detailed: bool,
28
29    /// Include timeline analysis.
30    #[arg(long)]
31    pub timeline: bool,
32}
33
34/// Run the report command.
35///
36/// # Errors
37///
38/// Returns an error if report generation fails.
39pub 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    // Collect statistics
57    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        // Count by status
63        *status_counts
64            .entry(adr.frontmatter.status.clone())
65            .or_insert(0) += 1;
66
67        // Count by tags
68        for tag in &adr.frontmatter.tags {
69            *tag_counts.entry(tag.clone()).or_insert(0) += 1;
70        }
71
72        // Count by month
73        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
115/// Generate JSON report.
116fn 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/// Generate markdown report.
140#[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    // Summary
159    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    // Status breakdown
168    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    // Top tags
192    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    // Timeline
204    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    // Detailed list
218    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/// Generate HTML report.
241#[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    // Summary stats
288    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    // Status breakdown
302    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    // Tags
326    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    // Timeline
337    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    // Detailed list
348    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/// Calculate acceptance rate.
374#[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}