1use std::collections::BTreeSet;
2use std::fs::{self, File};
3use std::io::{BufReader, Read};
4use std::path::{Path, PathBuf};
5
6use rusqlite::{Connection, Transaction, params};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::analysis::Analyzer;
11use crate::analysis::triage::TriageComputation;
12use crate::model::Issue;
13use crate::{BvrError, Result};
14
15pub const SQLITE_EXPORT_FILENAME: &str = "beads.sqlite3";
16pub const SQLITE_EXPORT_CONFIG_FILENAME: &str = "beads.sqlite3.config.json";
17pub const SQLITE_EXPORT_SCHEMA_VERSION: i32 = 2;
18pub const SQLITE_WRITER_CONTRACT_VERSION: &str = "1";
19pub const DEFAULT_SQLITE_PAGE_SIZE: u32 = 1_024;
20pub const DEFAULT_SQLITE_CHUNK_THRESHOLD_BYTES: u64 = 5 * 1024 * 1024;
21pub const DEFAULT_SQLITE_CHUNK_SIZE_BYTES: u64 = 1_048_576;
22
23const EXPORT_META_KEYS: &[(&str, &str)] = &[
24 ("schema_version", "2"),
25 ("writer_contract_version", SQLITE_WRITER_CONTRACT_VERSION),
26 ("layout", "single-file"),
27];
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SqliteBootstrapOptions {
31 pub page_size: u32,
32}
33
34impl Default for SqliteBootstrapOptions {
35 fn default() -> Self {
36 Self {
37 page_size: DEFAULT_SQLITE_PAGE_SIZE,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SqliteBootstrapSummary {
44 pub database_path: PathBuf,
45 pub schema_version: i32,
46 pub writer_contract_version: &'static str,
47 pub page_size: u32,
48 pub journal_mode: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SqliteBundleOptions {
53 pub chunk_threshold_bytes: u64,
54 pub chunk_size_bytes: u64,
55}
56
57impl Default for SqliteBundleOptions {
58 fn default() -> Self {
59 Self {
60 chunk_threshold_bytes: DEFAULT_SQLITE_CHUNK_THRESHOLD_BYTES,
61 chunk_size_bytes: DEFAULT_SQLITE_CHUNK_SIZE_BYTES,
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct SqliteChunkInfo {
68 pub path: String,
69 pub hash: String,
70 pub size: u64,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct SqliteBootstrapConfig {
75 pub chunked: bool,
76 pub chunk_count: usize,
77 pub chunk_size: u64,
78 pub total_size: u64,
79 pub hash: String,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub chunks: Vec<SqliteChunkInfo>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85struct ExportDependencyRow {
86 issue_id: String,
87 depends_on_id: String,
88 dep_type: String,
89 created_by: String,
90 created_at: Option<String>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94struct ExportCommentRow {
95 id: String,
96 comment_id: i64,
97 issue_id: String,
98 author: String,
99 text: String,
100 created_at: Option<String>,
101}
102
103#[must_use]
104pub fn export_database_path(output_dir: &Path) -> PathBuf {
105 output_dir.join(SQLITE_EXPORT_FILENAME)
106}
107
108#[must_use]
109pub fn export_config_path(output_dir: &Path) -> PathBuf {
110 output_dir.join(SQLITE_EXPORT_CONFIG_FILENAME)
111}
112
113pub fn bootstrap_export_database(
114 output_dir: &Path,
115 options: &SqliteBootstrapOptions,
116) -> Result<SqliteBootstrapSummary> {
117 fs::create_dir_all(output_dir)?;
118
119 let database_path = export_database_path(output_dir);
120 let connection = Connection::open(&database_path)?;
121
122 configure_connection(&connection, options.page_size)?;
123 verify_existing_contract(&connection)?;
124 create_schema(&connection)?;
125 rebuild_issue_search_index(&connection)?;
126 rebuild_issue_overview_mv(&connection)?;
127 upsert_contract_meta(&connection, options.page_size)?;
128
129 let actual_page_size = query_pragma_i64(&connection, "page_size")?;
130 let journal_mode = query_pragma_string(&connection, "journal_mode")?;
131
132 Ok(SqliteBootstrapSummary {
133 database_path,
134 schema_version: SQLITE_EXPORT_SCHEMA_VERSION,
135 writer_contract_version: SQLITE_WRITER_CONTRACT_VERSION,
136 page_size: u32::try_from(actual_page_size).map_err(|_| {
137 BvrError::InvalidArgument(format!(
138 "sqlite returned an invalid page_size value: {actual_page_size}"
139 ))
140 })?,
141 journal_mode,
142 })
143}
144
145pub fn emit_bootstrap_config(
146 output_dir: &Path,
147 options: &SqliteBundleOptions,
148) -> Result<SqliteBootstrapConfig> {
149 if options.chunk_size_bytes == 0 {
150 return Err(BvrError::InvalidArgument(
151 "sqlite export chunk_size_bytes must be greater than zero".to_string(),
152 ));
153 }
154
155 let database_path = export_database_path(output_dir);
156 let total_size = fs::metadata(&database_path)?.len();
157 let hash = hash_file(&database_path)?;
158
159 let mut config = SqliteBootstrapConfig {
160 chunked: false,
161 chunk_count: 0,
162 chunk_size: options.chunk_size_bytes,
163 total_size,
164 hash,
165 chunks: Vec::new(),
166 };
167
168 cleanup_existing_chunks(output_dir)?;
169
170 if total_size > options.chunk_threshold_bytes {
171 config.chunked = true;
172 config.chunks =
173 write_database_chunks(output_dir, &database_path, options.chunk_size_bytes)?;
174 config.chunk_count = config.chunks.len();
175 }
176
177 write_json_pretty(&export_config_path(output_dir), &config)?;
178 Ok(config)
179}
180
181fn cleanup_existing_chunks(output_dir: &Path) -> Result<()> {
182 let chunks_dir = output_dir.join("chunks");
183 if chunks_dir.exists() {
184 fs::remove_dir_all(&chunks_dir)?;
185 }
186 Ok(())
187}
188
189pub fn populate_export_database(
190 output_dir: &Path,
191 title: Option<&str>,
192 issues: &[Issue],
193 analyzer: &Analyzer,
194 triage: &TriageComputation,
195) -> Result<()> {
196 let database_path = export_database_path(output_dir);
197 let mut connection = Connection::open(&database_path)?;
198
199 let dependency_rows = collect_dependency_rows(issues);
200 let comment_rows = collect_comment_rows(issues);
201
202 {
203 let tx = connection.transaction()?;
204 clear_export_rows(&tx)?;
205 insert_issues(&tx, issues)?;
206 insert_dependencies(&tx, &dependency_rows)?;
207 insert_comments(&tx, &comment_rows)?;
208 insert_metrics(&tx, issues, analyzer, triage)?;
209 insert_triage_recommendations(&tx, triage, analyzer)?;
210 upsert_export_content_meta(
211 &tx,
212 title,
213 issues.len(),
214 dependency_rows.len(),
215 comment_rows.len(),
216 )?;
217 tx.commit()?;
218 }
219
220 rebuild_issue_search_index(&connection)?;
221 rebuild_issue_overview_mv(&connection)?;
222 Ok(())
223}
224
225fn clear_export_rows(tx: &Transaction<'_>) -> Result<()> {
226 tx.execute_batch(
227 "
228 DELETE FROM triage_recommendations;
229 DELETE FROM issue_metrics;
230 DELETE FROM comments;
231 DELETE FROM dependencies;
232 DELETE FROM issues;
233 DELETE FROM issue_overview_mv;
234 ",
235 )?;
236 tx.execute(
237 "DELETE FROM export_meta WHERE key IN ('title', 'issue_count', 'dependency_count', 'comment_count')",
238 [],
239 )?;
240 Ok(())
241}
242
243fn insert_issues(tx: &Transaction<'_>, issues: &[Issue]) -> Result<()> {
244 let mut sorted = issues.iter().collect::<Vec<_>>();
245 sorted.sort_by(|left, right| left.id.cmp(&right.id));
246
247 let mut stmt = tx.prepare(
248 "
249 INSERT INTO issues (
250 id, title, description, design, acceptance_criteria, notes, status,
251 priority, issue_type, assignee, estimated_minutes, labels, created_at,
252 updated_at, due_date, closed_at, source_repo
253 )
254 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
255 ",
256 )?;
257
258 for issue in sorted {
259 let labels = serde_json::to_string(&issue.labels)?;
260 let source_repo = normalized_source_repo(issue);
261 let created_at = issue
262 .created_at
263 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true));
264 let updated_at = issue
265 .updated_at
266 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true));
267 let due_date = issue
268 .due_date
269 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true));
270 let closed_at = issue
271 .closed_at
272 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true));
273 stmt.execute(params![
274 issue.id.as_str(),
275 issue.title.as_str(),
276 issue.description.as_str(),
277 issue.design.as_str(),
278 issue.acceptance_criteria.as_str(),
279 issue.notes.as_str(),
280 issue.status.as_str(),
281 issue.priority,
282 issue.issue_type.as_str(),
283 issue.assignee.as_str(),
284 issue.estimated_minutes,
285 labels,
286 created_at.as_deref(),
287 updated_at.as_deref(),
288 due_date.as_deref(),
289 closed_at.as_deref(),
290 source_repo,
291 ])?;
292 }
293
294 Ok(())
295}
296
297fn insert_dependencies(
298 tx: &Transaction<'_>,
299 dependency_rows: &[ExportDependencyRow],
300) -> Result<()> {
301 let mut stmt = tx.prepare(
302 "
303 INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
304 VALUES (?, ?, ?, ?, ?)
305 ",
306 )?;
307
308 for dep in dependency_rows {
309 stmt.execute(params![
310 dep.issue_id.as_str(),
311 dep.depends_on_id.as_str(),
312 dep.dep_type.as_str(),
313 dep.created_by.as_str(),
314 dep.created_at.as_deref(),
315 ])?;
316 }
317
318 Ok(())
319}
320
321fn insert_comments(tx: &Transaction<'_>, comment_rows: &[ExportCommentRow]) -> Result<()> {
322 let mut stmt = tx.prepare(
323 "
324 INSERT INTO comments (id, issue_id, author, text, created_at)
325 VALUES (?, ?, ?, ?, ?)
326 ",
327 )?;
328
329 for comment in comment_rows {
330 stmt.execute(params![
331 comment.id.as_str(),
332 comment.issue_id.as_str(),
333 comment.author.as_str(),
334 comment.text.as_str(),
335 comment.created_at.as_deref(),
336 ])?;
337 }
338
339 Ok(())
340}
341
342fn insert_metrics(
343 tx: &Transaction<'_>,
344 issues: &[Issue],
345 analyzer: &Analyzer,
346 triage: &TriageComputation,
347) -> Result<()> {
348 let mut sorted = issues.iter().collect::<Vec<_>>();
349 sorted.sort_by(|left, right| left.id.cmp(&right.id));
350
351 let mut stmt = tx.prepare(
352 "
353 INSERT INTO issue_metrics (
354 issue_id, pagerank, betweenness, critical_path_depth,
355 triage_score, blocks_count, blocked_by_count, in_cycle
356 )
357 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
358 ",
359 )?;
360
361 let cycle_issue_ids = analyzer
362 .metrics
363 .cycles
364 .iter()
365 .flat_map(|cycle| cycle.iter().cloned())
366 .collect::<BTreeSet<_>>();
367
368 for issue in sorted {
369 stmt.execute(params![
370 issue.id.as_str(),
371 analyzer
372 .metrics
373 .pagerank
374 .get(&issue.id)
375 .copied()
376 .unwrap_or_default(),
377 analyzer
378 .metrics
379 .betweenness
380 .get(&issue.id)
381 .copied()
382 .unwrap_or_default(),
383 i64::try_from(
384 analyzer
385 .metrics
386 .critical_depth
387 .get(&issue.id)
388 .copied()
389 .unwrap_or_default(),
390 )
391 .map_err(|_| {
392 BvrError::InvalidArgument(format!(
393 "critical depth does not fit in sqlite integer for {}",
394 issue.id
395 ))
396 })?,
397 triage
398 .score_by_id
399 .get(&issue.id)
400 .copied()
401 .unwrap_or_default(),
402 i64::try_from(
403 analyzer
404 .metrics
405 .blocks_count
406 .get(&issue.id)
407 .copied()
408 .unwrap_or_default(),
409 )
410 .map_err(|_| {
411 BvrError::InvalidArgument(format!(
412 "blocks_count does not fit in sqlite integer for {}",
413 issue.id
414 ))
415 })?,
416 i64::try_from(
417 analyzer
418 .metrics
419 .blocked_by_count
420 .get(&issue.id)
421 .copied()
422 .unwrap_or_default(),
423 )
424 .map_err(|_| {
425 BvrError::InvalidArgument(format!(
426 "blocked_by_count does not fit in sqlite integer for {}",
427 issue.id
428 ))
429 })?,
430 i64::from(cycle_issue_ids.contains(&issue.id)),
431 ])?;
432 }
433
434 Ok(())
435}
436
437fn insert_triage_recommendations(
438 tx: &Transaction<'_>,
439 triage: &TriageComputation,
440 analyzer: &Analyzer,
441) -> Result<()> {
442 let mut recommendations = triage.result.recommendations.iter().collect::<Vec<_>>();
443 recommendations.sort_by(|left, right| left.id.cmp(&right.id));
444
445 let mut stmt = tx.prepare(
446 "
447 INSERT INTO triage_recommendations (issue_id, score, action, reasons, unblocks_ids, blocked_by_ids)
448 VALUES (?, ?, ?, ?, ?, ?)
449 ",
450 )?;
451
452 for recommendation in recommendations {
453 let reasons = serde_json::to_string(&recommendation.reasons)?;
454 let unblocks_ids = serde_json::to_string(&analyzer.graph.dependents(&recommendation.id))?;
455 let blocked_by_ids = serde_json::to_string(&analyzer.graph.blockers(&recommendation.id))?;
456 stmt.execute(params![
457 recommendation.id.as_str(),
458 recommendation.score,
459 recommendation.claim_command.as_str(),
460 reasons,
461 unblocks_ids,
462 blocked_by_ids,
463 ])?;
464 }
465
466 Ok(())
467}
468
469fn upsert_export_content_meta(
470 tx: &Transaction<'_>,
471 title: Option<&str>,
472 issue_count: usize,
473 dependency_count: usize,
474 comment_count: usize,
475) -> Result<()> {
476 upsert_meta_value(tx, "issue_count", &issue_count.to_string())?;
477 upsert_meta_value(tx, "dependency_count", &dependency_count.to_string())?;
478 upsert_meta_value(tx, "comment_count", &comment_count.to_string())?;
479
480 if let Some(title) = title.map(str::trim).filter(|value| !value.is_empty()) {
481 upsert_meta_value(tx, "title", title)?;
482 }
483
484 Ok(())
485}
486
487fn upsert_meta_value(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
488 tx.execute(
489 "INSERT INTO export_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
490 [key, value],
491 )?;
492 Ok(())
493}
494
495fn collect_dependency_rows(issues: &[Issue]) -> Vec<ExportDependencyRow> {
496 let exported_ids = issues
497 .iter()
498 .map(|issue| issue.id.clone())
499 .collect::<BTreeSet<_>>();
500 let mut rows = Vec::new();
501
502 for issue in issues {
503 if !exported_ids.contains(&issue.id) {
504 continue;
505 }
506
507 for dep in &issue.dependencies {
508 if dep.depends_on_id.trim().is_empty() || !exported_ids.contains(&dep.depends_on_id) {
509 continue;
510 }
511
512 rows.push(ExportDependencyRow {
513 issue_id: issue.id.clone(),
514 depends_on_id: dep.depends_on_id.clone(),
515 dep_type: dep.dep_type.clone(),
516 created_by: dep.created_by.clone(),
517 created_at: dep
518 .created_at
519 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)),
520 });
521 }
522 }
523
524 rows.sort_by(|left, right| {
525 left.issue_id
526 .cmp(&right.issue_id)
527 .then_with(|| left.depends_on_id.cmp(&right.depends_on_id))
528 .then_with(|| left.dep_type.cmp(&right.dep_type))
529 .then_with(|| left.created_by.cmp(&right.created_by))
530 .then_with(|| left.created_at.cmp(&right.created_at))
531 });
532 rows.dedup();
533 rows
534}
535
536fn collect_comment_rows(issues: &[Issue]) -> Vec<ExportCommentRow> {
537 let mut rows = Vec::new();
538
539 for issue in issues {
540 for comment in &issue.comments {
541 rows.push(ExportCommentRow {
542 id: format!("{}:{}", issue.id, comment.id),
543 comment_id: comment.id,
544 issue_id: issue.id.clone(),
545 author: comment.author.clone(),
546 text: comment.text.clone(),
547 created_at: comment
548 .created_at
549 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)),
550 });
551 }
552 }
553
554 rows.sort_by(|left, right| {
555 left.issue_id
556 .cmp(&right.issue_id)
557 .then_with(|| left.created_at.cmp(&right.created_at))
558 .then_with(|| left.comment_id.cmp(&right.comment_id))
559 });
560 rows
561}
562
563fn normalized_source_repo(issue: &Issue) -> &str {
564 let source_repo = issue.source_repo.trim();
565 if source_repo.is_empty() {
566 "."
567 } else {
568 source_repo
569 }
570}
571
572pub fn rebuild_issue_search_index(connection: &Connection) -> Result<()> {
573 connection.execute_batch(
574 "
575 CREATE VIRTUAL TABLE IF NOT EXISTS issues_fts USING fts5(
576 id UNINDEXED,
577 title,
578 description,
579 design,
580 acceptance_criteria,
581 notes,
582 labels,
583 assignee,
584 source_repo,
585 content='issues',
586 content_rowid='rowid',
587 tokenize='porter unicode61'
588 );
589
590 INSERT INTO issues_fts(issues_fts) VALUES('rebuild');
591 ",
592 )?;
593
594 Ok(())
595}
596
597pub fn rebuild_issue_overview_mv(connection: &Connection) -> Result<()> {
598 connection.execute_batch(
599 "
600 DELETE FROM issue_overview_mv;
601
602 INSERT INTO issue_overview_mv (
603 id,
604 title,
605 description,
606 design,
607 acceptance_criteria,
608 notes,
609 status,
610 priority,
611 issue_type,
612 assignee,
613 estimated_minutes,
614 labels,
615 created_at,
616 updated_at,
617 due_date,
618 closed_at,
619 source_repo,
620 pagerank,
621 betweenness,
622 critical_path_depth,
623 triage_score,
624 blocks_count,
625 blocked_by_count,
626 blocker_count,
627 dependent_count,
628 critical_depth,
629 in_cycle,
630 comment_count,
631 blocks_ids,
632 blocked_by_ids
633 )
634 SELECT
635 i.id,
636 i.title,
637 i.description,
638 i.design,
639 i.acceptance_criteria,
640 i.notes,
641 i.status,
642 i.priority,
643 i.issue_type,
644 i.assignee,
645 i.estimated_minutes,
646 i.labels,
647 i.created_at,
648 i.updated_at,
649 i.due_date,
650 i.closed_at,
651 i.source_repo,
652 COALESCE(m.pagerank, 0),
653 COALESCE(m.betweenness, 0),
654 COALESCE(m.critical_path_depth, 0),
655 COALESCE(m.triage_score, 0),
656 COALESCE(m.blocks_count, 0),
657 COALESCE(m.blocked_by_count, 0),
658 COALESCE(m.blocked_by_count, 0),
659 COALESCE(m.blocks_count, 0),
660 COALESCE(m.critical_path_depth, 0),
661 COALESCE(m.in_cycle, 0),
662 (
663 SELECT COUNT(*)
664 FROM comments c
665 WHERE c.issue_id = i.id
666 ),
667 (
668 SELECT GROUP_CONCAT(issue_id)
669 FROM (
670 SELECT issue_id
671 FROM dependencies
672 WHERE depends_on_id = i.id
673 AND (type = 'blocks' OR type = '')
674 ORDER BY issue_id
675 )
676 ),
677 (
678 SELECT GROUP_CONCAT(depends_on_id)
679 FROM (
680 SELECT depends_on_id
681 FROM dependencies
682 WHERE issue_id = i.id
683 AND (type = 'blocks' OR type = '')
684 ORDER BY depends_on_id
685 )
686 )
687 FROM issues i
688 LEFT JOIN issue_metrics m
689 ON i.id = m.issue_id
690 ORDER BY i.id;
691 ",
692 )?;
693 Ok(())
694}
695
696fn configure_connection(connection: &Connection, page_size: u32) -> Result<()> {
697 connection.execute_batch(
698 "
699 PRAGMA foreign_keys = ON;
700 PRAGMA journal_mode = DELETE;
701 PRAGMA synchronous = NORMAL;
702 ",
703 )?;
704 connection.pragma_update(None, "page_size", page_size)?;
705 Ok(())
706}
707
708fn verify_existing_contract(connection: &Connection) -> Result<()> {
709 let existing_schema_version = query_pragma_i64(connection, "user_version")?;
710 if existing_schema_version != 0
711 && existing_schema_version != i64::from(SQLITE_EXPORT_SCHEMA_VERSION)
712 {
713 return Err(BvrError::InvalidArgument(format!(
714 "existing export database uses schema version {existing_schema_version}, expected {SQLITE_EXPORT_SCHEMA_VERSION}"
715 )));
716 }
717 Ok(())
718}
719
720fn create_schema(connection: &Connection) -> Result<()> {
721 connection.execute_batch(
722 "
723 CREATE TABLE IF NOT EXISTS issues (
724 id TEXT PRIMARY KEY,
725 title TEXT NOT NULL,
726 description TEXT NOT NULL DEFAULT '',
727 design TEXT NOT NULL DEFAULT '',
728 acceptance_criteria TEXT NOT NULL DEFAULT '',
729 notes TEXT NOT NULL DEFAULT '',
730 status TEXT NOT NULL,
731 priority INTEGER NOT NULL,
732 issue_type TEXT NOT NULL,
733 assignee TEXT NOT NULL DEFAULT '',
734 estimated_minutes INTEGER,
735 labels TEXT NOT NULL DEFAULT '[]',
736 created_at TEXT,
737 updated_at TEXT,
738 due_date TEXT,
739 closed_at TEXT,
740 source_repo TEXT NOT NULL DEFAULT '.'
741 );
742
743 CREATE TABLE IF NOT EXISTS dependencies (
744 id INTEGER PRIMARY KEY AUTOINCREMENT,
745 issue_id TEXT NOT NULL,
746 depends_on_id TEXT NOT NULL,
747 type TEXT NOT NULL DEFAULT 'blocks',
748 created_by TEXT NOT NULL DEFAULT '',
749 created_at TEXT,
750 FOREIGN KEY (issue_id) REFERENCES issues(id),
751 FOREIGN KEY (depends_on_id) REFERENCES issues(id)
752 );
753
754 CREATE TABLE IF NOT EXISTS comments (
755 id TEXT PRIMARY KEY,
756 issue_id TEXT NOT NULL,
757 author TEXT NOT NULL DEFAULT '',
758 text TEXT NOT NULL,
759 created_at TEXT,
760 FOREIGN KEY (issue_id) REFERENCES issues(id)
761 );
762
763 CREATE TABLE IF NOT EXISTS issue_metrics (
764 issue_id TEXT PRIMARY KEY,
765 pagerank REAL NOT NULL DEFAULT 0,
766 betweenness REAL NOT NULL DEFAULT 0,
767 critical_path_depth INTEGER NOT NULL DEFAULT 0,
768 triage_score REAL NOT NULL DEFAULT 0,
769 blocks_count INTEGER NOT NULL DEFAULT 0,
770 blocked_by_count INTEGER NOT NULL DEFAULT 0,
771 in_cycle INTEGER NOT NULL DEFAULT 0,
772 FOREIGN KEY (issue_id) REFERENCES issues(id)
773 );
774
775 CREATE TABLE IF NOT EXISTS triage_recommendations (
776 issue_id TEXT PRIMARY KEY,
777 score REAL NOT NULL,
778 action TEXT NOT NULL,
779 reasons TEXT NOT NULL DEFAULT '[]',
780 unblocks_ids TEXT NOT NULL DEFAULT '[]',
781 blocked_by_ids TEXT NOT NULL DEFAULT '[]',
782 FOREIGN KEY (issue_id) REFERENCES issues(id)
783 );
784
785 CREATE TABLE IF NOT EXISTS export_meta (
786 key TEXT PRIMARY KEY,
787 value TEXT NOT NULL
788 );
789
790 CREATE TABLE IF NOT EXISTS issue_overview_mv (
791 id TEXT PRIMARY KEY,
792 title TEXT NOT NULL,
793 description TEXT NOT NULL DEFAULT '',
794 design TEXT NOT NULL DEFAULT '',
795 acceptance_criteria TEXT NOT NULL DEFAULT '',
796 notes TEXT NOT NULL DEFAULT '',
797 status TEXT NOT NULL,
798 priority INTEGER NOT NULL,
799 issue_type TEXT NOT NULL,
800 assignee TEXT NOT NULL DEFAULT '',
801 estimated_minutes INTEGER,
802 labels TEXT NOT NULL DEFAULT '[]',
803 created_at TEXT,
804 updated_at TEXT,
805 due_date TEXT,
806 closed_at TEXT,
807 source_repo TEXT NOT NULL DEFAULT '.',
808 pagerank REAL NOT NULL DEFAULT 0,
809 betweenness REAL NOT NULL DEFAULT 0,
810 critical_path_depth INTEGER NOT NULL DEFAULT 0,
811 triage_score REAL NOT NULL DEFAULT 0,
812 blocks_count INTEGER NOT NULL DEFAULT 0,
813 blocked_by_count INTEGER NOT NULL DEFAULT 0,
814 blocker_count INTEGER NOT NULL DEFAULT 0,
815 dependent_count INTEGER NOT NULL DEFAULT 0,
816 critical_depth INTEGER NOT NULL DEFAULT 0,
817 in_cycle INTEGER NOT NULL DEFAULT 0,
818 comment_count INTEGER NOT NULL DEFAULT 0,
819 blocks_ids TEXT,
820 blocked_by_ids TEXT
821 );
822
823 CREATE INDEX IF NOT EXISTS idx_issues_status
824 ON issues(status);
825 CREATE INDEX IF NOT EXISTS idx_issues_priority
826 ON issues(priority, status);
827 CREATE INDEX IF NOT EXISTS idx_issues_updated
828 ON issues(updated_at DESC);
829 CREATE INDEX IF NOT EXISTS idx_issues_type_status
830 ON issues(issue_type, status);
831 CREATE INDEX IF NOT EXISTS idx_issues_source_repo
832 ON issues(source_repo, status);
833
834 CREATE INDEX IF NOT EXISTS idx_deps_issue
835 ON dependencies(issue_id);
836 CREATE INDEX IF NOT EXISTS idx_deps_depends
837 ON dependencies(depends_on_id);
838 CREATE INDEX IF NOT EXISTS idx_deps_type
839 ON dependencies(type);
840
841 CREATE INDEX IF NOT EXISTS idx_comments_issue
842 ON comments(issue_id);
843 CREATE INDEX IF NOT EXISTS idx_comments_created
844 ON comments(created_at DESC);
845
846 CREATE INDEX IF NOT EXISTS idx_metrics_score
847 ON issue_metrics(triage_score DESC);
848 CREATE INDEX IF NOT EXISTS idx_metrics_pagerank
849 ON issue_metrics(pagerank DESC);
850
851 CREATE INDEX IF NOT EXISTS idx_mv_status
852 ON issue_overview_mv(status);
853 CREATE INDEX IF NOT EXISTS idx_mv_priority
854 ON issue_overview_mv(priority);
855 CREATE INDEX IF NOT EXISTS idx_mv_score
856 ON issue_overview_mv(triage_score DESC);
857 ",
858 )?;
859 connection.pragma_update(None, "user_version", SQLITE_EXPORT_SCHEMA_VERSION)?;
860 Ok(())
861}
862
863fn upsert_contract_meta(connection: &Connection, page_size: u32) -> Result<()> {
864 for (key, value) in EXPORT_META_KEYS {
865 connection.execute(
866 "INSERT INTO export_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
867 [*key, *value],
868 )?;
869 }
870
871 let actual_page_size = query_pragma_i64(connection, "page_size")?;
872 let page_size_value = if actual_page_size > 0 {
873 actual_page_size.to_string()
874 } else {
875 page_size.to_string()
876 };
877 connection.execute(
878 "INSERT INTO export_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
879 ["page_size", page_size_value.as_str()],
880 )?;
881
882 let journal_mode = query_pragma_string(connection, "journal_mode")?;
883 connection.execute(
884 "INSERT INTO export_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
885 ["journal_mode", journal_mode.as_str()],
886 )?;
887 Ok(())
888}
889
890fn query_pragma_i64(connection: &Connection, pragma_name: &str) -> Result<i64> {
891 debug_assert!(
892 pragma_name
893 .chars()
894 .all(|c| c.is_ascii_alphanumeric() || c == '_'),
895 "pragma name must be alphanumeric"
896 );
897 let sql = format!("PRAGMA {pragma_name}");
898 Ok(connection.query_row(&sql, [], |row| row.get(0))?)
899}
900
901fn query_pragma_string(connection: &Connection, pragma_name: &str) -> Result<String> {
902 debug_assert!(
903 pragma_name
904 .chars()
905 .all(|c| c.is_ascii_alphanumeric() || c == '_'),
906 "pragma name must be alphanumeric"
907 );
908 let sql = format!("PRAGMA {pragma_name}");
909 Ok(connection.query_row(&sql, [], |row| row.get(0))?)
910}
911
912fn hash_file(path: &Path) -> Result<String> {
913 let file = File::open(path)?;
914 let mut reader = BufReader::new(file);
915 let mut hasher = Sha256::new();
916 let mut buffer = vec![0_u8; 64 * 1024];
917
918 loop {
919 let read = reader.read(&mut buffer)?;
920 if read == 0 {
921 break;
922 }
923 hasher.update(&buffer[..read]);
924 }
925
926 Ok(format!("{:x}", hasher.finalize()))
927}
928
929fn write_database_chunks(
930 output_dir: &Path,
931 database_path: &Path,
932 chunk_size_bytes: u64,
933) -> Result<Vec<SqliteChunkInfo>> {
934 let chunk_size = usize::try_from(chunk_size_bytes).map_err(|_| {
935 BvrError::InvalidArgument(format!(
936 "sqlite export chunk_size_bytes is too large for this platform: {chunk_size_bytes}"
937 ))
938 })?;
939
940 let chunks_dir = output_dir.join("chunks");
941 fs::create_dir_all(&chunks_dir)?;
942
943 let file = File::open(database_path)?;
944 let mut reader = BufReader::new(file);
945 let mut buffer = vec![0_u8; chunk_size];
946 let mut chunks = Vec::new();
947
948 loop {
949 let read = reader.read(&mut buffer)?;
950 if read == 0 {
951 break;
952 }
953
954 let file_name = format!("{:05}.bin", chunks.len());
955 let relative_path = format!("chunks/{file_name}");
956 let chunk_path = chunks_dir.join(&file_name);
957 let bytes = &buffer[..read];
958 fs::write(&chunk_path, bytes)?;
959
960 let mut hasher = Sha256::new();
961 hasher.update(bytes);
962
963 chunks.push(SqliteChunkInfo {
964 path: relative_path,
965 hash: format!("{:x}", hasher.finalize()),
966 size: u64::try_from(read).map_err(|_| {
967 BvrError::InvalidArgument(format!(
968 "sqlite export chunk size does not fit in u64: {read}"
969 ))
970 })?,
971 });
972 }
973
974 Ok(chunks)
975}
976
977fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> Result<()> {
978 let file = File::create(path)?;
979 serde_json::to_writer_pretty(file, value)?;
980 Ok(())
981}
982
983#[cfg(test)]
984mod tests {
985 use std::collections::{BTreeMap, BTreeSet};
986
987 use rusqlite::params;
988 use tempfile::tempdir;
989
990 use crate::analysis::Analyzer;
991 use crate::analysis::triage::TriageOptions;
992 use crate::model::{Comment, Dependency, Issue, ts};
993
994 use super::*;
995
996 #[test]
997 fn bootstrap_export_database_creates_expected_schema_inventory() {
998 let temp = tempdir().expect("tempdir");
999 let summary = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1000 .expect("bootstrap sqlite export database");
1001
1002 assert_eq!(
1003 summary.database_path,
1004 temp.path().join(SQLITE_EXPORT_FILENAME)
1005 );
1006 assert_eq!(summary.schema_version, SQLITE_EXPORT_SCHEMA_VERSION);
1007 assert_eq!(
1008 summary.writer_contract_version,
1009 SQLITE_WRITER_CONTRACT_VERSION
1010 );
1011 assert_eq!(summary.page_size, DEFAULT_SQLITE_PAGE_SIZE);
1012 assert_eq!(summary.journal_mode, "delete");
1013
1014 let connection =
1015 Connection::open(&summary.database_path).expect("open bootstrapped sqlite database");
1016
1017 assert_eq!(
1018 query_pragma_i64(&connection, "user_version").expect("pragma user_version"),
1019 i64::from(SQLITE_EXPORT_SCHEMA_VERSION)
1020 );
1021 assert_eq!(
1022 query_pragma_i64(&connection, "page_size").expect("pragma page_size"),
1023 i64::from(DEFAULT_SQLITE_PAGE_SIZE)
1024 );
1025
1026 let object_names = sqlite_object_inventory(&connection);
1027
1028 for table in [
1029 "issues",
1030 "dependencies",
1031 "comments",
1032 "issue_metrics",
1033 "triage_recommendations",
1034 "export_meta",
1035 "issue_overview_mv",
1036 "issues_fts",
1037 ] {
1038 assert!(
1039 object_names.contains(table),
1040 "missing sqlite object {table}; inventory: {object_names:?}"
1041 );
1042 }
1043
1044 for index in [
1045 "idx_issues_status",
1046 "idx_issues_priority",
1047 "idx_issues_updated",
1048 "idx_issues_type_status",
1049 "idx_issues_source_repo",
1050 "idx_deps_issue",
1051 "idx_deps_depends",
1052 "idx_deps_type",
1053 "idx_comments_issue",
1054 "idx_comments_created",
1055 "idx_metrics_score",
1056 "idx_metrics_pagerank",
1057 "idx_mv_status",
1058 "idx_mv_priority",
1059 "idx_mv_score",
1060 ] {
1061 assert!(
1062 object_names.contains(index),
1063 "missing sqlite object {index}; inventory: {object_names:?}"
1064 );
1065 }
1066
1067 let meta = export_meta(&connection);
1068 assert_eq!(
1069 meta.get("schema_version"),
1070 Some(&SQLITE_EXPORT_SCHEMA_VERSION.to_string())
1071 );
1072 assert_eq!(
1073 meta.get("writer_contract_version"),
1074 Some(&SQLITE_WRITER_CONTRACT_VERSION.to_string())
1075 );
1076 assert_eq!(
1077 meta.get("page_size"),
1078 Some(&DEFAULT_SQLITE_PAGE_SIZE.to_string())
1079 );
1080 assert_eq!(meta.get("journal_mode"), Some(&"delete".to_string()));
1081 assert_eq!(meta.get("layout"), Some(&"single-file".to_string()));
1082 }
1083
1084 #[test]
1085 fn bootstrap_export_database_is_idempotent() {
1086 let temp = tempdir().expect("tempdir");
1087 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1088 .expect("first bootstrap");
1089 let second = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1090 .expect("second bootstrap");
1091
1092 let connection =
1093 Connection::open(&second.database_path).expect("open bootstrapped sqlite database");
1094
1095 let issue_count: i64 = connection
1096 .query_row("SELECT COUNT(*) FROM issue_overview_mv", [], |row| {
1097 row.get(0)
1098 })
1099 .expect("issue_overview_mv count");
1100 let fts_count: i64 = connection
1101 .query_row("SELECT COUNT(*) FROM issues_fts", [], |row| row.get(0))
1102 .expect("issues_fts count");
1103
1104 assert_eq!(issue_count, 0);
1105 assert_eq!(fts_count, 0);
1106 assert_eq!(export_meta(&connection).len(), 5);
1107 }
1108
1109 #[test]
1110 fn bootstrap_export_database_rejects_conflicting_schema_version() {
1111 let temp = tempdir().expect("tempdir");
1112 let db_path = export_database_path(temp.path());
1113 let connection = Connection::open(&db_path).expect("open sqlite database");
1114 connection
1115 .pragma_update(None, "user_version", 99_i32)
1116 .expect("set conflicting schema version");
1117 drop(connection);
1118
1119 let err = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1120 .expect_err("conflicting schema version should fail");
1121 assert!(
1122 err.to_string().contains("schema version 99"),
1123 "unexpected error: {err}"
1124 );
1125 }
1126
1127 #[test]
1128 fn bootstrap_export_database_rejects_previous_schema_version() {
1129 let temp = tempdir().expect("tempdir");
1130 let db_path = export_database_path(temp.path());
1131 let connection = Connection::open(&db_path).expect("open sqlite database");
1132 connection
1133 .pragma_update(None, "user_version", 1_i32)
1134 .expect("set prior schema version");
1135 drop(connection);
1136
1137 let err = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1138 .expect_err("previous schema version should fail");
1139 assert!(
1140 err.to_string().contains("schema version 1"),
1141 "unexpected error: {err}"
1142 );
1143 }
1144
1145 #[test]
1146 fn rebuild_issue_overview_mv_derives_counts_and_aliases() {
1147 let temp = tempdir().expect("tempdir");
1148 let summary = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1149 .expect("bootstrap sqlite export database");
1150 let connection =
1151 Connection::open(&summary.database_path).expect("open bootstrapped sqlite database");
1152
1153 connection
1154 .execute(
1155 "
1156 INSERT INTO issues (
1157 id, title, description, design, acceptance_criteria, notes, status,
1158 priority, issue_type, assignee, estimated_minutes, labels, created_at,
1159 updated_at, due_date, closed_at, source_repo
1160 )
1161 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1162 ",
1163 params![
1164 "ISSUE-1",
1165 "Export SQLite",
1166 "Ship the export schema",
1167 "Use a dedicated module",
1168 "Schema is explicit",
1169 "Keep bootstrap deterministic",
1170 "open",
1171 1_i32,
1172 "task",
1173 "alex",
1174 45_i32,
1175 "[\"export\",\"sqlite\"]",
1176 "2026-03-08T18:00:00Z",
1177 "2026-03-08T18:30:00Z",
1178 "2026-03-10T00:00:00Z",
1179 Option::<String>::None,
1180 "core"
1181 ],
1182 )
1183 .expect("insert issue");
1184 connection
1185 .execute(
1186 "
1187 INSERT INTO issues (
1188 id, title, description, design, acceptance_criteria, notes, status,
1189 priority, issue_type, assignee, estimated_minutes, labels, created_at,
1190 updated_at, due_date, closed_at, source_repo
1191 )
1192 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1193 ",
1194 params![
1195 "ISSUE-2",
1196 "Downstream consumer",
1197 "",
1198 "",
1199 "",
1200 "",
1201 "blocked",
1202 2_i32,
1203 "task",
1204 "",
1205 Option::<i32>::None,
1206 "[]",
1207 Option::<String>::None,
1208 Option::<String>::None,
1209 Option::<String>::None,
1210 Option::<String>::None,
1211 "core"
1212 ],
1213 )
1214 .expect("insert second issue");
1215 connection
1216 .execute(
1217 "
1218 INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
1219 VALUES (?, ?, ?, ?, ?)
1220 ",
1221 params![
1222 "ISSUE-2",
1223 "ISSUE-1",
1224 "blocks",
1225 "tester",
1226 "2026-03-08T18:31:00Z"
1227 ],
1228 )
1229 .expect("insert dependency");
1230 connection
1231 .execute(
1232 "
1233 INSERT INTO comments (id, issue_id, author, text, created_at)
1234 VALUES (?, ?, ?, ?, ?)
1235 ",
1236 params![
1237 "ISSUE-1:1",
1238 "ISSUE-1",
1239 "alex",
1240 "Need the schema locked down before population.",
1241 "2026-03-08T18:40:00Z"
1242 ],
1243 )
1244 .expect("insert comment");
1245 connection
1246 .execute(
1247 "
1248 INSERT INTO issue_metrics (
1249 issue_id, pagerank, betweenness, critical_path_depth,
1250 triage_score, blocks_count, blocked_by_count, in_cycle
1251 )
1252 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1253 ",
1254 params![
1255 "ISSUE-1", 0.25_f64, 0.1_f64, 4_i32, 0.8_f64, 1_i32, 0_i32, 0_i32
1256 ],
1257 )
1258 .expect("insert metrics");
1259
1260 rebuild_issue_overview_mv(&connection).expect("rebuild issue_overview_mv");
1261
1262 let derived = connection
1263 .query_row(
1264 "
1265 SELECT
1266 blocker_count,
1267 dependent_count,
1268 critical_depth,
1269 comment_count,
1270 blocks_ids,
1271 blocked_by_ids,
1272 source_repo,
1273 design,
1274 acceptance_criteria,
1275 notes
1276 FROM issue_overview_mv
1277 WHERE id = ?
1278 ",
1279 ["ISSUE-1"],
1280 |row| {
1281 Ok((
1282 row.get::<_, i64>(0)?,
1283 row.get::<_, i64>(1)?,
1284 row.get::<_, i64>(2)?,
1285 row.get::<_, i64>(3)?,
1286 row.get::<_, Option<String>>(4)?,
1287 row.get::<_, Option<String>>(5)?,
1288 row.get::<_, String>(6)?,
1289 row.get::<_, String>(7)?,
1290 row.get::<_, String>(8)?,
1291 row.get::<_, String>(9)?,
1292 ))
1293 },
1294 )
1295 .expect("query issue_overview_mv");
1296
1297 assert_eq!(derived.0, 0);
1298 assert_eq!(derived.1, 1);
1299 assert_eq!(derived.2, 4);
1300 assert_eq!(derived.3, 1);
1301 assert_eq!(derived.4.as_deref(), Some("ISSUE-2"));
1302 assert_eq!(derived.5, None);
1303 assert_eq!(derived.6, "core");
1304 assert_eq!(derived.7, "Use a dedicated module");
1305 assert_eq!(derived.8, "Schema is explicit");
1306 assert_eq!(derived.9, "Keep bootstrap deterministic");
1307 }
1308
1309 #[test]
1310 fn rebuild_issue_search_index_supports_prefix_queries() {
1311 let temp = tempdir().expect("tempdir");
1312 let summary = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1313 .expect("bootstrap sqlite export database");
1314 let connection =
1315 Connection::open(&summary.database_path).expect("open bootstrapped sqlite database");
1316
1317 connection
1318 .execute(
1319 "
1320 INSERT INTO issues (
1321 id, title, description, design, acceptance_criteria, notes, status,
1322 priority, issue_type, assignee, estimated_minutes, labels, created_at,
1323 updated_at, due_date, closed_at, source_repo
1324 )
1325 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1326 ",
1327 params![
1328 "SEARCH-1",
1329 "Authentication hardening",
1330 "Tighten export viewer auth checks",
1331 "Keep search parity with legacy",
1332 "",
1333 "",
1334 "open",
1335 2_i32,
1336 "feature",
1337 "sam",
1338 Option::<i32>::None,
1339 "[\"auth\",\"export\"]",
1340 Option::<String>::None,
1341 Option::<String>::None,
1342 Option::<String>::None,
1343 Option::<String>::None,
1344 "security"
1345 ],
1346 )
1347 .expect("insert searchable issue");
1348
1349 rebuild_issue_search_index(&connection).expect("rebuild issues_fts");
1350
1351 let exact_matches: i64 = connection
1352 .query_row(
1353 "SELECT COUNT(*) FROM issues_fts WHERE issues_fts MATCH 'authentication'",
1354 [],
1355 |row| row.get(0),
1356 )
1357 .expect("exact fts query");
1358 let prefix_matches: i64 = connection
1359 .query_row(
1360 "SELECT COUNT(*) FROM issues_fts WHERE issues_fts MATCH 'hard*'",
1361 [],
1362 |row| row.get(0),
1363 )
1364 .expect("prefix fts query");
1365
1366 assert_eq!(exact_matches, 1);
1367 assert_eq!(prefix_matches, 1);
1368 }
1369
1370 #[test]
1371 fn populate_export_database_writes_core_rows_and_meta() {
1372 let temp = tempdir().expect("tempdir");
1373 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1374 .expect("bootstrap sqlite export database");
1375
1376 let issues = vec![
1377 Issue {
1378 id: "ISSUE-1".to_string(),
1379 title: "Export SQLite".to_string(),
1380 description: "Ship the populated export database".to_string(),
1381 design: "Reuse analyzer output for deterministic rows".to_string(),
1382 acceptance_criteria: "Core issue records are queryable".to_string(),
1383 notes: "Keep row order deterministic".to_string(),
1384 status: "open".to_string(),
1385 priority: 1,
1386 issue_type: "task".to_string(),
1387 assignee: "alex".to_string(),
1388 estimated_minutes: Some(45),
1389 created_at: ts("2026-03-08T18:00:00Z"),
1390 updated_at: ts("2026-03-08T18:30:00Z"),
1391 due_date: ts("2026-03-10T00:00:00Z"),
1392 labels: vec!["export".to_string(), "sqlite".to_string()],
1393 comments: vec![Comment {
1394 id: 7,
1395 issue_id: "ISSUE-1".to_string(),
1396 author: "alex".to_string(),
1397 text: "Need populated export rows".to_string(),
1398 created_at: ts("2026-03-08T18:40:00Z"),
1399 }],
1400 source_repo: "services/api".to_string(),
1401 ..Issue::default()
1402 },
1403 Issue {
1404 id: "ISSUE-2".to_string(),
1405 title: "Downstream consumer".to_string(),
1406 status: "blocked".to_string(),
1407 priority: 2,
1408 issue_type: "task".to_string(),
1409 dependencies: vec![Dependency {
1410 issue_id: "ISSUE-2".to_string(),
1411 depends_on_id: "ISSUE-1".to_string(),
1412 dep_type: "blocks".to_string(),
1413 created_by: "tester".to_string(),
1414 created_at: ts("2026-03-08T18:31:00Z"),
1415 }],
1416 source_repo: "services/web".to_string(),
1417 ..Issue::default()
1418 },
1419 ];
1420
1421 let analyzer = Analyzer::new(issues.clone());
1422 let triage = analyzer.triage(TriageOptions {
1423 group_by_track: false,
1424 group_by_label: false,
1425 max_recommendations: 50,
1426 ..TriageOptions::default()
1427 });
1428
1429 populate_export_database(
1430 temp.path(),
1431 Some("SQLite Export Fixture"),
1432 &issues,
1433 &analyzer,
1434 &triage,
1435 )
1436 .expect("populate sqlite export database");
1437
1438 let connection =
1439 Connection::open(export_database_path(temp.path())).expect("open populated database");
1440
1441 let issue_count: i64 = connection
1442 .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
1443 .expect("query issues count");
1444 let dependency_count: i64 = connection
1445 .query_row("SELECT COUNT(*) FROM dependencies", [], |row| row.get(0))
1446 .expect("query dependency count");
1447 let comment_count: i64 = connection
1448 .query_row("SELECT COUNT(*) FROM comments", [], |row| row.get(0))
1449 .expect("query comment count");
1450 let metrics_count: i64 = connection
1451 .query_row("SELECT COUNT(*) FROM issue_metrics", [], |row| row.get(0))
1452 .expect("query metrics count");
1453 let recommendation_count: i64 = connection
1454 .query_row("SELECT COUNT(*) FROM triage_recommendations", [], |row| {
1455 row.get(0)
1456 })
1457 .expect("query recommendation count");
1458 let overview_count: i64 = connection
1459 .query_row("SELECT COUNT(*) FROM issue_overview_mv", [], |row| {
1460 row.get(0)
1461 })
1462 .expect("query overview count");
1463
1464 assert_eq!(issue_count, 2);
1465 assert_eq!(dependency_count, 1);
1466 assert_eq!(comment_count, 1);
1467 assert_eq!(metrics_count, 2);
1468 assert_eq!(overview_count, 2);
1469 assert_eq!(recommendation_count, 1);
1470
1471 let exported_issue = connection
1472 .query_row(
1473 "
1474 SELECT title, design, acceptance_criteria, notes, labels, source_repo
1475 FROM issues
1476 WHERE id = ?
1477 ",
1478 ["ISSUE-1"],
1479 |row| {
1480 Ok((
1481 row.get::<_, String>(0)?,
1482 row.get::<_, String>(1)?,
1483 row.get::<_, String>(2)?,
1484 row.get::<_, String>(3)?,
1485 row.get::<_, String>(4)?,
1486 row.get::<_, String>(5)?,
1487 ))
1488 },
1489 )
1490 .expect("query exported issue");
1491
1492 assert_eq!(exported_issue.0, "Export SQLite");
1493 assert_eq!(
1494 exported_issue.1,
1495 "Reuse analyzer output for deterministic rows"
1496 );
1497 assert_eq!(exported_issue.2, "Core issue records are queryable");
1498 assert_eq!(exported_issue.3, "Keep row order deterministic");
1499 assert_eq!(exported_issue.4, "[\"export\",\"sqlite\"]");
1500 assert_eq!(exported_issue.5, "services/api");
1501
1502 let overview = connection
1503 .query_row(
1504 "
1505 SELECT dependent_count, blocker_count, comment_count, blocks_ids, blocked_by_ids
1506 FROM issue_overview_mv
1507 WHERE id = ?
1508 ",
1509 ["ISSUE-1"],
1510 |row| {
1511 Ok((
1512 row.get::<_, i64>(0)?,
1513 row.get::<_, i64>(1)?,
1514 row.get::<_, i64>(2)?,
1515 row.get::<_, Option<String>>(3)?,
1516 row.get::<_, Option<String>>(4)?,
1517 ))
1518 },
1519 )
1520 .expect("query overview row");
1521
1522 assert_eq!(overview.0, 1);
1523 assert_eq!(overview.1, 0);
1524 assert_eq!(overview.2, 1);
1525 assert_eq!(overview.3.as_deref(), Some("ISSUE-2"));
1526 assert_eq!(overview.4, None);
1527
1528 let triage_score: f64 = connection
1529 .query_row(
1530 "SELECT triage_score FROM issue_metrics WHERE issue_id = ?",
1531 ["ISSUE-1"],
1532 |row| row.get(0),
1533 )
1534 .expect("query triage score");
1535 assert!(triage_score > 0.0);
1536
1537 let meta = export_meta(&connection);
1538 assert_eq!(
1539 meta.get("title"),
1540 Some(&"SQLite Export Fixture".to_string())
1541 );
1542 assert_eq!(meta.get("issue_count"), Some(&"2".to_string()));
1543 assert_eq!(meta.get("dependency_count"), Some(&"1".to_string()));
1544 assert_eq!(meta.get("comment_count"), Some(&"1".to_string()));
1545 }
1546
1547 #[test]
1548 fn populate_export_database_marks_cycle_members_in_issue_overview() {
1549 let temp = tempdir().expect("tempdir");
1550 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1551 .expect("bootstrap sqlite export database");
1552
1553 let issues = vec![
1554 Issue {
1555 id: "CYCLE-1".to_string(),
1556 title: "Cycle 1".to_string(),
1557 status: "open".to_string(),
1558 priority: 1,
1559 issue_type: "task".to_string(),
1560 dependencies: vec![Dependency {
1561 issue_id: "CYCLE-1".to_string(),
1562 depends_on_id: "CYCLE-2".to_string(),
1563 dep_type: "blocks".to_string(),
1564 created_by: "tester".to_string(),
1565 created_at: None,
1566 }],
1567 ..Issue::default()
1568 },
1569 Issue {
1570 id: "CYCLE-2".to_string(),
1571 title: "Cycle 2".to_string(),
1572 status: "open".to_string(),
1573 priority: 1,
1574 issue_type: "task".to_string(),
1575 dependencies: vec![Dependency {
1576 issue_id: "CYCLE-2".to_string(),
1577 depends_on_id: "CYCLE-1".to_string(),
1578 dep_type: "blocks".to_string(),
1579 created_by: "tester".to_string(),
1580 created_at: None,
1581 }],
1582 ..Issue::default()
1583 },
1584 ];
1585
1586 let analyzer = Analyzer::new(issues.clone());
1587 let triage = analyzer.triage(TriageOptions::default());
1588
1589 populate_export_database(
1590 temp.path(),
1591 Some("Cycle Fixture"),
1592 &issues,
1593 &analyzer,
1594 &triage,
1595 )
1596 .expect("populate sqlite export database");
1597
1598 let connection =
1599 Connection::open(export_database_path(temp.path())).expect("open populated database");
1600
1601 let cycle_flags = connection
1602 .prepare("SELECT id, in_cycle FROM issue_overview_mv ORDER BY id")
1603 .expect("prepare cycle query")
1604 .query_map([], |row| {
1605 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
1606 })
1607 .expect("query cycle flags")
1608 .collect::<std::result::Result<Vec<_>, _>>()
1609 .expect("collect cycle flags");
1610
1611 assert_eq!(
1612 cycle_flags,
1613 vec![
1614 ("CYCLE-1".to_string(), 1_i64),
1615 ("CYCLE-2".to_string(), 1_i64)
1616 ]
1617 );
1618 }
1619
1620 #[test]
1621 fn emit_bootstrap_config_writes_hash_and_size_for_single_file_bundle() {
1622 let temp = tempdir().expect("tempdir");
1623 let summary = bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1624 .expect("bootstrap sqlite export database");
1625
1626 let config = emit_bootstrap_config(temp.path(), &SqliteBundleOptions::default())
1627 .expect("emit bootstrap config");
1628
1629 assert!(summary.database_path.is_file());
1630 assert!(export_config_path(temp.path()).is_file());
1631 assert!(!config.chunked);
1632 assert_eq!(config.chunk_count, 0);
1633 assert_eq!(config.chunk_size, DEFAULT_SQLITE_CHUNK_SIZE_BYTES);
1634 assert!(config.total_size > 0);
1635 assert_eq!(config.hash.len(), 64);
1636 assert!(config.chunks.is_empty());
1637
1638 let persisted: SqliteBootstrapConfig = serde_json::from_str(
1639 &fs::read_to_string(export_config_path(temp.path())).expect("read config json"),
1640 )
1641 .expect("parse config json");
1642 assert_eq!(persisted, config);
1643 }
1644
1645 #[test]
1646 fn emit_bootstrap_config_writes_chunk_inventory_when_threshold_is_exceeded() {
1647 let temp = tempdir().expect("tempdir");
1648 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1649 .expect("bootstrap sqlite export database");
1650
1651 let config = emit_bootstrap_config(
1652 temp.path(),
1653 &SqliteBundleOptions {
1654 chunk_threshold_bytes: 1,
1655 chunk_size_bytes: 256,
1656 },
1657 )
1658 .expect("emit chunked bootstrap config");
1659
1660 assert!(config.chunked);
1661 assert!(!config.chunks.is_empty());
1662 assert_eq!(config.chunk_count, config.chunks.len());
1663 assert!(config.chunks.iter().all(|chunk| chunk.hash.len() == 64));
1664 assert!(
1665 config
1666 .chunks
1667 .iter()
1668 .all(|chunk| temp.path().join(&chunk.path).is_file())
1669 );
1670
1671 let total_chunk_size = config.chunks.iter().map(|chunk| chunk.size).sum::<u64>();
1672 assert_eq!(total_chunk_size, config.total_size);
1673 }
1674
1675 #[test]
1676 fn emit_bootstrap_config_removes_stale_chunk_dir_when_database_is_no_longer_chunked() {
1677 let temp = tempdir().expect("tempdir");
1678 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1679 .expect("bootstrap sqlite export database");
1680
1681 let chunked = emit_bootstrap_config(
1682 temp.path(),
1683 &SqliteBundleOptions {
1684 chunk_threshold_bytes: 1,
1685 chunk_size_bytes: 256,
1686 },
1687 )
1688 .expect("emit chunked bootstrap config");
1689 assert!(chunked.chunked);
1690 assert!(temp.path().join("chunks").is_dir());
1691
1692 let single_file = emit_bootstrap_config(temp.path(), &SqliteBundleOptions::default())
1693 .expect("emit single-file bootstrap config");
1694
1695 assert!(!single_file.chunked);
1696 assert!(single_file.chunks.is_empty());
1697 assert!(!temp.path().join("chunks").exists());
1698 }
1699
1700 #[test]
1701 fn collect_dependency_rows_filters_out_unknown_targets() {
1702 let issues = vec![
1703 Issue {
1704 id: "A".to_string(),
1705 title: "A".to_string(),
1706 issue_type: "task".to_string(),
1707 dependencies: vec![Dependency {
1708 issue_id: "A".to_string(),
1709 depends_on_id: "B".to_string(),
1710 dep_type: "blocks".to_string(),
1711 created_by: "tester".to_string(),
1712 created_at: None,
1713 }],
1714 ..Issue::default()
1715 },
1716 Issue {
1717 id: "B".to_string(),
1718 title: "B".to_string(),
1719 issue_type: "task".to_string(),
1720 dependencies: vec![Dependency {
1721 issue_id: "B".to_string(),
1722 depends_on_id: "MISSING".to_string(),
1723 dep_type: "blocks".to_string(),
1724 created_by: "tester".to_string(),
1725 created_at: None,
1726 }],
1727 ..Issue::default()
1728 },
1729 ];
1730
1731 let rows = collect_dependency_rows(&issues);
1732 assert_eq!(rows.len(), 1);
1733 assert_eq!(rows[0].issue_id, "A");
1734 assert_eq!(rows[0].depends_on_id, "B");
1735 }
1736
1737 #[test]
1738 fn collect_dependency_rows_skips_empty_depends_on_id() {
1739 let issues = vec![Issue {
1740 id: "X".to_string(),
1741 title: "X".to_string(),
1742 issue_type: "task".to_string(),
1743 dependencies: vec![Dependency {
1744 issue_id: "X".to_string(),
1745 depends_on_id: " ".to_string(),
1746 dep_type: "blocks".to_string(),
1747 created_by: "tester".to_string(),
1748 created_at: None,
1749 }],
1750 ..Issue::default()
1751 }];
1752
1753 let rows = collect_dependency_rows(&issues);
1754 assert!(rows.is_empty());
1755 }
1756
1757 #[test]
1758 fn collect_dependency_rows_deduplicates_identical_entries() {
1759 let dep = Dependency {
1760 issue_id: "A".to_string(),
1761 depends_on_id: "B".to_string(),
1762 dep_type: "blocks".to_string(),
1763 created_by: "tester".to_string(),
1764 created_at: None,
1765 };
1766 let issues = vec![
1767 Issue {
1768 id: "A".to_string(),
1769 title: "A".to_string(),
1770 issue_type: "task".to_string(),
1771 dependencies: vec![dep.clone(), dep],
1772 ..Issue::default()
1773 },
1774 Issue {
1775 id: "B".to_string(),
1776 title: "B".to_string(),
1777 issue_type: "task".to_string(),
1778 ..Issue::default()
1779 },
1780 ];
1781
1782 let rows = collect_dependency_rows(&issues);
1783 assert_eq!(rows.len(), 1);
1784 }
1785
1786 #[test]
1787 fn collect_dependency_rows_sorts_deterministically() {
1788 let issues = vec![
1789 Issue {
1790 id: "C".to_string(),
1791 title: "C".to_string(),
1792 issue_type: "task".to_string(),
1793 dependencies: vec![Dependency {
1794 issue_id: "C".to_string(),
1795 depends_on_id: "A".to_string(),
1796 dep_type: "blocks".to_string(),
1797 created_by: "z".to_string(),
1798 created_at: None,
1799 }],
1800 ..Issue::default()
1801 },
1802 Issue {
1803 id: "A".to_string(),
1804 title: "A".to_string(),
1805 issue_type: "task".to_string(),
1806 dependencies: vec![Dependency {
1807 issue_id: "A".to_string(),
1808 depends_on_id: "C".to_string(),
1809 dep_type: "blocks".to_string(),
1810 created_by: "a".to_string(),
1811 created_at: None,
1812 }],
1813 ..Issue::default()
1814 },
1815 ];
1816
1817 let rows = collect_dependency_rows(&issues);
1818 assert_eq!(rows.len(), 2);
1819 assert_eq!(rows[0].issue_id, "A");
1820 assert_eq!(rows[1].issue_id, "C");
1821 }
1822
1823 #[test]
1824 fn collect_comment_rows_formats_composite_id() {
1825 let issues = vec![Issue {
1826 id: "ISSUE-1".to_string(),
1827 title: "Test".to_string(),
1828 issue_type: "task".to_string(),
1829 comments: vec![
1830 Comment {
1831 id: 5,
1832 issue_id: "ISSUE-1".to_string(),
1833 author: "alice".to_string(),
1834 text: "First".to_string(),
1835 created_at: None,
1836 },
1837 Comment {
1838 id: 10,
1839 issue_id: "ISSUE-1".to_string(),
1840 author: "bob".to_string(),
1841 text: "Second".to_string(),
1842 created_at: None,
1843 },
1844 ],
1845 ..Issue::default()
1846 }];
1847
1848 let rows = collect_comment_rows(&issues);
1849 assert_eq!(rows.len(), 2);
1850 assert_eq!(rows[0].id, "ISSUE-1:5");
1851 assert_eq!(rows[1].id, "ISSUE-1:10");
1852 assert_eq!(rows[0].author, "alice");
1853 assert_eq!(rows[1].author, "bob");
1854 }
1855
1856 #[test]
1857 fn collect_comment_rows_sorts_across_issues() {
1858 let issues = vec![
1859 Issue {
1860 id: "Z".to_string(),
1861 title: "Z".to_string(),
1862 issue_type: "task".to_string(),
1863 comments: vec![Comment {
1864 id: 1,
1865 issue_id: "Z".to_string(),
1866 author: "a".to_string(),
1867 text: "z-comment".to_string(),
1868 created_at: None,
1869 }],
1870 ..Issue::default()
1871 },
1872 Issue {
1873 id: "A".to_string(),
1874 title: "A".to_string(),
1875 issue_type: "task".to_string(),
1876 comments: vec![Comment {
1877 id: 2,
1878 issue_id: "A".to_string(),
1879 author: "b".to_string(),
1880 text: "a-comment".to_string(),
1881 created_at: None,
1882 }],
1883 ..Issue::default()
1884 },
1885 ];
1886
1887 let rows = collect_comment_rows(&issues);
1888 assert_eq!(rows[0].issue_id, "A");
1889 assert_eq!(rows[1].issue_id, "Z");
1890 }
1891
1892 #[test]
1893 fn collect_comment_rows_prefers_created_at_then_numeric_id() {
1894 let issues = vec![Issue {
1895 id: "ISSUE-1".to_string(),
1896 title: "Test".to_string(),
1897 issue_type: "task".to_string(),
1898 comments: vec![
1899 Comment {
1900 id: 10,
1901 issue_id: "ISSUE-1".to_string(),
1902 author: "later-id-earlier-time".to_string(),
1903 text: "First chronologically".to_string(),
1904 created_at: ts("2026-03-08T18:00:00Z"),
1905 },
1906 Comment {
1907 id: 5,
1908 issue_id: "ISSUE-1".to_string(),
1909 author: "earlier-id-later-time".to_string(),
1910 text: "Second chronologically".to_string(),
1911 created_at: ts("2026-03-08T18:05:00Z"),
1912 },
1913 Comment {
1914 id: 20,
1915 issue_id: "ISSUE-1".to_string(),
1916 author: "same-time-higher-id".to_string(),
1917 text: "Third chronologically".to_string(),
1918 created_at: ts("2026-03-08T18:05:00Z"),
1919 },
1920 ],
1921 ..Issue::default()
1922 }];
1923
1924 let rows = collect_comment_rows(&issues);
1925 let ordered_ids = rows.iter().map(|row| row.id.as_str()).collect::<Vec<_>>();
1926 assert_eq!(ordered_ids, vec!["ISSUE-1:10", "ISSUE-1:5", "ISSUE-1:20"]);
1927 }
1928
1929 #[test]
1930 fn normalized_source_repo_returns_dot_for_empty_or_whitespace() {
1931 let empty = Issue {
1932 id: "X".to_string(),
1933 title: "X".to_string(),
1934 issue_type: "task".to_string(),
1935 source_repo: String::new(),
1936 ..Issue::default()
1937 };
1938 assert_eq!(normalized_source_repo(&empty), ".");
1939
1940 let whitespace = Issue {
1941 source_repo: " ".to_string(),
1942 ..empty.clone()
1943 };
1944 assert_eq!(normalized_source_repo(&whitespace), ".");
1945
1946 let normal = Issue {
1947 source_repo: "services/api".to_string(),
1948 ..empty
1949 };
1950 assert_eq!(normalized_source_repo(&normal), "services/api");
1951 }
1952
1953 #[test]
1954 fn emit_bootstrap_config_rejects_zero_chunk_size() {
1955 let temp = tempdir().expect("tempdir");
1956 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1957 .expect("bootstrap");
1958
1959 let err = emit_bootstrap_config(
1960 temp.path(),
1961 &SqliteBundleOptions {
1962 chunk_threshold_bytes: 1,
1963 chunk_size_bytes: 0,
1964 },
1965 )
1966 .expect_err("zero chunk size should fail");
1967
1968 assert!(
1969 err.to_string().contains("greater than zero"),
1970 "unexpected error: {err}"
1971 );
1972 }
1973
1974 #[test]
1975 fn populate_export_database_handles_empty_issue_list() {
1976 let temp = tempdir().expect("tempdir");
1977 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
1978 .expect("bootstrap");
1979
1980 let issues: Vec<Issue> = vec![];
1981 let analyzer = Analyzer::new(issues.clone());
1982 let triage = analyzer.triage(TriageOptions::default());
1983
1984 populate_export_database(temp.path(), Some("Empty"), &issues, &analyzer, &triage)
1985 .expect("populate with empty issues");
1986
1987 let connection =
1988 Connection::open(export_database_path(temp.path())).expect("open database");
1989
1990 let count: i64 = connection
1991 .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
1992 .expect("count issues");
1993 assert_eq!(count, 0);
1994
1995 let meta = export_meta(&connection);
1996 assert_eq!(meta.get("issue_count"), Some(&"0".to_string()));
1997 assert_eq!(meta.get("title"), Some(&"Empty".to_string()));
1998 }
1999
2000 #[test]
2001 fn populate_export_database_is_idempotent() {
2002 let temp = tempdir().expect("tempdir");
2003 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
2004 .expect("bootstrap");
2005
2006 let issues = vec![Issue {
2007 id: "ONLY-1".to_string(),
2008 title: "Only issue".to_string(),
2009 status: "open".to_string(),
2010 priority: 1,
2011 issue_type: "task".to_string(),
2012 ..Issue::default()
2013 }];
2014
2015 let analyzer = Analyzer::new(issues.clone());
2016 let triage = analyzer.triage(TriageOptions::default());
2017
2018 populate_export_database(temp.path(), Some("First"), &issues, &analyzer, &triage)
2019 .expect("first populate");
2020 populate_export_database(temp.path(), Some("Second"), &issues, &analyzer, &triage)
2021 .expect("second populate");
2022
2023 let connection =
2024 Connection::open(export_database_path(temp.path())).expect("open database");
2025
2026 let count: i64 = connection
2027 .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
2028 .expect("count issues");
2029 assert_eq!(count, 1);
2030
2031 let meta = export_meta(&connection);
2032 assert_eq!(meta.get("title"), Some(&"Second".to_string()));
2033 }
2034
2035 #[test]
2036 fn populate_export_database_omits_title_when_none() {
2037 let temp = tempdir().expect("tempdir");
2038 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
2039 .expect("bootstrap");
2040
2041 let issues: Vec<Issue> = vec![];
2042 let analyzer = Analyzer::new(issues.clone());
2043 let triage = analyzer.triage(TriageOptions::default());
2044
2045 populate_export_database(temp.path(), None, &issues, &analyzer, &triage)
2046 .expect("populate without title");
2047
2048 let connection =
2049 Connection::open(export_database_path(temp.path())).expect("open database");
2050 let meta = export_meta(&connection);
2051 assert!(meta.get("title").is_none());
2052 }
2053
2054 #[test]
2055 fn populate_export_database_omits_blank_title() {
2056 let temp = tempdir().expect("tempdir");
2057 bootstrap_export_database(temp.path(), &SqliteBootstrapOptions::default())
2058 .expect("bootstrap");
2059
2060 let issues: Vec<Issue> = vec![];
2061 let analyzer = Analyzer::new(issues.clone());
2062 let triage = analyzer.triage(TriageOptions::default());
2063
2064 populate_export_database(temp.path(), Some(" "), &issues, &analyzer, &triage)
2065 .expect("populate with blank title");
2066
2067 let connection =
2068 Connection::open(export_database_path(temp.path())).expect("open database");
2069 let meta = export_meta(&connection);
2070 assert!(meta.get("title").is_none());
2071 }
2072
2073 #[test]
2074 fn hash_file_produces_consistent_sha256() {
2075 let temp = tempdir().expect("tempdir");
2076 let file_path = temp.path().join("test.bin");
2077 std::fs::write(&file_path, b"hello world").expect("write test file");
2078
2079 let hash1 = hash_file(&file_path).expect("first hash");
2080 let hash2 = hash_file(&file_path).expect("second hash");
2081
2082 assert_eq!(hash1, hash2);
2083 assert_eq!(hash1.len(), 64);
2084 assert!(hash1.chars().all(|c| c.is_ascii_hexdigit()));
2085 }
2086
2087 #[test]
2088 fn export_database_path_joins_filename() {
2089 let path = export_database_path(Path::new("/tmp/out"));
2090 assert_eq!(path, PathBuf::from("/tmp/out/beads.sqlite3"));
2091 }
2092
2093 #[test]
2094 fn export_config_path_joins_filename() {
2095 let path = export_config_path(Path::new("/tmp/out"));
2096 assert_eq!(path, PathBuf::from("/tmp/out/beads.sqlite3.config.json"));
2097 }
2098
2099 #[test]
2100 fn write_database_chunks_produces_correct_reassembly() {
2101 let temp = tempdir().expect("tempdir");
2102 let db_path = temp.path().join("test.db");
2103 let content = vec![0xAA_u8; 1000];
2104 std::fs::write(&db_path, &content).expect("write test db");
2105
2106 let chunks = write_database_chunks(temp.path(), &db_path, 300).expect("write chunks");
2107
2108 assert_eq!(chunks.len(), 4);
2109 let total_size: u64 = chunks.iter().map(|c| c.size).sum();
2110 assert_eq!(total_size, 1000);
2111
2112 let mut reassembled = Vec::new();
2113 for chunk in &chunks {
2114 let chunk_path = temp.path().join(&chunk.path);
2115 let data = std::fs::read(&chunk_path).expect("read chunk");
2116 assert_eq!(data.len() as u64, chunk.size);
2117 reassembled.extend_from_slice(&data);
2118 }
2119 assert_eq!(reassembled, content);
2120 }
2121
2122 fn sqlite_object_inventory(connection: &Connection) -> BTreeSet<String> {
2123 let mut stmt = connection
2124 .prepare(
2125 "
2126 SELECT name
2127 FROM sqlite_master
2128 WHERE type IN ('table', 'index')
2129 AND name NOT LIKE 'sqlite_%'
2130 ORDER BY name
2131 ",
2132 )
2133 .expect("prepare sqlite inventory query");
2134 let names = stmt
2135 .query_map([], |row| row.get::<_, String>(0))
2136 .expect("query sqlite inventory");
2137 names
2138 .collect::<std::result::Result<BTreeSet<_>, _>>()
2139 .expect("collect sqlite inventory")
2140 }
2141
2142 fn export_meta(connection: &Connection) -> BTreeMap<String, String> {
2143 let mut stmt = connection
2144 .prepare("SELECT key, value FROM export_meta ORDER BY key")
2145 .expect("prepare export_meta query");
2146 let rows = stmt
2147 .query_map([], |row| {
2148 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2149 })
2150 .expect("query export_meta");
2151 rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()
2152 .expect("collect export_meta")
2153 }
2154}