git_adr/cli/
metrics.rs

1//! Export ADR metrics in JSON format.
2
3use 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/// Arguments for the metrics command.
14#[derive(ClapArgs, Debug)]
15pub struct Args {
16    /// Output file (stdout if not specified).
17    #[arg(long, short)]
18    pub output: Option<String>,
19
20    /// Include individual ADR metrics.
21    #[arg(long)]
22    pub include_adrs: bool,
23
24    /// Pretty print JSON output.
25    #[arg(long)]
26    pub pretty: bool,
27}
28
29/// Run the metrics command.
30///
31/// # Errors
32///
33/// Returns an error if metrics export fails.
34pub 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    // Collect metrics
44    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        // Count by status
51        *status_counts
52            .entry(adr.frontmatter.status.to_string())
53            .or_insert(0) += 1;
54
55        // Count by tags
56        for tag in &adr.frontmatter.tags {
57            *tag_counts.entry(tag.clone()).or_insert(0) += 1;
58        }
59
60        // Count by month
61        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        // Individual ADR metrics
67        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    // Calculate derived metrics
81    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    // Build metrics JSON
102    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
155/// Get the earliest ADR date.
156fn 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
163/// Get the latest ADR date.
164fn 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}