1use std::path::PathBuf;
4use std::process::ExitCode;
5
6use serde::Serialize;
7
8use crate::cli::StatsArgs;
9use crate::error::RippyError;
10use crate::tracking;
11
12pub 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}