1use std::fs;
2use std::io::Read;
3use std::path::Path;
4use std::time::UNIX_EPOCH;
5
6use anyhow::{Context, Result, bail};
7use rusqlite::{Connection, OptionalExtension, params};
8
9const MAX_COMMAND_BYTES: usize = 64 * 1024;
10const MAX_HISTORY_BYTES: u64 = 32 * 1024 * 1024;
11const MAX_HISTORY_ENTRIES: usize = 250_000;
12const SCHEMA_VERSION: i64 = 1;
13
14#[derive(Debug)]
15pub struct Store {
16 connection: Connection,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ImportResult {
21 pub imported: usize,
22 pub skipped: bool,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct HistoryCandidate {
27 pub command: String,
28 pub same_cwd: bool,
29 pub uses: usize,
30}
31
32impl Store {
33 pub fn open(path: &Path) -> Result<Self> {
34 let connection = Connection::open(path)
35 .with_context(|| format!("failed to open database {}", path.display()))?;
36 Self::from_connection(connection)
37 }
38
39 pub fn in_memory() -> Result<Self> {
40 Self::from_connection(Connection::open_in_memory()?)
41 }
42
43 fn from_connection(connection: Connection) -> Result<Self> {
44 connection.busy_timeout(std::time::Duration::from_secs(2))?;
45 connection.pragma_update(None, "journal_mode", "WAL")?;
46 connection.pragma_update(None, "foreign_keys", "ON")?;
47 connection.pragma_update(None, "case_sensitive_like", "ON")?;
48 let schema_version: i64 =
49 connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
50 if schema_version > SCHEMA_VERSION {
51 bail!(
52 "database schema {schema_version} is newer than supported schema {SCHEMA_VERSION}"
53 );
54 }
55 if schema_version == 0 {
56 connection.execute_batch(
57 "
58 CREATE TABLE IF NOT EXISTS command_events (
59 id INTEGER PRIMARY KEY,
60 command TEXT NOT NULL,
61 cwd TEXT NOT NULL,
62 observed_at_ms INTEGER NOT NULL,
63 exit_code INTEGER,
64 source TEXT NOT NULL,
65 source_key TEXT,
66 UNIQUE(source, source_key)
67 );
68 CREATE INDEX IF NOT EXISTS command_events_command_idx
69 ON command_events(command);
70 CREATE INDEX IF NOT EXISTS command_events_cwd_idx
71 ON command_events(cwd);
72
73 CREATE TABLE IF NOT EXISTS history_imports (
74 path TEXT PRIMARY KEY,
75 size_bytes INTEGER NOT NULL,
76 modified_ns INTEGER NOT NULL
77 );
78 PRAGMA user_version = 1;
79 ",
80 )?;
81 }
82 Ok(Self { connection })
83 }
84
85 pub fn record(
86 &self,
87 command: &str,
88 cwd: &str,
89 exit_code: i32,
90 observed_at_ms: i64,
91 session_id: &str,
92 ignore_leading_space: bool,
93 ) -> Result<bool> {
94 if !eligible_command(command, ignore_leading_space) {
95 return Ok(false);
96 }
97 self.connection.execute(
98 "INSERT INTO command_events
99 (command, cwd, observed_at_ms, exit_code, source, source_key)
100 VALUES (?1, ?2, ?3, ?4, ?5, NULL)",
101 params![
102 command,
103 cwd,
104 observed_at_ms,
105 exit_code,
106 format!("session:{session_id}")
107 ],
108 )?;
109 Ok(true)
110 }
111
112 pub fn history_candidates(
113 &self,
114 prefix: &str,
115 cwd: &str,
116 limit: usize,
117 successful_first: bool,
118 ) -> Result<Vec<HistoryCandidate>> {
119 let pattern = format!("{}%", escape_like(prefix));
120 let success_order = i64::from(successful_first);
121 let mut statement = self.connection.prepare(
122 "SELECT
123 command,
124 MAX(CASE WHEN cwd = ?2 THEN 1 ELSE 0 END) AS same_cwd,
125 SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) AS successes,
126 MAX(observed_at_ms) AS latest,
127 COUNT(*) AS uses
128 FROM command_events
129 WHERE command LIKE ?1 ESCAPE '\\' AND command <> ?3
130 GROUP BY command
131 ORDER BY
132 same_cwd DESC,
133 CASE WHEN ?5 = 1 AND successes > 0 THEN 1 ELSE 0 END DESC,
134 latest DESC,
135 uses DESC,
136 command ASC
137 LIMIT ?4",
138 )?;
139 let rows = statement.query_map(
140 params![pattern, cwd, prefix, limit as i64, success_order],
141 |row| {
142 Ok(HistoryCandidate {
143 command: row.get(0)?,
144 same_cwd: row.get::<_, i64>(1)? != 0,
145 uses: row.get::<_, i64>(4)?.max(0) as usize,
146 })
147 },
148 )?;
149 rows.collect::<rusqlite::Result<Vec<HistoryCandidate>>>()
150 .context("failed to read history candidates")
151 }
152
153 pub fn history_inventory(
154 &self,
155 cwd: &str,
156 limit: usize,
157 successful_first: bool,
158 ) -> Result<Vec<HistoryCandidate>> {
159 let success_order = i64::from(successful_first);
160 let mut statement = self.connection.prepare(
161 "SELECT
162 command,
163 MAX(CASE WHEN cwd = ?1 THEN 1 ELSE 0 END) AS same_cwd,
164 SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) AS successes,
165 MAX(observed_at_ms) AS latest,
166 COUNT(*) AS uses
167 FROM command_events
168 GROUP BY command
169 ORDER BY
170 same_cwd DESC,
171 CASE WHEN ?3 = 1 AND successes > 0 THEN 1 ELSE 0 END DESC,
172 latest DESC,
173 uses DESC,
174 command ASC
175 LIMIT ?2",
176 )?;
177 let rows = statement.query_map(params![cwd, limit as i64, success_order], |row| {
178 Ok(HistoryCandidate {
179 command: row.get(0)?,
180 same_cwd: row.get::<_, i64>(1)? != 0,
181 uses: row.get::<_, i64>(4)?.max(0) as usize,
182 })
183 })?;
184 rows.collect::<rusqlite::Result<Vec<HistoryCandidate>>>()
185 .context("failed to read fuzzy history inventory")
186 }
187
188 pub fn import_zsh_history(
189 &mut self,
190 path: &Path,
191 ignore_leading_space: bool,
192 ) -> Result<ImportResult> {
193 let canonical = path
194 .canonicalize()
195 .with_context(|| format!("failed to resolve history file {}", path.display()))?;
196 let metadata = fs::metadata(&canonical)
197 .with_context(|| format!("failed to inspect history file {}", canonical.display()))?;
198 if !metadata.is_file() {
199 bail!(
200 "history path is not a regular file: {}",
201 canonical.display()
202 );
203 }
204 if metadata.len() > MAX_HISTORY_BYTES {
205 bail!(
206 "history file exceeds {} MiB: {}",
207 MAX_HISTORY_BYTES / (1024 * 1024),
208 canonical.display()
209 );
210 }
211 let modified_ns = metadata
212 .modified()?
213 .duration_since(UNIX_EPOCH)
214 .unwrap_or_default()
215 .as_nanos()
216 .min(i64::MAX as u128) as i64;
217 let size_bytes = metadata.len().min(i64::MAX as u64) as i64;
218 let source = format!("zsh:{}", canonical.display());
219
220 let previous: Option<(i64, i64)> = self
221 .connection
222 .query_row(
223 "SELECT size_bytes, modified_ns FROM history_imports WHERE path = ?1",
224 params![canonical.to_string_lossy()],
225 |row| Ok((row.get(0)?, row.get(1)?)),
226 )
227 .optional()?;
228 if previous == Some((size_bytes, modified_ns)) {
229 return Ok(ImportResult {
230 imported: 0,
231 skipped: true,
232 });
233 }
234
235 let mut content = Vec::new();
236 fs::File::open(&canonical)
237 .with_context(|| format!("failed to open history file {}", canonical.display()))?
238 .take(MAX_HISTORY_BYTES + 1)
239 .read_to_end(&mut content)
240 .with_context(|| format!("failed to read history file {}", canonical.display()))?;
241 if content.len() as u64 > MAX_HISTORY_BYTES {
242 bail!("history file grew beyond the import limit while reading");
243 }
244 let content = String::from_utf8(content).context("history file is not valid UTF-8")?;
245 let entries = parse_zsh_history(&content, ignore_leading_space)?;
246
247 let transaction = self.connection.transaction()?;
248 transaction.execute(
249 "DELETE FROM command_events WHERE source = ?1",
250 params![source],
251 )?;
252 {
253 let mut insert = transaction.prepare(
254 "INSERT INTO command_events
255 (command, cwd, observed_at_ms, exit_code, source, source_key)
256 VALUES (?1, '', ?2, NULL, ?3, ?4)",
257 )?;
258 for (index, entry) in entries.iter().enumerate() {
259 insert.execute(params![
260 entry.command,
261 entry.observed_at_ms,
262 source,
263 index.to_string()
264 ])?;
265 }
266 }
267 transaction.execute(
268 "INSERT INTO history_imports (path, size_bytes, modified_ns)
269 VALUES (?1, ?2, ?3)
270 ON CONFLICT(path) DO UPDATE SET
271 size_bytes = excluded.size_bytes,
272 modified_ns = excluded.modified_ns",
273 params![canonical.to_string_lossy(), size_bytes, modified_ns],
274 )?;
275 transaction.commit()?;
276
277 Ok(ImportResult {
278 imported: entries.len(),
279 skipped: false,
280 })
281 }
282}
283
284#[derive(Debug, PartialEq, Eq)]
285struct HistoryEntry {
286 command: String,
287 observed_at_ms: i64,
288}
289
290fn parse_zsh_history(content: &str, ignore_leading_space: bool) -> Result<Vec<HistoryEntry>> {
291 let mut entries = Vec::new();
292 let mut lines = content.lines().enumerate();
293 while let Some((index, line)) = lines.next() {
294 if line.ends_with('\\') {
295 let mut continued = true;
296 while continued {
297 let Some((_, continuation)) = lines.next() else {
298 break;
299 };
300 continued = continuation.ends_with('\\');
301 }
302 continue;
303 }
304
305 let (command, observed_at_ms) = parse_zsh_history_line(line, index as i64);
306 if eligible_command(command, ignore_leading_space) {
307 entries.push(HistoryEntry {
308 command: command.to_owned(),
309 observed_at_ms,
310 });
311 if entries.len() > MAX_HISTORY_ENTRIES {
312 bail!("history file exceeds {MAX_HISTORY_ENTRIES} entries");
313 }
314 }
315 }
316 Ok(entries)
317}
318
319fn parse_zsh_history_line(line: &str, fallback_order: i64) -> (&str, i64) {
320 let Some(metadata_and_command) = line.strip_prefix(": ") else {
321 return (line, fallback_order);
322 };
323 let Some((metadata, command)) = metadata_and_command.split_once(';') else {
324 return (line, fallback_order);
325 };
326 let timestamp = metadata
327 .split(':')
328 .next()
329 .and_then(|value| value.parse::<i64>().ok())
330 .map(|seconds| seconds.saturating_mul(1_000))
331 .unwrap_or(fallback_order);
332 (command, timestamp)
333}
334
335fn eligible_command(command: &str, ignore_leading_space: bool) -> bool {
336 if command.is_empty() || command.len() > MAX_COMMAND_BYTES {
337 return false;
338 }
339 if ignore_leading_space && command.starts_with(char::is_whitespace) {
340 return false;
341 }
342 !command.chars().any(|character| {
343 character == '\0' || (character.is_control() && !matches!(character, '\t'))
344 })
345}
346
347fn escape_like(value: &str) -> String {
348 let mut escaped = String::with_capacity(value.len());
349 for character in value.chars() {
350 if matches!(character, '%' | '_' | '\\') {
351 escaped.push('\\');
352 }
353 escaped.push(character);
354 }
355 escaped
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use tempfile::tempdir;
362
363 #[test]
364 fn ranks_same_directory_and_successful_history_first() {
365 let store = Store::in_memory().unwrap();
366 store
367 .record("git status", "/other", 0, 300, "one", true)
368 .unwrap();
369 store
370 .record("git stash", "/repo", 1, 200, "one", true)
371 .unwrap();
372 store
373 .record("git switch main", "/repo", 0, 100, "one", true)
374 .unwrap();
375
376 let candidates = store
377 .history_candidates("git s", "/repo", 10, true)
378 .unwrap();
379 assert!(candidates[0].same_cwd);
380 assert!(!candidates[2].same_cwd);
381 assert_eq!(
382 candidates
383 .into_iter()
384 .map(|candidate| candidate.command)
385 .collect::<Vec<_>>(),
386 vec!["git switch main", "git stash", "git status"]
387 );
388 }
389
390 #[test]
391 fn fuzzy_inventory_is_not_prefix_limited() {
392 let store = Store::in_memory().unwrap();
393 store
394 .record("cargo test", "/repo", 0, 100, "one", true)
395 .unwrap();
396 store
397 .record("git status", "/other", 0, 200, "one", true)
398 .unwrap();
399
400 let candidates = store.history_inventory("/repo", 10, true).unwrap();
401 assert_eq!(candidates[0].command, "cargo test");
402 assert!(
403 candidates
404 .iter()
405 .any(|candidate| candidate.command == "git status")
406 );
407 }
408
409 #[test]
410 fn parses_extended_zsh_history() {
411 let entries =
412 parse_zsh_history(": 1700000000:4;git status\nplain command\n", true).unwrap();
413 assert_eq!(entries[0].command, "git status");
414 assert_eq!(entries[0].observed_at_ms, 1_700_000_000_000);
415 assert_eq!(entries[1].command, "plain command");
416 }
417
418 #[test]
419 fn escapes_like_metacharacters() {
420 assert_eq!(escape_like("echo 100%_done"), "echo 100\\%\\_done");
421 }
422
423 #[test]
424 fn ignores_leading_space_when_configured() {
425 assert!(!eligible_command(" secret command", true));
426 assert!(eligible_command(" secret command", false));
427 }
428
429 #[test]
430 fn imports_a_history_file_once_until_it_changes() {
431 let directory = tempdir().unwrap();
432 let history = directory.path().join("history");
433 fs::write(
434 &history,
435 ": 1700000000:0;cargo test\n: 1700000001:0;cargo check\n",
436 )
437 .unwrap();
438 let mut store = Store::in_memory().unwrap();
439
440 let first = store.import_zsh_history(&history, true).unwrap();
441 assert_eq!(first.imported, 2);
442 assert!(!first.skipped);
443
444 let second = store.import_zsh_history(&history, true).unwrap();
445 assert!(second.skipped);
446 assert_eq!(
447 store
448 .history_candidates("cargo ", "/repo", 10, true)
449 .unwrap()
450 .into_iter()
451 .map(|candidate| candidate.command)
452 .collect::<Vec<_>>(),
453 vec!["cargo check", "cargo test"]
454 );
455 }
456
457 #[test]
458 fn skips_multiline_history_entries_conservatively() {
459 let entries = parse_zsh_history(
460 ": 1700000000:0;echo first \\\n+continued\n: 1700000001:0;git status\n",
461 true,
462 )
463 .unwrap();
464 assert_eq!(entries.len(), 1);
465 assert_eq!(entries[0].command, "git status");
466 }
467
468 #[test]
469 fn rejects_invalid_utf8_history() {
470 let directory = tempdir().unwrap();
471 let history = directory.path().join("history");
472 fs::write(&history, [0xff, 0xfe]).unwrap();
473 let mut store = Store::in_memory().unwrap();
474 assert!(store.import_zsh_history(&history, true).is_err());
475 }
476
477 #[test]
478 fn rejects_newer_database_schema() {
479 let connection = Connection::open_in_memory().unwrap();
480 connection.pragma_update(None, "user_version", 2).unwrap();
481 assert!(Store::from_connection(connection).is_err());
482 }
483}