1use crate::model::{ParsedIdlDefinition, ParsedReference, ParsedSection};
3use anyhow::Result;
4use rusqlite::{Connection, OptionalExtension};
5
6pub fn insert_or_get_spec(
9 conn: &Connection,
10 name: &str,
11 base_url: &str,
12 provider: &str,
13) -> Result<i64> {
14 conn.execute(
16 "INSERT OR IGNORE INTO specs (name, base_url, provider) VALUES (?1, ?2, ?3)",
17 (name, base_url, provider),
18 )?;
19
20 let id: i64 = conn.query_row("SELECT id FROM specs WHERE name = ?1", [name], |row| {
22 row.get(0)
23 })?;
24
25 Ok(id)
26}
27
28pub fn seed_spec(conn: &Connection, name: &str, base_url: &str, provider: &str) -> Result<()> {
33 let existing: Option<(i64, String)> = conn
34 .query_row(
35 "SELECT id, base_url FROM specs WHERE name = ?1",
36 [name],
37 |row| Ok((row.get(0)?, row.get(1)?)),
38 )
39 .optional()?;
40
41 match existing {
42 None => {
43 conn.execute(
44 "INSERT INTO specs (name, base_url, provider) VALUES (?1, ?2, ?3)",
45 (name, base_url, provider),
46 )?;
47 }
48 Some((id, old_url)) if old_url != base_url => {
49 conn.execute(
50 "UPDATE specs SET base_url = ?1, provider = ?2 WHERE id = ?3",
51 (base_url, provider, id),
52 )?;
53 delete_spec_data(conn, id)?;
54 }
55 _ => {}
56 }
57 Ok(())
58}
59
60pub fn insert_snapshot(
62 conn: &Connection,
63 spec_id: i64,
64 sha: &str,
65 commit_date: &str,
66) -> Result<i64> {
67 let indexed_at = chrono::Utc::now().to_rfc3339();
69
70 conn.execute(
72 "INSERT INTO snapshots (spec_id, sha, commit_date, indexed_at)
73 VALUES (?1, ?2, ?3, ?4)",
74 (spec_id, sha, commit_date, &indexed_at),
75 )?;
76
77 let id = conn.last_insert_rowid();
79
80 Ok(id)
81}
82
83pub fn insert_sections_bulk(
85 conn: &Connection,
86 snapshot_id: i64,
87 sections: &[ParsedSection],
88) -> Result<()> {
89 let tx = conn.unchecked_transaction()?;
90
91 {
92 let mut stmt = tx.prepare(
93 "INSERT OR IGNORE INTO sections
94 (snapshot_id, anchor, title, content_text, section_type, parent_anchor, prev_anchor, next_anchor, depth)
95 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
96 )?;
97
98 for section in sections {
99 stmt.execute((
100 snapshot_id,
101 §ion.anchor,
102 §ion.title,
103 §ion.content_text,
104 section.section_type.as_str(),
105 §ion.parent_anchor,
106 §ion.prev_anchor,
107 §ion.next_anchor,
108 section.depth,
109 ))?;
110 }
111 }
112
113 tx.commit()?;
114 Ok(())
115}
116
117pub fn insert_refs_bulk(
119 conn: &Connection,
120 snapshot_id: i64,
121 refs: &[ParsedReference],
122) -> Result<()> {
123 let tx = conn.unchecked_transaction()?;
124
125 {
126 let mut stmt = tx.prepare(
127 "INSERT INTO refs (snapshot_id, from_anchor, to_spec, to_anchor)
128 VALUES (?1, ?2, ?3, ?4)",
129 )?;
130
131 for reference in refs {
132 stmt.execute((
133 snapshot_id,
134 &reference.from_anchor,
135 &reference.to_spec,
136 &reference.to_anchor,
137 ))?;
138 }
139 }
140
141 tx.commit()?;
142 Ok(())
143}
144
145pub fn insert_idl_defs_bulk(
147 conn: &Connection,
148 snapshot_id: i64,
149 defs: &[ParsedIdlDefinition],
150) -> Result<()> {
151 let tx = conn.unchecked_transaction()?;
152
153 {
154 let mut stmt = tx.prepare(
155 "INSERT INTO idl_defs (snapshot_id, anchor, name, owner, kind, canonical_name, idl_text)
156 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
157 )?;
158
159 for def in defs {
160 stmt.execute((
161 snapshot_id,
162 &def.anchor,
163 &def.name,
164 &def.owner,
165 &def.kind,
166 &def.canonical_name,
167 &def.idl_text,
168 ))?;
169 }
170 }
171
172 tx.commit()?;
173 Ok(())
174}
175
176pub fn insert_pr_snapshot(
179 conn: &Connection,
180 spec_id: i64,
181 sha: &str,
182 commit_date: &str,
183 pr_number: i64,
184 merge_base_sha: &str,
185 pr_pages: &[String],
186) -> Result<i64> {
187 let indexed_at = chrono::Utc::now().to_rfc3339();
188 let pages_str = pr_pages.join(",");
189 conn.execute(
190 "INSERT OR REPLACE INTO snapshots (spec_id, sha, commit_date, indexed_at, pr_number, merge_base_sha, pr_pages)
191 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
192 (spec_id, sha, commit_date, &indexed_at, pr_number, merge_base_sha, &pages_str),
193 )?;
194 Ok(conn.last_insert_rowid())
195}
196
197pub fn delete_pr_data(conn: &Connection, spec_id: i64, pr_number: i64) -> Result<()> {
199 let tx = conn.unchecked_transaction()?;
200 tx.execute(
201 "DELETE FROM refs WHERE snapshot_id IN \
202 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
203 (spec_id, pr_number),
204 )?;
205 tx.execute(
206 "DELETE FROM idl_defs WHERE snapshot_id IN \
207 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
208 (spec_id, pr_number),
209 )?;
210 tx.execute(
211 "DELETE FROM sections WHERE snapshot_id IN \
212 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
213 (spec_id, pr_number),
214 )?;
215 tx.execute(
216 "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2",
217 (spec_id, pr_number),
218 )?;
219 tx.commit()?;
220 Ok(())
221}
222
223pub fn delete_all_pr_data_for_spec(conn: &Connection, spec_id: i64) -> Result<usize> {
226 let tx = conn.unchecked_transaction()?;
227 let pr_count: i64 = tx.query_row(
228 "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL",
229 [spec_id],
230 |row| row.get(0),
231 )?;
232 tx.execute(
233 "DELETE FROM refs WHERE snapshot_id IN \
234 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
235 [spec_id],
236 )?;
237 tx.execute(
238 "DELETE FROM idl_defs WHERE snapshot_id IN \
239 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
240 [spec_id],
241 )?;
242 tx.execute(
243 "DELETE FROM sections WHERE snapshot_id IN \
244 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
245 [spec_id],
246 )?;
247 tx.execute(
248 "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL",
249 [spec_id],
250 )?;
251 tx.execute(
253 "DELETE FROM refs WHERE snapshot_id IN \
254 (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
255 AND s.sha NOT LIKE 'hash:%' \
256 AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
257 [spec_id],
258 )?;
259 tx.execute(
260 "DELETE FROM idl_defs WHERE snapshot_id IN \
261 (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
262 AND s.sha NOT LIKE 'hash:%' \
263 AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
264 [spec_id],
265 )?;
266 tx.execute(
267 "DELETE FROM sections WHERE snapshot_id IN \
268 (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
269 AND s.sha NOT LIKE 'hash:%' \
270 AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
271 [spec_id],
272 )?;
273 tx.execute(
274 "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL \
275 AND sha NOT LIKE 'hash:%' \
276 AND sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
277 [spec_id],
278 )?;
279 tx.commit()?;
280 Ok(pr_count as usize)
281}
282
283pub fn delete_spec_data(conn: &Connection, spec_id: i64) -> Result<()> {
288 let tx = conn.unchecked_transaction()?;
289
290 tx.execute(
291 "DELETE FROM refs WHERE snapshot_id IN \
292 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
293 [spec_id],
294 )?;
295 tx.execute(
296 "DELETE FROM idl_defs WHERE snapshot_id IN \
297 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
298 [spec_id],
299 )?;
300 tx.execute(
301 "DELETE FROM sections WHERE snapshot_id IN \
302 (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
303 [spec_id],
304 )?;
305 tx.execute(
306 "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%'",
307 [spec_id],
308 )?;
309
310 tx.commit()?;
311 Ok(())
312}
313
314pub fn record_update_check(
316 conn: &Connection,
317 spec_id: i64,
318 last_checked: &str,
319 last_indexed: Option<&str>,
320 content_hash: Option<&str>,
321) -> Result<()> {
322 conn.execute(
323 "INSERT OR REPLACE INTO update_checks (spec_id, last_checked, last_indexed, content_hash)
324 VALUES (?1, ?2, ?3, ?4)",
325 (spec_id, last_checked, last_indexed, content_hash),
326 )?;
327
328 Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::db;
335 use crate::model::SectionType;
336
337 #[test]
338 fn test_insert_or_get_spec() {
339 let conn = db::open_test_db().unwrap();
340
341 let id1 =
343 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
344 assert!(id1 > 0);
345
346 let id2 =
348 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
349 assert_eq!(id1, id2);
350
351 let id3 =
353 insert_or_get_spec(&conn, "DOM", "https://dom.spec.whatwg.org", "whatwg").unwrap();
354 assert_ne!(id1, id3);
355 }
356
357 #[test]
358 fn test_insert_snapshot() {
359 let conn = db::open_test_db().unwrap();
360
361 let spec_id =
362 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
363
364 let snapshot_id =
365 insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
366
367 assert!(snapshot_id > 0);
368
369 let count: i64 = conn
371 .query_row(
372 "SELECT COUNT(*) FROM snapshots WHERE id = ?1",
373 [snapshot_id],
374 |row| row.get(0),
375 )
376 .unwrap();
377 assert_eq!(count, 1);
378 }
379
380 #[test]
381 fn test_insert_sections_bulk() {
382 let conn = db::open_test_db().unwrap();
383
384 let spec_id =
385 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
386 let snapshot_id =
387 insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
388
389 let sections = vec![
390 ParsedSection {
391 anchor: "intro".to_string(),
392 title: Some("Introduction".to_string()),
393 content_text: Some("This is the intro".to_string()),
394 section_type: SectionType::Heading,
395 parent_anchor: None,
396 prev_anchor: None,
397 next_anchor: None,
398 depth: Some(2),
399 },
400 ParsedSection {
401 anchor: "details".to_string(),
402 title: Some("Details".to_string()),
403 content_text: Some("More details here".to_string()),
404 section_type: SectionType::Heading,
405 parent_anchor: Some("intro".to_string()),
406 prev_anchor: None,
407 next_anchor: None,
408 depth: Some(3),
409 },
410 ];
411
412 insert_sections_bulk(&conn, snapshot_id, §ions).unwrap();
413
414 let count: i64 = conn
416 .query_row(
417 "SELECT COUNT(*) FROM sections WHERE snapshot_id = ?1",
418 [snapshot_id],
419 |row| row.get(0),
420 )
421 .unwrap();
422 assert_eq!(count, 2);
423
424 let fts_count: i64 = conn
426 .query_row("SELECT COUNT(*) FROM sections_fts", [], |row| row.get(0))
427 .unwrap();
428 assert_eq!(fts_count, 2);
429 }
430
431 #[test]
432 fn test_insert_refs_bulk() {
433 let conn = db::open_test_db().unwrap();
434
435 let spec_id =
436 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
437 let snapshot_id =
438 insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
439
440 let refs = vec![
441 ParsedReference {
442 from_anchor: "intro".to_string(),
443 to_spec: "DOM".to_string(),
444 to_anchor: "concept-tree".to_string(),
445 },
446 ParsedReference {
447 from_anchor: "intro".to_string(),
448 to_spec: "HTML".to_string(),
449 to_anchor: "details".to_string(),
450 },
451 ];
452
453 insert_refs_bulk(&conn, snapshot_id, &refs).unwrap();
454
455 let count: i64 = conn
457 .query_row(
458 "SELECT COUNT(*) FROM refs WHERE snapshot_id = ?1",
459 [snapshot_id],
460 |row| row.get(0),
461 )
462 .unwrap();
463 assert_eq!(count, 2);
464 }
465
466 #[test]
467 fn test_delete_spec_data() {
468 let conn = db::open_test_db().unwrap();
469
470 let spec_id =
471 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
472 let snapshot_id =
474 insert_snapshot(&conn, spec_id, "hash:abc123", "2026-01-01T00:00:00Z").unwrap();
475
476 let sections = vec![crate::model::ParsedSection {
477 anchor: "intro".to_string(),
478 title: Some("Introduction".to_string()),
479 content_text: None,
480 section_type: SectionType::Heading,
481 parent_anchor: None,
482 prev_anchor: None,
483 next_anchor: None,
484 depth: Some(2),
485 }];
486 insert_sections_bulk(&conn, snapshot_id, §ions).unwrap();
487
488 let count: i64 = conn
490 .query_row("SELECT COUNT(*) FROM sections", [], |row| row.get(0))
491 .unwrap();
492 assert_eq!(count, 1);
493
494 delete_spec_data(&conn, spec_id).unwrap();
496
497 let snap_count: i64 = conn
499 .query_row("SELECT COUNT(*) FROM snapshots", [], |row| row.get(0))
500 .unwrap();
501 assert_eq!(snap_count, 0);
502
503 let sec_count: i64 = conn
504 .query_row("SELECT COUNT(*) FROM sections", [], |row| row.get(0))
505 .unwrap();
506 assert_eq!(sec_count, 0);
507 }
508
509 #[test]
510 fn test_insert_pr_snapshot() {
511 let conn = db::open_test_db().unwrap();
512 let spec_id =
513 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
514
515 let snapshot_id = insert_pr_snapshot(
516 &conn,
517 spec_id,
518 "hash:abc123",
519 "2026-01-01T00:00:00Z",
520 12345,
521 "def456full",
522 &[],
523 )
524 .unwrap();
525 assert!(snapshot_id > 0);
526
527 let (pr, base): (Option<i64>, Option<String>) = conn
528 .query_row(
529 "SELECT pr_number, merge_base_sha FROM snapshots WHERE id = ?1",
530 [snapshot_id],
531 |row| Ok((row.get(0)?, row.get(1)?)),
532 )
533 .unwrap();
534 assert_eq!(pr, Some(12345));
535 assert_eq!(base.as_deref(), Some("def456full"));
536 }
537
538 #[test]
539 fn test_delete_pr_data() {
540 let conn = db::open_test_db().unwrap();
541 let spec_id =
542 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
543
544 let trunk_id = insert_snapshot(&conn, spec_id, "trunk1", "2026-01-01T00:00:00Z").unwrap();
546 insert_sections_bulk(
547 &conn,
548 trunk_id,
549 &[ParsedSection {
550 anchor: "intro".into(),
551 title: Some("Intro".into()),
552 content_text: None,
553 section_type: SectionType::Heading,
554 parent_anchor: None,
555 prev_anchor: None,
556 next_anchor: None,
557 depth: Some(2),
558 }],
559 )
560 .unwrap();
561
562 let pr_id = insert_pr_snapshot(
564 &conn,
565 spec_id,
566 "pr1",
567 "2026-01-01T00:00:00Z",
568 123,
569 "base1",
570 &[],
571 )
572 .unwrap();
573 insert_sections_bulk(
574 &conn,
575 pr_id,
576 &[ParsedSection {
577 anchor: "new-section".into(),
578 title: Some("New".into()),
579 content_text: None,
580 section_type: SectionType::Heading,
581 parent_anchor: None,
582 prev_anchor: None,
583 next_anchor: None,
584 depth: Some(2),
585 }],
586 )
587 .unwrap();
588
589 delete_pr_data(&conn, spec_id, 123).unwrap();
591
592 let trunk_count: i64 = conn
594 .query_row(
595 "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL",
596 [spec_id],
597 |row| row.get(0),
598 )
599 .unwrap();
600 assert_eq!(trunk_count, 1);
601
602 let pr_count: i64 = conn
604 .query_row(
605 "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number = 123",
606 [spec_id],
607 |row| row.get(0),
608 )
609 .unwrap();
610 assert_eq!(pr_count, 0);
611 }
612
613 #[test]
614 fn test_delete_spec_data_preserves_pr_and_commit_snapshots() {
615 let conn = db::open_test_db().unwrap();
616 let spec_id =
617 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
618
619 insert_snapshot(&conn, spec_id, "hash:abc123", "2026-01-01T00:00:00Z").unwrap();
621 insert_snapshot(&conn, spec_id, "74cbe0af38fee8a0", "2026-01-01T00:00:00Z").unwrap();
622 insert_pr_snapshot(
623 &conn,
624 spec_id,
625 "pr:123:def",
626 "2026-01-01T00:00:00Z",
627 123,
628 "74cbe0af38fee8a0",
629 &[],
630 )
631 .unwrap();
632
633 delete_spec_data(&conn, spec_id).unwrap();
635
636 let total: i64 = conn
637 .query_row(
638 "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1",
639 [spec_id],
640 |row| row.get(0),
641 )
642 .unwrap();
643 assert_eq!(total, 2); }
645
646 #[test]
647 fn test_insert_idl_defs_bulk() {
648 let conn = db::open_test_db().unwrap();
649 let spec_id =
650 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
651 let snapshot_id =
652 insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
653
654 let defs = vec![
655 ParsedIdlDefinition {
656 anchor: "dom-window".to_string(),
657 name: "Window".to_string(),
658 owner: None,
659 kind: "interface".to_string(),
660 canonical_name: "Window".to_string(),
661 idl_text: Some("interface Window {};".to_string()),
662 },
663 ParsedIdlDefinition {
664 anchor: "dom-window-navigation".to_string(),
665 name: "navigation".to_string(),
666 owner: Some("Window".to_string()),
667 kind: "attribute".to_string(),
668 canonical_name: "Window.navigation".to_string(),
669 idl_text: Some(
670 "interface Window { attribute Navigation navigation; };".to_string(),
671 ),
672 },
673 ];
674
675 insert_idl_defs_bulk(&conn, snapshot_id, &defs).unwrap();
676
677 let count: i64 = conn
678 .query_row(
679 "SELECT COUNT(*) FROM idl_defs WHERE snapshot_id = ?1",
680 [snapshot_id],
681 |row| row.get(0),
682 )
683 .unwrap();
684 assert_eq!(count, 2);
685 }
686
687 #[test]
688 fn test_record_update_check() {
689 let conn = db::open_test_db().unwrap();
690
691 let spec_id =
692 insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
693
694 record_update_check(
695 &conn,
696 spec_id,
697 "2026-01-01T00:00:00Z",
698 Some("2026-01-01T00:00:00Z"),
699 Some("deadbeef"),
700 )
701 .unwrap();
702
703 let count: i64 = conn
705 .query_row(
706 "SELECT COUNT(*) FROM update_checks WHERE spec_id = ?1",
707 [spec_id],
708 |row| row.get(0),
709 )
710 .unwrap();
711 assert_eq!(count, 1);
712
713 record_update_check(
715 &conn,
716 spec_id,
717 "2026-01-02T00:00:00Z",
718 None,
719 Some("beadfeed"),
720 )
721 .unwrap();
722
723 let count: i64 = conn
724 .query_row(
725 "SELECT COUNT(*) FROM update_checks WHERE spec_id = ?1",
726 [spec_id],
727 |row| row.get(0),
728 )
729 .unwrap();
730 assert_eq!(count, 1);
731
732 let (checked, indexed, hash): (String, Option<String>, Option<String>) = conn
733 .query_row(
734 "SELECT last_checked, last_indexed, content_hash FROM update_checks WHERE spec_id = ?1",
735 [spec_id],
736 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
737 )
738 .unwrap();
739 assert_eq!(checked, "2026-01-02T00:00:00Z");
740 assert_eq!(indexed, None);
741 assert_eq!(hash.as_deref(), Some("beadfeed"));
742 }
743}