1use 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
48const SCHEMA_VERSION: i32 = 3;
52
53#[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
67pub struct Database {
71 conn: Connection,
72}
73
74impl Database {
75 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 pub fn begin_transaction(&self) -> Result<()> {
93 self.conn.execute("BEGIN TRANSACTION", [])?;
94 Ok(())
95 }
96
97 pub fn commit(&self) -> Result<()> {
101 self.conn.execute("COMMIT", [])?;
102 Ok(())
103 }
104
105 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 fn init(&self) -> Result<()> {
124 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 self.create_schema()?;
134 } else {
135 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn clear_scan(&self) -> Result<()> {
493 self.conn.execute("DELETE FROM packages", [])?;
494 self.clear_full_scan_complete()?;
495 Ok(())
496 }
497
498 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 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 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 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 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 pub fn clear_resolved_depends(&self) -> Result<()> {
631 self.conn.execute("DELETE FROM resolved_depends", [])?;
632 Ok(())
633 }
634
635 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 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 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 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 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 pub fn clear_builds(&self) -> Result<usize> {
737 let rows = self.conn.execute("DELETE FROM builds", [])?;
738 Ok(rows)
739 }
740
741 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 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 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 (_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 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 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 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 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 pub fn get_indirect_failures(&self) -> Result<Vec<(String, Option<String>, String)>> {
962 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 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 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 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 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 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 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 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 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 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 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 let affected = stmt.execute([])?;
1188 if affected > 0 {
1189 println!("{} row(s) affected", affected);
1190 }
1191 } else {
1192 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
1221fn 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
1243fn 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}