1use std::path::Path;
4
5use rusqlite::Connection;
6
7use crate::error::RippyError;
8use crate::mode::Mode;
9use crate::verdict::Decision;
10
11pub struct TrackingEntry<'a> {
13 pub session_id: Option<&'a str>,
14 pub mode: Mode,
15 pub tool_name: &'a str,
16 pub command: Option<&'a str>,
17 pub decision: Decision,
18 pub reason: &'a str,
19 pub payload_json: Option<&'a str>,
20}
21
22pub fn open_db(path: &Path) -> Result<Connection, RippyError> {
29 if let Some(parent) = path.parent() {
30 std::fs::create_dir_all(parent).map_err(|e| {
31 RippyError::Tracking(format!(
32 "could not create directory {}: {e}",
33 parent.display()
34 ))
35 })?;
36 }
37
38 let conn = Connection::open(path)
39 .map_err(|e| RippyError::Tracking(format!("could not open {}: {e}", path.display())))?;
40
41 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
43 .map_err(|e| RippyError::Tracking(format!("could not set pragmas: {e}")))?;
44
45 ensure_schema(&conn)?;
46 Ok(conn)
47}
48
49fn ensure_schema(conn: &Connection) -> Result<(), RippyError> {
50 conn.execute_batch(
51 "CREATE TABLE IF NOT EXISTS decisions (
52 id INTEGER PRIMARY KEY,
53 timestamp TEXT NOT NULL DEFAULT (datetime('now')),
54 session_id TEXT,
55 mode TEXT,
56 tool_name TEXT NOT NULL,
57 command TEXT,
58 decision TEXT NOT NULL,
59 reason TEXT,
60 payload_json TEXT
61 );
62 CREATE INDEX IF NOT EXISTS idx_decisions_timestamp ON decisions(timestamp);
63 CREATE INDEX IF NOT EXISTS idx_decisions_decision ON decisions(decision);
64 CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
65 INSERT OR IGNORE INTO schema_version (rowid, version) VALUES (1, 1);",
66 )
67 .map_err(|e| RippyError::Tracking(format!("could not create schema: {e}")))
68}
69
70pub fn record_decision(conn: &Connection, entry: &TrackingEntry) -> Result<(), RippyError> {
76 conn.execute(
77 "INSERT INTO decisions (session_id, mode, tool_name, command, decision, reason, payload_json)
78 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
79 rusqlite::params![
80 entry.session_id,
81 mode_str(entry.mode),
82 entry.tool_name,
83 entry.command,
84 entry.decision.as_str(),
85 entry.reason,
86 entry.payload_json,
87 ],
88 )
89 .map_err(|e| RippyError::Tracking(format!("could not insert decision: {e}")))?;
90 Ok(())
91}
92
93pub fn record(db_path: &Path, entry: &TrackingEntry) {
97 if let Err(e) = try_record(db_path, entry) {
98 eprintln!("[rippy] tracking error: {e}");
99 }
100}
101
102fn try_record(db_path: &Path, entry: &TrackingEntry) -> Result<(), RippyError> {
103 let conn = open_db(db_path)?;
104 record_decision(&conn, entry)
105}
106
107const fn mode_str(mode: Mode) -> &'static str {
108 match mode {
109 Mode::Claude => "claude",
110 Mode::Gemini => "gemini",
111 Mode::Cursor => "cursor",
112 Mode::Codex => "codex",
113 }
114}
115
116pub fn query_counts(conn: &Connection, since: Option<&str>) -> Result<DecisionCounts, RippyError> {
122 let mut counts = DecisionCounts::default();
123
124 if let Some(duration) = since {
125 let modifier = format!("-{duration}");
126 let mut stmt = conn
127 .prepare(
128 "SELECT decision, COUNT(*) FROM decisions \
129 WHERE timestamp >= datetime('now', ?1) GROUP BY decision",
130 )
131 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
132 collect_counts(&mut stmt, rusqlite::params![modifier], &mut counts)?;
133 } else {
134 let mut stmt = conn
135 .prepare("SELECT decision, COUNT(*) FROM decisions GROUP BY decision")
136 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
137 collect_counts(&mut stmt, [], &mut counts)?;
138 }
139
140 counts.total = counts.allow + counts.ask + counts.deny;
141 Ok(counts)
142}
143
144fn collect_counts(
145 stmt: &mut rusqlite::Statement<'_>,
146 params: impl rusqlite::Params,
147 counts: &mut DecisionCounts,
148) -> Result<(), RippyError> {
149 let rows = stmt
150 .query_map(params, |row| {
151 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
152 })
153 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
154
155 for row in rows {
156 let (decision, count) = row.map_err(|e| RippyError::Tracking(format!("{e}")))?;
157 match decision.as_str() {
158 "allow" => counts.allow = count,
159 "ask" => counts.ask = count,
160 "deny" => counts.deny = count,
161 _ => {}
162 }
163 }
164 Ok(())
165}
166
167pub fn query_top_commands(
173 conn: &Connection,
174 decision_filter: &str,
175 since: Option<&str>,
176 limit: usize,
177) -> Result<Vec<(String, i64)>, RippyError> {
178 if let Some(duration) = since {
179 let modifier = format!("-{duration}");
180 let mut stmt = conn
181 .prepare(
182 "SELECT command, COUNT(*) as cnt FROM decisions \
183 WHERE timestamp >= datetime('now', ?1) AND decision = ?2 \
184 AND command IS NOT NULL \
185 GROUP BY command ORDER BY cnt DESC LIMIT ?3",
186 )
187 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
188 collect_top(
189 &mut stmt,
190 rusqlite::params![modifier, decision_filter, limit],
191 )
192 } else {
193 let mut stmt = conn
194 .prepare(
195 "SELECT command, COUNT(*) as cnt FROM decisions \
196 WHERE decision = ?1 AND command IS NOT NULL \
197 GROUP BY command ORDER BY cnt DESC LIMIT ?2",
198 )
199 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
200 collect_top(&mut stmt, rusqlite::params![decision_filter, limit])
201 }
202}
203
204fn collect_top(
205 stmt: &mut rusqlite::Statement<'_>,
206 params: impl rusqlite::Params,
207) -> Result<Vec<(String, i64)>, RippyError> {
208 let rows = stmt
209 .query_map(params, |row| {
210 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
211 })
212 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
213
214 rows.map(|r| r.map_err(|e| RippyError::Tracking(format!("{e}"))))
215 .collect()
216}
217
218#[must_use]
222pub fn parse_duration(input: &str) -> Option<String> {
223 let input = input.trim();
224 if input.len() < 2 {
225 return None;
226 }
227 let (num_str, unit) = input.split_at(input.len() - 1);
228 let num: u64 = num_str.parse().ok()?;
229 let sqlite_unit = match unit {
230 "s" => "seconds",
231 "m" => "minutes",
232 "h" => "hours",
233 "d" => "days",
234 _ => return None,
235 };
236 Some(format!("{num} {sqlite_unit}"))
237}
238
239pub fn resolve_db_path(
245 explicit: Option<&std::path::Path>,
246) -> Result<std::path::PathBuf, RippyError> {
247 if let Some(db) = explicit {
248 return Ok(db.to_path_buf());
249 }
250 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
251 let cfg = crate::config::Config::load(&cwd, None)?;
252 cfg.tracking_db.ok_or_else(|| {
253 RippyError::Tracking(
254 "no tracking database configured. Enable with `set tracking on` in \
255 .rippy config, or use --db <path>"
256 .to_string(),
257 )
258 })
259}
260
261#[derive(Debug, Default, serde::Serialize)]
263pub struct DecisionCounts {
264 pub total: i64,
265 pub allow: i64,
266 pub ask: i64,
267 pub deny: i64,
268}
269
270#[derive(Debug, Clone, serde::Serialize)]
272pub struct CommandBreakdown {
273 pub command: String,
274 pub allow_count: i64,
275 pub ask_count: i64,
276 pub deny_count: i64,
277}
278
279pub fn query_command_breakdown(
287 conn: &Connection,
288 since: Option<&str>,
289) -> Result<Vec<CommandBreakdown>, RippyError> {
290 let base_query = "SELECT command, decision, COUNT(*) FROM decisions \
291 WHERE command IS NOT NULL";
292
293 if let Some(duration) = since {
294 let modifier = format!("-{duration}");
295 let mut stmt = conn
296 .prepare(&format!(
297 "{base_query} AND timestamp >= datetime('now', ?1) \
298 GROUP BY command, decision"
299 ))
300 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
301 collect_breakdown(&mut stmt, rusqlite::params![modifier])
302 } else {
303 let mut stmt = conn
304 .prepare(&format!("{base_query} GROUP BY command, decision"))
305 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
306 collect_breakdown(&mut stmt, [])
307 }
308}
309
310fn collect_breakdown(
311 stmt: &mut rusqlite::Statement<'_>,
312 params: impl rusqlite::Params,
313) -> Result<Vec<CommandBreakdown>, RippyError> {
314 let rows = stmt
315 .query_map(params, |row| {
316 Ok((
317 row.get::<_, String>(0)?,
318 row.get::<_, String>(1)?,
319 row.get::<_, i64>(2)?,
320 ))
321 })
322 .map_err(|e| RippyError::Tracking(format!("query failed: {e}")))?;
323
324 let mut map: std::collections::HashMap<String, CommandBreakdown> =
326 std::collections::HashMap::new();
327 for row in rows {
328 let (command, decision, count) = row.map_err(|e| RippyError::Tracking(format!("{e}")))?;
329 let entry = map
330 .entry(command.clone())
331 .or_insert_with(|| CommandBreakdown {
332 command,
333 allow_count: 0,
334 ask_count: 0,
335 deny_count: 0,
336 });
337 match decision.as_str() {
338 "allow" => entry.allow_count = count,
339 "ask" => entry.ask_count = count,
340 "deny" => entry.deny_count = count,
341 _ => {}
342 }
343 }
344
345 let mut result: Vec<CommandBreakdown> = map.into_values().collect();
346 result.sort_by(|a, b| {
347 let total_b = b.allow_count + b.ask_count + b.deny_count;
348 let total_a = a.allow_count + a.ask_count + a.deny_count;
349 total_b
350 .cmp(&total_a)
351 .then_with(|| a.command.cmp(&b.command))
352 });
353 Ok(result)
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used)]
358mod tests {
359 use super::*;
360
361 fn in_memory_db() -> Connection {
362 let conn = Connection::open_in_memory().unwrap();
363 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
364 .unwrap();
365 ensure_schema(&conn).unwrap();
366 conn
367 }
368
369 fn sample_entry() -> TrackingEntry<'static> {
370 TrackingEntry {
371 session_id: Some("test-session"),
372 mode: Mode::Claude,
373 tool_name: "Bash",
374 command: Some("git status"),
375 decision: Decision::Allow,
376 reason: "safe command",
377 payload_json: None,
378 }
379 }
380
381 #[test]
382 fn record_and_query_counts() {
383 let conn = in_memory_db();
384 record_decision(&conn, &sample_entry()).unwrap();
385 record_decision(
386 &conn,
387 &TrackingEntry {
388 decision: Decision::Ask,
389 command: Some("git push"),
390 reason: "needs review",
391 ..sample_entry()
392 },
393 )
394 .unwrap();
395 record_decision(
396 &conn,
397 &TrackingEntry {
398 decision: Decision::Deny,
399 command: Some("rm -rf /"),
400 reason: "dangerous",
401 ..sample_entry()
402 },
403 )
404 .unwrap();
405
406 let counts = query_counts(&conn, None).unwrap();
407 assert_eq!(counts.total, 3);
408 assert_eq!(counts.allow, 1);
409 assert_eq!(counts.ask, 1);
410 assert_eq!(counts.deny, 1);
411 }
412
413 #[test]
414 fn query_top_commands() {
415 let conn = in_memory_db();
416 for _ in 0..5 {
417 record_decision(
418 &conn,
419 &TrackingEntry {
420 decision: Decision::Ask,
421 command: Some("git push"),
422 reason: "review",
423 ..sample_entry()
424 },
425 )
426 .unwrap();
427 }
428 for _ in 0..3 {
429 record_decision(
430 &conn,
431 &TrackingEntry {
432 decision: Decision::Ask,
433 command: Some("npm install"),
434 reason: "review",
435 ..sample_entry()
436 },
437 )
438 .unwrap();
439 }
440
441 let top = super::query_top_commands(&conn, "ask", None, 5).unwrap();
442 assert_eq!(top.len(), 2);
443 assert_eq!(top[0].0, "git push");
444 assert_eq!(top[0].1, 5);
445 assert_eq!(top[1].0, "npm install");
446 assert_eq!(top[1].1, 3);
447 }
448
449 #[test]
450 fn open_db_creates_file() {
451 let dir = tempfile::TempDir::new().unwrap();
452 let db_path = dir.path().join("sub").join("tracking.db");
453 let conn = open_db(&db_path).unwrap();
454 record_decision(&conn, &sample_entry()).unwrap();
455 assert!(db_path.exists());
456 }
457
458 #[test]
459 fn parse_duration_valid() {
460 assert_eq!(parse_duration("7d"), Some("7 days".to_string()));
461 assert_eq!(parse_duration("1h"), Some("1 hours".to_string()));
462 assert_eq!(parse_duration("30m"), Some("30 minutes".to_string()));
463 assert_eq!(parse_duration("60s"), Some("60 seconds".to_string()));
464 }
465
466 #[test]
467 fn parse_duration_invalid() {
468 assert_eq!(parse_duration(""), None);
469 assert_eq!(parse_duration("d"), None);
470 assert_eq!(parse_duration("abc"), None);
471 assert_eq!(parse_duration("7x"), None);
472 }
473
474 #[test]
475 fn schema_version_recorded() {
476 let conn = in_memory_db();
477 let version: i32 = conn
478 .query_row("SELECT version FROM schema_version", [], |row| row.get(0))
479 .unwrap();
480 assert_eq!(version, 1);
481 }
482
483 #[test]
484 fn null_fields_handled() {
485 let conn = in_memory_db();
486 record_decision(
487 &conn,
488 &TrackingEntry {
489 session_id: None,
490 command: None,
491 payload_json: None,
492 ..sample_entry()
493 },
494 )
495 .unwrap();
496 let counts = query_counts(&conn, None).unwrap();
497 assert_eq!(counts.total, 1);
498 }
499
500 #[test]
501 fn query_command_breakdown_groups_by_decision() {
502 let conn = in_memory_db();
503 for _ in 0..10 {
504 record_decision(&conn, &sample_entry()).unwrap(); }
506 for _ in 0..5 {
507 record_decision(
508 &conn,
509 &TrackingEntry {
510 decision: Decision::Ask,
511 command: Some("git push"),
512 reason: "review",
513 ..sample_entry()
514 },
515 )
516 .unwrap();
517 }
518 for _ in 0..2 {
519 record_decision(
520 &conn,
521 &TrackingEntry {
522 decision: Decision::Allow,
523 command: Some("git push"),
524 reason: "ok",
525 ..sample_entry()
526 },
527 )
528 .unwrap();
529 }
530
531 let breakdown = super::query_command_breakdown(&conn, None).unwrap();
532 assert_eq!(breakdown.len(), 2);
533
534 assert_eq!(breakdown[0].command, "git status");
536 assert_eq!(breakdown[0].allow_count, 10);
537 assert_eq!(breakdown[0].ask_count, 0);
538
539 assert_eq!(breakdown[1].command, "git push");
540 assert_eq!(breakdown[1].allow_count, 2);
541 assert_eq!(breakdown[1].ask_count, 5);
542 }
543
544 #[test]
545 fn idempotent_schema() {
546 let conn = in_memory_db();
547 ensure_schema(&conn).unwrap();
549 record_decision(&conn, &sample_entry()).unwrap();
550 }
551}