Skip to main content

chronicle/cli/
status.rs

1use crate::error::Result;
2use crate::git::{CliOps, GitOps};
3
4#[derive(serde::Serialize)]
5pub struct StatusOutput {
6    pub total_annotations: usize,
7    pub recent_commits: usize,
8    pub recent_annotated: usize,
9    pub coverage_pct: f64,
10    pub unannotated_commits: Vec<String>,
11}
12
13/// Build status data from a GitOps instance. Separated from `run()` so the web
14/// API can call it directly without printing.
15pub fn build_status(git_ops: &dyn GitOps) -> Result<StatusOutput> {
16    let annotated =
17        git_ops
18            .list_annotated_commits(10000)
19            .map_err(|e| crate::error::ChronicleError::Git {
20                source: e,
21                location: snafu::Location::default(),
22            })?;
23    let annotated_set: std::collections::HashSet<_> = annotated.iter().collect();
24
25    let recent_shas = get_recent_commits_dyn(git_ops, 20)?;
26    let recent_count = recent_shas.len();
27
28    let mut annotated_count = 0;
29    let mut unannotated = Vec::new();
30    for sha in &recent_shas {
31        if annotated_set.contains(sha) {
32            annotated_count += 1;
33        } else {
34            unannotated.push(sha.clone());
35        }
36    }
37
38    let coverage = if recent_count > 0 {
39        (annotated_count as f64 / recent_count as f64) * 100.0
40    } else {
41        0.0
42    };
43
44    Ok(StatusOutput {
45        total_annotations: annotated.len(),
46        recent_commits: recent_count,
47        recent_annotated: annotated_count,
48        coverage_pct: (coverage * 10.0).round() / 10.0,
49        unannotated_commits: unannotated,
50    })
51}
52
53pub fn run(format: String) -> Result<()> {
54    let _ = format; // reserved for future pretty-print support
55    let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
56        source: e,
57        location: snafu::Location::default(),
58    })?;
59    let git_ops = CliOps::new(repo_dir);
60
61    let output = build_status(&git_ops)?;
62
63    let json =
64        serde_json::to_string_pretty(&output).map_err(|e| crate::error::ChronicleError::Json {
65            source: e,
66            location: snafu::Location::default(),
67        })?;
68    println!("{json}");
69
70    Ok(())
71}
72
73/// Get recent commits using the git log command. Works with any GitOps via
74/// resolve_ref to find HEAD then walking back, but for simplicity we shell out.
75fn get_recent_commits_dyn(git_ops: &dyn GitOps, count: usize) -> Result<Vec<String>> {
76    // We need the repo_dir from CliOps. Since this is only called from CLI
77    // contexts where we have CliOps, use the resolve_ref approach to get HEAD
78    // then use git log. For the generic case, we need to shell out.
79    // Since GitOps doesn't expose a "log recent N" method, we'll get the
80    // repo dir from the current directory (same as how run() works).
81    let repo_dir = std::env::current_dir().map_err(|e| crate::error::ChronicleError::Io {
82        source: e,
83        location: snafu::Location::default(),
84    })?;
85
86    // Verify the git ops is working by resolving HEAD
87    let _head = git_ops
88        .resolve_ref("HEAD")
89        .map_err(|e| crate::error::ChronicleError::Git {
90            source: e,
91            location: snafu::Location::default(),
92        })?;
93
94    let output = std::process::Command::new("git")
95        .args(["log", "--format=%H", &format!("-{count}")])
96        .current_dir(&repo_dir)
97        .output()
98        .map_err(|e| crate::error::ChronicleError::Io {
99            source: e,
100            location: snafu::Location::default(),
101        })?;
102
103    let stdout = String::from_utf8_lossy(&output.stdout);
104    Ok(stdout
105        .lines()
106        .filter(|l| !l.is_empty())
107        .map(|s| s.to_string())
108        .collect())
109}