Skip to main content

rippy_cli/
stats.rs

1//! The `rippy stats` command — query decision tracking data.
2
3use std::path::PathBuf;
4use std::process::ExitCode;
5
6use serde::Serialize;
7
8use crate::cli::StatsArgs;
9use crate::error::RippyError;
10use crate::tracking;
11
12/// Run the `rippy stats` command.
13///
14/// # Errors
15///
16/// Returns `RippyError::Tracking` if the database cannot be opened or queried.
17pub fn run(args: &StatsArgs) -> Result<ExitCode, RippyError> {
18    let db_path = resolve_db_path(args)?;
19    let conn = tracking::open_db(&db_path)?;
20
21    let since_modifier = if let Some(since_str) = &args.since {
22        Some(tracking::parse_duration(since_str).ok_or_else(|| {
23            RippyError::Tracking(format!(
24                "invalid duration: {since_str}. Use format like 7d, 1h, 30m"
25            ))
26        })?)
27    } else {
28        None
29    };
30
31    let counts = tracking::query_counts(&conn, since_modifier.as_deref())?;
32    let top_asked = tracking::query_top_commands(&conn, "ask", since_modifier.as_deref(), 5)?;
33    let top_denied = tracking::query_top_commands(&conn, "deny", since_modifier.as_deref(), 5)?;
34
35    let output = StatsOutput {
36        db_path: db_path.display().to_string(),
37        since: args.since.clone(),
38        counts,
39        top_asked,
40        top_denied,
41    };
42
43    if args.json {
44        let json = serde_json::to_string_pretty(&output)
45            .map_err(|e| RippyError::Tracking(format!("JSON serialization failed: {e}")))?;
46        println!("{json}");
47    } else {
48        print_stats_text(&output);
49    }
50
51    Ok(ExitCode::SUCCESS)
52}
53
54fn resolve_db_path(args: &StatsArgs) -> Result<PathBuf, RippyError> {
55    tracking::resolve_db_path(args.db.as_deref())
56}
57
58fn print_stats_text(output: &StatsOutput) {
59    println!("Tracking: {}", output.db_path);
60    if let Some(since) = &output.since {
61        println!("Period: last {since}");
62    }
63    println!();
64    println!("Decisions: {} total", output.counts.total);
65    print_count_line("  Allow", output.counts.allow, output.counts.total);
66    print_count_line("  Ask", output.counts.ask, output.counts.total);
67    print_count_line("  Deny", output.counts.deny, output.counts.total);
68
69    if !output.top_asked.is_empty() {
70        println!("\nTop asked commands:");
71        for (cmd, count) in &output.top_asked {
72            println!("  {cmd:<40} {count} times");
73        }
74    }
75
76    if !output.top_denied.is_empty() {
77        println!("\nTop denied commands:");
78        for (cmd, count) in &output.top_denied {
79            println!("  {cmd:<40} {count} times");
80        }
81    }
82}
83
84fn print_count_line(label: &str, count: i64, total: i64) {
85    if total > 0 {
86        #[allow(clippy::cast_precision_loss)]
87        let pct = (count as f64 / total as f64) * 100.0;
88        println!("{label:<8} {count:>6} ({pct:.1}%)");
89    } else {
90        println!("{label:<8} {count:>6}");
91    }
92}
93
94#[derive(Debug, Serialize)]
95struct StatsOutput {
96    db_path: String,
97    since: Option<String>,
98    counts: tracking::DecisionCounts,
99    top_asked: Vec<(String, i64)>,
100    top_denied: Vec<(String, i64)>,
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used)]
105mod tests {
106    use super::*;
107    use crate::mode::Mode;
108    use crate::verdict::Decision;
109
110    fn populate_db(conn: &rusqlite::Connection) {
111        let entry = tracking::TrackingEntry {
112            session_id: None,
113            mode: Mode::Claude,
114            tool_name: "Bash",
115            command: Some("git status"),
116            decision: Decision::Allow,
117            reason: "safe",
118            payload_json: None,
119        };
120        for _ in 0..10 {
121            tracking::record_decision(conn, &entry).unwrap();
122        }
123        for _ in 0..5 {
124            tracking::record_decision(
125                conn,
126                &tracking::TrackingEntry {
127                    decision: Decision::Ask,
128                    command: Some("git push"),
129                    reason: "review",
130                    ..entry
131                },
132            )
133            .unwrap();
134        }
135        for _ in 0..2 {
136            tracking::record_decision(
137                conn,
138                &tracking::TrackingEntry {
139                    decision: Decision::Deny,
140                    command: Some("rm -rf /"),
141                    reason: "dangerous",
142                    ..entry
143                },
144            )
145            .unwrap();
146        }
147    }
148
149    #[test]
150    fn stats_output_from_populated_db() {
151        let dir = tempfile::TempDir::new().unwrap();
152        let db_path = dir.path().join("test.db");
153        let conn = tracking::open_db(&db_path).unwrap();
154        populate_db(&conn);
155
156        let counts = tracking::query_counts(&conn, None).unwrap();
157        assert_eq!(counts.total, 17);
158        assert_eq!(counts.allow, 10);
159        assert_eq!(counts.ask, 5);
160        assert_eq!(counts.deny, 2);
161
162        let top_asked = tracking::query_top_commands(&conn, "ask", None, 5).unwrap();
163        assert_eq!(top_asked.len(), 1);
164        assert_eq!(top_asked[0].0, "git push");
165
166        let top_denied = tracking::query_top_commands(&conn, "deny", None, 5).unwrap();
167        assert_eq!(top_denied.len(), 1);
168        assert_eq!(top_denied[0].0, "rm -rf /");
169    }
170
171    #[test]
172    fn stats_json_serializes() {
173        let output = StatsOutput {
174            db_path: "/tmp/test.db".to_string(),
175            since: Some("7d".to_string()),
176            counts: tracking::DecisionCounts {
177                total: 100,
178                allow: 70,
179                ask: 25,
180                deny: 5,
181            },
182            top_asked: vec![("git push".to_string(), 20)],
183            top_denied: vec![("rm -rf /".to_string(), 3)],
184        };
185        let json = serde_json::to_string(&output).unwrap();
186        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
187        assert_eq!(parsed["counts"]["total"], 100);
188    }
189}