1use anyhow::{Context, Result};
4use chrono::Utc;
5use rusqlite::{params, Connection};
6use std::path::PathBuf;
7
8#[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
20pub fn init_database(db_path: &PathBuf) -> Result<Connection> {
22 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 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 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
63pub 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#[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
92pub 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 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
142pub 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 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 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 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_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 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_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 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_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 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 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 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}