1use anyhow::Result;
4use chrono::{Datelike, Utc};
5use clap::Args as ClapArgs;
6use colored::Colorize;
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use crate::core::{ConfigManager, Git, NotesManager};
12
13#[derive(ClapArgs, Debug)]
15pub struct Args {
16 #[arg(long, short)]
18 pub output: Option<String>,
19
20 #[arg(long)]
22 pub include_adrs: bool,
23
24 #[arg(long)]
26 pub pretty: bool,
27}
28
29pub fn run(args: Args) -> Result<()> {
35 let git = Git::new();
36 git.check_repository()?;
37
38 let config = ConfigManager::new(git.clone()).load()?;
39 let notes = NotesManager::new(git, config.clone());
40
41 let adrs = notes.list()?;
42
43 let mut status_counts: HashMap<String, usize> = HashMap::new();
45 let mut tag_counts: HashMap<String, usize> = HashMap::new();
46 let mut monthly_counts: HashMap<String, usize> = HashMap::new();
47 let mut adr_metrics = Vec::new();
48
49 for adr in &adrs {
50 *status_counts
52 .entry(adr.frontmatter.status.to_string())
53 .or_insert(0) += 1;
54
55 for tag in &adr.frontmatter.tags {
57 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
58 }
59
60 if let Some(date) = &adr.frontmatter.date {
62 let month_key = format!("{}-{:02}", date.0.year(), date.0.month());
63 *monthly_counts.entry(month_key).or_insert(0) += 1;
64 }
65
66 if args.include_adrs {
68 adr_metrics.push(serde_json::json!({
69 "id": adr.id,
70 "title": adr.frontmatter.title,
71 "status": adr.frontmatter.status.to_string(),
72 "date": adr.frontmatter.date.as_ref().map(|d| d.0.to_rfc3339()),
73 "tags": adr.frontmatter.tags,
74 "authors": adr.frontmatter.authors,
75 "commit": if adr.commit.is_empty() { None } else { Some(&adr.commit) },
76 }));
77 }
78 }
79
80 let accepted = status_counts.get("accepted").unwrap_or(&0);
82 let rejected = status_counts.get("rejected").unwrap_or(&0);
83 let deprecated = status_counts.get("deprecated").unwrap_or(&0);
84 let superseded = status_counts.get("superseded").unwrap_or(&0);
85
86 let total_decided = accepted + rejected + deprecated + superseded;
87 #[allow(clippy::cast_precision_loss)]
88 let acceptance_rate = if total_decided == 0 {
89 100.0
90 } else {
91 (*accepted as f64 / total_decided as f64) * 100.0
92 };
93
94 #[allow(clippy::cast_precision_loss)]
95 let churn_rate = if adrs.is_empty() {
96 0.0
97 } else {
98 ((deprecated + superseded) as f64 / adrs.len() as f64) * 100.0
99 };
100
101 let mut metrics = serde_json::json!({
103 "metadata": {
104 "generated_at": Utc::now().to_rfc3339(),
105 "tool_version": env!("CARGO_PKG_VERSION"),
106 "prefix": config.prefix,
107 },
108 "summary": {
109 "total_adrs": adrs.len(),
110 "acceptance_rate": format!("{:.1}", acceptance_rate),
111 "churn_rate": format!("{:.1}", churn_rate),
112 },
113 "status_breakdown": status_counts,
114 "tags": {
115 "total_unique": tag_counts.len(),
116 "counts": tag_counts,
117 },
118 "timeline": {
119 "monthly_counts": monthly_counts,
120 "first_adr_date": get_first_date(&adrs),
121 "last_adr_date": get_last_date(&adrs),
122 },
123 });
124
125 if args.include_adrs {
126 metrics["adrs"] = serde_json::json!(adr_metrics);
127 }
128
129 let output = if args.pretty {
130 serde_json::to_string_pretty(&metrics)?
131 } else {
132 serde_json::to_string(&metrics)?
133 };
134
135 if let Some(output_path) = args.output {
136 let path = Path::new(&output_path);
137 if let Some(parent) = path.parent() {
138 if !parent.exists() {
139 fs::create_dir_all(parent)?;
140 }
141 }
142 fs::write(&output_path, &output)?;
143 eprintln!(
144 "{} Metrics exported to: {}",
145 "✓".green(),
146 output_path.cyan()
147 );
148 } else {
149 println!("{output}");
150 }
151
152 Ok(())
153}
154
155fn get_first_date(adrs: &[crate::core::Adr]) -> Option<String> {
157 adrs.iter()
158 .filter_map(|a| a.frontmatter.date.as_ref())
159 .min_by_key(|d| d.0)
160 .map(|d| d.0.to_rfc3339())
161}
162
163fn get_last_date(adrs: &[crate::core::Adr]) -> Option<String> {
165 adrs.iter()
166 .filter_map(|a| a.frontmatter.date.as_ref())
167 .max_by_key(|d| d.0)
168 .map(|d| d.0.to_rfc3339())
169}