Skip to main content

rippy_cli/
tracking.rs

1//! SQLite-based decision tracking for auditing and analysis.
2
3use std::path::Path;
4
5use rusqlite::Connection;
6
7use crate::error::RippyError;
8use crate::mode::Mode;
9use crate::verdict::Decision;
10
11/// A decision entry to record in the tracking database.
12pub 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
22/// Open (or create) the tracking database and ensure the schema exists.
23///
24/// # Errors
25///
26/// Returns `RippyError::Tracking` if the database cannot be opened or
27/// the schema cannot be created.
28pub 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    // WAL mode for concurrent reads during writes, NORMAL sync for speed.
42    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
70/// Record a single decision in the tracking database.
71///
72/// # Errors
73///
74/// Returns `RippyError::Tracking` if the insert fails.
75pub 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
93/// Record a decision, logging errors to stderr instead of propagating.
94///
95/// This is the hook-path entry point — it must never block or fail the hook.
96pub 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
116/// Query aggregate decision counts from the tracking database.
117///
118/// # Errors
119///
120/// Returns `RippyError::Tracking` if the database cannot be queried.
121pub 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
167/// Query top commands by decision type.
168///
169/// # Errors
170///
171/// Returns `RippyError::Tracking` if the database cannot be queried.
172pub 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/// Parse a duration string like `7d`, `30d`, `1h`, `30m` into a `SQLite` modifier format.
219///
220/// Returns `None` if the format is invalid.
221#[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
239/// Resolve the tracking database path from an explicit override or config.
240///
241/// # Errors
242///
243/// Returns `RippyError::Tracking` if no database is configured.
244pub 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/// Aggregate decision counts.
262#[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/// Per-command decision breakdown for suggestion analysis.
271#[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
279/// Query per-command decision breakdowns from the tracking database.
280///
281/// Returns one entry per unique command string with counts for each decision type.
282///
283/// # Errors
284///
285/// Returns `RippyError::Tracking` if the database cannot be queried.
286pub 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    // Pivot: group (command, decision, count) rows into per-command breakdowns.
325    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(); // git status, allow
505        }
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        // Sorted by total descending: git status (10) > git push (7)
535        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        // Second call should not error.
548        ensure_schema(&conn).unwrap();
549        record_decision(&conn, &sample_entry()).unwrap();
550    }
551}