1use rusqlite::{params, Connection};
33use std::path::PathBuf;
34use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
35use std::sync::OnceLock;
36use std::time::{Instant, SystemTime, UNIX_EPOCH};
37
38fn perf_db_path() -> Option<PathBuf> {
41 let base = if let Ok(home) = std::env::var("STRYKE_HOME") {
42 PathBuf::from(home)
43 } else if let Ok(home) = std::env::var("HOME") {
44 PathBuf::from(home).join(".stryke")
45 } else {
46 return None;
47 };
48 if std::fs::create_dir_all(&base).is_err() {
49 return None;
50 }
51 Some(base.join("perf.sqlite"))
52}
53
54pub fn open_db() -> Option<Connection> {
57 let path = perf_db_path()?;
58 let conn = Connection::open(&path).ok()?;
59 let _ = conn.pragma_update(None, "journal_mode", "WAL");
62 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
63 conn.execute_batch(
64 "CREATE TABLE IF NOT EXISTS runs (
65 id INTEGER PRIMARY KEY,
66 path TEXT NOT NULL,
67 argv TEXT,
68 started_ns INTEGER NOT NULL,
69 duration_ns INTEGER NOT NULL,
70 exit_code INTEGER NOT NULL,
71 version TEXT NOT NULL,
72 host TEXT,
73 pid INTEGER,
74 parent_pid INTEGER
75 );
76 CREATE INDEX IF NOT EXISTS idx_runs_path ON runs(path);
77 CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_ns);
78 CREATE INDEX IF NOT EXISTS idx_runs_duration ON runs(duration_ns);",
79 )
80 .ok()?;
81 Some(conn)
82}
83
84fn hostname() -> String {
86 std::env::var("HOSTNAME")
87 .or_else(|_| std::env::var("HOST"))
88 .unwrap_or_default()
89}
90
91fn argv_json(argv: &[String]) -> String {
94 let mut s = String::from("[");
95 for (i, a) in argv.iter().enumerate() {
96 if i > 0 {
97 s.push(',');
98 }
99 s.push('"');
100 for c in a.chars() {
101 match c {
102 '"' => s.push_str("\\\""),
103 '\\' => s.push_str("\\\\"),
104 '\n' => s.push_str("\\n"),
105 '\r' => s.push_str("\\r"),
106 '\t' => s.push_str("\\t"),
107 c if (c as u32) < 0x20 => s.push_str(&format!("\\u{:04x}", c as u32)),
108 c => s.push(c),
109 }
110 }
111 s.push('"');
112 }
113 s.push(']');
114 s
115}
116
117#[derive(Debug, Clone)]
119pub struct RunRow {
120 pub path: String,
121 pub argv: Vec<String>,
122 pub started_ns: i64,
123 pub duration_ns: i64,
124 pub exit_code: i32,
125 pub version: String,
126 pub host: String,
127 pub pid: i64,
128 pub parent_pid: i64,
129}
130
131pub fn insert(row: &RunRow) -> bool {
134 let Some(conn) = open_db() else { return false };
135 let argv_str = argv_json(&row.argv);
136 conn.execute(
137 "INSERT INTO runs (path, argv, started_ns, duration_ns, exit_code, version, host, pid, parent_pid)
138 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
139 params![
140 row.path,
141 argv_str,
142 row.started_ns,
143 row.duration_ns,
144 row.exit_code,
145 row.version,
146 row.host,
147 row.pid,
148 row.parent_pid,
149 ],
150 )
151 .is_ok()
152}
153
154#[derive(Debug, Default, Clone)]
156pub struct QueryFilter {
157 pub name_substr: Option<String>,
159 pub name_regex: Option<String>,
161 pub since_ns: Option<i64>,
163 pub exact_path: Option<String>,
165 pub slowest_first: Option<bool>,
168 pub limit: usize,
170}
171
172impl QueryFilter {
173 pub fn slowest_top(n: usize) -> Self {
174 Self {
175 slowest_first: Some(true),
176 limit: n,
177 ..Default::default()
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
184pub struct QueryRow {
185 pub id: i64,
186 pub path: String,
187 pub argv: String,
188 pub started_ns: i64,
189 pub duration_ns: i64,
190 pub exit_code: i32,
191 pub version: String,
192 pub host: String,
193 pub pid: i64,
194 pub parent_pid: i64,
195}
196
197pub fn query(f: &QueryFilter) -> Vec<QueryRow> {
199 let Some(conn) = open_db() else {
200 return Vec::new();
201 };
202 let mut sql = String::from(
203 "SELECT id, path, argv, started_ns, duration_ns, exit_code, version,
204 COALESCE(host, ''), COALESCE(pid, 0), COALESCE(parent_pid, 0)
205 FROM runs",
206 );
207 let mut clauses: Vec<String> = Vec::new();
208 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
209 if let Some(p) = &f.exact_path {
210 clauses.push("path = ?".to_string());
211 bind.push(Box::new(p.clone()));
212 }
213 if let Some(s) = &f.name_substr {
214 clauses.push("path LIKE ?".to_string());
215 bind.push(Box::new(format!("%{}%", s)));
216 }
217 if let Some(ns) = f.since_ns {
218 clauses.push("started_ns >= ?".to_string());
219 bind.push(Box::new(ns));
220 }
221 if !clauses.is_empty() {
222 sql.push_str(" WHERE ");
223 sql.push_str(&clauses.join(" AND "));
224 }
225 match f.slowest_first {
226 Some(true) => sql.push_str(" ORDER BY duration_ns DESC"),
227 Some(false) => sql.push_str(" ORDER BY duration_ns ASC"),
228 None => sql.push_str(" ORDER BY id DESC"),
229 }
230 let limit = if f.limit == 0 { 1000 } else { f.limit };
231 sql.push_str(&format!(" LIMIT {}", limit));
232
233 let mut stmt = match conn.prepare(&sql) {
234 Ok(s) => s,
235 Err(_) => return Vec::new(),
236 };
237 let params_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
238 let mut out: Vec<QueryRow> = Vec::new();
239 let rows = stmt.query_map(rusqlite::params_from_iter(params_refs), |r| {
240 Ok(QueryRow {
241 id: r.get(0)?,
242 path: r.get(1)?,
243 argv: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
244 started_ns: r.get(3)?,
245 duration_ns: r.get(4)?,
246 exit_code: r.get(5)?,
247 version: r.get(6)?,
248 host: r.get(7)?,
249 pid: r.get(8)?,
250 parent_pid: r.get(9)?,
251 })
252 });
253 if let Ok(iter) = rows {
254 for r in iter.flatten() {
255 if let Some(rx) = &f.name_regex {
257 if let Ok(re) = regex::Regex::new(rx) {
258 if !re.is_match(&r.path) {
259 continue;
260 }
261 }
262 }
263 out.push(r);
264 }
265 }
266 out
267}
268
269pub fn parse_duration_secs(s: &str) -> Option<i64> {
272 let s = s.trim();
273 if s.is_empty() {
274 return None;
275 }
276 let (num_str, unit) = match s.chars().last() {
277 Some(c) if c.is_ascii_alphabetic() => (&s[..s.len() - 1], c.to_ascii_lowercase()),
278 _ => (s, 's'),
279 };
280 let n: i64 = num_str.parse().ok()?;
281 let mult = match unit {
282 's' => 1,
283 'm' => 60,
284 'h' => 3600,
285 'd' => 86_400,
286 'w' => 86_400 * 7,
287 _ => return None,
288 };
289 Some(n * mult)
290}
291
292pub fn prune_older_than(days: i64) -> Option<usize> {
294 let conn = open_db()?;
295 let cutoff_ns = (now_ns() - days * 86_400 * 1_000_000_000).max(0);
296 conn.execute("DELETE FROM runs WHERE started_ns < ?1", params![cutoff_ns])
297 .ok()
298}
299
300fn maybe_auto_prune() {
304 static COUNTER: AtomicUsize = AtomicUsize::new(0);
305 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
306 if n.is_multiple_of(1000) && n > 0 {
307 let _ = prune_older_than(90);
308 }
309}
310
311pub fn now_ns() -> i64 {
313 SystemTime::now()
314 .duration_since(UNIX_EPOCH)
315 .map(|d| d.as_nanos() as i64)
316 .unwrap_or(0)
317}
318
319struct RecorderState {
321 started_at: Instant,
322 started_ns: i64,
323 path: String,
324 argv: Vec<String>,
325}
326
327static RECORDER: OnceLock<RecorderState> = OnceLock::new();
328static EXIT_CODE: AtomicI32 = AtomicI32::new(0);
329
330pub fn install(path: String, argv: Vec<String>) {
346 if path == "<repl>" {
347 return; }
349 if RECORDER
350 .set(RecorderState {
351 started_at: Instant::now(),
352 started_ns: now_ns(),
353 path,
354 argv,
355 })
356 .is_err()
357 {
358 return; }
360
361 unsafe {
367 libc::atexit(atexit_record);
368 }
369
370 let prev = std::panic::take_hook();
373 std::panic::set_hook(Box::new(move |info| {
374 EXIT_CODE.store(101, Ordering::Relaxed);
375 prev(info);
376 }));
377}
378
379pub fn set_exit_code(code: i32) {
383 EXIT_CODE.store(code, Ordering::Relaxed);
384}
385
386extern "C" fn atexit_record() {
389 let Some(state) = RECORDER.get() else { return };
390 let duration_ns = state.started_at.elapsed().as_nanos() as i64;
391 let pid = std::process::id() as i64;
392 let parent_pid = parent_pid();
393 let row = RunRow {
394 path: state.path.clone(),
395 argv: state.argv.clone(),
396 started_ns: state.started_ns,
397 duration_ns,
398 exit_code: EXIT_CODE.load(Ordering::Relaxed),
399 version: env!("CARGO_PKG_VERSION").to_string(),
400 host: hostname(),
401 pid,
402 parent_pid,
403 };
404 let _ = insert(&row);
405 maybe_auto_prune();
406}
407
408#[cfg(unix)]
409fn parent_pid() -> i64 {
410 unsafe { libc::getppid() as i64 }
412}
413
414#[cfg(not(unix))]
415fn parent_pid() -> i64 {
416 0
417}
418
419pub fn recording_enabled_in_env() -> bool {
422 std::env::var("STRYKE_RECORD")
423 .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
424 .unwrap_or(false)
425}
426
427pub fn classify_invocation(argv: &[String]) -> String {
434 if argv.len() <= 1 {
435 return "<repl>".to_string();
436 }
437 let mut i = 1;
438 while i < argv.len() {
439 let a = &argv[i];
440 if a == "--" {
441 break;
442 }
443 if a == "-e" || a == "--exec" {
444 return "<eval>".to_string();
445 }
446 if a.starts_with('-') {
447 i += 1;
448 continue;
449 }
450 if is_subcommand_name(a) {
453 return format!("<subcmd:{}>", a);
454 }
455 if std::path::Path::new(a).exists() {
456 if let Ok(abs) = std::fs::canonicalize(a) {
457 return abs.display().to_string();
458 }
459 }
460 return a.clone();
461 }
462 "<repl>".to_string()
463}
464
465fn is_subcommand_name(name: &str) -> bool {
468 matches!(
469 name,
470 "t" | "test"
471 | "check"
472 | "fmt"
473 | "format"
474 | "lint"
475 | "docs"
476 | "doc"
477 | "repl"
478 | "build"
479 | "run"
480 | "install"
481 | "uninstall"
482 | "publish"
483 | "init"
484 | "new"
485 | "search"
486 | "list"
487 | "info"
488 | "lsp"
489 | "completion"
490 | "completions"
491 | "perfview"
492 | "version"
493 | "help"
494 )
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn argv_json_escapes_quotes_and_specials() {
503 let argv = vec!["s".to_string(), "-e".to_string(), "p \"hi\"\n".to_string()];
504 let out = argv_json(&argv);
505 assert_eq!(out, "[\"s\",\"-e\",\"p \\\"hi\\\"\\n\"]");
506 }
507
508 #[test]
509 fn classify_eval() {
510 let argv = vec!["s".to_string(), "-e".to_string(), "p 42".to_string()];
511 assert_eq!(classify_invocation(&argv), "<eval>");
512 }
513
514 #[test]
515 fn classify_repl_when_no_args() {
516 let argv = vec!["s".to_string()];
517 assert_eq!(classify_invocation(&argv), "<repl>");
518 }
519
520 #[test]
521 fn classify_subcommand() {
522 let argv = vec!["s".to_string(), "test".to_string(), "t/".to_string()];
523 assert_eq!(classify_invocation(&argv), "<subcmd:test>");
524 }
525
526 #[test]
527 fn classify_t_short() {
528 let argv = vec!["s".to_string(), "t".to_string(), "t/".to_string()];
529 assert_eq!(classify_invocation(&argv), "<subcmd:t>");
530 }
531
532 #[test]
533 fn recording_enabled_only_when_env_truthy() {
534 let key = "STRYKE_RECORD";
535 let saved = std::env::var(key).ok();
536 std::env::remove_var(key);
537 assert!(!recording_enabled_in_env());
538 std::env::set_var(key, "1");
539 assert!(recording_enabled_in_env());
540 std::env::set_var(key, "0");
541 assert!(!recording_enabled_in_env());
542 std::env::set_var(key, "false");
543 assert!(!recording_enabled_in_env());
544 std::env::set_var(key, "");
545 assert!(!recording_enabled_in_env());
546 match saved {
547 Some(v) => std::env::set_var(key, v),
548 None => std::env::remove_var(key),
549 }
550 }
551}