1use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, bail};
9use rusqlite::{Connection, OptionalExtension, params};
10
11use super::schema::{self, SCHEMA_VERSION};
12use super::{SessionKind, TargetClass};
13
14pub struct SessionDb {
16 conn: Connection,
17 session_id: String,
18 label: String,
19 kind: SessionKind,
20 target_class: TargetClass,
21 target: String,
22 db_path: Option<PathBuf>,
23}
24
25impl std::fmt::Debug for SessionDb {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 f.debug_struct("SessionDb")
28 .field("session_id", &self.session_id)
29 .field("label", &self.label)
30 .field("kind", &self.kind)
31 .field("target_class", &self.target_class)
32 .field("target", &self.target)
33 .field("db_path", &self.db_path)
34 .finish()
35 }
36}
37
38pub struct CreateOptions<'a> {
40 pub kind: SessionKind,
41 pub target: &'a str,
42 pub target_class: TargetClass,
43 pub cwd: &'a Path,
44 pub db_path: Option<&'a Path>,
46 pub label: Option<String>,
48 pub target_hash: Option<String>,
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum PrunePolicy {
55 AutoOnly,
56 All,
57}
58
59impl SessionDb {
60 pub fn create(opts: CreateOptions<'_>) -> Result<Self> {
63 let conn = match opts.db_path {
64 Some(p) => {
65 if let Some(parent) = p.parent() {
66 fs::create_dir_all(parent).ok();
67 }
68 Connection::open(p)?
69 }
70 None => Connection::open_in_memory()?,
71 };
72 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
73 .ok(); schema::apply(&conn, opts.target_class)?;
75 conn.execute(
76 &format!("PRAGMA user_version = {SCHEMA_VERSION}"),
77 [],
78 )?;
79
80 let session_id = random_id(&conn)?;
81 let label = match opts.label {
82 Some(l) => l,
83 None => auto_label(opts.target)?,
84 };
85 let target_hash = match opts.target_hash {
86 Some(h) => Some(h),
87 None => compute_target_hash(Path::new(opts.target)).ok(),
88 };
89
90 conn.execute(
91 "INSERT INTO sessions
92 (id, kind, target, target_class, target_hash,
93 started_at, label, created_by)
94 VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), ?6, 'auto')",
95 params![
96 session_id,
97 opts.kind.as_str(),
98 opts.target,
99 opts.target_class.as_str(),
100 target_hash,
101 label,
102 ],
103 )?;
104
105 let cwd_canonical = opts
108 .cwd
109 .canonicalize()
110 .unwrap_or_else(|_| opts.cwd.to_path_buf());
111 let group = group_key(&cwd_canonical, target_hash.as_deref().unwrap_or(""));
112 conn.execute(
113 "INSERT INTO meta (session_id, key, value) VALUES (?1, 'session_group_key', ?2)",
114 params![session_id, group],
115 )?;
116 conn.execute(
117 "INSERT INTO meta (session_id, key, value) VALUES (?1, 'cwd', ?2)",
118 params![session_id, cwd_canonical.to_string_lossy().as_ref()],
119 )?;
120
121 Ok(SessionDb {
122 conn,
123 session_id,
124 label,
125 kind: opts.kind,
126 target_class: opts.target_class,
127 target: opts.target.to_string(),
128 db_path: opts.db_path.map(Path::to_path_buf),
129 })
130 }
131
132 pub fn open(path: &Path) -> Result<Self> {
135 if !path.exists() {
136 bail!("session DB not found: {}", path.display());
137 }
138 let conn = Connection::open(path)
139 .with_context(|| format!("opening session DB {}", path.display()))?;
140
141 let found: i64 = conn
142 .query_row("PRAGMA user_version", [], |r| r.get(0))
143 .with_context(|| format!("reading user_version from {}", path.display()))?;
144 if found != SCHEMA_VERSION {
145 bail!(
146 "session DB schema_version={found}, expected {SCHEMA_VERSION} at {path}.\n\
147 No migration path. Re-collect against the raw files in {raw} \
148 (or delete the DB and re-run the originating command).",
149 found = found,
150 SCHEMA_VERSION = SCHEMA_VERSION,
151 path = path.display(),
152 raw = path
153 .parent()
154 .map(|p| p.join(path.file_stem().unwrap_or_default()).join("raw"))
155 .map(|p| p.display().to_string())
156 .unwrap_or_else(|| "(no raw dir)".into()),
157 );
158 }
159
160 let (session_id, label, kind_s, class_s, target): (String, String, String, String, String) =
162 conn.query_row(
163 "SELECT id, label, kind, target_class, target FROM sessions LIMIT 1",
164 [],
165 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
166 )
167 .with_context(|| format!("reading session row from {}", path.display()))?;
168
169 let kind = match kind_s.as_str() {
170 "debug" => SessionKind::Debug,
171 "profile" => SessionKind::Profile,
172 other => bail!("unknown session kind in DB: {other}"),
173 };
174 let target_class: TargetClass = class_s
175 .parse()
176 .with_context(|| format!("parsing target_class {class_s}"))?;
177
178 Ok(SessionDb {
179 conn,
180 session_id,
181 label,
182 kind,
183 target_class,
184 target,
185 db_path: Some(path.to_path_buf()),
186 })
187 }
188
189 pub fn save_to(&self, path: &Path) -> Result<()> {
192 if let Some(parent) = path.parent() {
193 fs::create_dir_all(parent)?;
194 }
195 let mut dst = Connection::open(path)?;
196 let backup = rusqlite::backup::Backup::new(&self.conn, &mut dst)?;
197 backup.run_to_completion(128, Duration::from_millis(50), None)?;
198 drop(backup);
201 dst.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])?;
202 Ok(())
203 }
204
205 pub fn has_captured_data(&self) -> Result<bool> {
210 let hits: i64 = self.conn.query_row(
211 "SELECT COUNT(*) FROM breakpoint_hits WHERE session_id = ?1",
212 params![self.session_id],
213 |r| r.get(0),
214 )?;
215 if hits > 0 {
216 return Ok(true);
217 }
218 let layers: i64 = self.conn.query_row(
219 "SELECT COUNT(*) FROM layers WHERE session_id = ?1",
220 params![self.session_id],
221 |r| r.get(0),
222 )?;
223 if layers > 0 {
224 return Ok(true);
225 }
226 let profile_raw: i64 = self.conn.query_row(
232 "SELECT COUNT(*) FROM meta WHERE session_id = ?1 AND key = 'profile_raw'",
233 params![self.session_id],
234 |r| r.get(0),
235 )?;
236 Ok(profile_raw > 0)
237 }
238
239 pub fn promote_to_user(&self) -> Result<()> {
241 self.conn.execute(
242 "UPDATE sessions SET created_by = 'user' WHERE id = ?1",
243 params![self.session_id],
244 )?;
245 Ok(())
246 }
247
248 pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
249 self.conn.execute(
250 "INSERT INTO meta (session_id, key, value) VALUES (?1, ?2, ?3)
251 ON CONFLICT(session_id, key) DO UPDATE SET value = excluded.value",
252 params![self.session_id, key, value],
253 )?;
254 Ok(())
255 }
256
257 pub fn meta(&self, key: &str) -> Result<Option<String>> {
258 Ok(self.conn
259 .query_row(
260 "SELECT value FROM meta WHERE session_id = ?1 AND key = ?2",
261 params![self.session_id, key],
262 |r| r.get(0),
263 )
264 .optional()?)
265 }
266
267 pub fn conn(&self) -> &Connection { &self.conn }
268 pub fn session_id(&self) -> &str { &self.session_id }
269 pub fn label(&self) -> &str { &self.label }
270 pub fn kind(&self) -> SessionKind { self.kind }
271 pub fn target_class(&self) -> TargetClass { self.target_class }
272 pub fn target(&self) -> &str { &self.target }
273 pub fn db_path(&self) -> Option<&Path> { self.db_path.as_deref() }
274}
275
276pub fn sessions_dir(cwd: &Path) -> PathBuf {
278 cwd.join(".dbg").join("sessions")
279}
280
281pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
285 sessions_dir(cwd).join(label).join("raw")
286}
287
288pub fn group_key(cwd: &Path, target_hash: &str) -> String {
292 format!("{}|{}", cwd.display(), target_hash)
293}
294
295pub fn compute_target_hash(target: &Path) -> Result<String> {
298 let md = fs::metadata(target)
299 .with_context(|| format!("stat {}", target.display()))?;
300 let size = md.len();
301 let mtime = md
302 .modified()
303 .ok()
304 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
305 .map(|d| d.as_nanos())
306 .unwrap_or(0);
307 Ok(format!("{size}:{mtime}"))
308}
309
310pub fn auto_label(target: &str) -> Result<String> {
313 let basename = Path::new(target)
314 .file_name()
315 .and_then(|s| s.to_str())
316 .unwrap_or("session")
317 .to_string();
318 let secs = SystemTime::now()
319 .duration_since(UNIX_EPOCH)
320 .map(|d| d.as_secs())
321 .unwrap_or(0);
322 Ok(format!("{basename}-{}", format_timestamp(secs)))
323}
324
325fn format_timestamp(secs: u64) -> String {
328 let (y, mo, d, h, mi, s) = decompose_utc(secs);
329 format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
330}
331
332fn decompose_utc(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
333 let s = (secs % 60) as u32;
334 secs /= 60;
335 let mi = (secs % 60) as u32;
336 secs /= 60;
337 let h = (secs % 24) as u32;
338 secs /= 24;
339 let mut days = secs as i64;
340
341 days += 719_468;
344 let era = if days >= 0 { days / 146_097 } else { (days - 146_096) / 146_097 };
345 let doe = (days - era * 146_097) as u32;
346 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
347 let y = (yoe as i64) + era * 400;
348 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
349 let mp = (5 * doy + 2) / 153;
350 let d = doy - (153 * mp + 2) / 5 + 1;
351 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
352 let y = if mo <= 2 { y + 1 } else { y };
353 (y as u32, mo, d, h, mi, s)
354}
355
356fn random_id(conn: &Connection) -> Result<String> {
357 let id: String =
359 conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
360 Ok(id)
361}
362
363pub fn prune(
367 sessions_dir: &Path,
368 older_than: Duration,
369 policy: PrunePolicy,
370) -> Result<Vec<PathBuf>> {
371 if !sessions_dir.exists() {
372 return Ok(vec![]);
373 }
374 let cutoff = SystemTime::now() - older_than;
375 let mut deleted = vec![];
376
377 for entry in fs::read_dir(sessions_dir)? {
378 let entry = entry?;
379 let path = entry.path();
380 if path.extension().and_then(|s| s.to_str()) != Some("db") {
381 continue;
382 }
383 let md = match entry.metadata() {
384 Ok(m) => m,
385 Err(_) => continue,
386 };
387 let mtime = md.modified().unwrap_or(SystemTime::now());
388 if mtime > cutoff {
389 continue;
390 }
391
392 let should_delete = match policy {
396 PrunePolicy::All => true,
397 PrunePolicy::AutoOnly => is_auto_session(&path).unwrap_or(false),
398 };
399 if !should_delete {
400 continue;
401 }
402
403 fs::remove_file(&path).ok();
406 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
407 let raw = sessions_dir.join(stem);
408 if raw.is_dir() {
409 fs::remove_dir_all(&raw).ok();
410 }
411 }
412 deleted.push(path);
413 }
414
415 Ok(deleted)
416}
417
418fn is_auto_session(path: &Path) -> Result<bool> {
419 let conn = Connection::open(path)?;
420 let v: i64 = conn
421 .query_row("PRAGMA user_version", [], |r| r.get(0))
422 .unwrap_or(-1);
423 if v != SCHEMA_VERSION {
424 return Ok(true);
426 }
427 let created_by: String = conn
428 .query_row("SELECT created_by FROM sessions LIMIT 1", [], |r| r.get(0))
429 .unwrap_or_else(|_| "auto".to_string());
430 Ok(created_by == "auto")
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use tempfile::TempDir;
437
438 fn opts<'a>(cwd: &'a Path, target: &'a str) -> CreateOptions<'a> {
439 CreateOptions {
440 kind: SessionKind::Debug,
441 target,
442 target_class: TargetClass::NativeCpu,
443 cwd,
444 db_path: None,
445 label: Some(format!("t-{target}")),
446 target_hash: Some("test".into()),
447 }
448 }
449
450 #[test]
451 fn create_inserts_session_row() {
452 let tmp = TempDir::new().unwrap();
453 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
454 assert_eq!(db.kind(), SessionKind::Debug);
455 assert_eq!(db.target(), "/bin/ls");
456 let (label, class, created_by): (String, String, String) = db
457 .conn
458 .query_row(
459 "SELECT label, target_class, created_by FROM sessions",
460 [],
461 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
462 )
463 .unwrap();
464 assert_eq!(label, "t-/bin/ls");
465 assert_eq!(class, "native-cpu");
466 assert_eq!(created_by, "auto");
467 }
468
469 #[test]
470 fn save_and_open_roundtrip() {
471 let tmp = TempDir::new().unwrap();
472 let path = tmp.path().join("roundtrip.db");
473 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
474 src.save_to(&path).unwrap();
475
476 let opened = SessionDb::open(&path).unwrap();
477 assert_eq!(opened.session_id(), src.session_id());
478 assert_eq!(opened.label(), src.label());
479 assert_eq!(opened.target_class(), TargetClass::NativeCpu);
480 }
481
482 #[test]
483 fn open_refuses_mismatched_version() {
484 let tmp = TempDir::new().unwrap();
485 let path = tmp.path().join("bad.db");
486 {
487 let conn = Connection::open(&path).unwrap();
488 conn.execute("PRAGMA user_version = 9999", []).unwrap();
490 }
491 let err = SessionDb::open(&path).unwrap_err().to_string();
492 assert!(
493 err.contains("schema_version=9999") && err.contains("No migration path"),
494 "error missing expected phrases: {err}"
495 );
496 }
497
498 #[test]
499 fn has_captured_data_false_on_fresh_session() {
500 let tmp = TempDir::new().unwrap();
501 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
502 assert!(!db.has_captured_data().unwrap());
503 }
504
505 #[test]
506 fn has_captured_data_true_after_breakpoint_hit() {
507 let tmp = TempDir::new().unwrap();
508 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
509 db.conn
510 .execute(
511 "INSERT INTO breakpoint_hits
512 (session_id, location_key, hit_seq, ts)
513 VALUES (?1, 'main.c:42', 1, datetime('now'))",
514 params![db.session_id()],
515 )
516 .unwrap();
517 assert!(db.has_captured_data().unwrap());
518 }
519
520 #[test]
521 fn has_captured_data_true_after_layer() {
522 let tmp = TempDir::new().unwrap();
523 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
524 db.conn
525 .execute(
526 "INSERT INTO layers (session_id, source) VALUES (?1, 'perf')",
527 params![db.session_id()],
528 )
529 .unwrap();
530 assert!(db.has_captured_data().unwrap());
531 }
532
533 #[test]
534 fn promote_to_user_sticks_through_save() {
535 let tmp = TempDir::new().unwrap();
536 let path = tmp.path().join("user.db");
537 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
538 src.promote_to_user().unwrap();
539 src.save_to(&path).unwrap();
540 let opened = SessionDb::open(&path).unwrap();
541 let cb: String = opened
542 .conn
543 .query_row("SELECT created_by FROM sessions", [], |r| r.get(0))
544 .unwrap();
545 assert_eq!(cb, "user");
546 }
547
548 #[test]
549 fn set_and_get_meta() {
550 let tmp = TempDir::new().unwrap();
551 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
552 db.set_meta("custom", "hello").unwrap();
553 assert_eq!(db.meta("custom").unwrap(), Some("hello".into()));
554 db.set_meta("custom", "world").unwrap();
555 assert_eq!(db.meta("custom").unwrap(), Some("world".into()));
556 }
557
558 #[test]
559 fn session_group_key_persisted_at_create() {
560 let tmp = TempDir::new().unwrap();
561 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
562 let g = db.meta("session_group_key").unwrap().unwrap();
563 assert!(g.ends_with("|test"), "unexpected group key: {g}");
564 }
565
566 #[test]
567 fn prune_auto_only_keeps_user_sessions() {
568 let tmp = TempDir::new().unwrap();
569 let dir = tmp.path().to_path_buf();
570
571 let auto_path = dir.join("auto.db");
572 let user_path = dir.join("user.db");
573 let a = SessionDb::create(CreateOptions {
574 db_path: None,
575 ..opts(&dir, "/bin/ls")
576 })
577 .unwrap();
578 a.save_to(&auto_path).unwrap();
579
580 let u = SessionDb::create(CreateOptions {
581 db_path: None,
582 ..opts(&dir, "/bin/ls")
583 })
584 .unwrap();
585 u.promote_to_user().unwrap();
586 u.save_to(&user_path).unwrap();
587
588 std::thread::sleep(Duration::from_millis(10));
591
592 let deleted = prune(&dir, Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
593 assert_eq!(deleted.len(), 1, "deleted: {deleted:?}");
594 assert!(deleted[0].ends_with("auto.db"));
595 assert!(user_path.exists());
596 assert!(!auto_path.exists());
597 }
598
599 #[test]
600 fn prune_skips_files_newer_than_cutoff() {
601 let tmp = TempDir::new().unwrap();
602 let dir = tmp.path().to_path_buf();
603 let p = dir.join("fresh.db");
604 let s = SessionDb::create(CreateOptions {
605 db_path: None,
606 ..opts(&dir, "/bin/ls")
607 })
608 .unwrap();
609 s.save_to(&p).unwrap();
610 let deleted = prune(&dir, Duration::from_secs(86_400), PrunePolicy::AutoOnly).unwrap();
612 assert!(deleted.is_empty());
613 assert!(p.exists());
614 }
615
616 #[test]
617 fn auto_label_has_expected_shape() {
618 let lbl = auto_label("/usr/bin/ls").unwrap();
619 assert!(lbl.starts_with("ls-"), "label: {lbl}");
620 let suffix = lbl.trim_start_matches("ls-");
621 assert_eq!(suffix.len(), 15); let (date, time) = suffix.split_once('-').unwrap();
623 assert_eq!(date.len(), 8);
624 assert_eq!(time.len(), 6);
625 assert!(date.chars().all(|c| c.is_ascii_digit()));
626 assert!(time.chars().all(|c| c.is_ascii_digit()));
627 }
628
629 #[test]
630 fn raw_dir_is_sibling_of_label() {
631 let d = raw_dir(Path::new("/tmp/proj"), "myapp-20260415-120000");
632 assert_eq!(
633 d,
634 PathBuf::from("/tmp/proj/.dbg/sessions/myapp-20260415-120000/raw")
635 );
636 }
637
638 #[test]
639 fn group_key_stable_across_create_calls() {
640 let tmp = TempDir::new().unwrap();
641 let a = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
642 let b = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
643 assert_eq!(
644 a.meta("session_group_key").unwrap(),
645 b.meta("session_group_key").unwrap()
646 );
647 }
648}