Skip to main content

bob/
db.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17/*!
18 * SQLite database for caching scan and build results.
19 *
20 * This module provides optimized database access with:
21 * - Lazy loading to minimize memory usage
22 * - Indexed reverse dependency lookups for fast failure cascades
23 * - Normalized dependency tables for efficient queries
24 * - Hybrid storage: columns for hot fields, JSON for cold data
25 *
26 * # Schema
27 *
28 * - `packages` - Core package identity and status
29 * - `depends` - Raw dependency patterns from scans
30 * - `resolved_depends` - Resolved dependencies after pattern matching
31 * - `builds` - Build results with indexed outcome
32 * - `metadata` - Key-value store for flags and cached data
33 */
34
35use std::collections::{HashMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39use anyhow::{Context, Result};
40use pkgsrc::{PkgName, PkgPath, ScanIndex};
41use rusqlite::{Connection, params};
42use tracing::{debug, warn};
43
44use crate::build::{BuildOutcome, BuildResult};
45use crate::config::PkgsrcEnv;
46use crate::scan::SkipReason;
47
48/**
49 * Schema version - update when schema changes.
50 */
51const SCHEMA_VERSION: i32 = 3;
52
53/**
54 * Lightweight package row without full scan data.
55 */
56#[derive(Clone, Debug)]
57pub struct PackageRow {
58    pub id: i64,
59    pub pkgname: String,
60    pub pkgpath: String,
61    pub skip_reason: Option<String>,
62    pub fail_reason: Option<String>,
63    pub is_bootstrap: bool,
64    pub pbulk_weight: i32,
65}
66
67/**
68 * SQLite database for scan and build caching.
69 */
70pub struct Database {
71    conn: Connection,
72}
73
74impl Database {
75    /**
76     * Open or create a database at the given path.
77     */
78    pub fn open(path: &Path) -> Result<Self> {
79        if let Some(parent) = path.parent() {
80            std::fs::create_dir_all(parent).context("Failed to create database directory")?;
81        }
82        let conn = Connection::open(path).context("Failed to open database")?;
83        let db = Self { conn };
84        db.configure_pragmas()?;
85        db.init()?;
86        Ok(db)
87    }
88
89    /**
90     * Begin a transaction.
91     */
92    pub fn begin_transaction(&self) -> Result<()> {
93        self.conn.execute("BEGIN TRANSACTION", [])?;
94        Ok(())
95    }
96
97    /**
98     * Commit the current transaction.
99     */
100    pub fn commit(&self) -> Result<()> {
101        self.conn.execute("COMMIT", [])?;
102        Ok(())
103    }
104
105    /**
106     * Configure SQLite for performance.
107     */
108    fn configure_pragmas(&self) -> Result<()> {
109        self.conn.execute_batch(
110            "PRAGMA journal_mode = WAL;
111             PRAGMA synchronous = NORMAL;
112             PRAGMA cache_size = -64000;
113             PRAGMA temp_store = MEMORY;
114             PRAGMA mmap_size = 268435456;
115             PRAGMA foreign_keys = ON;",
116        )?;
117        Ok(())
118    }
119
120    /**
121     * Initialize schema or fail if version mismatch.
122     */
123    fn init(&self) -> Result<()> {
124        // Check if schema_version table exists
125        let has_schema_version: bool = self.conn.query_row(
126            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_version'",
127            [],
128            |row| row.get::<_, i32>(0).map(|c| c > 0),
129        )?;
130
131        if !has_schema_version {
132            // Fresh database, create schema
133            self.create_schema()?;
134        } else {
135            // Check version matches
136            let version: i32 =
137                self.conn
138                    .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| {
139                        row.get(0)
140                    })?;
141
142            if version != SCHEMA_VERSION {
143                anyhow::bail!(
144                    "Schema mismatch: found v{}, expected v{}. \
145                     Run 'bob clean' to restart.",
146                    version,
147                    SCHEMA_VERSION
148                );
149            }
150        }
151
152        Ok(())
153    }
154
155    /**
156     * Create the database schema.
157     */
158    fn create_schema(&self) -> Result<()> {
159        self.conn.execute_batch(&format!(
160            "CREATE TABLE schema_version (version INTEGER NOT NULL);
161             INSERT INTO schema_version (version) VALUES ({});
162
163             CREATE TABLE packages (
164                 id INTEGER PRIMARY KEY AUTOINCREMENT,
165                 pkgname TEXT UNIQUE NOT NULL,
166                 pkgpath TEXT NOT NULL,
167                 skip_reason TEXT,
168                 fail_reason TEXT,
169                 is_bootstrap INTEGER DEFAULT 0,
170                 pbulk_weight INTEGER DEFAULT 100,
171                 scan_data TEXT
172             );
173
174             CREATE INDEX idx_packages_pkgpath ON packages(pkgpath);
175             CREATE INDEX idx_packages_status ON packages(skip_reason, fail_reason);
176
177             CREATE TABLE depends (
178                 id INTEGER PRIMARY KEY AUTOINCREMENT,
179                 package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
180                 depend_pattern TEXT NOT NULL,
181                 depend_pkgpath TEXT NOT NULL,
182                 UNIQUE(package_id, depend_pattern)
183             );
184
185             CREATE INDEX idx_depends_package ON depends(package_id);
186             CREATE INDEX idx_depends_pkgpath ON depends(depend_pkgpath);
187
188             CREATE TABLE resolved_depends (
189                 id INTEGER PRIMARY KEY AUTOINCREMENT,
190                 package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
191                 depends_on_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
192                 UNIQUE(package_id, depends_on_id)
193             );
194
195             CREATE INDEX idx_resolved_depends_package ON resolved_depends(package_id);
196             CREATE INDEX idx_resolved_depends_depends_on ON resolved_depends(depends_on_id);
197
198             CREATE TABLE builds (
199                 id INTEGER PRIMARY KEY AUTOINCREMENT,
200                 package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
201                 outcome TEXT NOT NULL,
202                 outcome_detail TEXT,
203                 duration_ms INTEGER NOT NULL DEFAULT 0,
204                 log_dir TEXT,
205                 UNIQUE(package_id)
206             );
207
208             CREATE INDEX idx_builds_outcome ON builds(outcome);
209             CREATE INDEX idx_builds_package ON builds(package_id);
210
211             CREATE TABLE metadata (
212                 key TEXT PRIMARY KEY,
213                 value TEXT NOT NULL
214             );",
215            SCHEMA_VERSION
216        ))?;
217
218        debug!(version = SCHEMA_VERSION, "Created schema");
219        Ok(())
220    }
221
222    // ========================================================================
223    // PACKAGE QUERIES
224    // ========================================================================
225
226    /**
227     * Store a package from scan results.
228     */
229    pub fn store_package(&self, pkgpath: &str, index: &ScanIndex) -> Result<i64> {
230        let pkgname = index.pkgname.pkgname();
231
232        let skip_reason = index.pkg_skip_reason.as_ref().filter(|s| !s.is_empty());
233        let fail_reason = index.pkg_fail_reason.as_ref().filter(|s| !s.is_empty());
234        let is_bootstrap = index.bootstrap_pkg.as_deref() == Some("yes");
235        let pbulk_weight: i32 = index
236            .pbulk_weight
237            .as_ref()
238            .and_then(|s| s.parse().ok())
239            .unwrap_or(100);
240
241        let scan_data = serde_json::to_string(index)?;
242
243        {
244            let mut stmt = self.conn.prepare_cached(
245                "INSERT OR REPLACE INTO packages
246                 (pkgname, pkgpath, skip_reason, fail_reason,
247                  is_bootstrap, pbulk_weight, scan_data)
248                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
249            )?;
250            stmt.execute(params![
251                pkgname,
252                pkgpath,
253                skip_reason,
254                fail_reason,
255                is_bootstrap,
256                pbulk_weight,
257                scan_data
258            ])?;
259        }
260
261        let package_id = self.conn.last_insert_rowid();
262
263        // Store raw dependencies
264        if let Some(ref deps) = index.all_depends {
265            let mut stmt = self.conn.prepare_cached(
266                "INSERT OR IGNORE INTO depends (package_id, depend_pattern, depend_pkgpath)
267                 VALUES (?1, ?2, ?3)",
268            )?;
269            for dep in deps {
270                stmt.execute(params![
271                    package_id,
272                    dep.pattern().pattern(),
273                    dep.pkgpath().to_string()
274                ])?;
275            }
276        }
277
278        debug!(pkgname = pkgname, package_id = package_id, "Stored package");
279        Ok(package_id)
280    }
281
282    /**
283     * Store scan results for a pkgpath.
284     */
285    pub fn store_scan_pkgpath(&self, pkgpath: &str, indexes: &[ScanIndex]) -> Result<()> {
286        for index in indexes {
287            self.store_package(pkgpath, index)?;
288        }
289        Ok(())
290    }
291
292    /**
293     * Get package by name.
294     */
295    pub fn get_package_by_name(&self, pkgname: &str) -> Result<Option<PackageRow>> {
296        let result = self.conn.query_row(
297            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
298             FROM packages WHERE pkgname = ?1",
299            [pkgname],
300            |row| {
301                Ok(PackageRow {
302                    id: row.get(0)?,
303                    pkgname: row.get(1)?,
304                    pkgpath: row.get(2)?,
305                    skip_reason: row.get(3)?,
306                    fail_reason: row.get(4)?,
307                    is_bootstrap: row.get::<_, i32>(5)? != 0,
308                    pbulk_weight: row.get(6)?,
309                })
310            },
311        );
312
313        match result {
314            Ok(pkg) => Ok(Some(pkg)),
315            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
316            Err(e) => Err(e.into()),
317        }
318    }
319
320    /**
321     * Get package ID by name.
322     */
323    pub fn get_package_id(&self, pkgname: &str) -> Result<Option<i64>> {
324        let result = self.conn.query_row(
325            "SELECT id FROM packages WHERE pkgname = ?1",
326            [pkgname],
327            |row| row.get(0),
328        );
329
330        match result {
331            Ok(id) => Ok(Some(id)),
332            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
333            Err(e) => Err(e.into()),
334        }
335    }
336
337    /**
338     * Get pkgname by package ID.
339     */
340    pub fn get_pkgname(&self, package_id: i64) -> Result<String> {
341        self.conn
342            .query_row(
343                "SELECT pkgname FROM packages WHERE id = ?1",
344                [package_id],
345                |row| row.get(0),
346            )
347            .context("Package not found")
348    }
349
350    /**
351     * Get packages by pkgpath.
352     */
353    pub fn get_packages_by_path(&self, pkgpath: &str) -> Result<Vec<PackageRow>> {
354        let mut stmt = self.conn.prepare(
355            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
356             FROM packages WHERE pkgpath = ?1",
357        )?;
358
359        let rows = stmt.query_map([pkgpath], |row| {
360            Ok(PackageRow {
361                id: row.get(0)?,
362                pkgname: row.get(1)?,
363                pkgpath: row.get(2)?,
364                skip_reason: row.get(3)?,
365                fail_reason: row.get(4)?,
366                is_bootstrap: row.get::<_, i32>(5)? != 0,
367                pbulk_weight: row.get(6)?,
368            })
369        })?;
370
371        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
372    }
373
374    /**
375     * Get all scanned pkgpaths.
376     */
377    pub fn get_scanned_pkgpaths(&self) -> Result<HashSet<String>> {
378        let mut stmt = self.conn.prepare("SELECT DISTINCT pkgpath FROM packages")?;
379        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
380        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
381    }
382
383    /**
384     * Get pkgpaths that are referenced as dependencies but haven't been scanned
385     * yet. These are dependencies that were discovered during scanning but the
386     * scan was interrupted before they could be processed.
387     */
388    pub fn get_unscanned_dependencies(&self) -> Result<HashSet<String>> {
389        let mut stmt = self.conn.prepare(
390            "SELECT DISTINCT d.depend_pkgpath
391             FROM depends d
392             WHERE d.depend_pkgpath NOT IN (SELECT pkgpath FROM packages)",
393        )?;
394        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
395        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
396    }
397
398    /**
399     * Count of scanned packages.
400     */
401    pub fn count_packages(&self) -> Result<i64> {
402        self.conn
403            .query_row("SELECT COUNT(*) FROM packages", [], |row| row.get(0))
404            .context("Failed to count packages")
405    }
406
407    /**
408     * Get all packages (lightweight).
409     */
410    pub fn get_all_packages(&self) -> Result<Vec<PackageRow>> {
411        let mut stmt = self.conn.prepare(
412            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
413             FROM packages ORDER BY id",
414        )?;
415
416        let rows = stmt.query_map([], |row| {
417            Ok(PackageRow {
418                id: row.get(0)?,
419                pkgname: row.get(1)?,
420                pkgpath: row.get(2)?,
421                skip_reason: row.get(3)?,
422                fail_reason: row.get(4)?,
423                is_bootstrap: row.get::<_, i32>(5)? != 0,
424                pbulk_weight: row.get(6)?,
425            })
426        })?;
427
428        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
429    }
430
431    /**
432     * Get all buildable packages (no skip/fail reason).
433     */
434    pub fn get_buildable_packages(&self) -> Result<Vec<PackageRow>> {
435        let mut stmt = self.conn.prepare(
436            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
437             FROM packages WHERE skip_reason IS NULL AND fail_reason IS NULL",
438        )?;
439
440        let rows = stmt.query_map([], |row| {
441            Ok(PackageRow {
442                id: row.get(0)?,
443                pkgname: row.get(1)?,
444                pkgpath: row.get(2)?,
445                skip_reason: row.get(3)?,
446                fail_reason: row.get(4)?,
447                is_bootstrap: row.get::<_, i32>(5)? != 0,
448                pbulk_weight: row.get(6)?,
449            })
450        })?;
451
452        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
453    }
454
455    /**
456     * Load full ScanIndex for a package.
457     */
458    pub fn get_full_scan_index(&self, package_id: i64) -> Result<ScanIndex> {
459        let json: String = self.conn.query_row(
460            "SELECT scan_data FROM packages WHERE id = ?1",
461            [package_id],
462            |row| row.get(0),
463        )?;
464        serde_json::from_str(&json).context("Failed to deserialize scan data")
465    }
466
467    /**
468     * Load all ScanIndex data in one query.
469     */
470    pub fn get_all_scan_data(&self) -> Result<Vec<ScanIndex>> {
471        let mut stmt = self
472            .conn
473            .prepare("SELECT id, scan_data FROM packages ORDER BY id")?;
474        let rows = stmt.query_map([], |row| {
475            let id: i64 = row.get(0)?;
476            let json: String = row.get(1)?;
477            Ok((id, json))
478        })?;
479        let mut results = Vec::new();
480        for row in rows {
481            let (id, json) = row?;
482            let index: ScanIndex = serde_json::from_str(&json)
483                .with_context(|| format!("Failed to deserialize scan data for package {}", id))?;
484            results.push(index);
485        }
486        Ok(results)
487    }
488
489    /**
490     * Clear all scan data.
491     */
492    pub fn clear_scan(&self) -> Result<()> {
493        self.conn.execute("DELETE FROM packages", [])?;
494        self.clear_full_scan_complete()?;
495        Ok(())
496    }
497
498    // ========================================================================
499    // DEPENDENCY QUERIES
500    // ========================================================================
501
502    /**
503     * Store resolved dependencies from a ScanSummary.
504     */
505    pub fn store_resolved_deps(&self, summary: &crate::scan::ScanSummary) -> Result<()> {
506        use crate::scan::ScanResult;
507
508        let mut resolved_deps: Vec<(i64, i64)> = Vec::new();
509        for pkg in &summary.packages {
510            match pkg {
511                ScanResult::Buildable(resolved) => {
512                    if let Some(pkg_id) = self.get_package_id(resolved.pkgname().pkgname())? {
513                        for dep in resolved.depends() {
514                            if let Some(dep_id) = self.get_package_id(dep.pkgname())? {
515                                resolved_deps.push((pkg_id, dep_id));
516                            }
517                        }
518                    }
519                }
520                ScanResult::Skipped {
521                    index,
522                    resolved_depends,
523                    ..
524                } => {
525                    let Some(idx) = index else { continue };
526                    if let Some(pkg_id) = self.get_package_id(idx.pkgname.pkgname())? {
527                        for dep in resolved_depends {
528                            if let Some(dep_id) = self.get_package_id(dep.pkgname())? {
529                                resolved_deps.push((pkg_id, dep_id));
530                            }
531                        }
532                    }
533                }
534                ScanResult::ScanFail { .. } => {}
535            }
536        }
537
538        if !resolved_deps.is_empty() {
539            self.store_resolved_dependencies_batch(&resolved_deps)?;
540            debug!(count = resolved_deps.len(), "Stored resolved dependencies");
541        }
542        Ok(())
543    }
544
545    /**
546     * Store skipped packages from scan resolution.
547     *
548     * This persists all skip reasons to the builds table so that queries
549     * like `get_blockers` can find them consistently.
550     *
551     * At scan time, both IndirectSkip and IndirectFail come from prefailed
552     * packages (PKG_SKIP_REASON and PKG_FAIL_REASON respectively), so both
553     * are stored as indirect_skip to map to "indirect-prefailed" in the CLI.
554     */
555    pub fn store_scan_skipped(&self, summary: &crate::scan::ScanSummary) -> Result<()> {
556        use crate::scan::{ScanResult, SkipReason};
557
558        self.conn.execute("BEGIN TRANSACTION", [])?;
559
560        for pkg in &summary.packages {
561            if let ScanResult::Skipped { reason, index, .. } = pkg {
562                let Some(idx) = index else { continue };
563                let pkgname = idx.pkgname.pkgname();
564
565                let Some(pkg_row) = self.get_package_by_name(pkgname)? else {
566                    continue;
567                };
568
569                // At scan time, both IndirectSkip and IndirectFail are from
570                // prefailed deps, so store both as indirect_skip
571                let (outcome, detail) = match reason {
572                    SkipReason::PkgSkip(s) => ("pkg_skip", Some(s.clone())),
573                    SkipReason::PkgFail(s) => ("pkg_fail", Some(s.clone())),
574                    SkipReason::IndirectSkip(s) | SkipReason::IndirectFail(s) => {
575                        ("indirect_skip", Some(s.clone()))
576                    }
577                    SkipReason::UnresolvedDep(s) => ("unresolved_dep", Some(s.clone())),
578                };
579
580                self.conn.execute(
581                    "INSERT OR IGNORE INTO builds
582                     (package_id, outcome, outcome_detail, duration_ms)
583                     VALUES (?1, ?2, ?3, 0)",
584                    params![pkg_row.id, outcome, detail],
585                )?;
586            }
587        }
588
589        self.conn.execute("COMMIT", [])?;
590        Ok(())
591    }
592
593    /**
594     * Store resolved dependencies in batch.
595     */
596    fn store_resolved_dependencies_batch(&self, deps: &[(i64, i64)]) -> Result<()> {
597        self.conn.execute("BEGIN TRANSACTION", [])?;
598        let mut stmt = self.conn.prepare(
599            "INSERT OR IGNORE INTO resolved_depends (package_id, depends_on_id) VALUES (?1, ?2)",
600        )?;
601        for (package_id, depends_on_id) in deps {
602            stmt.execute(params![package_id, depends_on_id])?;
603        }
604        drop(stmt);
605        self.conn.execute("COMMIT", [])?;
606        Ok(())
607    }
608
609    /**
610     * Get all transitive reverse dependencies using recursive CTE.
611     */
612    pub fn get_transitive_reverse_deps(&self, package_id: i64) -> Result<Vec<i64>> {
613        let mut stmt = self.conn.prepare(
614            "WITH RECURSIVE affected(id) AS (
615                SELECT ?1
616                UNION
617                SELECT rd.package_id
618                FROM resolved_depends rd
619                JOIN affected a ON rd.depends_on_id = a.id
620            )
621            SELECT id FROM affected WHERE id != ?1",
622        )?;
623        let rows = stmt.query_map([package_id], |row| row.get::<_, i64>(0))?;
624        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
625    }
626
627    /**
628     * Clear all resolved dependencies.
629     */
630    pub fn clear_resolved_depends(&self) -> Result<()> {
631        self.conn.execute("DELETE FROM resolved_depends", [])?;
632        Ok(())
633    }
634
635    // ========================================================================
636    // BUILD QUERIES
637    // ========================================================================
638
639    /**
640     * Store a build result by package ID.
641     */
642    pub fn store_build_result(&self, package_id: i64, result: &BuildResult) -> Result<()> {
643        let (outcome, detail) = build_outcome_to_db(&result.outcome);
644        let duration_ms = result.duration.as_millis() as i64;
645        let log_dir = result.log_dir.as_ref().map(|p| p.display().to_string());
646
647        self.conn.execute(
648            "INSERT OR REPLACE INTO builds
649             (package_id, outcome, outcome_detail, duration_ms, log_dir)
650             VALUES (?1, ?2, ?3, ?4, ?5)",
651            params![package_id, outcome, detail, duration_ms, log_dir],
652        )?;
653
654        debug!(
655            package_id = package_id,
656            outcome = outcome,
657            "Stored build result"
658        );
659        Ok(())
660    }
661
662    /**
663     * Store a build result by pkgname.
664     */
665    pub fn store_build_by_name(&self, result: &BuildResult) -> Result<()> {
666        if let Some(pkg) = self.get_package_by_name(result.pkgname.pkgname())? {
667            self.store_build_result(pkg.id, result)
668        } else {
669            warn!(pkgname = %result.pkgname.pkgname(), "Package not found in database for build result");
670            Ok(())
671        }
672    }
673
674    /**
675     * Get build result for a package.
676     */
677    pub fn get_build_result(&self, package_id: i64) -> Result<Option<BuildResult>> {
678        let result = self.conn.query_row(
679            "SELECT p.pkgname, p.pkgpath, b.outcome, b.outcome_detail, b.duration_ms, b.log_dir
680             FROM builds b
681             JOIN packages p ON b.package_id = p.id
682             WHERE b.package_id = ?1",
683            [package_id],
684            |row| {
685                let pkgname: String = row.get(0)?;
686                let pkgpath: Option<String> = row.get(1)?;
687                let outcome: String = row.get(2)?;
688                let detail: Option<String> = row.get(3)?;
689                let duration_ms: i64 = row.get(4)?;
690                let log_dir: Option<String> = row.get(5)?;
691                Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir))
692            },
693        );
694
695        match result {
696            Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir)) => {
697                let build_outcome = db_outcome_to_build(&outcome, detail);
698                Ok(Some(BuildResult {
699                    pkgname: PkgName::new(&pkgname),
700                    pkgpath: pkgpath.and_then(|p| PkgPath::new(&p).ok()),
701                    outcome: build_outcome,
702                    duration: Duration::from_millis(duration_ms as u64),
703                    log_dir: log_dir.map(std::path::PathBuf::from),
704                }))
705            }
706            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
707            Err(e) => Err(e.into()),
708        }
709    }
710
711    /**
712     * Delete build result for a pkgname.
713     */
714    pub fn delete_build_by_name(&self, pkgname: &str) -> Result<bool> {
715        let rows = self.conn.execute(
716            "DELETE FROM builds WHERE package_id IN (SELECT id FROM packages WHERE pkgname = ?1)",
717            params![pkgname],
718        )?;
719        Ok(rows > 0)
720    }
721
722    /**
723     * Delete build results by pkgpath.
724     */
725    pub fn delete_build_by_pkgpath(&self, pkgpath: &str) -> Result<usize> {
726        let rows = self.conn.execute(
727            "DELETE FROM builds WHERE package_id IN (SELECT id FROM packages WHERE pkgpath = ?1)",
728            params![pkgpath],
729        )?;
730        Ok(rows)
731    }
732
733    /**
734     * Clear all build results.
735     */
736    pub fn clear_builds(&self) -> Result<usize> {
737        let rows = self.conn.execute("DELETE FROM builds", [])?;
738        Ok(rows)
739    }
740
741    /**
742     * Get all build results from the database.
743     */
744    pub fn get_all_build_results(&self) -> Result<Vec<BuildResult>> {
745        let mut stmt = self.conn.prepare(
746            "SELECT p.pkgname, p.pkgpath, b.outcome, b.outcome_detail, b.duration_ms, b.log_dir
747             FROM builds b
748             JOIN packages p ON b.package_id = p.id
749             ORDER BY p.pkgname",
750        )?;
751
752        let rows = stmt.query_map([], |row| {
753            let pkgname: String = row.get(0)?;
754            let pkgpath: Option<String> = row.get(1)?;
755            let outcome: String = row.get(2)?;
756            let detail: Option<String> = row.get(3)?;
757            let duration_ms: i64 = row.get(4)?;
758            let log_dir: Option<String> = row.get(5)?;
759            Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir))
760        })?;
761
762        let mut results = Vec::new();
763        for row in rows {
764            let (pkgname, pkgpath, outcome, detail, duration_ms, log_dir) = row?;
765            let build_outcome = db_outcome_to_build(&outcome, detail);
766            results.push(BuildResult {
767                pkgname: PkgName::new(&pkgname),
768                pkgpath: pkgpath.and_then(|p| PkgPath::new(&p).ok()),
769                outcome: build_outcome,
770                duration: Duration::from_millis(duration_ms as u64),
771                log_dir: log_dir.map(std::path::PathBuf::from),
772            });
773        }
774
775        Ok(results)
776    }
777
778    /**
779     * Count how many packages are broken by each failed package.
780     * Returns a map from pkgname to the count of packages that depend on it.
781     */
782    pub fn count_breaks_for_failed(&self) -> Result<std::collections::HashMap<String, usize>> {
783        use std::collections::HashMap;
784
785        let mut counts: HashMap<String, usize> = HashMap::new();
786
787        // Get all failed package IDs and their names
788        let mut stmt = self.conn.prepare(
789            "SELECT p.id, p.pkgname FROM builds b
790             JOIN packages p ON b.package_id = p.id
791             WHERE b.outcome = 'failed'",
792        )?;
793
794        let failed: Vec<(i64, String)> = stmt
795            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
796            .filter_map(|r| r.ok())
797            .collect();
798
799        // For each failed package, count indirect failures that reference it
800        for (_pkg_id, pkgname) in failed {
801            let count: i64 = self.conn.query_row(
802                "SELECT COUNT(*) FROM builds b
803                 JOIN packages p ON b.package_id = p.id
804                 WHERE b.outcome = 'indirect_failed'
805                 AND b.outcome_detail LIKE ?1",
806                params![format!("%{}", pkgname)],
807                |row| row.get(0),
808            )?;
809            counts.insert(pkgname, count as usize);
810        }
811
812        Ok(counts)
813    }
814
815    /**
816     * Get total build duration from all builds.
817     */
818    pub fn get_total_build_duration(&self) -> Result<Duration> {
819        let total_ms: i64 = self.conn.query_row(
820            "SELECT COALESCE(SUM(duration_ms), 0) FROM builds",
821            [],
822            |row| row.get(0),
823        )?;
824        Ok(Duration::from_millis(total_ms as u64))
825    }
826
827    /**
828     * Get blockers for a package - what's preventing it from building.
829     * Accepts either pkgname or pkgpath (auto-detected by presence of '/').
830     * Returns (pkgname, pkgpath, reason) for each blocking dependency.
831     */
832    pub fn get_blockers(&self, package: &str) -> Result<Vec<(String, String, String)>> {
833        let pkg = if package.contains('/') {
834            let pkgs = self.get_packages_by_path(package)?;
835            pkgs.into_iter().next()
836        } else {
837            self.get_package_by_name(package)?
838        };
839
840        let Some(pkg) = pkg else {
841            anyhow::bail!("Package '{}' not found in database", package);
842        };
843
844        let mut stmt = self.conn.prepare(
845            "WITH RECURSIVE
846             blocking(id, reason) AS (
847                 -- Direct dependencies that have failed/skipped builds
848                 SELECT rd.depends_on_id,
849                        CASE b.outcome
850                            WHEN 'failed' THEN 'failed'
851                            WHEN 'pkg_skip' THEN 'prefailed'
852                            WHEN 'pkg_fail' THEN 'prefailed'
853                            WHEN 'indirect_skip' THEN 'indirect-prefailed'
854                            WHEN 'indirect_fail' THEN 'indirect-failed'
855                            WHEN 'indirect_failed' THEN 'indirect-failed'
856                            WHEN 'unresolved_dep' THEN 'unresolved'
857                            ELSE b.outcome
858                        END
859                 FROM resolved_depends rd
860                 JOIN builds b ON b.package_id = rd.depends_on_id
861                 WHERE rd.package_id = ?1
862                   AND b.outcome NOT IN ('success', 'up_to_date')
863                 UNION
864                 -- Transitive: deps of deps that are blocked
865                 SELECT rd.depends_on_id,
866                        CASE b.outcome
867                            WHEN 'failed' THEN 'failed'
868                            WHEN 'pkg_skip' THEN 'prefailed'
869                            WHEN 'pkg_fail' THEN 'prefailed'
870                            WHEN 'indirect_skip' THEN 'indirect-prefailed'
871                            WHEN 'indirect_fail' THEN 'indirect-failed'
872                            WHEN 'indirect_failed' THEN 'indirect-failed'
873                            WHEN 'unresolved_dep' THEN 'unresolved'
874                            ELSE b.outcome
875                        END
876                 FROM resolved_depends rd
877                 JOIN blocking bl ON rd.package_id = bl.id
878                 JOIN builds b ON b.package_id = rd.depends_on_id
879                 WHERE b.outcome NOT IN ('success', 'up_to_date')
880             )
881             SELECT DISTINCT p.pkgname, p.pkgpath, bl.reason
882             FROM blocking bl
883             JOIN packages p ON bl.id = p.id
884             -- Only show root causes (failed or prefailed), not indirect
885             WHERE bl.reason IN ('failed', 'prefailed', 'unresolved')
886             ORDER BY p.pkgname",
887        )?;
888
889        let rows = stmt.query_map([pkg.id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
890
891        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
892    }
893
894    /**
895     * Get packages blocked by a failed package.
896     * Accepts either pkgname or pkgpath (auto-detected by presence of '/').
897     * Returns (pkgname, pkgpath) for each blocked package.
898     */
899    pub fn get_blocked_by(&self, package: &str) -> Result<Vec<(String, String)>> {
900        let pkg = if package.contains('/') {
901            let pkgs = self.get_packages_by_path(package)?;
902            pkgs.into_iter().next()
903        } else {
904            self.get_package_by_name(package)?
905        };
906
907        let Some(pkg) = pkg else {
908            anyhow::bail!("Package '{}' not found in database", package);
909        };
910
911        let mut stmt = self.conn.prepare(
912            "WITH RECURSIVE
913             affected(id) AS (
914                 -- Direct reverse dependencies
915                 SELECT rd.package_id
916                 FROM resolved_depends rd
917                 WHERE rd.depends_on_id = ?1
918                 UNION
919                 -- Transitive reverse dependencies
920                 SELECT rd.package_id
921                 FROM resolved_depends rd
922                 JOIN affected a ON rd.depends_on_id = a.id
923             )
924             SELECT p.pkgname, p.pkgpath
925             FROM affected a
926             JOIN packages p ON a.id = p.id
927             ORDER BY p.pkgname",
928        )?;
929
930        let rows = stmt.query_map([pkg.id], |row| Ok((row.get(0)?, row.get(1)?)))?;
931
932        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
933    }
934
935    /**
936     * Get pre-failed packages (those with skip_reason or fail_reason but no
937     * build result). Returns (pkgname, pkgpath, reason).
938     */
939    pub fn get_prefailed_packages(&self) -> Result<Vec<(String, Option<String>, String)>> {
940        let mut stmt = self.conn.prepare(
941            "SELECT p.pkgname, p.pkgpath,
942                    COALESCE(p.fail_reason, p.skip_reason) as reason
943             FROM packages p
944             WHERE (p.skip_reason IS NOT NULL OR p.fail_reason IS NOT NULL)
945               AND NOT EXISTS (SELECT 1 FROM builds b WHERE b.package_id = p.id)
946             ORDER BY p.pkgname",
947        )?;
948
949        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
950
951        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
952    }
953
954    /**
955     * Get packages without build results that depend on failed packages.
956     * Returns (pkgname, pkgpath, failed_deps) where failed_deps is
957     * comma-separated. Excludes packages that have skip_reason or fail_reason
958     * (they're pre-failed). Only lists root failures (direct failures), not
959     * indirect failures.
960     */
961    pub fn get_indirect_failures(&self) -> Result<Vec<(String, Option<String>, String)>> {
962        // Find packages that:
963        // 1. Have no build result
964        // 2. Have no skip_reason or fail_reason (not pre-failed)
965        // 3. Depend (transitively) on a package with a direct failure
966        // Group by package and aggregate failed deps into comma-separated string
967        // Only 'failed' and 'prefailed' are root causes, not 'indirect_*'
968        let mut stmt = self.conn.prepare(
969            "WITH RECURSIVE
970             -- Only direct failures are root causes
971             failed_pkgs(id) AS (
972                 SELECT package_id FROM builds
973                 WHERE outcome IN ('failed', 'prefailed')
974             ),
975             -- Packages affected by failures (transitive closure)
976             affected(id, root_id) AS (
977                 SELECT id, id FROM failed_pkgs
978                 UNION
979                 SELECT rd.package_id, a.root_id
980                 FROM resolved_depends rd
981                 JOIN affected a ON rd.depends_on_id = a.id
982                 WHERE rd.package_id NOT IN (SELECT id FROM failed_pkgs)
983             )
984             SELECT p.pkgname, p.pkgpath, GROUP_CONCAT(DISTINCT fp.pkgname) as failed_deps
985             FROM affected a
986             JOIN packages p ON a.id = p.id
987             JOIN packages fp ON a.root_id = fp.id
988             WHERE a.id != a.root_id
989               AND NOT EXISTS (SELECT 1 FROM builds b WHERE b.package_id = a.id)
990               AND p.skip_reason IS NULL
991               AND p.fail_reason IS NULL
992             GROUP BY p.id, p.pkgname, p.pkgpath
993             ORDER BY p.pkgname",
994        )?;
995
996        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
997
998        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
999    }
1000
1001    /**
1002     * Mark a package and all its transitive reverse dependencies as failed.
1003     * Returns the count of packages marked.
1004     */
1005    pub fn mark_failure_cascade(
1006        &self,
1007        package_id: i64,
1008        reason: &str,
1009        duration: Duration,
1010    ) -> Result<usize> {
1011        let pkgname = self.get_pkgname(package_id)?;
1012
1013        // Get all affected packages using recursive CTE
1014        let mut stmt = self.conn.prepare(
1015            "WITH RECURSIVE affected(id, depth) AS (
1016                SELECT ?1, 0
1017                UNION
1018                SELECT rd.package_id, a.depth + 1
1019                FROM resolved_depends rd
1020                JOIN affected a ON rd.depends_on_id = a.id
1021            )
1022            SELECT id, depth FROM affected ORDER BY depth",
1023        )?;
1024
1025        let affected: Vec<(i64, i32)> = stmt
1026            .query_map([package_id], |row| {
1027                Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?))
1028            })?
1029            .filter_map(|r| r.ok())
1030            .collect();
1031
1032        // Batch insert failures
1033        self.conn.execute("BEGIN TRANSACTION", [])?;
1034
1035        for (id, depth) in &affected {
1036            let (outcome, detail, dur) = if *depth == 0 {
1037                ("failed", reason.to_string(), duration.as_millis() as i64)
1038            } else {
1039                (
1040                    "indirect_failed",
1041                    format!("depends on failed {}", pkgname),
1042                    0,
1043                )
1044            };
1045
1046            self.conn.execute(
1047                "INSERT OR REPLACE INTO builds
1048                 (package_id, outcome, outcome_detail, duration_ms)
1049                 VALUES (?1, ?2, ?3, ?4)",
1050                params![id, outcome, detail, dur],
1051            )?;
1052        }
1053
1054        self.conn.execute("COMMIT", [])?;
1055
1056        debug!(
1057            package_id = package_id,
1058            affected_count = affected.len(),
1059            "Marked failure cascade"
1060        );
1061        Ok(affected.len())
1062    }
1063
1064    // ========================================================================
1065    // METADATA
1066    // ========================================================================
1067
1068    /**
1069     * Check if a full tree scan has been completed.
1070     */
1071    pub fn full_scan_complete(&self) -> bool {
1072        self.conn
1073            .query_row(
1074                "SELECT value FROM metadata WHERE key = 'full_scan_complete'",
1075                [],
1076                |row| row.get::<_, String>(0),
1077            )
1078            .map(|v| v == "true")
1079            .unwrap_or(false)
1080    }
1081
1082    /**
1083     * Mark a full tree scan as complete.
1084     */
1085    pub fn set_full_scan_complete(&self) -> Result<()> {
1086        self.conn.execute(
1087            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('full_scan_complete', 'true')",
1088            [],
1089        )?;
1090        Ok(())
1091    }
1092
1093    /**
1094     * Clear the full tree scan complete marker.
1095     */
1096    pub fn clear_full_scan_complete(&self) -> Result<()> {
1097        self.conn
1098            .execute("DELETE FROM metadata WHERE key = 'full_scan_complete'", [])?;
1099        Ok(())
1100    }
1101
1102    /**
1103     * Store the pkgsrc environment to the database.  Errors if already present
1104     * as this should only ever be done once.
1105     */
1106    pub fn store_pkgsrc_env(&self, env: &PkgsrcEnv) -> Result<()> {
1107        let json = serde_json::json!({
1108            "packages": env.packages,
1109            "pkgtools": env.pkgtools,
1110            "prefix": env.prefix,
1111            "pkg_dbdir": env.pkg_dbdir,
1112            "pkg_refcount_dbdir": env.pkg_refcount_dbdir,
1113            "cachevars": env.cachevars,
1114        });
1115        self.conn.execute(
1116            "INSERT INTO metadata (key, value) VALUES ('pkgsrc_env', ?1)",
1117            params![json.to_string()],
1118        )?;
1119        Ok(())
1120    }
1121
1122    /**
1123     * Load the pkgsrc environment from the database.
1124     */
1125    pub fn load_pkgsrc_env(&self) -> Result<PkgsrcEnv> {
1126        let json_str: String = self
1127            .conn
1128            .query_row(
1129                "SELECT value FROM metadata WHERE key = 'pkgsrc_env'",
1130                [],
1131                |row| row.get(0),
1132            )
1133            .context("pkgsrc environment not found in database")?;
1134
1135        let json: serde_json::Value =
1136            serde_json::from_str(&json_str).context("Invalid pkgsrc_env JSON")?;
1137
1138        let get_path = |key: &str| -> Result<PathBuf> {
1139            json.get(key)
1140                .and_then(|v| v.as_str())
1141                .map(PathBuf::from)
1142                .ok_or_else(|| anyhow::anyhow!("Missing {} in pkgsrc_env", key))
1143        };
1144
1145        let cachevars: HashMap<String, String> = json
1146            .get("cachevars")
1147            .and_then(|v| serde_json::from_value(v.clone()).ok())
1148            .unwrap_or_default();
1149
1150        Ok(PkgsrcEnv {
1151            packages: get_path("packages")?,
1152            pkgtools: get_path("pkgtools")?,
1153            prefix: get_path("prefix")?,
1154            pkg_dbdir: get_path("pkg_dbdir")?,
1155            pkg_refcount_dbdir: get_path("pkg_refcount_dbdir")?,
1156            cachevars,
1157        })
1158    }
1159
1160    /**
1161     * Get all package names with successful build outcomes.
1162     */
1163    pub fn get_successful_packages(&self) -> Result<Vec<String>> {
1164        let mut stmt = self.conn.prepare(
1165            "SELECT p.pkgname FROM builds b
1166             JOIN packages p ON b.package_id = p.id
1167             WHERE b.outcome IN ('success', 'up_to_date')
1168             ORDER BY p.pkgname",
1169        )?;
1170
1171        let pkgnames = stmt
1172            .query_map([], |row| row.get::<_, String>(0))?
1173            .collect::<std::result::Result<Vec<_>, _>>()?;
1174
1175        Ok(pkgnames)
1176    }
1177
1178    /**
1179     * Execute arbitrary SQL and print results.
1180     */
1181    pub fn execute_raw(&self, sql: &str) -> Result<()> {
1182        let mut stmt = self.conn.prepare(sql)?;
1183        let column_count = stmt.column_count();
1184
1185        if column_count == 0 {
1186            // Non-query statement (INSERT, UPDATE, DELETE, etc.)
1187            let affected = stmt.execute([])?;
1188            if affected > 0 {
1189                println!("{} row(s) affected", affected);
1190            }
1191        } else {
1192            // Query statement (SELECT, PRAGMA, etc.)
1193            let mut rows = stmt.query([])?;
1194
1195            while let Some(row) = rows.next()? {
1196                let values: Vec<String> = (0..column_count)
1197                    .map(|i| {
1198                        row.get_ref(i)
1199                            .map(|v| match v {
1200                                rusqlite::types::ValueRef::Null => String::new(),
1201                                rusqlite::types::ValueRef::Integer(i) => i.to_string(),
1202                                rusqlite::types::ValueRef::Real(f) => f.to_string(),
1203                                rusqlite::types::ValueRef::Text(s) => {
1204                                    String::from_utf8_lossy(s).to_string()
1205                                }
1206                                rusqlite::types::ValueRef::Blob(b) => {
1207                                    format!("<blob:{} bytes>", b.len())
1208                                }
1209                            })
1210                            .unwrap_or_default()
1211                    })
1212                    .collect();
1213                println!("{}", values.join("|"));
1214            }
1215        }
1216
1217        Ok(())
1218    }
1219}
1220
1221// ============================================================================
1222// HELPER FUNCTIONS
1223// ============================================================================
1224
1225/**
1226 * Convert BuildOutcome to database format.
1227 */
1228fn build_outcome_to_db(outcome: &BuildOutcome) -> (&'static str, Option<String>) {
1229    match outcome {
1230        BuildOutcome::Success => ("success", None),
1231        BuildOutcome::UpToDate => ("up_to_date", None),
1232        BuildOutcome::Failed(s) => ("failed", Some(s.clone())),
1233        BuildOutcome::Skipped(reason) => match reason {
1234            SkipReason::PkgSkip(s) => ("pkg_skip", Some(s.clone())),
1235            SkipReason::PkgFail(s) => ("pkg_fail", Some(s.clone())),
1236            SkipReason::IndirectSkip(s) => ("indirect_skip", Some(s.clone())),
1237            SkipReason::IndirectFail(s) => ("indirect_fail", Some(s.clone())),
1238            SkipReason::UnresolvedDep(s) => ("unresolved_dep", Some(s.clone())),
1239        },
1240    }
1241}
1242
1243/**
1244 * Convert database format to BuildOutcome.
1245 */
1246fn db_outcome_to_build(outcome: &str, detail: Option<String>) -> BuildOutcome {
1247    match outcome {
1248        "success" => BuildOutcome::Success,
1249        "up_to_date" => BuildOutcome::UpToDate,
1250        "failed" => BuildOutcome::Failed(detail.unwrap_or_default()),
1251        "pkg_skip" => BuildOutcome::Skipped(SkipReason::PkgSkip(detail.unwrap_or_default())),
1252        "pkg_fail" => BuildOutcome::Skipped(SkipReason::PkgFail(detail.unwrap_or_default())),
1253        "indirect_skip" => {
1254            BuildOutcome::Skipped(SkipReason::IndirectSkip(detail.unwrap_or_default()))
1255        }
1256        "indirect_fail" | "indirect_failed" => {
1257            BuildOutcome::Skipped(SkipReason::IndirectFail(detail.unwrap_or_default()))
1258        }
1259        "unresolved_dep" => {
1260            BuildOutcome::Skipped(SkipReason::UnresolvedDep(detail.unwrap_or_default()))
1261        }
1262        _ => BuildOutcome::Failed(format!("Unknown outcome: {}", outcome)),
1263    }
1264}