Skip to main content

claude_hook_advisor/
history.rs

1//! Command history tracking with SQLite
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5use rusqlite::{params, Connection};
6use std::path::PathBuf;
7
8/// Represents a single command execution in history
9#[derive(Debug, Clone)]
10pub struct CommandRecord {
11    pub timestamp: String,
12    pub session_id: String,
13    pub command: String,
14    pub exit_code: Option<i32>,
15    pub cwd: Option<String>,
16    pub was_replaced: bool,
17    pub original_command: Option<String>,
18}
19
20/// Initialize the command history database
21pub fn init_database(db_path: &PathBuf) -> Result<Connection> {
22    // Create parent directory if it doesn't exist
23    if let Some(parent) = db_path.parent() {
24        std::fs::create_dir_all(parent)
25            .context("Failed to create history database directory")?;
26    }
27
28    let conn = Connection::open(db_path)
29        .context("Failed to open history database")?;
30
31    // Create table if it doesn't exist
32    conn.execute(
33        "CREATE TABLE IF NOT EXISTS commands (
34            id INTEGER PRIMARY KEY AUTOINCREMENT,
35            timestamp TEXT NOT NULL,
36            session_id TEXT NOT NULL,
37            command TEXT NOT NULL,
38            exit_code INTEGER,
39            cwd TEXT,
40            was_replaced INTEGER NOT NULL DEFAULT 0,
41            original_command TEXT
42        )",
43        [],
44    )
45    .context("Failed to create commands table")?;
46
47    // Create indexes for common queries
48    conn.execute(
49        "CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
50        [],
51    )
52    .context("Failed to create timestamp index")?;
53
54    conn.execute(
55        "CREATE INDEX IF NOT EXISTS idx_session ON commands(session_id)",
56        [],
57    )
58    .context("Failed to create session_id index")?;
59
60    Ok(conn)
61}
62
63/// Log a command to the history database
64pub fn log_command(conn: &Connection, record: &CommandRecord) -> Result<()> {
65    conn.execute(
66        "INSERT INTO commands (timestamp, session_id, command, exit_code, cwd, was_replaced, original_command)
67         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
68        params![
69            record.timestamp,
70            record.session_id,
71            record.command,
72            record.exit_code,
73            record.cwd,
74            record.was_replaced as i32,
75            record.original_command,
76        ],
77    )
78    .context("Failed to insert command into history")?;
79
80    Ok(())
81}
82
83/// Query options for retrieving command history
84#[derive(Debug, Default)]
85pub struct HistoryQuery {
86    pub limit: Option<usize>,
87    pub session_id: Option<String>,
88    pub failures_only: bool,
89    pub command_pattern: Option<String>,
90}
91
92/// Retrieve command history with optional filters
93pub fn query_history(conn: &Connection, query: &HistoryQuery) -> Result<Vec<CommandRecord>> {
94    let mut sql = String::from(
95        "SELECT timestamp, session_id, command, exit_code, cwd, was_replaced, original_command
96         FROM commands WHERE 1=1"
97    );
98
99    // Build query based on filters
100    if let Some(ref session_id) = query.session_id {
101        sql.push_str(&format!(" AND session_id = '{}'", session_id));
102    }
103
104    if query.failures_only {
105        sql.push_str(" AND exit_code != 0");
106    }
107
108    if let Some(ref pattern) = query.command_pattern {
109        sql.push_str(&format!(" AND command LIKE '%{}%'", pattern));
110    }
111
112    sql.push_str(" ORDER BY timestamp DESC");
113
114    if let Some(limit) = query.limit {
115        sql.push_str(&format!(" LIMIT {}", limit));
116    }
117
118    let mut stmt = conn.prepare(&sql)
119        .context("Failed to prepare query")?;
120
121    let records = stmt.query_map([], |row| {
122        Ok(CommandRecord {
123            timestamp: row.get(0)?,
124            session_id: row.get(1)?,
125            command: row.get(2)?,
126            exit_code: row.get(3)?,
127            cwd: row.get(4)?,
128            was_replaced: row.get::<_, i32>(5)? != 0,
129            original_command: row.get(6)?,
130        })
131    })
132    .context("Failed to execute query")?;
133
134    let mut results = Vec::new();
135    for record in records {
136        results.push(record.context("Failed to parse command record")?);
137    }
138
139    Ok(results)
140}
141
142/// Create a command record from hook data
143pub fn create_record(
144    session_id: &str,
145    command: &str,
146    exit_code: Option<i32>,
147    cwd: Option<&str>,
148    was_replaced: bool,
149    original_command: Option<&str>,
150) -> CommandRecord {
151    CommandRecord {
152        timestamp: Utc::now().to_rfc3339(),
153        session_id: session_id.to_string(),
154        command: command.to_string(),
155        exit_code,
156        cwd: cwd.map(|s| s.to_string()),
157        was_replaced,
158        original_command: original_command.map(|s| s.to_string()),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use tempfile::NamedTempFile;
166
167    #[test]
168    fn test_database_initialization() {
169        let temp_file = NamedTempFile::new().unwrap();
170        let db_path = temp_file.path().to_path_buf();
171
172        let conn = init_database(&db_path).unwrap();
173
174        // Verify table exists
175        let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='commands'").unwrap();
176        let exists: bool = stmt.exists([]).unwrap();
177        assert!(exists);
178    }
179
180    #[test]
181    fn test_log_and_query_command() {
182        let temp_file = NamedTempFile::new().unwrap();
183        let db_path = temp_file.path().to_path_buf();
184        let conn = init_database(&db_path).unwrap();
185
186        // Log a command
187        let record = create_record(
188            "test-session",
189            "npm install",
190            Some(0),
191            Some("/home/user/project"),
192            false,
193            None,
194        );
195        log_command(&conn, &record).unwrap();
196
197        // Query it back
198        let query = HistoryQuery {
199            limit: Some(10),
200            ..Default::default()
201        };
202        let results = query_history(&conn, &query).unwrap();
203
204        assert_eq!(results.len(), 1);
205        assert_eq!(results[0].command, "npm install");
206        assert_eq!(results[0].session_id, "test-session");
207        assert_eq!(results[0].exit_code, Some(0));
208    }
209
210    #[test]
211    fn test_query_with_session_filter() {
212        let temp_file = NamedTempFile::new().unwrap();
213        let db_path = temp_file.path().to_path_buf();
214        let conn = init_database(&db_path).unwrap();
215
216        // Log commands from different sessions
217        log_command(&conn, &create_record("session-1", "npm install", Some(0), None, false, None)).unwrap();
218        log_command(&conn, &create_record("session-2", "yarn build", Some(0), None, false, None)).unwrap();
219        log_command(&conn, &create_record("session-1", "npm test", Some(1), None, false, None)).unwrap();
220
221        // Query session-1 only
222        let query = HistoryQuery {
223            session_id: Some("session-1".to_string()),
224            ..Default::default()
225        };
226        let results = query_history(&conn, &query).unwrap();
227
228        assert_eq!(results.len(), 2);
229        assert!(results.iter().all(|r| r.session_id == "session-1"));
230    }
231
232    #[test]
233    fn test_query_failures_only() {
234        let temp_file = NamedTempFile::new().unwrap();
235        let db_path = temp_file.path().to_path_buf();
236        let conn = init_database(&db_path).unwrap();
237
238        // Log successful and failed commands
239        log_command(&conn, &create_record("session-1", "npm install", Some(0), None, false, None)).unwrap();
240        log_command(&conn, &create_record("session-1", "npm test", Some(1), None, false, None)).unwrap();
241        log_command(&conn, &create_record("session-1", "npm build", Some(2), None, false, None)).unwrap();
242
243        // Query failures only
244        let query = HistoryQuery {
245            failures_only: true,
246            ..Default::default()
247        };
248        let results = query_history(&conn, &query).unwrap();
249
250        assert_eq!(results.len(), 2);
251        assert!(results.iter().all(|r| r.exit_code != Some(0)));
252    }
253
254    #[test]
255    fn test_query_with_command_pattern() {
256        let temp_file = NamedTempFile::new().unwrap();
257        let db_path = temp_file.path().to_path_buf();
258        let conn = init_database(&db_path).unwrap();
259
260        // Log various commands
261        log_command(&conn, &create_record("session-1", "git status", Some(0), None, false, None)).unwrap();
262        log_command(&conn, &create_record("session-1", "git commit", Some(0), None, false, None)).unwrap();
263        log_command(&conn, &create_record("session-1", "npm install", Some(0), None, false, None)).unwrap();
264
265        // Query for git commands only
266        let query = HistoryQuery {
267            command_pattern: Some("git".to_string()),
268            ..Default::default()
269        };
270        let results = query_history(&conn, &query).unwrap();
271
272        assert_eq!(results.len(), 2);
273        assert!(results.iter().all(|r| r.command.contains("git")));
274    }
275
276    #[test]
277    fn test_command_replacement_tracking() {
278        let temp_file = NamedTempFile::new().unwrap();
279        let db_path = temp_file.path().to_path_buf();
280        let conn = init_database(&db_path).unwrap();
281
282        // Log a replaced command
283        let record = create_record(
284            "test-session",
285            "bun install",
286            Some(0),
287            None,
288            true,
289            Some("npm install"),
290        );
291        log_command(&conn, &record).unwrap();
292
293        // Query it back
294        let query = HistoryQuery::default();
295        let results = query_history(&conn, &query).unwrap();
296
297        assert_eq!(results.len(), 1);
298        assert_eq!(results[0].command, "bun install");
299        assert!(results[0].was_replaced);
300        assert_eq!(results[0].original_command, Some("npm install".to_string()));
301    }
302}