bob/
db.rs

1/*
2 * Copyright (c) 2025 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//! SQLite database for caching scan and build results.
18//!
19//! This module provides optimized database access with:
20//! - Lazy loading to minimize memory usage
21//! - Indexed reverse dependency lookups for fast failure cascades
22//! - Normalized dependency tables for efficient queries
23//! - Hybrid storage: columns for hot fields, JSON for cold data
24//!
25//! # Schema
26//!
27//! - `packages` - Core package identity and status
28//! - `depends` - Raw dependency patterns from scans
29//! - `resolved_depends` - Resolved dependencies after pattern matching
30//! - `builds` - Build results with indexed outcome
31//! - `metadata` - Key-value store for flags and cached data
32
33use crate::build::{BuildOutcome, BuildResult};
34use anyhow::{Context, Result};
35use pkgsrc::{PkgName, PkgPath, ScanIndex};
36use rusqlite::{Connection, params};
37use std::collections::HashSet;
38use std::path::Path;
39use std::time::Duration;
40use tracing::{debug, info, warn};
41
42/// Current schema version for migrations.
43const SCHEMA_VERSION: i32 = 2;
44
45/// Lightweight package row without full scan data.
46#[derive(Clone, Debug)]
47pub struct PackageRow {
48    pub id: i64,
49    pub pkgname: String,
50    pub pkgpath: String,
51    pub skip_reason: Option<String>,
52    pub fail_reason: Option<String>,
53    pub is_bootstrap: bool,
54    pub pbulk_weight: i32,
55}
56
57/// SQLite database for scan and build caching.
58pub struct Database {
59    conn: Connection,
60}
61
62impl Database {
63    /// Open or create a database at the given path.
64    pub fn open(path: &Path) -> Result<Self> {
65        if let Some(parent) = path.parent() {
66            std::fs::create_dir_all(parent)
67                .context("Failed to create database directory")?;
68        }
69        let conn = Connection::open(path).context("Failed to open database")?;
70        let db = Self { conn };
71        db.configure_pragmas()?;
72        db.init_or_migrate()?;
73        Ok(db)
74    }
75
76    /// Configure SQLite for performance.
77    fn configure_pragmas(&self) -> Result<()> {
78        self.conn.execute_batch(
79            "PRAGMA journal_mode = WAL;
80             PRAGMA synchronous = NORMAL;
81             PRAGMA cache_size = -64000;
82             PRAGMA temp_store = MEMORY;
83             PRAGMA mmap_size = 268435456;
84             PRAGMA foreign_keys = ON;",
85        )?;
86        Ok(())
87    }
88
89    /// Initialize schema or migrate from older versions.
90    fn init_or_migrate(&self) -> Result<()> {
91        // Check if schema_version table exists
92        let has_schema_version: bool = self.conn.query_row(
93            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_version'",
94            [],
95            |row| row.get::<_, i32>(0).map(|c| c > 0),
96        )?;
97
98        if !has_schema_version {
99            // Check for old schema (v1)
100            let has_old_scan: bool = self.conn.query_row(
101                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='scan'",
102                [],
103                |row| row.get::<_, i32>(0).map(|c| c > 0),
104            )?;
105
106            if has_old_scan {
107                info!("Migrating from schema v1 to v{}", SCHEMA_VERSION);
108                self.migrate_v1_to_v2()?;
109            } else {
110                self.create_schema_v2()?;
111            }
112        } else {
113            let version: i32 = self.conn.query_row(
114                "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1",
115                [],
116                |row| row.get(0),
117            ).unwrap_or(1);
118
119            if version < SCHEMA_VERSION {
120                info!(
121                    "Migrating from schema v{} to v{}",
122                    version, SCHEMA_VERSION
123                );
124                if version == 1 {
125                    self.migrate_v1_to_v2()?;
126                }
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Create the v2 schema from scratch.
134    fn create_schema_v2(&self) -> Result<()> {
135        self.conn.execute_batch(
136            "CREATE TABLE IF NOT EXISTS schema_version (
137                version INTEGER PRIMARY KEY
138            );
139
140            INSERT OR REPLACE INTO schema_version (version) VALUES (2);
141
142            CREATE TABLE IF NOT EXISTS packages (
143                id INTEGER PRIMARY KEY AUTOINCREMENT,
144                pkgname TEXT UNIQUE NOT NULL,
145                pkgpath TEXT NOT NULL,
146                pkgname_base TEXT NOT NULL,
147                version TEXT NOT NULL,
148                skip_reason TEXT,
149                fail_reason TEXT,
150                is_bootstrap INTEGER DEFAULT 0,
151                pbulk_weight INTEGER DEFAULT 100,
152                scan_data TEXT,
153                scanned_at INTEGER NOT NULL
154            );
155
156            CREATE INDEX IF NOT EXISTS idx_packages_pkgpath ON packages(pkgpath);
157            CREATE INDEX IF NOT EXISTS idx_packages_pkgname_base ON packages(pkgname_base);
158            CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(skip_reason, fail_reason);
159
160            CREATE TABLE IF NOT EXISTS depends (
161                id INTEGER PRIMARY KEY AUTOINCREMENT,
162                package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
163                depend_pattern TEXT NOT NULL,
164                depend_pkgpath TEXT NOT NULL,
165                UNIQUE(package_id, depend_pattern)
166            );
167
168            CREATE INDEX IF NOT EXISTS idx_depends_package ON depends(package_id);
169            CREATE INDEX IF NOT EXISTS idx_depends_pkgpath ON depends(depend_pkgpath);
170
171            CREATE TABLE IF NOT EXISTS resolved_depends (
172                id INTEGER PRIMARY KEY AUTOINCREMENT,
173                package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
174                depends_on_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
175                UNIQUE(package_id, depends_on_id)
176            );
177
178            CREATE INDEX IF NOT EXISTS idx_resolved_depends_package ON resolved_depends(package_id);
179            CREATE INDEX IF NOT EXISTS idx_resolved_depends_depends_on ON resolved_depends(depends_on_id);
180
181            CREATE TABLE IF NOT EXISTS builds (
182                id INTEGER PRIMARY KEY AUTOINCREMENT,
183                package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
184                outcome TEXT NOT NULL,
185                outcome_detail TEXT,
186                duration_ms INTEGER NOT NULL DEFAULT 0,
187                built_at INTEGER NOT NULL,
188                log_dir TEXT,
189                UNIQUE(package_id)
190            );
191
192            CREATE INDEX IF NOT EXISTS idx_builds_outcome ON builds(outcome);
193            CREATE INDEX IF NOT EXISTS idx_builds_package ON builds(package_id);
194
195            CREATE TABLE IF NOT EXISTS metadata (
196                key TEXT PRIMARY KEY,
197                value TEXT NOT NULL
198            );"
199        )?;
200
201        debug!("Created schema v2");
202        Ok(())
203    }
204
205    /// Migrate from v1 (old scan/build tables) to v2.
206    fn migrate_v1_to_v2(&self) -> Result<()> {
207        // Create new schema first
208        self.create_schema_v2()?;
209
210        // Migrate scan data
211        let mut stmt = self
212            .conn
213            .prepare("SELECT pkgpath, data FROM scan ORDER BY rowid")?;
214
215        let rows: Vec<(String, String)> = stmt
216            .query_map([], |row| {
217                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
218            })?
219            .filter_map(|r| r.ok())
220            .collect();
221
222        let mut migrated_count = 0;
223        for (pkgpath, json) in rows {
224            let indexes: Vec<ScanIndex> = match serde_json::from_str(&json) {
225                Ok(idx) => idx,
226                Err(e) => {
227                    warn!(pkgpath = %pkgpath, error = %e, "Failed to parse scan data during migration");
228                    continue;
229                }
230            };
231
232            for idx in indexes {
233                if let Err(e) = self.store_package(&pkgpath, &idx) {
234                    warn!(pkgpath = %pkgpath, error = %e, "Failed to migrate package");
235                }
236            }
237            migrated_count += 1;
238        }
239
240        // Migrate build data
241        let mut stmt = self
242            .conn
243            .prepare("SELECT pkgname, data FROM build ORDER BY rowid")?;
244
245        let rows: Vec<(String, String)> = stmt
246            .query_map([], |row| {
247                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
248            })?
249            .filter_map(|r| r.ok())
250            .collect();
251
252        let mut build_count = 0;
253        for (pkgname, json) in rows {
254            let result: BuildResult = match serde_json::from_str(&json) {
255                Ok(r) => r,
256                Err(e) => {
257                    warn!(pkgname = %pkgname, error = %e, "Failed to parse build data during migration");
258                    continue;
259                }
260            };
261
262            // Look up package_id
263            if let Ok(Some(pkg)) = self.get_package_by_name(&pkgname) {
264                if let Err(e) = self.store_build_result(pkg.id, &result) {
265                    warn!(pkgname = %pkgname, error = %e, "Failed to migrate build result");
266                }
267                build_count += 1;
268            }
269        }
270
271        // Drop old tables
272        self.conn.execute_batch(
273            "DROP TABLE IF EXISTS scan;
274             DROP TABLE IF EXISTS build;",
275        )?;
276
277        info!(
278            packages = migrated_count,
279            builds = build_count,
280            "Migration to v2 complete"
281        );
282        Ok(())
283    }
284
285    // ========================================================================
286    // PACKAGE QUERIES
287    // ========================================================================
288
289    /// Store a package from scan results.
290    pub fn store_package(
291        &self,
292        pkgpath: &str,
293        index: &ScanIndex,
294    ) -> Result<i64> {
295        let pkgname = index.pkgname.pkgname();
296        let (base, version) = split_pkgname(pkgname);
297
298        let skip_reason =
299            index.pkg_skip_reason.as_ref().filter(|s| !s.is_empty());
300        let fail_reason =
301            index.pkg_fail_reason.as_ref().filter(|s| !s.is_empty());
302        let is_bootstrap = index.bootstrap_pkg.as_deref() == Some("yes");
303        let pbulk_weight: i32 = index
304            .pbulk_weight
305            .as_ref()
306            .and_then(|s| s.parse().ok())
307            .unwrap_or(100);
308
309        let scan_data = serde_json::to_string(index)?;
310        let now = std::time::SystemTime::now()
311            .duration_since(std::time::UNIX_EPOCH)?
312            .as_secs() as i64;
313
314        self.conn.execute(
315            "INSERT OR REPLACE INTO packages
316             (pkgname, pkgpath, pkgname_base, version, skip_reason, fail_reason,
317              is_bootstrap, pbulk_weight, scan_data, scanned_at)
318             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
319            params![
320                pkgname,
321                pkgpath,
322                base,
323                version,
324                skip_reason,
325                fail_reason,
326                is_bootstrap,
327                pbulk_weight,
328                scan_data,
329                now
330            ],
331        )?;
332
333        let package_id = self.conn.last_insert_rowid();
334
335        // Store raw dependencies
336        if let Some(ref deps) = index.all_depends {
337            for dep in deps {
338                self.conn.execute(
339                    "INSERT OR IGNORE INTO depends (package_id, depend_pattern, depend_pkgpath)
340                     VALUES (?1, ?2, ?3)",
341                    params![package_id, dep.pattern().pattern(), dep.pkgpath().to_string()],
342                )?;
343            }
344        }
345
346        debug!(pkgname = pkgname, package_id = package_id, "Stored package");
347        Ok(package_id)
348    }
349
350    /// Store scan results for a pkgpath.
351    pub fn store_scan_pkgpath(
352        &self,
353        pkgpath: &str,
354        indexes: &[ScanIndex],
355    ) -> Result<()> {
356        for index in indexes {
357            self.store_package(pkgpath, index)?;
358        }
359        Ok(())
360    }
361
362    /// Get package by name.
363    pub fn get_package_by_name(
364        &self,
365        pkgname: &str,
366    ) -> Result<Option<PackageRow>> {
367        let result = self.conn.query_row(
368            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
369             FROM packages WHERE pkgname = ?1",
370            [pkgname],
371            |row| Ok(PackageRow {
372                id: row.get(0)?,
373                pkgname: row.get(1)?,
374                pkgpath: row.get(2)?,
375                skip_reason: row.get(3)?,
376                fail_reason: row.get(4)?,
377                is_bootstrap: row.get::<_, i32>(5)? != 0,
378                pbulk_weight: row.get(6)?,
379            }),
380        );
381
382        match result {
383            Ok(pkg) => Ok(Some(pkg)),
384            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
385            Err(e) => Err(e.into()),
386        }
387    }
388
389    /// Get package ID by name.
390    pub fn get_package_id(&self, pkgname: &str) -> Result<Option<i64>> {
391        let result = self.conn.query_row(
392            "SELECT id FROM packages WHERE pkgname = ?1",
393            [pkgname],
394            |row| row.get(0),
395        );
396
397        match result {
398            Ok(id) => Ok(Some(id)),
399            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
400            Err(e) => Err(e.into()),
401        }
402    }
403
404    /// Get pkgname by package ID.
405    pub fn get_pkgname(&self, package_id: i64) -> Result<String> {
406        self.conn
407            .query_row(
408                "SELECT pkgname FROM packages WHERE id = ?1",
409                [package_id],
410                |row| row.get(0),
411            )
412            .context("Package not found")
413    }
414
415    /// Get packages by pkgpath.
416    pub fn get_packages_by_path(
417        &self,
418        pkgpath: &str,
419    ) -> Result<Vec<PackageRow>> {
420        let mut stmt = self.conn.prepare(
421            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
422             FROM packages WHERE pkgpath = ?1"
423        )?;
424
425        let rows = stmt.query_map([pkgpath], |row| {
426            Ok(PackageRow {
427                id: row.get(0)?,
428                pkgname: row.get(1)?,
429                pkgpath: row.get(2)?,
430                skip_reason: row.get(3)?,
431                fail_reason: row.get(4)?,
432                is_bootstrap: row.get::<_, i32>(5)? != 0,
433                pbulk_weight: row.get(6)?,
434            })
435        })?;
436
437        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
438    }
439
440    /// Check if pkgpath is scanned.
441    pub fn is_pkgpath_scanned(&self, pkgpath: &str) -> Result<bool> {
442        let count: i32 = self.conn.query_row(
443            "SELECT COUNT(*) FROM packages WHERE pkgpath = ?1",
444            [pkgpath],
445            |row| row.get(0),
446        )?;
447        Ok(count > 0)
448    }
449
450    /// Get all scanned pkgpaths.
451    pub fn get_scanned_pkgpaths(&self) -> Result<HashSet<String>> {
452        let mut stmt =
453            self.conn.prepare("SELECT DISTINCT pkgpath FROM packages")?;
454        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
455        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
456    }
457
458    /// Get pkgpaths that are referenced as dependencies but haven't been scanned yet.
459    /// These are dependencies that were discovered during scanning but the scan was
460    /// interrupted before they could be processed.
461    pub fn get_unscanned_dependencies(&self) -> Result<HashSet<String>> {
462        let mut stmt = self.conn.prepare(
463            "SELECT DISTINCT d.depend_pkgpath
464             FROM depends d
465             WHERE d.depend_pkgpath NOT IN (SELECT pkgpath FROM packages)",
466        )?;
467        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
468        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
469    }
470
471    /// Count of scanned packages.
472    pub fn count_packages(&self) -> Result<i64> {
473        self.conn
474            .query_row("SELECT COUNT(*) FROM packages", [], |row| row.get(0))
475            .context("Failed to count packages")
476    }
477
478    /// Count of scanned pkgpaths.
479    pub fn count_scan(&self) -> Result<i64> {
480        self.conn
481            .query_row(
482                "SELECT COUNT(DISTINCT pkgpath) FROM packages",
483                [],
484                |row| row.get(0),
485            )
486            .context("Failed to count scan")
487    }
488
489    /// Get all packages (lightweight).
490    pub fn get_all_packages(&self) -> Result<Vec<PackageRow>> {
491        let mut stmt = self.conn.prepare(
492            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
493             FROM packages ORDER BY id"
494        )?;
495
496        let rows = stmt.query_map([], |row| {
497            Ok(PackageRow {
498                id: row.get(0)?,
499                pkgname: row.get(1)?,
500                pkgpath: row.get(2)?,
501                skip_reason: row.get(3)?,
502                fail_reason: row.get(4)?,
503                is_bootstrap: row.get::<_, i32>(5)? != 0,
504                pbulk_weight: row.get(6)?,
505            })
506        })?;
507
508        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
509    }
510
511    /// Get all buildable packages (no skip/fail reason).
512    pub fn get_buildable_packages(&self) -> Result<Vec<PackageRow>> {
513        let mut stmt = self.conn.prepare(
514            "SELECT id, pkgname, pkgpath, skip_reason, fail_reason, is_bootstrap, pbulk_weight
515             FROM packages WHERE skip_reason IS NULL AND fail_reason IS NULL"
516        )?;
517
518        let rows = stmt.query_map([], |row| {
519            Ok(PackageRow {
520                id: row.get(0)?,
521                pkgname: row.get(1)?,
522                pkgpath: row.get(2)?,
523                skip_reason: row.get(3)?,
524                fail_reason: row.get(4)?,
525                is_bootstrap: row.get::<_, i32>(5)? != 0,
526                pbulk_weight: row.get(6)?,
527            })
528        })?;
529
530        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
531    }
532
533    /// Load full ScanIndex for a package.
534    pub fn get_full_scan_index(&self, package_id: i64) -> Result<ScanIndex> {
535        let json: String = self.conn.query_row(
536            "SELECT scan_data FROM packages WHERE id = ?1",
537            [package_id],
538            |row| row.get(0),
539        )?;
540        serde_json::from_str(&json).context("Failed to deserialize scan data")
541    }
542
543    /// Load full ScanIndex by pkgname.
544    pub fn get_scan_index_by_name(
545        &self,
546        pkgname: &str,
547    ) -> Result<Option<ScanIndex>> {
548        let result = self.conn.query_row(
549            "SELECT scan_data FROM packages WHERE pkgname = ?1",
550            [pkgname],
551            |row| row.get::<_, String>(0),
552        );
553
554        match result {
555            Ok(json) => {
556                let index: ScanIndex = serde_json::from_str(&json)
557                    .context("Failed to deserialize scan data")?;
558                Ok(Some(index))
559            }
560            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
561            Err(e) => Err(e.into()),
562        }
563    }
564
565    /// Clear all scan data.
566    pub fn clear_scan(&self) -> Result<()> {
567        self.conn.execute("DELETE FROM packages", [])?;
568        self.clear_full_scan_complete()?;
569        self.clear_resolved_depends()?;
570        Ok(())
571    }
572
573    // ========================================================================
574    // DEPENDENCY QUERIES
575    // ========================================================================
576
577    /// Store a resolved dependency.
578    pub fn store_resolved_dependency(
579        &self,
580        package_id: i64,
581        depends_on_id: i64,
582    ) -> Result<()> {
583        self.conn.execute(
584            "INSERT OR IGNORE INTO resolved_depends (package_id, depends_on_id) VALUES (?1, ?2)",
585            params![package_id, depends_on_id],
586        )?;
587        Ok(())
588    }
589
590    /// Store resolved dependencies in batch.
591    pub fn store_resolved_dependencies_batch(
592        &self,
593        deps: &[(i64, i64)],
594    ) -> Result<()> {
595        self.conn.execute("BEGIN TRANSACTION", [])?;
596        for (package_id, depends_on_id) in deps {
597            self.conn.execute(
598                "INSERT OR IGNORE INTO resolved_depends (package_id, depends_on_id) VALUES (?1, ?2)",
599                params![package_id, depends_on_id],
600            )?;
601        }
602        self.conn.execute("COMMIT", [])?;
603        Ok(())
604    }
605
606    /// Get direct dependencies of a package.
607    pub fn get_dependencies(&self, package_id: i64) -> Result<Vec<i64>> {
608        let mut stmt = self.conn.prepare(
609            "SELECT depends_on_id FROM resolved_depends WHERE package_id = ?1",
610        )?;
611        let rows = stmt.query_map([package_id], |row| row.get::<_, i64>(0))?;
612        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
613    }
614
615    /// Get reverse dependencies (packages that depend on this one).
616    pub fn get_reverse_dependencies(
617        &self,
618        package_id: i64,
619    ) -> Result<Vec<i64>> {
620        let mut stmt = self.conn.prepare(
621            "SELECT package_id FROM resolved_depends WHERE depends_on_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    /// Get all transitive reverse dependencies using recursive CTE.
628    pub fn get_transitive_reverse_deps(
629        &self,
630        package_id: i64,
631    ) -> Result<Vec<i64>> {
632        let mut stmt = self.conn.prepare(
633            "WITH RECURSIVE affected(id) AS (
634                SELECT ?1
635                UNION
636                SELECT rd.package_id
637                FROM resolved_depends rd
638                JOIN affected a ON rd.depends_on_id = a.id
639            )
640            SELECT id FROM affected WHERE id != ?1",
641        )?;
642        let rows = stmt.query_map([package_id], |row| row.get::<_, i64>(0))?;
643        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
644    }
645
646    /// Check if dependencies are resolved.
647    pub fn is_resolved(&self) -> Result<bool> {
648        let count: i64 = self.conn.query_row(
649            "SELECT COUNT(*) FROM resolved_depends",
650            [],
651            |row| row.get(0),
652        )?;
653        Ok(count > 0)
654    }
655
656    /// Clear all resolved dependencies.
657    pub fn clear_resolved_depends(&self) -> Result<()> {
658        self.conn.execute("DELETE FROM resolved_depends", [])?;
659        Ok(())
660    }
661
662    /// Get raw dependencies for pattern matching.
663    pub fn get_raw_dependencies(
664        &self,
665        package_id: i64,
666    ) -> Result<Vec<(String, String)>> {
667        let mut stmt = self.conn.prepare(
668            "SELECT depend_pattern, depend_pkgpath FROM depends WHERE package_id = ?1"
669        )?;
670        let rows = stmt.query_map([package_id], |row| {
671            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
672        })?;
673        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
674    }
675
676    // ========================================================================
677    // BUILD QUERIES
678    // ========================================================================
679
680    /// Store a build result by package ID.
681    pub fn store_build_result(
682        &self,
683        package_id: i64,
684        result: &BuildResult,
685    ) -> Result<()> {
686        let (outcome, detail) = build_outcome_to_db(&result.outcome);
687        let duration_ms = result.duration.as_millis() as i64;
688        let now = std::time::SystemTime::now()
689            .duration_since(std::time::UNIX_EPOCH)?
690            .as_secs() as i64;
691        let log_dir = result.log_dir.as_ref().map(|p| p.display().to_string());
692
693        self.conn.execute(
694            "INSERT OR REPLACE INTO builds
695             (package_id, outcome, outcome_detail, duration_ms, built_at, log_dir)
696             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
697            params![package_id, outcome, detail, duration_ms, now, log_dir],
698        )?;
699
700        debug!(
701            package_id = package_id,
702            outcome = outcome,
703            "Stored build result"
704        );
705        Ok(())
706    }
707
708    /// Store a build result by pkgname.
709    pub fn store_build_by_name(&self, result: &BuildResult) -> Result<()> {
710        if let Some(pkg) = self.get_package_by_name(result.pkgname.pkgname())? {
711            self.store_build_result(pkg.id, result)
712        } else {
713            warn!(pkgname = %result.pkgname.pkgname(), "Package not found in database for build result");
714            Ok(())
715        }
716    }
717
718    /// Store multiple build results in a transaction.
719    pub fn store_build_batch(&self, results: &[BuildResult]) -> Result<()> {
720        self.conn.execute("BEGIN TRANSACTION", [])?;
721        for result in results {
722            if let Err(e) = self.store_build_by_name(result) {
723                let _ = self.conn.execute("ROLLBACK", []);
724                return Err(e);
725            }
726        }
727        self.conn.execute("COMMIT", [])?;
728        debug!(count = results.len(), "Stored build results batch");
729        Ok(())
730    }
731
732    /// Get build result for a package.
733    pub fn get_build_result(
734        &self,
735        package_id: i64,
736    ) -> Result<Option<BuildResult>> {
737        let result = self.conn.query_row(
738            "SELECT p.pkgname, p.pkgpath, b.outcome, b.outcome_detail, b.duration_ms, b.log_dir
739             FROM builds b
740             JOIN packages p ON b.package_id = p.id
741             WHERE b.package_id = ?1",
742            [package_id],
743            |row| {
744                let pkgname: String = row.get(0)?;
745                let pkgpath: Option<String> = row.get(1)?;
746                let outcome: String = row.get(2)?;
747                let detail: Option<String> = row.get(3)?;
748                let duration_ms: i64 = row.get(4)?;
749                let log_dir: Option<String> = row.get(5)?;
750                Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir))
751            },
752        );
753
754        match result {
755            Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir)) => {
756                let build_outcome = db_outcome_to_build(&outcome, detail);
757                Ok(Some(BuildResult {
758                    pkgname: PkgName::new(&pkgname),
759                    pkgpath: pkgpath.and_then(|p| PkgPath::new(&p).ok()),
760                    outcome: build_outcome,
761                    duration: Duration::from_millis(duration_ms as u64),
762                    log_dir: log_dir.map(std::path::PathBuf::from),
763                }))
764            }
765            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
766            Err(e) => Err(e.into()),
767        }
768    }
769
770    /// Check if a package is already built (success or up_to_date).
771    pub fn is_package_complete(&self, package_id: i64) -> Result<bool> {
772        let result = self.conn.query_row(
773            "SELECT outcome FROM builds WHERE package_id = ?1",
774            [package_id],
775            |row| row.get::<_, String>(0),
776        );
777
778        match result {
779            Ok(outcome) => Ok(outcome == "success" || outcome == "up_to_date"),
780            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
781            Err(e) => Err(e.into()),
782        }
783    }
784
785    /// Check if a package build has failed.
786    pub fn is_package_failed(&self, package_id: i64) -> Result<bool> {
787        let result = self.conn.query_row(
788            "SELECT outcome FROM builds WHERE package_id = ?1",
789            [package_id],
790            |row| row.get::<_, String>(0),
791        );
792
793        match result {
794            Ok(outcome) => Ok(outcome != "success" && outcome != "up_to_date"),
795            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
796            Err(e) => Err(e.into()),
797        }
798    }
799
800    /// Get all completed package IDs.
801    pub fn get_completed_package_ids(&self) -> Result<HashSet<i64>> {
802        let mut stmt = self.conn.prepare(
803            "SELECT package_id FROM builds WHERE outcome IN ('success', 'up_to_date')"
804        )?;
805        let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
806        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
807    }
808
809    /// Get all failed package IDs.
810    pub fn get_failed_package_ids(&self) -> Result<HashSet<i64>> {
811        let mut stmt = self.conn.prepare(
812            "SELECT package_id FROM builds WHERE outcome NOT IN ('success', 'up_to_date')"
813        )?;
814        let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
815        rows.collect::<Result<HashSet<_>, _>>().map_err(Into::into)
816    }
817
818    /// Count of build results.
819    pub fn count_build(&self) -> Result<i64> {
820        self.conn
821            .query_row("SELECT COUNT(*) FROM builds", [], |row| row.get(0))
822            .context("Failed to count builds")
823    }
824
825    /// Clear all build data.
826    pub fn clear_build(&self) -> Result<()> {
827        self.conn.execute("DELETE FROM builds", [])?;
828        Ok(())
829    }
830
831    /// Delete build result for a pkgname.
832    pub fn delete_build_by_name(&self, pkgname: &str) -> Result<bool> {
833        let rows = self.conn.execute(
834            "DELETE FROM builds WHERE package_id IN (SELECT id FROM packages WHERE pkgname = ?1)",
835            params![pkgname],
836        )?;
837        Ok(rows > 0)
838    }
839
840    /// Delete build results by pkgpath.
841    pub fn delete_build_by_pkgpath(&self, pkgpath: &str) -> Result<usize> {
842        let rows = self.conn.execute(
843            "DELETE FROM builds WHERE package_id IN (SELECT id FROM packages WHERE pkgpath = ?1)",
844            params![pkgpath],
845        )?;
846        Ok(rows)
847    }
848
849    /// Get all build results from the database.
850    pub fn get_all_build_results(&self) -> Result<Vec<BuildResult>> {
851        let mut stmt = self.conn.prepare(
852            "SELECT p.pkgname, p.pkgpath, b.outcome, b.outcome_detail, b.duration_ms, b.log_dir
853             FROM builds b
854             JOIN packages p ON b.package_id = p.id
855             ORDER BY p.pkgname"
856        )?;
857
858        let rows = stmt.query_map([], |row| {
859            let pkgname: String = row.get(0)?;
860            let pkgpath: Option<String> = row.get(1)?;
861            let outcome: String = row.get(2)?;
862            let detail: Option<String> = row.get(3)?;
863            let duration_ms: i64 = row.get(4)?;
864            let log_dir: Option<String> = row.get(5)?;
865            Ok((pkgname, pkgpath, outcome, detail, duration_ms, log_dir))
866        })?;
867
868        let mut results = Vec::new();
869        for row in rows {
870            let (pkgname, pkgpath, outcome, detail, duration_ms, log_dir) =
871                row?;
872            let build_outcome = db_outcome_to_build(&outcome, detail);
873            results.push(BuildResult {
874                pkgname: PkgName::new(&pkgname),
875                pkgpath: pkgpath.and_then(|p| PkgPath::new(&p).ok()),
876                outcome: build_outcome,
877                duration: Duration::from_millis(duration_ms as u64),
878                log_dir: log_dir.map(std::path::PathBuf::from),
879            });
880        }
881
882        Ok(results)
883    }
884
885    /// Count how many packages are broken by each failed package.
886    /// Returns a map from pkgname to the count of packages that depend on it.
887    pub fn count_breaks_for_failed(
888        &self,
889    ) -> Result<std::collections::HashMap<String, usize>> {
890        use std::collections::HashMap;
891
892        let mut counts: HashMap<String, usize> = HashMap::new();
893
894        // Get all failed package IDs and their names
895        let mut stmt = self.conn.prepare(
896            "SELECT p.id, p.pkgname FROM builds b
897             JOIN packages p ON b.package_id = p.id
898             WHERE b.outcome = 'failed'",
899        )?;
900
901        let failed: Vec<(i64, String)> = stmt
902            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
903            .filter_map(|r| r.ok())
904            .collect();
905
906        // For each failed package, count indirect failures that reference it
907        for (_pkg_id, pkgname) in failed {
908            let count: i64 = self.conn.query_row(
909                "SELECT COUNT(*) FROM builds b
910                 JOIN packages p ON b.package_id = p.id
911                 WHERE b.outcome = 'indirect_failed'
912                 AND b.outcome_detail LIKE ?1",
913                params![format!("%{}", pkgname)],
914                |row| row.get(0),
915            )?;
916            counts.insert(pkgname, count as usize);
917        }
918
919        Ok(counts)
920    }
921
922    /// Get total build duration from all builds.
923    pub fn get_total_build_duration(&self) -> Result<Duration> {
924        let total_ms: i64 = self.conn.query_row(
925            "SELECT COALESCE(SUM(duration_ms), 0) FROM builds",
926            [],
927            |row| row.get(0),
928        )?;
929        Ok(Duration::from_millis(total_ms as u64))
930    }
931
932    /// Mark a package and all its transitive reverse dependencies as failed.
933    /// Returns the count of packages marked.
934    pub fn mark_failure_cascade(
935        &self,
936        package_id: i64,
937        reason: &str,
938        duration: Duration,
939    ) -> Result<usize> {
940        let now = std::time::SystemTime::now()
941            .duration_since(std::time::UNIX_EPOCH)?
942            .as_secs() as i64;
943
944        let pkgname = self.get_pkgname(package_id)?;
945
946        // Get all affected packages using recursive CTE
947        let mut stmt = self.conn.prepare(
948            "WITH RECURSIVE affected(id, depth) AS (
949                SELECT ?1, 0
950                UNION
951                SELECT rd.package_id, a.depth + 1
952                FROM resolved_depends rd
953                JOIN affected a ON rd.depends_on_id = a.id
954            )
955            SELECT id, depth FROM affected ORDER BY depth",
956        )?;
957
958        let affected: Vec<(i64, i32)> = stmt
959            .query_map([package_id], |row| {
960                Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?))
961            })?
962            .filter_map(|r| r.ok())
963            .collect();
964
965        // Batch insert failures
966        self.conn.execute("BEGIN TRANSACTION", [])?;
967
968        for (id, depth) in &affected {
969            let (outcome, detail, dur) = if *depth == 0 {
970                ("failed", reason.to_string(), duration.as_millis() as i64)
971            } else {
972                ("indirect_failed", format!("depends on failed {}", pkgname), 0)
973            };
974
975            self.conn.execute(
976                "INSERT OR REPLACE INTO builds
977                 (package_id, outcome, outcome_detail, duration_ms, built_at)
978                 VALUES (?1, ?2, ?3, ?4, ?5)",
979                params![id, outcome, detail, dur, now],
980            )?;
981        }
982
983        self.conn.execute("COMMIT", [])?;
984
985        debug!(
986            package_id = package_id,
987            affected_count = affected.len(),
988            "Marked failure cascade"
989        );
990        Ok(affected.len())
991    }
992
993    // ========================================================================
994    // METADATA
995    // ========================================================================
996
997    /// Check if a full tree scan has been completed.
998    pub fn full_scan_complete(&self) -> bool {
999        self.conn
1000            .query_row(
1001                "SELECT value FROM metadata WHERE key = 'full_scan_complete'",
1002                [],
1003                |row| row.get::<_, String>(0),
1004            )
1005            .map(|v| v == "true")
1006            .unwrap_or(false)
1007    }
1008
1009    /// Mark a full tree scan as complete.
1010    pub fn set_full_scan_complete(&self) -> Result<()> {
1011        self.conn.execute(
1012            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('full_scan_complete', 'true')",
1013            [],
1014        )?;
1015        Ok(())
1016    }
1017
1018    /// Clear the full tree scan complete marker.
1019    pub fn clear_full_scan_complete(&self) -> Result<()> {
1020        self.conn.execute(
1021            "DELETE FROM metadata WHERE key = 'full_scan_complete'",
1022            [],
1023        )?;
1024        Ok(())
1025    }
1026
1027    /// Get the buildable package count.
1028    pub fn get_buildable_count(&self) -> Result<i64> {
1029        self.conn.query_row(
1030            "SELECT COUNT(*) FROM packages WHERE skip_reason IS NULL AND fail_reason IS NULL",
1031            [],
1032            |row| row.get(0),
1033        ).context("Failed to count buildable packages")
1034    }
1035
1036    // ========================================================================
1037    // CHANGE DETECTION
1038    // ========================================================================
1039
1040    /// Compare requested pkgpaths against cached ones.
1041    pub fn compare_pkgpath_lists(
1042        &self,
1043        requested: &[&str],
1044    ) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
1045        let scanned = self.get_scanned_pkgpaths()?;
1046        let requested_set: HashSet<_> =
1047            requested.iter().map(|s| s.to_string()).collect();
1048
1049        let to_add: Vec<_> =
1050            requested_set.difference(&scanned).cloned().collect();
1051        let to_remove: Vec<_> =
1052            scanned.difference(&requested_set).cloned().collect();
1053        let unchanged: Vec<_> =
1054            scanned.intersection(&requested_set).cloned().collect();
1055
1056        Ok((to_add, to_remove, unchanged))
1057    }
1058
1059    /// Delete packages for pkgpaths no longer in the list.
1060    pub fn delete_pkgpaths(&self, pkgpaths: &[&str]) -> Result<usize> {
1061        if pkgpaths.is_empty() {
1062            return Ok(0);
1063        }
1064
1065        let mut count = 0;
1066        for pkgpath in pkgpaths {
1067            count += self.conn.execute(
1068                "DELETE FROM packages WHERE pkgpath = ?1",
1069                [pkgpath],
1070            )?;
1071        }
1072        Ok(count)
1073    }
1074}
1075
1076// ============================================================================
1077// HELPER FUNCTIONS
1078// ============================================================================
1079
1080/// Split pkgname into base and version.
1081fn split_pkgname(pkgname: &str) -> (String, String) {
1082    // Find the last dash that's followed by a digit
1083    let bytes = pkgname.as_bytes();
1084    for i in (0..bytes.len()).rev() {
1085        if bytes[i] == b'-'
1086            && i + 1 < bytes.len()
1087            && bytes[i + 1].is_ascii_digit()
1088        {
1089            return (pkgname[..i].to_string(), pkgname[i + 1..].to_string());
1090        }
1091    }
1092    // No version found
1093    (pkgname.to_string(), String::new())
1094}
1095
1096/// Convert BuildOutcome to database format.
1097fn build_outcome_to_db(
1098    outcome: &BuildOutcome,
1099) -> (&'static str, Option<String>) {
1100    match outcome {
1101        BuildOutcome::Success => ("success", None),
1102        BuildOutcome::UpToDate => ("up_to_date", None),
1103        BuildOutcome::Failed(s) => ("failed", Some(s.clone())),
1104        BuildOutcome::PreFailed(s) => ("pre_failed", Some(s.clone())),
1105        BuildOutcome::IndirectFailed(s) => ("indirect_failed", Some(s.clone())),
1106        BuildOutcome::IndirectPreFailed(s) => {
1107            ("indirect_pre_failed", Some(s.clone()))
1108        }
1109    }
1110}
1111
1112/// Convert database format to BuildOutcome.
1113fn db_outcome_to_build(outcome: &str, detail: Option<String>) -> BuildOutcome {
1114    match outcome {
1115        "success" => BuildOutcome::Success,
1116        "up_to_date" => BuildOutcome::UpToDate,
1117        "failed" => BuildOutcome::Failed(detail.unwrap_or_default()),
1118        "pre_failed" => BuildOutcome::PreFailed(detail.unwrap_or_default()),
1119        "indirect_failed" => {
1120            BuildOutcome::IndirectFailed(detail.unwrap_or_default())
1121        }
1122        "indirect_pre_failed" => {
1123            BuildOutcome::IndirectPreFailed(detail.unwrap_or_default())
1124        }
1125        _ => BuildOutcome::Failed(format!("Unknown outcome: {}", outcome)),
1126    }
1127}