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 Ok(layers > 0)
224 }
225
226 pub fn promote_to_user(&self) -> Result<()> {
228 self.conn.execute(
229 "UPDATE sessions SET created_by = 'user' WHERE id = ?1",
230 params![self.session_id],
231 )?;
232 Ok(())
233 }
234
235 pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
236 self.conn.execute(
237 "INSERT INTO meta (session_id, key, value) VALUES (?1, ?2, ?3)
238 ON CONFLICT(session_id, key) DO UPDATE SET value = excluded.value",
239 params![self.session_id, key, value],
240 )?;
241 Ok(())
242 }
243
244 pub fn meta(&self, key: &str) -> Result<Option<String>> {
245 Ok(self.conn
246 .query_row(
247 "SELECT value FROM meta WHERE session_id = ?1 AND key = ?2",
248 params![self.session_id, key],
249 |r| r.get(0),
250 )
251 .optional()?)
252 }
253
254 pub fn conn(&self) -> &Connection { &self.conn }
255 pub fn session_id(&self) -> &str { &self.session_id }
256 pub fn label(&self) -> &str { &self.label }
257 pub fn kind(&self) -> SessionKind { self.kind }
258 pub fn target_class(&self) -> TargetClass { self.target_class }
259 pub fn target(&self) -> &str { &self.target }
260 pub fn db_path(&self) -> Option<&Path> { self.db_path.as_deref() }
261}
262
263pub fn sessions_dir(cwd: &Path) -> PathBuf {
265 cwd.join(".dbg").join("sessions")
266}
267
268pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
272 sessions_dir(cwd).join(label).join("raw")
273}
274
275pub fn group_key(cwd: &Path, target_hash: &str) -> String {
279 format!("{}|{}", cwd.display(), target_hash)
280}
281
282pub fn compute_target_hash(target: &Path) -> Result<String> {
285 let md = fs::metadata(target)
286 .with_context(|| format!("stat {}", target.display()))?;
287 let size = md.len();
288 let mtime = md
289 .modified()
290 .ok()
291 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
292 .map(|d| d.as_nanos())
293 .unwrap_or(0);
294 Ok(format!("{size}:{mtime}"))
295}
296
297pub fn auto_label(target: &str) -> Result<String> {
300 let basename = Path::new(target)
301 .file_name()
302 .and_then(|s| s.to_str())
303 .unwrap_or("session")
304 .to_string();
305 let secs = SystemTime::now()
306 .duration_since(UNIX_EPOCH)
307 .map(|d| d.as_secs())
308 .unwrap_or(0);
309 Ok(format!("{basename}-{}", format_timestamp(secs)))
310}
311
312fn format_timestamp(secs: u64) -> String {
315 let (y, mo, d, h, mi, s) = decompose_utc(secs);
316 format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
317}
318
319fn decompose_utc(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
320 let s = (secs % 60) as u32;
321 secs /= 60;
322 let mi = (secs % 60) as u32;
323 secs /= 60;
324 let h = (secs % 24) as u32;
325 secs /= 24;
326 let mut days = secs as i64;
327
328 days += 719_468;
331 let era = if days >= 0 { days / 146_097 } else { (days - 146_096) / 146_097 };
332 let doe = (days - era * 146_097) as u32;
333 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
334 let y = (yoe as i64) + era * 400;
335 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
336 let mp = (5 * doy + 2) / 153;
337 let d = doy - (153 * mp + 2) / 5 + 1;
338 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
339 let y = if mo <= 2 { y + 1 } else { y };
340 (y as u32, mo, d, h, mi, s)
341}
342
343fn random_id(conn: &Connection) -> Result<String> {
344 let id: String =
346 conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
347 Ok(id)
348}
349
350pub fn prune(
354 sessions_dir: &Path,
355 older_than: Duration,
356 policy: PrunePolicy,
357) -> Result<Vec<PathBuf>> {
358 if !sessions_dir.exists() {
359 return Ok(vec![]);
360 }
361 let cutoff = SystemTime::now() - older_than;
362 let mut deleted = vec![];
363
364 for entry in fs::read_dir(sessions_dir)? {
365 let entry = entry?;
366 let path = entry.path();
367 if path.extension().and_then(|s| s.to_str()) != Some("db") {
368 continue;
369 }
370 let md = match entry.metadata() {
371 Ok(m) => m,
372 Err(_) => continue,
373 };
374 let mtime = md.modified().unwrap_or(SystemTime::now());
375 if mtime > cutoff {
376 continue;
377 }
378
379 let should_delete = match policy {
383 PrunePolicy::All => true,
384 PrunePolicy::AutoOnly => is_auto_session(&path).unwrap_or(false),
385 };
386 if !should_delete {
387 continue;
388 }
389
390 fs::remove_file(&path).ok();
393 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
394 let raw = sessions_dir.join(stem);
395 if raw.is_dir() {
396 fs::remove_dir_all(&raw).ok();
397 }
398 }
399 deleted.push(path);
400 }
401
402 Ok(deleted)
403}
404
405fn is_auto_session(path: &Path) -> Result<bool> {
406 let conn = Connection::open(path)?;
407 let v: i64 = conn
408 .query_row("PRAGMA user_version", [], |r| r.get(0))
409 .unwrap_or(-1);
410 if v != SCHEMA_VERSION {
411 return Ok(true);
413 }
414 let created_by: String = conn
415 .query_row("SELECT created_by FROM sessions LIMIT 1", [], |r| r.get(0))
416 .unwrap_or_else(|_| "auto".to_string());
417 Ok(created_by == "auto")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use tempfile::TempDir;
424
425 fn opts<'a>(cwd: &'a Path, target: &'a str) -> CreateOptions<'a> {
426 CreateOptions {
427 kind: SessionKind::Debug,
428 target,
429 target_class: TargetClass::NativeCpu,
430 cwd,
431 db_path: None,
432 label: Some(format!("t-{target}")),
433 target_hash: Some("test".into()),
434 }
435 }
436
437 #[test]
438 fn create_inserts_session_row() {
439 let tmp = TempDir::new().unwrap();
440 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
441 assert_eq!(db.kind(), SessionKind::Debug);
442 assert_eq!(db.target(), "/bin/ls");
443 let (label, class, created_by): (String, String, String) = db
444 .conn
445 .query_row(
446 "SELECT label, target_class, created_by FROM sessions",
447 [],
448 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
449 )
450 .unwrap();
451 assert_eq!(label, "t-/bin/ls");
452 assert_eq!(class, "native-cpu");
453 assert_eq!(created_by, "auto");
454 }
455
456 #[test]
457 fn save_and_open_roundtrip() {
458 let tmp = TempDir::new().unwrap();
459 let path = tmp.path().join("roundtrip.db");
460 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
461 src.save_to(&path).unwrap();
462
463 let opened = SessionDb::open(&path).unwrap();
464 assert_eq!(opened.session_id(), src.session_id());
465 assert_eq!(opened.label(), src.label());
466 assert_eq!(opened.target_class(), TargetClass::NativeCpu);
467 }
468
469 #[test]
470 fn open_refuses_mismatched_version() {
471 let tmp = TempDir::new().unwrap();
472 let path = tmp.path().join("bad.db");
473 {
474 let conn = Connection::open(&path).unwrap();
475 conn.execute("PRAGMA user_version = 9999", []).unwrap();
477 }
478 let err = SessionDb::open(&path).unwrap_err().to_string();
479 assert!(
480 err.contains("schema_version=9999") && err.contains("No migration path"),
481 "error missing expected phrases: {err}"
482 );
483 }
484
485 #[test]
486 fn has_captured_data_false_on_fresh_session() {
487 let tmp = TempDir::new().unwrap();
488 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
489 assert!(!db.has_captured_data().unwrap());
490 }
491
492 #[test]
493 fn has_captured_data_true_after_breakpoint_hit() {
494 let tmp = TempDir::new().unwrap();
495 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
496 db.conn
497 .execute(
498 "INSERT INTO breakpoint_hits
499 (session_id, location_key, hit_seq, ts)
500 VALUES (?1, 'main.c:42', 1, datetime('now'))",
501 params![db.session_id()],
502 )
503 .unwrap();
504 assert!(db.has_captured_data().unwrap());
505 }
506
507 #[test]
508 fn has_captured_data_true_after_layer() {
509 let tmp = TempDir::new().unwrap();
510 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
511 db.conn
512 .execute(
513 "INSERT INTO layers (session_id, source) VALUES (?1, 'perf')",
514 params![db.session_id()],
515 )
516 .unwrap();
517 assert!(db.has_captured_data().unwrap());
518 }
519
520 #[test]
521 fn promote_to_user_sticks_through_save() {
522 let tmp = TempDir::new().unwrap();
523 let path = tmp.path().join("user.db");
524 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
525 src.promote_to_user().unwrap();
526 src.save_to(&path).unwrap();
527 let opened = SessionDb::open(&path).unwrap();
528 let cb: String = opened
529 .conn
530 .query_row("SELECT created_by FROM sessions", [], |r| r.get(0))
531 .unwrap();
532 assert_eq!(cb, "user");
533 }
534
535 #[test]
536 fn set_and_get_meta() {
537 let tmp = TempDir::new().unwrap();
538 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
539 db.set_meta("custom", "hello").unwrap();
540 assert_eq!(db.meta("custom").unwrap(), Some("hello".into()));
541 db.set_meta("custom", "world").unwrap();
542 assert_eq!(db.meta("custom").unwrap(), Some("world".into()));
543 }
544
545 #[test]
546 fn session_group_key_persisted_at_create() {
547 let tmp = TempDir::new().unwrap();
548 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
549 let g = db.meta("session_group_key").unwrap().unwrap();
550 assert!(g.ends_with("|test"), "unexpected group key: {g}");
551 }
552
553 #[test]
554 fn prune_auto_only_keeps_user_sessions() {
555 let tmp = TempDir::new().unwrap();
556 let dir = tmp.path().to_path_buf();
557
558 let auto_path = dir.join("auto.db");
559 let user_path = dir.join("user.db");
560 let a = SessionDb::create(CreateOptions {
561 db_path: None,
562 ..opts(&dir, "/bin/ls")
563 })
564 .unwrap();
565 a.save_to(&auto_path).unwrap();
566
567 let u = SessionDb::create(CreateOptions {
568 db_path: None,
569 ..opts(&dir, "/bin/ls")
570 })
571 .unwrap();
572 u.promote_to_user().unwrap();
573 u.save_to(&user_path).unwrap();
574
575 std::thread::sleep(Duration::from_millis(10));
578
579 let deleted = prune(&dir, Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
580 assert_eq!(deleted.len(), 1, "deleted: {deleted:?}");
581 assert!(deleted[0].ends_with("auto.db"));
582 assert!(user_path.exists());
583 assert!(!auto_path.exists());
584 }
585
586 #[test]
587 fn prune_skips_files_newer_than_cutoff() {
588 let tmp = TempDir::new().unwrap();
589 let dir = tmp.path().to_path_buf();
590 let p = dir.join("fresh.db");
591 let s = SessionDb::create(CreateOptions {
592 db_path: None,
593 ..opts(&dir, "/bin/ls")
594 })
595 .unwrap();
596 s.save_to(&p).unwrap();
597 let deleted = prune(&dir, Duration::from_secs(86_400), PrunePolicy::AutoOnly).unwrap();
599 assert!(deleted.is_empty());
600 assert!(p.exists());
601 }
602
603 #[test]
604 fn auto_label_has_expected_shape() {
605 let lbl = auto_label("/usr/bin/ls").unwrap();
606 assert!(lbl.starts_with("ls-"), "label: {lbl}");
607 let suffix = lbl.trim_start_matches("ls-");
608 assert_eq!(suffix.len(), 15); let (date, time) = suffix.split_once('-').unwrap();
610 assert_eq!(date.len(), 8);
611 assert_eq!(time.len(), 6);
612 assert!(date.chars().all(|c| c.is_ascii_digit()));
613 assert!(time.chars().all(|c| c.is_ascii_digit()));
614 }
615
616 #[test]
617 fn raw_dir_is_sibling_of_label() {
618 let d = raw_dir(Path::new("/tmp/proj"), "myapp-20260415-120000");
619 assert_eq!(
620 d,
621 PathBuf::from("/tmp/proj/.dbg/sessions/myapp-20260415-120000/raw")
622 );
623 }
624
625 #[test]
626 fn group_key_stable_across_create_calls() {
627 let tmp = TempDir::new().unwrap();
628 let a = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
629 let b = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
630 assert_eq!(
631 a.meta("session_group_key").unwrap(),
632 b.meta("session_group_key").unwrap()
633 );
634 }
635}