Skip to main content

browser_control/registry/
mod.rs

1//! SQLite-backed registry of running browser instances.
2
3pub mod bidi_lock;
4mod db;
5pub mod naming;
6pub mod schema;
7pub mod scratches;
8pub mod tabs;
9pub mod words;
10
11pub use bidi_lock::{BidiLockBusy, BidiLockGuard, BidiLockRow};
12pub use scratches::ScratchRow;
13pub use tabs::TabRow;
14
15use anyhow::{anyhow, bail, Context, Result};
16use fs2::FileExt;
17use rusqlite::{params, OpenFlags};
18use serde::{Deserialize, Serialize};
19use std::fs::OpenOptions;
20use std::net::{SocketAddr, TcpStream};
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use crate::detect::{Engine, Kind};
25
26/// One row in the `browsers` table.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct BrowserRow {
29    pub name: String,
30    pub kind: Kind,
31    pub engine: Engine,
32    pub pid: u32,
33    pub endpoint: String,
34    pub port: u16,
35    pub profile_dir: PathBuf,
36    pub executable: PathBuf,
37    pub headless: bool,
38    pub started_at: String,
39}
40
41/// Current registry liveness classification for a browser row.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum BrowserLiveness {
44    /// The recorded process exists and the debugging endpoint accepts TCP.
45    Alive,
46    /// The recorded process no longer exists. This is high-confidence stale
47    /// state and can be pruned.
48    DeadPid,
49    /// The recorded process exists, but the debugging endpoint did not accept
50    /// TCP right now. Treat as unavailable, but do not delete the row: short
51    /// endpoint outages can be transient during browser startup/restart.
52    EndpointUnreachable,
53}
54
55/// SQLite registry handle. SQLite-level WAL + `busy_timeout` handles
56/// concurrent reader/writer coordination across processes; we no longer
57/// hold a long-lived exclusive file lock for the lifetime of the
58/// handle. A short exclusive lock is taken only across the
59/// schema-migration window in `open_at` to serialise initial
60/// `CREATE TABLE` / `CREATE INDEX` against a concurrent first open.
61pub struct Registry {
62    conn: rusqlite::Connection,
63    /// Path the registry was opened at. Read by `bidi_lock_acquire` to
64    /// stamp the `BidiLockGuard`, whose `Drop` reopens a fresh connection
65    /// here to release the row (the guard outlives the borrowing handle).
66    db_path: PathBuf,
67}
68
69impl Registry {
70    /// Open the registry at the OS-standard location.
71    pub fn open() -> Result<Self> {
72        let p = crate::paths::registry_db_path()?;
73        Self::open_at(&p)
74    }
75
76    /// Open the registry at an explicit path.
77    pub fn open_at(path: &Path) -> Result<Self> {
78        if let Some(parent) = path.parent() {
79            if !parent.as_os_str().is_empty() {
80                std::fs::create_dir_all(parent)
81                    .with_context(|| format!("creating registry dir {}", parent.display()))?;
82            }
83        }
84
85        // Hold an exclusive file lock only across the initial schema
86        // migration. SQLite's own `CREATE TABLE IF NOT EXISTS` is
87        // idempotent and safe under concurrent execution, but
88        // narrowing the lock to this window keeps the historical
89        // serialisation guarantee for any future schema-altering
90        // change while letting steady-state CLI invocations proceed
91        // in parallel.
92        let lock_path = lock_path_for(path);
93        let lock_file = OpenOptions::new()
94            .create(true)
95            .read(true)
96            .write(true)
97            .truncate(false)
98            .open(&lock_path)
99            .with_context(|| format!("opening lock file {}", lock_path.display()))?;
100        FileExt::lock_exclusive(&lock_file)
101            .with_context(|| format!("acquiring exclusive lock on {}", lock_path.display()))?;
102
103        let conn = rusqlite::Connection::open_with_flags(
104            path,
105            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
106        )
107        .with_context(|| format!("opening registry db {}", path.display()))?;
108
109        configure_conn(&conn)?;
110        schema::apply(&conn)?;
111
112        // Release the migration lock. From here on, SQLite's
113        // WAL + busy_timeout handles inter-process coordination
114        // for both reads and writes; CLI invocations no longer
115        // serialise on browser I/O.
116        let _ = FileExt::unlock(&lock_file);
117        drop(lock_file);
118
119        Ok(Self {
120            conn,
121            db_path: path.to_path_buf(),
122        })
123    }
124
125    /// Open an in-memory registry (tests only). No file lock taken.
126    pub fn open_in_memory() -> Result<Self> {
127        let conn = rusqlite::Connection::open_in_memory().context("opening in-memory registry")?;
128        // WAL is not supported for :memory:; only set synchronous.
129        let _ = conn.pragma_update(None, "synchronous", "NORMAL");
130        schema::apply(&conn)?;
131        Ok(Self {
132            conn,
133            db_path: PathBuf::from(":memory:"),
134        })
135    }
136
137    /// Insert (or replace) a row.
138    pub fn insert(&self, row: &BrowserRow) -> Result<()> {
139        db::execute(
140            &self.conn,
141            "INSERT OR REPLACE INTO browsers
142                    (name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at)
143                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
144            params![
145                row.name,
146                kind_to_str(row.kind),
147                engine_to_str(row.engine),
148                row.pid as i64,
149                row.endpoint,
150                row.port as i64,
151                row.profile_dir.to_string_lossy(),
152                row.executable.to_string_lossy(),
153                row.headless as i64,
154                row.started_at,
155            ],
156            || format!("inserting registry row {}", row.name),
157        )
158    }
159
160    /// Delete a row by name. No error if it does not exist.
161    pub fn delete(&self, name: &str) -> Result<()> {
162        db::execute(
163            &self.conn,
164            "DELETE FROM browsers WHERE name = ?1",
165            params![name],
166            || format!("deleting registry row {name}"),
167        )
168    }
169
170    /// Look up a row by name.
171    pub fn get_by_name(&self, name: &str) -> Result<Option<BrowserRow>> {
172        db::query_optional(
173            &self.conn,
174            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at FROM browsers WHERE name = ?1",
175            params![name],
176            row_from_sqlite,
177        )
178    }
179
180    /// All rows, no liveness check, ordered by started_at DESC.
181    pub fn list_all(&self) -> Result<Vec<BrowserRow>> {
182        db::query_vec(
183            &self.conn,
184            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
185             FROM browsers ORDER BY started_at DESC",
186            [],
187            row_from_sqlite,
188        )
189    }
190
191    /// All rows of a given kind ordered by started_at DESC, without liveness check.
192    pub(crate) fn list_by_kind_all(&self, kind: Kind) -> Result<Vec<BrowserRow>> {
193        db::query_vec(
194            &self.conn,
195            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
196             FROM browsers WHERE kind = ?1 ORDER BY started_at DESC",
197            params![kind_to_str(kind)],
198            row_from_sqlite,
199        )
200    }
201
202    /// All alive rows. Dead-PID rows are deleted as a side-effect; rows whose
203    /// endpoint is temporarily unreachable are omitted but retained.
204    pub fn list_alive(&self) -> Result<Vec<BrowserRow>> {
205        let all = self.list_all()?;
206        let mut alive = Vec::with_capacity(all.len());
207        for row in all {
208            match liveness(&row) {
209                BrowserLiveness::Alive => alive.push(row),
210                BrowserLiveness::DeadPid => {
211                    self.delete(&row.name)?;
212                }
213                BrowserLiveness::EndpointUnreachable => {}
214            }
215        }
216        Ok(alive)
217    }
218
219    /// First alive row of the given kind (most recent first). Dead-PID matches
220    /// are pruned; endpoint-unreachable rows are retained.
221    pub fn first_alive_by_kind(&self, kind: Kind) -> Result<Option<BrowserRow>> {
222        for row in self.list_by_kind_all(kind)? {
223            match liveness(&row) {
224                BrowserLiveness::Alive => return Ok(Some(row)),
225                BrowserLiveness::DeadPid => {
226                    self.delete(&row.name)?;
227                }
228                BrowserLiveness::EndpointUnreachable => {}
229            }
230        }
231        Ok(None)
232    }
233
234    /// Most recently started alive row across all kinds.
235    pub fn most_recent_alive(&self) -> Result<Option<BrowserRow>> {
236        for row in self.list_all()? {
237            match liveness(&row) {
238                BrowserLiveness::Alive => return Ok(Some(row)),
239                BrowserLiveness::DeadPid => {
240                    self.delete(&row.name)?;
241                }
242                BrowserLiveness::EndpointUnreachable => {}
243            }
244        }
245        Ok(None)
246    }
247}
248
249fn configure_conn(conn: &rusqlite::Connection) -> Result<()> {
250    // WAL allows concurrent readers + one writer per process group.
251    conn.pragma_update(None, "journal_mode", "WAL")
252        .context("setting journal_mode = WAL")?;
253    conn.pragma_update(None, "synchronous", "NORMAL")
254        .context("setting synchronous = NORMAL")?;
255    // `busy_timeout` is what makes concurrent invocations safe now that
256    // we no longer hold the long-lived advisory file lock. SQLite will
257    // retry a contended write for up to this duration before returning
258    // SQLITE_BUSY. Five seconds is generous for our workloads (single
259    // INSERT/UPDATE per CLI invocation) and short enough that a stuck
260    // process surfaces visibly instead of hanging forever.
261    conn.busy_timeout(Duration::from_secs(5))
262        .context("setting busy_timeout")?;
263    Ok(())
264}
265
266fn lock_path_for(db: &Path) -> PathBuf {
267    let mut name = db
268        .file_name()
269        .map(|n| n.to_os_string())
270        .unwrap_or_else(|| std::ffi::OsString::from("registry.db"));
271    name.push(".lock");
272    match db.parent() {
273        Some(p) if !p.as_os_str().is_empty() => p.join(name),
274        _ => PathBuf::from(name),
275    }
276}
277
278fn row_from_sqlite(r: &rusqlite::Row<'_>) -> Result<BrowserRow> {
279    let name: String = r.get(0)?;
280    let kind_s: String = r.get(1)?;
281    let engine_s: String = r.get(2)?;
282    let pid: i64 = r.get(3)?;
283    let endpoint: String = r.get(4)?;
284    let port: i64 = r.get(5)?;
285    let profile_dir: String = r.get(6)?;
286    let executable: String = r.get(7)?;
287    let headless: i64 = r.get(8)?;
288    let started_at: String = r.get(9)?;
289
290    Ok(BrowserRow {
291        name,
292        kind: parse_kind(&kind_s)?,
293        engine: parse_engine(&engine_s)?,
294        pid: pid as u32,
295        endpoint,
296        port: port as u16,
297        profile_dir: PathBuf::from(profile_dir),
298        executable: PathBuf::from(executable),
299        headless: headless != 0,
300        started_at,
301    })
302}
303
304fn kind_to_str(k: Kind) -> &'static str {
305    k.as_str()
306}
307
308fn parse_kind(s: &str) -> Result<Kind> {
309    Kind::parse(s).ok_or_else(|| anyhow!("invalid kind {s}"))
310}
311
312fn engine_to_str(e: Engine) -> &'static str {
313    match e {
314        Engine::Cdp => "cdp",
315        Engine::Bidi => "bidi",
316    }
317}
318
319fn parse_engine(s: &str) -> Result<Engine> {
320    match s {
321        "cdp" => Ok(Engine::Cdp),
322        "bidi" => Ok(Engine::Bidi),
323        _ => bail!("invalid engine {s}"),
324    }
325}
326
327/// Liveness check: PID exists AND a TCP connect to the local port succeeds.
328pub fn is_alive(row: &BrowserRow) -> bool {
329    liveness(row) == BrowserLiveness::Alive
330}
331
332/// Classify row liveness. Only [`BrowserLiveness::DeadPid`] is safe to prune.
333pub fn liveness(row: &BrowserRow) -> BrowserLiveness {
334    let pid = sysinfo::Pid::from_u32(row.pid);
335    let mut sys = sysinfo::System::new();
336    // Refresh only the target PID rather than the whole process table:
337    // these run per-row in `list_alive`/`first_alive_by_kind`/etc., and we
338    // only ever query this one PID below.
339    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
340    if sys.process(pid).is_none() {
341        return BrowserLiveness::DeadPid;
342    }
343    let addr = SocketAddr::from(([127, 0, 0, 1], row.port));
344    if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
345        BrowserLiveness::Alive
346    } else {
347        BrowserLiveness::EndpointUnreachable
348    }
349}
350
351/// Cheap check: is process `pid` still running on this machine? Used by
352/// stale-row eviction in `scratches`, `tabs`, and `bidi_locks` to avoid
353/// keeping rows registered to a crashed CLI process.
354pub fn pid_alive(pid: u32) -> bool {
355    let pid = sysinfo::Pid::from_u32(pid);
356    let mut sys = sysinfo::System::new();
357    // Refresh only this PID, not the entire process table — this is hit
358    // per-row by stale-row eviction across scratches/tabs/bidi_locks.
359    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
360    sys.process(pid).is_some()
361}
362
363/// Current Unix epoch seconds.
364pub fn now_epoch_s() -> i64 {
365    std::time::SystemTime::now()
366        .duration_since(std::time::UNIX_EPOCH)
367        .map(|d| d.as_secs() as i64)
368        .unwrap_or(0)
369}
370
371// -- ISO-8601 helpers --------------------------------------------------------
372
373/// Current time formatted as `YYYY-MM-DDTHH:MM:SSZ` (UTC).
374pub fn now_iso8601() -> String {
375    let secs = std::time::SystemTime::now()
376        .duration_since(std::time::UNIX_EPOCH)
377        .map(|d| d.as_secs() as i64)
378        .unwrap_or(0);
379    format_unix_seconds_as_iso8601(secs)
380}
381
382/// Format a Unix epoch second count as `YYYY-MM-DDTHH:MM:SSZ`.
383///
384/// Uses Howard Hinnant's `civil_from_days` algorithm.
385pub fn format_unix_seconds_as_iso8601(secs: i64) -> String {
386    // Split into days and time-of-day, handling negative seconds correctly.
387    let days = secs.div_euclid(86_400);
388    let tod = secs.rem_euclid(86_400);
389    let hour = (tod / 3600) as u32;
390    let minute = ((tod % 3600) / 60) as u32;
391    let second = (tod % 60) as u32;
392
393    let (y, m, d) = civil_from_days(days);
394    format!(
395        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
396        y, m, d, hour, minute, second
397    )
398}
399
400/// Convert days since 1970-01-01 to (year, month, day) using Hinnant's algorithm.
401fn civil_from_days(z: i64) -> (i64, u32, u32) {
402    let z = z + 719_468;
403    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
404    let doe = (z - era * 146_097) as u64; // [0, 146096]
405    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
406    let y = yoe as i64 + era * 400;
407    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
408    let mp = (5 * doy + 2) / 153; // [0, 11]
409    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
410    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
411    let y = if m <= 2 { y + 1 } else { y };
412    (y, m, d)
413}
414
415// ---------------------------------------------------------------------------
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    fn sample_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
422        BrowserRow {
423            name: name.to_string(),
424            kind,
425            engine: kind.engine(),
426            pid: 99_999_999, // unlikely to exist
427            endpoint: format!("http://127.0.0.1:{port}"),
428            port,
429            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
430            executable: PathBuf::from("/usr/bin/example"),
431            headless: false,
432            started_at: started_at.to_string(),
433        }
434    }
435
436    #[test]
437    fn insert_then_get_round_trip() {
438        let reg = Registry::open_in_memory().unwrap();
439        let row = sample_row("alpha-bravo", Kind::Chrome, 9222, "2024-01-02T03:04:05Z");
440        reg.insert(&row).unwrap();
441        let got = reg.get_by_name("alpha-bravo").unwrap().unwrap();
442        assert_eq!(got, row);
443        assert!(reg.get_by_name("missing").unwrap().is_none());
444    }
445
446    #[test]
447    fn list_all_returns_all_rows() {
448        let reg = Registry::open_in_memory().unwrap();
449        reg.insert(&sample_row("a", Kind::Chrome, 9001, "2024-01-01T00:00:00Z"))
450            .unwrap();
451        reg.insert(&sample_row(
452            "b",
453            Kind::Firefox,
454            9002,
455            "2024-01-02T00:00:00Z",
456        ))
457        .unwrap();
458        reg.insert(&sample_row("c", Kind::Edge, 9003, "2024-01-03T00:00:00Z"))
459            .unwrap();
460        let all = reg.list_all().unwrap();
461        assert_eq!(all.len(), 3);
462        // ordered DESC by started_at
463        assert_eq!(all[0].name, "c");
464        assert_eq!(all[2].name, "a");
465    }
466
467    #[test]
468    fn delete_removes_row() {
469        let reg = Registry::open_in_memory().unwrap();
470        let row = sample_row("x", Kind::Brave, 9010, "2024-05-05T05:05:05Z");
471        reg.insert(&row).unwrap();
472        reg.delete("x").unwrap();
473        assert!(reg.get_by_name("x").unwrap().is_none());
474        // deleting a missing row is a no-op
475        reg.delete("ghost").unwrap();
476    }
477
478    #[test]
479    fn first_alive_by_kind_returns_most_recent() {
480        // We can't easily mock `is_alive`. Instead verify the underlying SQL ordering
481        // via the pub(crate) helper.
482        let reg = Registry::open_in_memory().unwrap();
483        reg.insert(&sample_row(
484            "older",
485            Kind::Chrome,
486            9101,
487            "2024-01-01T00:00:00Z",
488        ))
489        .unwrap();
490        reg.insert(&sample_row(
491            "newer",
492            Kind::Chrome,
493            9102,
494            "2024-06-01T00:00:00Z",
495        ))
496        .unwrap();
497        reg.insert(&sample_row(
498            "ff",
499            Kind::Firefox,
500            9103,
501            "2024-07-01T00:00:00Z",
502        ))
503        .unwrap();
504        let chromes = reg.list_by_kind_all(Kind::Chrome).unwrap();
505        assert_eq!(chromes.len(), 2);
506        assert_eq!(chromes[0].name, "newer");
507        assert_eq!(chromes[1].name, "older");
508
509        // first_alive_by_kind on these synthetic rows should find none alive
510        // and prune dead-process entries.
511        assert!(reg.first_alive_by_kind(Kind::Chrome).unwrap().is_none());
512        assert!(reg.list_by_kind_all(Kind::Chrome).unwrap().is_empty());
513    }
514
515    #[test]
516    fn list_alive_prunes_stale() {
517        let reg = Registry::open_in_memory().unwrap();
518        reg.insert(&sample_row("a", Kind::Chrome, 9201, "2024-01-01T00:00:00Z"))
519            .unwrap();
520        reg.insert(&sample_row("b", Kind::Chrome, 9202, "2024-01-02T00:00:00Z"))
521            .unwrap();
522        let alive = reg.list_alive().unwrap();
523        assert!(alive.is_empty());
524        assert!(reg.list_all().unwrap().is_empty());
525    }
526
527    #[test]
528    fn list_alive_retains_live_pid_with_unreachable_endpoint() {
529        let reg = Registry::open_in_memory().unwrap();
530        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
531        let port = listener.local_addr().unwrap().port();
532        drop(listener);
533        let mut row = sample_row(
534            "temporarily-unreachable",
535            Kind::Chrome,
536            port,
537            "2024-01-01T00:00:00Z",
538        );
539        row.pid = std::process::id();
540        reg.insert(&row).unwrap();
541
542        let alive = reg.list_alive().unwrap();
543        assert!(alive.is_empty());
544        assert!(reg
545            .get_by_name("temporarily-unreachable")
546            .unwrap()
547            .is_some());
548    }
549
550    #[test]
551    fn most_recent_alive_with_no_live_rows_is_none() {
552        let reg = Registry::open_in_memory().unwrap();
553        reg.insert(&sample_row("a", Kind::Chrome, 9301, "2024-01-01T00:00:00Z"))
554            .unwrap();
555        assert!(reg.most_recent_alive().unwrap().is_none());
556    }
557
558    #[test]
559    fn now_iso8601_format() {
560        let s = now_iso8601();
561        assert_eq!(s.len(), 20, "got {s}");
562        assert!(s.ends_with('Z'));
563        assert_eq!(&s[4..5], "-");
564        assert_eq!(&s[7..8], "-");
565        assert_eq!(&s[10..11], "T");
566        assert_eq!(&s[13..14], ":");
567        assert_eq!(&s[16..17], ":");
568
569        // Epoch sanity.
570        assert_eq!(format_unix_seconds_as_iso8601(0), "1970-01-01T00:00:00Z");
571    }
572
573    #[test]
574    fn iso8601_known_dates() {
575        let cases = [
576            (0_i64, "1970-01-01T00:00:00Z"),
577            (951_782_400, "2000-02-29T00:00:00Z"), // leap day
578            (1_700_000_000, "2023-11-14T22:13:20Z"),
579            (1_583_020_799, "2020-02-29T23:59:59Z"), // last second of leap day
580            (1_583_020_800, "2020-03-01T00:00:00Z"),
581            (1_577_836_799, "2019-12-31T23:59:59Z"), // end of year
582        ];
583        for (secs, want) in cases {
584            assert_eq!(format_unix_seconds_as_iso8601(secs), want, "epoch {secs}");
585        }
586    }
587
588    #[test]
589    fn concurrent_file_lock_serializes() {
590        use std::thread;
591
592        let tmp = tempfile::TempDir::new().unwrap();
593        let db_path = tmp.path().join("registry.db");
594
595        let p1 = db_path.clone();
596        let p2 = db_path.clone();
597        let t1 = thread::spawn(move || {
598            let reg = Registry::open_at(&p1).unwrap();
599            reg.insert(&BrowserRow {
600                name: "one".to_string(),
601                kind: Kind::Chrome,
602                engine: Engine::Cdp,
603                pid: 1,
604                endpoint: "http://127.0.0.1:9001".to_string(),
605                port: 9001,
606                profile_dir: PathBuf::from("/tmp/p1"),
607                executable: PathBuf::from("/usr/bin/chrome"),
608                headless: false,
609                started_at: "2024-01-01T00:00:00Z".to_string(),
610            })
611            .unwrap();
612        });
613        let t2 = thread::spawn(move || {
614            let reg = Registry::open_at(&p2).unwrap();
615            reg.insert(&BrowserRow {
616                name: "two".to_string(),
617                kind: Kind::Firefox,
618                engine: Engine::Bidi,
619                pid: 2,
620                endpoint: "ws://127.0.0.1:9002".to_string(),
621                port: 9002,
622                profile_dir: PathBuf::from("/tmp/p2"),
623                executable: PathBuf::from("/usr/bin/firefox"),
624                headless: false,
625                started_at: "2024-01-02T00:00:00Z".to_string(),
626            })
627            .unwrap();
628        });
629        t1.join().unwrap();
630        t2.join().unwrap();
631
632        let reg = Registry::open_at(&db_path).unwrap();
633        let all = reg.list_all().unwrap();
634        assert_eq!(all.len(), 2);
635        let names: Vec<&str> = all.iter().map(|r| r.name.as_str()).collect();
636        assert!(names.contains(&"one"));
637        assert!(names.contains(&"two"));
638    }
639
640    #[test]
641    fn open_at_creates_parent_dir_and_db() {
642        let tmp = tempfile::TempDir::new().unwrap();
643        let nested = tmp.path().join("a/b/c/registry.db");
644        let reg = Registry::open_at(&nested).unwrap();
645        reg.insert(&sample_row("x", Kind::Chrome, 9999, "2024-01-01T00:00:00Z"))
646            .unwrap();
647        assert!(nested.exists());
648        let lock = nested.parent().unwrap().join("registry.db.lock");
649        assert!(lock.exists());
650    }
651}