Skip to main content

bvr/
export_sqlite.rs

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}