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,
122 pub argv: Vec<String>,
124 pub started_ns: i64,
126 pub duration_ns: i64,
128 pub exit_code: i32,
130 pub version: String,
132 pub host: String,
134 pub pid: i64,
136 pub parent_pid: i64,
138}
139
140pub fn insert(row: &RunRow) -> bool {
143 let Some(conn) = open_db() else { return false };
144 let argv_str = argv_json(&row.argv);
145 conn.execute(
146 "INSERT INTO runs (path, argv, started_ns, duration_ns, exit_code, version, host, pid, parent_pid)
147 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
148 params![
149 row.path,
150 argv_str,
151 row.started_ns,
152 row.duration_ns,
153 row.exit_code,
154 row.version,
155 row.host,
156 row.pid,
157 row.parent_pid,
158 ],
159 )
160 .is_ok()
161}
162
163#[derive(Debug, Default, Clone)]
165pub struct QueryFilter {
166 pub name_substr: Option<String>,
168 pub name_regex: Option<String>,
170 pub since_ns: Option<i64>,
172 pub exact_path: Option<String>,
174 pub slowest_first: Option<bool>,
177 pub limit: usize,
179}
180
181impl QueryFilter {
182 pub fn slowest_top(n: usize) -> Self {
184 Self {
185 slowest_first: Some(true),
186 limit: n,
187 ..Default::default()
188 }
189 }
190}
191
192#[derive(Debug, Clone)]
194pub struct QueryRow {
195 pub id: i64,
197 pub path: String,
199 pub argv: String,
201 pub started_ns: i64,
203 pub duration_ns: i64,
205 pub exit_code: i32,
207 pub version: String,
209 pub host: String,
211 pub pid: i64,
213 pub parent_pid: i64,
215}
216
217pub fn query(f: &QueryFilter) -> Vec<QueryRow> {
219 let Some(conn) = open_db() else {
220 return Vec::new();
221 };
222 let mut sql = String::from(
223 "SELECT id, path, argv, started_ns, duration_ns, exit_code, version,
224 COALESCE(host, ''), COALESCE(pid, 0), COALESCE(parent_pid, 0)
225 FROM runs",
226 );
227 let mut clauses: Vec<String> = Vec::new();
228 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
229 if let Some(p) = &f.exact_path {
230 clauses.push("path = ?".to_string());
231 bind.push(Box::new(p.clone()));
232 }
233 if let Some(s) = &f.name_substr {
234 clauses.push("path LIKE ?".to_string());
235 bind.push(Box::new(format!("%{}%", s)));
236 }
237 if let Some(ns) = f.since_ns {
238 clauses.push("started_ns >= ?".to_string());
239 bind.push(Box::new(ns));
240 }
241 if !clauses.is_empty() {
242 sql.push_str(" WHERE ");
243 sql.push_str(&clauses.join(" AND "));
244 }
245 match f.slowest_first {
246 Some(true) => sql.push_str(" ORDER BY duration_ns DESC"),
247 Some(false) => sql.push_str(" ORDER BY duration_ns ASC"),
248 None => sql.push_str(" ORDER BY id DESC"),
249 }
250 let limit = if f.limit == 0 { 1000 } else { f.limit };
251 sql.push_str(&format!(" LIMIT {}", limit));
252
253 let mut stmt = match conn.prepare(&sql) {
254 Ok(s) => s,
255 Err(_) => return Vec::new(),
256 };
257 let params_refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
258 let mut out: Vec<QueryRow> = Vec::new();
259 let rows = stmt.query_map(rusqlite::params_from_iter(params_refs), |r| {
260 Ok(QueryRow {
261 id: r.get(0)?,
262 path: r.get(1)?,
263 argv: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
264 started_ns: r.get(3)?,
265 duration_ns: r.get(4)?,
266 exit_code: r.get(5)?,
267 version: r.get(6)?,
268 host: r.get(7)?,
269 pid: r.get(8)?,
270 parent_pid: r.get(9)?,
271 })
272 });
273 if let Ok(iter) = rows {
274 for r in iter.flatten() {
275 if let Some(rx) = &f.name_regex {
277 if let Ok(re) = regex::Regex::new(rx) {
278 if !re.is_match(&r.path) {
279 continue;
280 }
281 }
282 }
283 out.push(r);
284 }
285 }
286 out
287}
288
289pub fn parse_duration_secs(s: &str) -> Option<i64> {
292 let s = s.trim();
293 if s.is_empty() {
294 return None;
295 }
296 let (num_str, unit) = match s.chars().last() {
297 Some(c) if c.is_ascii_alphabetic() => (&s[..s.len() - 1], c.to_ascii_lowercase()),
298 _ => (s, 's'),
299 };
300 let n: i64 = num_str.parse().ok()?;
301 let mult = match unit {
302 's' => 1,
303 'm' => 60,
304 'h' => 3600,
305 'd' => 86_400,
306 'w' => 86_400 * 7,
307 _ => return None,
308 };
309 Some(n * mult)
310}
311
312pub fn prune_older_than(days: i64) -> Option<usize> {
314 let conn = open_db()?;
315 let cutoff_ns = (now_ns() - days * 86_400 * 1_000_000_000).max(0);
316 conn.execute("DELETE FROM runs WHERE started_ns < ?1", params![cutoff_ns])
317 .ok()
318}
319
320fn maybe_auto_prune() {
324 static COUNTER: AtomicUsize = AtomicUsize::new(0);
325 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
326 if n.is_multiple_of(1000) && n > 0 {
327 let _ = prune_older_than(90);
328 }
329}
330
331pub fn now_ns() -> i64 {
333 SystemTime::now()
334 .duration_since(UNIX_EPOCH)
335 .map(|d| d.as_nanos() as i64)
336 .unwrap_or(0)
337}
338
339struct RecorderState {
341 started_at: Instant,
342 started_ns: i64,
343 path: String,
344 argv: Vec<String>,
345}
346
347static RECORDER: OnceLock<RecorderState> = OnceLock::new();
348static EXIT_CODE: AtomicI32 = AtomicI32::new(0);
349
350pub fn install(path: String, argv: Vec<String>) {
366 if path == "<repl>" {
367 return; }
369 if RECORDER
370 .set(RecorderState {
371 started_at: Instant::now(),
372 started_ns: now_ns(),
373 path,
374 argv,
375 })
376 .is_err()
377 {
378 return; }
380
381 unsafe {
387 libc::atexit(atexit_record);
388 }
389
390 let prev = std::panic::take_hook();
393 std::panic::set_hook(Box::new(move |info| {
394 EXIT_CODE.store(101, Ordering::Relaxed);
395 prev(info);
396 }));
397}
398
399pub fn set_exit_code(code: i32) {
403 EXIT_CODE.store(code, Ordering::Relaxed);
404}
405
406extern "C" fn atexit_record() {
409 let Some(state) = RECORDER.get() else { return };
410 let duration_ns = state.started_at.elapsed().as_nanos() as i64;
411 let pid = std::process::id() as i64;
412 let parent_pid = parent_pid();
413 let row = RunRow {
414 path: state.path.clone(),
415 argv: state.argv.clone(),
416 started_ns: state.started_ns,
417 duration_ns,
418 exit_code: EXIT_CODE.load(Ordering::Relaxed),
419 version: env!("CARGO_PKG_VERSION").to_string(),
420 host: hostname(),
421 pid,
422 parent_pid,
423 };
424 let _ = insert(&row);
425 maybe_auto_prune();
426}
427
428#[cfg(unix)]
429fn parent_pid() -> i64 {
430 unsafe { libc::getppid() as i64 }
432}
433
434#[cfg(not(unix))]
435fn parent_pid() -> i64 {
436 0
437}
438
439pub fn recording_enabled_in_env() -> bool {
442 std::env::var("STRYKE_RECORD")
443 .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
444 .unwrap_or(false)
445}
446
447pub fn classify_invocation(argv: &[String]) -> String {
454 if argv.len() <= 1 {
455 return "<repl>".to_string();
456 }
457 let mut i = 1;
458 while i < argv.len() {
459 let a = &argv[i];
460 if a == "--" {
461 break;
462 }
463 if a == "-e" || a == "--exec" {
464 return "<eval>".to_string();
465 }
466 if a.starts_with('-') {
467 i += 1;
468 continue;
469 }
470 if is_subcommand_name(a) {
473 return format!("<subcmd:{}>", a);
474 }
475 if std::path::Path::new(a).exists() {
476 if let Ok(abs) = std::fs::canonicalize(a) {
477 return abs.display().to_string();
478 }
479 }
480 return a.clone();
481 }
482 "<repl>".to_string()
483}
484
485fn is_subcommand_name(name: &str) -> bool {
488 matches!(
489 name,
490 "t" | "test"
491 | "check"
492 | "fmt"
493 | "format"
494 | "lint"
495 | "docs"
496 | "doc"
497 | "repl"
498 | "build"
499 | "run"
500 | "install"
501 | "uninstall"
502 | "publish"
503 | "init"
504 | "new"
505 | "search"
506 | "list"
507 | "info"
508 | "lsp"
509 | "completion"
510 | "completions"
511 | "perfview"
512 | "version"
513 | "help"
514 )
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn argv_json_escapes_quotes_and_specials() {
523 let argv = vec!["s".to_string(), "-e".to_string(), "p \"hi\"\n".to_string()];
524 let out = argv_json(&argv);
525 assert_eq!(out, "[\"s\",\"-e\",\"p \\\"hi\\\"\\n\"]");
526 }
527
528 #[test]
529 fn classify_eval() {
530 let argv = vec!["s".to_string(), "-e".to_string(), "p 42".to_string()];
531 assert_eq!(classify_invocation(&argv), "<eval>");
532 }
533
534 #[test]
535 fn classify_repl_when_no_args() {
536 let argv = vec!["s".to_string()];
537 assert_eq!(classify_invocation(&argv), "<repl>");
538 }
539
540 #[test]
541 fn classify_subcommand() {
542 let argv = vec!["s".to_string(), "test".to_string(), "t/".to_string()];
543 assert_eq!(classify_invocation(&argv), "<subcmd:test>");
544 }
545
546 #[test]
547 fn classify_t_short() {
548 let argv = vec!["s".to_string(), "t".to_string(), "t/".to_string()];
549 assert_eq!(classify_invocation(&argv), "<subcmd:t>");
550 }
551
552 #[test]
553 fn recording_enabled_only_when_env_truthy() {
554 let key = "STRYKE_RECORD";
555 let saved = std::env::var(key).ok();
556 std::env::remove_var(key);
557 assert!(!recording_enabled_in_env());
558 std::env::set_var(key, "1");
559 assert!(recording_enabled_in_env());
560 std::env::set_var(key, "0");
561 assert!(!recording_enabled_in_env());
562 std::env::set_var(key, "false");
563 assert!(!recording_enabled_in_env());
564 std::env::set_var(key, "");
565 assert!(!recording_enabled_in_env());
566 match saved {
567 Some(v) => std::env::set_var(key, v),
568 None => std::env::remove_var(key),
569 }
570 }
571}