bob/
scan.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//! Package dependency scanning and resolution.
18//!
19//! This module provides the [`Scan`] struct for discovering package dependencies
20//! and building a directed acyclic graph (DAG) for build ordering.
21//!
22//! # Scan Process
23//!
24//! 1. Create a scan sandbox
25//! 2. Run `make pbulk-index` on each package to discover dependencies
26//! 3. Recursively discover all transitive dependencies
27//! 4. Resolve dependency patterns to specific package versions
28//! 5. Verify no circular dependencies exist
29//! 6. Return buildable and skipped package lists
30//!
31//! # Skip Reasons
32//!
33//! Packages may be skipped for several reasons:
34//!
35//! - `PKG_SKIP_REASON` - Package explicitly marked to skip on this platform
36//! - `PKG_FAIL_REASON` - Package expected to fail on this platform
37//! - Unresolved dependencies - Required dependency not found
38//! - Circular dependencies - Package has a dependency cycle
39
40use crate::config::PkgsrcEnv;
41use crate::sandbox::{SingleSandboxScope, wait_output_with_shutdown};
42use crate::tui::{MultiProgress, REFRESH_INTERVAL, format_duration};
43use crate::{Config, RunContext, Sandbox};
44use anyhow::{Context, Result, bail};
45use crossterm::event;
46use indexmap::IndexMap;
47use petgraph::graphmap::DiGraphMap;
48use pkgsrc::{Depend, PkgName, PkgPath, ScanIndex};
49use rayon::prelude::*;
50use std::collections::{HashMap, HashSet};
51use std::io::BufReader;
52use std::sync::atomic::{AtomicBool, Ordering};
53use std::sync::{Arc, Mutex};
54use tracing::{debug, error, info, info_span, trace, warn};
55
56/// Reason why a package was skipped (not built).
57#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58pub enum SkipReason {
59    /// Package has `PKG_SKIP_REASON` set.
60    PkgSkip(String),
61    /// Package has `PKG_FAIL_REASON` set.
62    PkgFail(String),
63    /// Package skipped because a dependency was skipped.
64    IndirectSkip(String),
65    /// Package failed because a dependency failed.
66    IndirectFail(String),
67    /// Dependency could not be resolved.
68    UnresolvedDep(String),
69}
70
71impl SkipReason {
72    /// Returns the status label for this skip reason.
73    pub fn status(&self) -> &'static str {
74        match self {
75            SkipReason::PkgSkip(_) => "pre-skipped",
76            SkipReason::PkgFail(_) => "pre-failed",
77            SkipReason::IndirectSkip(_) => "indirect-skipped",
78            SkipReason::IndirectFail(_) => "indirect-failed",
79            SkipReason::UnresolvedDep(_) => "unresolved",
80        }
81    }
82
83    /// Returns true if this is a direct skip (not inherited from a dependency).
84    pub fn is_direct(&self) -> bool {
85        matches!(
86            self,
87            SkipReason::PkgSkip(_)
88                | SkipReason::PkgFail(_)
89                | SkipReason::UnresolvedDep(_)
90        )
91    }
92}
93
94impl std::fmt::Display for SkipReason {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            SkipReason::PkgSkip(r)
98            | SkipReason::PkgFail(r)
99            | SkipReason::IndirectSkip(r)
100            | SkipReason::IndirectFail(r) => write!(f, "{}", r),
101            SkipReason::UnresolvedDep(p) => {
102                write!(f, "Could not resolve: {}", p)
103            }
104        }
105    }
106}
107
108/// Counts of skipped packages by SkipReason category.
109#[derive(Clone, Debug, Default)]
110pub struct SkippedCounts {
111    /// Packages with `PKG_SKIP_REASON` set.
112    pub pkg_skip: usize,
113    /// Packages with `PKG_FAIL_REASON` set.
114    pub pkg_fail: usize,
115    /// Packages with unresolved dependencies.
116    pub unresolved: usize,
117    /// Packages skipped due to a dependency being skipped.
118    pub indirect_skip: usize,
119    /// Packages skipped due to a dependency failure.
120    pub indirect_fail: usize,
121}
122
123/// A successfully resolved package that is ready to build.
124#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
125pub struct ResolvedPackage {
126    /// The scan index data (always present for resolved packages).
127    pub index: ScanIndex,
128    /// Package path.
129    pub pkgpath: PkgPath,
130    /// Resolved dependencies.
131    pub resolved_depends: Vec<PkgName>,
132}
133
134impl ResolvedPackage {
135    /// Returns the package name.
136    pub fn pkgname(&self) -> &PkgName {
137        &self.index.pkgname
138    }
139
140    /// Returns resolved dependencies.
141    pub fn depends(&self) -> &[PkgName] {
142        &self.resolved_depends
143    }
144
145    /// Returns bootstrap_pkg if set.
146    pub fn bootstrap_pkg(&self) -> Option<&str> {
147        self.index.bootstrap_pkg.as_deref()
148    }
149
150    /// Returns usergroup_phase if set.
151    pub fn usergroup_phase(&self) -> Option<&str> {
152        self.index.usergroup_phase.as_deref()
153    }
154
155    /// Returns multi_version if set.
156    pub fn multi_version(&self) -> Option<&[String]> {
157        self.index.multi_version.as_deref()
158    }
159
160    /// Returns pbulk_weight if set.
161    pub fn pbulk_weight(&self) -> Option<&str> {
162        self.index.pbulk_weight.as_deref()
163    }
164}
165
166impl std::fmt::Display for ResolvedPackage {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        write!(f, "{}", self.index)?;
169        if !self.resolved_depends.is_empty() {
170            write!(f, "DEPENDS=")?;
171            for (i, d) in self.resolved_depends.iter().enumerate() {
172                if i > 0 {
173                    write!(f, " ")?;
174                }
175                write!(f, "{d}")?;
176            }
177            writeln!(f)?;
178        }
179        Ok(())
180    }
181}
182
183/// Result of scanning/resolving a single package.
184#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
185pub enum ScanResult {
186    /// Package is buildable.
187    Buildable(ResolvedPackage),
188    /// Package was skipped for a reason.
189    Skipped {
190        /// Package path.
191        pkgpath: PkgPath,
192        /// Reason for skipping.
193        reason: SkipReason,
194        /// Scan index if available (present for most skipped packages).
195        index: Option<ScanIndex>,
196        /// Resolved dependencies (may be partial for unresolved deps).
197        resolved_depends: Vec<PkgName>,
198    },
199    /// Package failed to scan (bmake pbulk-index failed).
200    ScanFail {
201        /// Package path.
202        pkgpath: PkgPath,
203        /// Error message.
204        error: String,
205    },
206}
207
208impl ScanResult {
209    /// Returns the package path.
210    pub fn pkgpath(&self) -> &PkgPath {
211        match self {
212            ScanResult::Buildable(pkg) => &pkg.pkgpath,
213            ScanResult::Skipped { pkgpath, .. } => pkgpath,
214            ScanResult::ScanFail { pkgpath, .. } => pkgpath,
215        }
216    }
217
218    /// Returns the package name if available.
219    pub fn pkgname(&self) -> Option<&PkgName> {
220        match self {
221            ScanResult::Buildable(pkg) => Some(pkg.pkgname()),
222            ScanResult::Skipped { index, .. } => {
223                index.as_ref().map(|i| &i.pkgname)
224            }
225            ScanResult::ScanFail { .. } => None,
226        }
227    }
228
229    /// Returns true if this package is buildable.
230    pub fn is_buildable(&self) -> bool {
231        matches!(self, ScanResult::Buildable(_))
232    }
233
234    /// Returns the resolved package if buildable.
235    pub fn as_buildable(&self) -> Option<&ResolvedPackage> {
236        match self {
237            ScanResult::Buildable(pkg) => Some(pkg),
238            _ => None,
239        }
240    }
241
242    /// Returns resolved dependencies.
243    pub fn depends(&self) -> &[PkgName] {
244        match self {
245            ScanResult::Buildable(pkg) => &pkg.resolved_depends,
246            ScanResult::Skipped { resolved_depends, .. } => resolved_depends,
247            ScanResult::ScanFail { .. } => &[],
248        }
249    }
250}
251
252impl std::fmt::Display for ScanResult {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            ScanResult::Buildable(pkg) => write!(f, "{}", pkg),
256            ScanResult::Skipped {
257                index,
258                pkgpath,
259                reason,
260                resolved_depends,
261            } => {
262                if let Some(idx) = index {
263                    write!(f, "{}", idx)?;
264                    // Don't emit DEPENDS for unresolved deps (pbulk compat)
265                    if !matches!(reason, SkipReason::UnresolvedDep(_))
266                        && !resolved_depends.is_empty()
267                    {
268                        write!(f, "DEPENDS=")?;
269                        for (i, d) in resolved_depends.iter().enumerate() {
270                            if i > 0 {
271                                write!(f, " ")?;
272                            }
273                            write!(f, "{d}")?;
274                        }
275                        writeln!(f)?;
276                    }
277                } else {
278                    writeln!(f, "PKGPATH={}", pkgpath)?;
279                }
280                Ok(())
281            }
282            ScanResult::ScanFail { pkgpath, .. } => {
283                writeln!(f, "PKGPATH={}", pkgpath)
284            }
285        }
286    }
287}
288
289/// Result of scanning and resolving packages.
290///
291/// Returned by [`Scan::resolve`], contains all scanned packages with their outcomes.
292#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
293pub struct ScanSummary {
294    /// Number of unique package paths scanned.
295    pub pkgpaths: usize,
296    /// All packages in scan order with their outcomes.
297    pub packages: Vec<ScanResult>,
298}
299
300/// Counts of packages by outcome category.
301#[derive(Clone, Debug, Default)]
302pub struct ScanCounts {
303    /// Packages that are buildable.
304    pub buildable: usize,
305    /// Packages that were skipped.
306    pub skipped: SkippedCounts,
307    /// Packages that failed to scan.
308    pub scanfail: usize,
309}
310
311impl ScanSummary {
312    /// Compute all outcome counts in a single pass.
313    pub fn counts(&self) -> ScanCounts {
314        let mut c = ScanCounts::default();
315        for p in &self.packages {
316            match p {
317                ScanResult::Buildable(_) => c.buildable += 1,
318                ScanResult::Skipped {
319                    reason: SkipReason::PkgSkip(_), ..
320                } => c.skipped.pkg_skip += 1,
321                ScanResult::Skipped {
322                    reason: SkipReason::PkgFail(_), ..
323                } => c.skipped.pkg_fail += 1,
324                ScanResult::Skipped {
325                    reason: SkipReason::IndirectSkip(_),
326                    ..
327                } => c.skipped.indirect_skip += 1,
328                ScanResult::Skipped {
329                    reason: SkipReason::IndirectFail(_),
330                    ..
331                } => c.skipped.indirect_fail += 1,
332                ScanResult::Skipped {
333                    reason: SkipReason::UnresolvedDep(_),
334                    ..
335                } => c.skipped.unresolved += 1,
336                ScanResult::ScanFail { .. } => c.scanfail += 1,
337            }
338        }
339        c
340    }
341
342    /// Iterator over buildable packages.
343    pub fn buildable(&self) -> impl Iterator<Item = &ResolvedPackage> {
344        self.packages.iter().filter_map(|p| p.as_buildable())
345    }
346
347    /// Iterator over non-buildable packages.
348    pub fn failed(&self) -> impl Iterator<Item = &ScanResult> {
349        self.packages.iter().filter(|p| !p.is_buildable())
350    }
351
352    /// Count of buildable packages.
353    pub fn count_buildable(&self) -> usize {
354        self.packages.iter().filter(|p| p.is_buildable()).count()
355    }
356
357    /// Errors derived from scan failures and unresolved dependencies.
358    pub fn errors(&self) -> impl Iterator<Item = &str> {
359        self.packages.iter().filter_map(|p| match p {
360            ScanResult::ScanFail { error, .. } => Some(error.as_str()),
361            ScanResult::Skipped {
362                reason: SkipReason::UnresolvedDep(e),
363                ..
364            } => Some(e.as_str()),
365            _ => None,
366        })
367    }
368}
369
370impl std::fmt::Display for ScanSummary {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        let c = self.counts();
373        let s = &c.skipped;
374        write!(
375            f,
376            "Resolved {} total packages from {} package paths\n{} buildable, {} pre-skipped, {} pre-failed, {} unresolved",
377            self.packages.len(),
378            self.pkgpaths,
379            c.buildable,
380            s.pkg_skip + s.indirect_skip,
381            s.pkg_fail + s.indirect_fail,
382            s.unresolved
383        )
384    }
385}
386
387/// Package dependency scanner.
388#[derive(Debug, Default)]
389pub struct Scan {
390    config: Config,
391    sandbox: Sandbox,
392    incoming: HashSet<PkgPath>,
393    /// Pkgpaths we've completed scanning (in this session).
394    done: HashSet<PkgPath>,
395    /// Number of pkgpaths loaded from cache at start of scan.
396    initial_cached: usize,
397    /// Number of pkgpaths discovered as cached during dependency discovery.
398    discovered_cached: usize,
399    /// Packages loaded from scan, indexed by pkgname.
400    packages: IndexMap<PkgName, ScanIndex>,
401    /// Full tree scan - discover all packages, skip recursive dependency discovery.
402    /// Defaults to true; set to false when packages are explicitly added.
403    full_tree: bool,
404    /// A previous full tree scan completed successfully.
405    full_scan_complete: bool,
406    /// Packages that failed to scan (pkgpath, error message).
407    scan_failures: Vec<(PkgPath, String)>,
408    /// Pkgsrc environment variables (populated after pre-build).
409    pkgsrc_env: Option<PkgsrcEnv>,
410}
411
412impl Scan {
413    pub fn new(config: &Config) -> Scan {
414        let sandbox = Sandbox::new(config);
415        debug!(pkgsrc = %config.pkgsrc().display(),
416            make = %config.make().display(),
417            scan_threads = config.scan_threads(),
418            "Created new Scan instance"
419        );
420        Scan {
421            config: config.clone(),
422            sandbox,
423            incoming: HashSet::new(),
424            done: HashSet::new(),
425            initial_cached: 0,
426            discovered_cached: 0,
427            packages: IndexMap::new(),
428            full_tree: true,
429            full_scan_complete: false,
430            scan_failures: Vec::new(),
431            pkgsrc_env: None,
432        }
433    }
434
435    pub fn add(&mut self, pkgpath: &PkgPath) {
436        info!(pkgpath = %pkgpath.as_path().display(), "Adding package to scan queue");
437        self.full_tree = false;
438        self.incoming.insert(pkgpath.clone());
439    }
440
441    /// Returns true if this is a full tree scan.
442    pub fn is_full_tree(&self) -> bool {
443        self.full_tree
444    }
445
446    /// Mark that a previous full tree scan completed successfully.
447    pub fn set_full_scan_complete(&mut self) {
448        self.full_scan_complete = true;
449    }
450
451    /// Initialize scan from database, checking what's already scanned.
452    /// Returns (cached_count, pending_deps_count) where pending_deps_count is the
453    /// number of dependencies discovered but not yet scanned (from interrupted scans).
454    pub fn init_from_db(
455        &mut self,
456        db: &crate::db::Database,
457    ) -> Result<(usize, usize)> {
458        let scanned = db.get_scanned_pkgpaths()?;
459        let cached_count = scanned.len();
460        let mut pending_count = 0;
461
462        if cached_count > 0 {
463            info!(
464                cached_count = cached_count,
465                "Found cached scan results in database"
466            );
467
468            // For full tree scans with full_scan_complete, we'll skip scanning
469            // For limited scans, remove already-scanned from incoming
470            if !self.full_tree {
471                self.incoming.retain(|p| !scanned.contains(&p.to_string()));
472            }
473
474            // Add scanned pkgpaths to done set
475            for pkgpath_str in &scanned {
476                if let Ok(pkgpath) = PkgPath::new(pkgpath_str) {
477                    self.done.insert(pkgpath);
478                }
479            }
480
481            // Check for dependencies that were discovered but not yet scanned.
482            // This handles the case where a scan was interrupted partway through.
483            let unscanned = db.get_unscanned_dependencies()?;
484            if !unscanned.is_empty() {
485                info!(
486                    unscanned_count = unscanned.len(),
487                    "Found unscanned dependencies from interrupted scan"
488                );
489                for pkgpath_str in unscanned {
490                    if let Ok(pkgpath) = PkgPath::new(&pkgpath_str) {
491                        if !self.done.contains(&pkgpath) {
492                            self.incoming.insert(pkgpath);
493                            pending_count += 1;
494                        }
495                    }
496                }
497            }
498        }
499
500        Ok((cached_count, pending_count))
501    }
502
503    /// Discover all packages in pkgsrc tree.
504    fn discover_packages(
505        &mut self,
506        pool: &rayon::ThreadPool,
507        shutdown: &AtomicBool,
508    ) -> anyhow::Result<()> {
509        println!("Discovering packages...");
510        let pkgsrc = self.config.pkgsrc().display().to_string();
511
512        // Get top-level SUBDIR (categories + USER_ADDITIONAL_PKGS)
513        let child = self.sandbox.execute_command(
514            0,
515            self.config.make(),
516            ["-C", &pkgsrc, "show-subdir-var", "VARNAME=SUBDIR"],
517            vec![],
518        )?;
519        let output = wait_output_with_shutdown(child, shutdown)
520            .context("Failed to run show-subdir-var")?;
521
522        if !output.status.success() {
523            let stderr = String::from_utf8_lossy(&output.stderr);
524            bail!("Failed to get categories: {}", stderr);
525        }
526
527        let stdout = String::from_utf8_lossy(&output.stdout);
528        let entries: Vec<&str> = stdout.split_whitespace().collect();
529
530        // Separate USER_ADDITIONAL_PKGS (contain '/') from categories
531        let mut categories: Vec<&str> = Vec::new();
532        for entry in entries {
533            if entry.contains('/') {
534                if let Ok(pkgpath) = PkgPath::new(entry) {
535                    self.incoming.insert(pkgpath);
536                }
537            } else {
538                categories.push(entry);
539            }
540        }
541
542        // Process categories in parallel
543        let make = self.config.make();
544        let sandbox = &self.sandbox;
545        let discovered: Vec<PkgPath> = pool.install(|| {
546            categories
547                .par_iter()
548                .flat_map(|category| {
549                    let workdir = format!("{}/{}", pkgsrc, category);
550                    let result = sandbox
551                        .execute_command(
552                            0,
553                            make,
554                            [
555                                "-C",
556                                &workdir,
557                                "show-subdir-var",
558                                "VARNAME=SUBDIR",
559                            ],
560                            vec![],
561                        )
562                        .and_then(|c| wait_output_with_shutdown(c, shutdown));
563
564                    match result {
565                        Ok(o) if o.status.success() => {
566                            let pkgs = String::from_utf8_lossy(&o.stdout);
567                            pkgs.split_whitespace()
568                                .filter_map(|pkg| {
569                                    let path = format!("{}/{}", category, pkg);
570                                    PkgPath::new(&path).ok()
571                                })
572                                .collect::<Vec<_>>()
573                        }
574                        Ok(o) => {
575                            let stderr = String::from_utf8_lossy(&o.stderr);
576                            debug!(category = *category, stderr = %stderr,
577                                "Failed to get packages for category");
578                            vec![]
579                        }
580                        Err(e) => {
581                            debug!(category = *category, error = %e,
582                                "Failed to run make in category");
583                            vec![]
584                        }
585                    }
586                })
587                .collect()
588        });
589
590        self.incoming.extend(discovered);
591
592        info!(discovered = self.incoming.len(), "Package discovery complete");
593        println!("Discovered {} package paths", self.incoming.len());
594
595        Ok(())
596    }
597
598    pub fn start(
599        &mut self,
600        ctx: &RunContext,
601        db: &crate::db::Database,
602    ) -> anyhow::Result<bool> {
603        info!(
604            incoming_count = self.incoming.len(),
605            sandbox_enabled = self.sandbox.enabled(),
606            "Starting package scan"
607        );
608
609        let pool = rayon::ThreadPoolBuilder::new()
610            .num_threads(self.config.scan_threads())
611            .build()
612            .context("Failed to build scan thread pool")?;
613
614        let shutdown_flag = Arc::clone(&ctx.shutdown);
615
616        // For full tree scans where a previous scan completed, all packages
617        // are already cached - nothing to do.
618        if self.full_tree && self.full_scan_complete && !self.done.is_empty() {
619            println!("All {} package paths already scanned", self.done.len());
620            return Ok(false);
621        }
622
623        // For non-full-tree scans, prune already-cached packages from incoming
624        // before sandbox creation to avoid unnecessary setup/teardown.
625        if !self.full_tree {
626            self.incoming.retain(|p| !self.done.contains(p));
627            if self.incoming.is_empty() {
628                if !self.done.is_empty() {
629                    println!(
630                        "All {} package paths already scanned",
631                        self.done.len()
632                    );
633                }
634                return Ok(false);
635            }
636        }
637
638        /*
639         * Only a single sandbox is required, 'make pbulk-index' can safely be
640         * run in parallel inside one sandbox.
641         *
642         * Create scope which handles sandbox lifecycle - creates on construction,
643         * destroys on drop. This ensures cleanup even on error paths.
644         */
645        let _scope = SingleSandboxScope::new(self.sandbox.clone())?;
646
647        if self.sandbox.enabled() {
648            // Run pre-build script if defined
649            if !self.sandbox.run_pre_build(
650                0,
651                &self.config,
652                self.config.script_env(None),
653            )? {
654                warn!("pre-build script failed");
655            }
656        }
657
658        let env = match db.load_pkgsrc_env() {
659            Ok(env) => env,
660            Err(_) => {
661                let env = PkgsrcEnv::fetch(&self.config, &self.sandbox)?;
662                db.store_pkgsrc_env(&env)?;
663                env
664            }
665        };
666        self.pkgsrc_env = Some(env);
667
668        // For full tree scans, always discover all packages
669        if self.full_tree {
670            self.discover_packages(&pool, &shutdown_flag)?;
671            self.incoming.retain(|p| !self.done.contains(p));
672        }
673
674        // Nothing to scan - all packages are cached
675        if self.incoming.is_empty() {
676            if !self.done.is_empty() {
677                println!(
678                    "All {} package paths already scanned",
679                    self.done.len()
680                );
681            }
682
683            if self.sandbox.enabled() {
684                self.run_post_build()?;
685            }
686            // Guard dropped here, destroys sandbox
687            return Ok(false);
688        }
689
690        // Clear resolved dependencies since we're scanning new packages
691        db.clear_resolved_depends()?;
692
693        println!("Scanning packages...");
694
695        // Track initial cached count for final summary
696        self.initial_cached = self.done.len();
697
698        // Set up multi-line progress display using ratatui inline viewport
699        // Note: finished_title is unused since we print our own summary
700        let total_count = self.initial_cached + self.incoming.len();
701        let progress = Arc::new(Mutex::new(
702            MultiProgress::new(
703                "Scanning",
704                "",
705                total_count,
706                self.config.scan_threads(),
707            )
708            .expect("Failed to initialize progress display"),
709        ));
710
711        // Mark cached packages in progress display
712        if self.initial_cached > 0 {
713            if let Ok(mut p) = progress.lock() {
714                p.state_mut().cached = self.initial_cached;
715            }
716        }
717
718        // Flag to stop the refresh thread
719        let stop_refresh = Arc::new(AtomicBool::new(false));
720
721        // Spawn a thread to periodically refresh the display (for timer updates)
722        let progress_refresh = Arc::clone(&progress);
723        let stop_flag = Arc::clone(&stop_refresh);
724        let shutdown_for_refresh = Arc::clone(&shutdown_flag);
725        let refresh_thread = std::thread::spawn(move || {
726            while !stop_flag.load(Ordering::Relaxed)
727                && !shutdown_for_refresh.load(Ordering::SeqCst)
728            {
729                // Poll outside lock to avoid blocking main thread
730                let has_event = event::poll(REFRESH_INTERVAL).unwrap_or(false);
731
732                if let Ok(mut p) = progress_refresh.lock() {
733                    if has_event {
734                        let _ = p.handle_event();
735                    }
736                    let _ = p.render();
737                }
738            }
739        });
740
741        // Start transaction for all writes
742        db.begin_transaction()?;
743
744        let mut interrupted = false;
745
746        // Borrow config and sandbox separately for use in scanner thread,
747        // allowing main thread to mutate self.done, self.incoming, etc.
748        let config = &self.config;
749        let sandbox = &self.sandbox;
750        let scan_env: Vec<(String, String)> = self
751            .pkgsrc_env
752            .as_ref()
753            .map(|e| {
754                e.cachevars
755                    .iter()
756                    .map(|(k, v)| (k.clone(), v.clone()))
757                    .collect()
758            })
759            .unwrap_or_default();
760
761        /*
762         * Continuously iterate over incoming queue, moving to done once
763         * processed, and adding any dependencies to incoming to be processed
764         * next.
765         */
766        loop {
767            // Check for shutdown signal.
768            // Use SeqCst for consistency with signal handler's store ordering,
769            // ensuring the shutdown is observed promptly on all architectures.
770            if shutdown_flag.load(Ordering::SeqCst) {
771                stop_refresh.store(true, Ordering::Relaxed);
772                if let Ok(mut p) = progress.lock() {
773                    let _ = p.finish_interrupted();
774                }
775                interrupted = true;
776                break;
777            }
778
779            /*
780             * Convert the incoming HashSet into a Vec for parallel processing.
781             */
782            let pkgpaths: Vec<PkgPath> = self.incoming.drain().collect();
783            if pkgpaths.is_empty() {
784                break;
785            }
786
787            // Create bounded channel for streaming results
788            const CHANNEL_BUFFER_SIZE: usize = 128;
789            let (tx, rx) = std::sync::mpsc::sync_channel::<(
790                PkgPath,
791                Result<Vec<ScanIndex>>,
792            )>(CHANNEL_BUFFER_SIZE);
793
794            let mut new_incoming: HashSet<PkgPath> = HashSet::new();
795
796            std::thread::scope(|s| {
797                // Spawn scanning thread
798                let progress_clone = Arc::clone(&progress);
799                let shutdown_clone = Arc::clone(&shutdown_flag);
800                let pool_ref = &pool;
801                let scan_env_ref = &scan_env;
802
803                s.spawn(move || {
804                    pool_ref.install(|| {
805                        pkgpaths.par_iter().for_each(|pkgpath| {
806                            // Check for shutdown before starting
807                            if shutdown_clone.load(Ordering::SeqCst) {
808                                return;
809                            }
810
811                            let pathname =
812                                pkgpath.as_path().to_string_lossy().to_string();
813                            let thread_id =
814                                rayon::current_thread_index().unwrap_or(0);
815
816                            // Update progress - show current package
817                            if let Ok(mut p) = progress_clone.lock() {
818                                p.state_mut()
819                                    .set_worker_active(thread_id, &pathname);
820                            }
821
822                            let result = Self::scan_pkgpath_with(
823                                config,
824                                sandbox,
825                                pkgpath,
826                                scan_env_ref,
827                                &shutdown_clone,
828                            );
829
830                            // Update progress counter
831                            if let Ok(mut p) = progress_clone.lock() {
832                                p.state_mut().set_worker_idle(thread_id);
833                                if result.is_ok() {
834                                    p.state_mut().increment_completed();
835                                } else {
836                                    p.state_mut().increment_failed();
837                                }
838                            }
839
840                            // Send result (blocks if buffer full = backpressure)
841                            let _ = tx.send((pkgpath.clone(), result));
842                        });
843                    });
844                    drop(tx);
845                });
846
847                // Check if we were interrupted during parallel processing
848                let was_interrupted = shutdown_flag.load(Ordering::SeqCst);
849
850                /*
851                 * Process results - write to DB and extract dependencies.
852                 */
853                for (pkgpath, result) in rx {
854                    let scanpkgs = match result {
855                        Ok(pkgs) => pkgs,
856                        Err(e) => {
857                            self.scan_failures
858                                .push((pkgpath.clone(), e.to_string()));
859                            self.done.insert(pkgpath);
860                            continue;
861                        }
862                    };
863                    self.done.insert(pkgpath.clone());
864
865                    // Save to database
866                    if !scanpkgs.is_empty() {
867                        if let Err(e) = db
868                            .store_scan_pkgpath(&pkgpath.to_string(), &scanpkgs)
869                        {
870                            error!(error = %e, "Failed to store scan results");
871                        }
872                    }
873
874                    // Skip dependency discovery for full tree scans (all
875                    // packages already discovered) or if interrupted
876                    if self.full_tree || was_interrupted {
877                        continue;
878                    }
879
880                    // Discover dependencies not yet seen
881                    for pkg in &scanpkgs {
882                        if let Some(ref all_deps) = pkg.all_depends {
883                            for dep in all_deps {
884                                let dep_path = dep.pkgpath();
885                                if self.done.contains(dep_path)
886                                    || new_incoming.contains(dep_path)
887                                {
888                                    continue;
889                                }
890                                // Check database for cached dependency
891                                match db
892                                    .is_pkgpath_scanned(&dep_path.to_string())
893                                {
894                                    Ok(true) => {
895                                        self.done.insert(dep_path.clone());
896                                        self.discovered_cached += 1;
897                                        if let Ok(mut p) = progress.lock() {
898                                            p.state_mut().total += 1;
899                                            p.state_mut().cached += 1;
900                                        }
901                                    }
902                                    Ok(false) => {
903                                        new_incoming.insert(dep_path.clone());
904                                        if let Ok(mut p) = progress.lock() {
905                                            p.state_mut().total += 1;
906                                        }
907                                    }
908                                    Err(_) => {}
909                                }
910                            }
911                        }
912                    }
913                }
914            });
915
916            /*
917             * We're finished with the current incoming, replace it with the
918             * new incoming list.  If it is empty then we've already processed
919             * all known PKGPATHs and are done.
920             *
921             * Filter out any pkgpaths that were already scanned this wave.
922             * This handles a race where dependency discovery finds a pkgpath
923             * before its parallel scan completes and adds it to done.
924             */
925            new_incoming.retain(|p| !self.done.contains(p));
926            self.incoming = new_incoming;
927        }
928
929        // Commit transaction (partial on interrupt, full on success)
930        db.commit()?;
931
932        // Stop the refresh thread and print final summary
933        stop_refresh.store(true, Ordering::Relaxed);
934        let _ = refresh_thread.join();
935
936        // Only print summary for normal completion; finish_interrupted()
937        // was already called immediately when interrupt was detected
938        if !interrupted {
939            // Get elapsed time and clean up TUI without printing generic summary
940            let elapsed = if let Ok(mut p) = progress.lock() {
941                p.finish_silent().ok()
942            } else {
943                None
944            };
945
946            // Print scan-specific summary from source of truth
947            // total = initial_cached + discovered_cached + actually_scanned
948            // where actually_scanned = succeeded + failed
949            let total = self.done.len();
950            let cached = self.initial_cached + self.discovered_cached;
951            let failed = self.scan_failures.len();
952            let succeeded = total.saturating_sub(cached).saturating_sub(failed);
953
954            let elapsed_str =
955                elapsed.map(format_duration).unwrap_or_else(|| "?".to_string());
956
957            if cached > 0 {
958                println!(
959                    "Scanned {} package paths in {} ({} scanned, {} cached, {} failed)",
960                    total, elapsed_str, succeeded, cached, failed
961                );
962            } else {
963                println!(
964                    "Scanned {} package paths in {} ({} succeeded, {} failed)",
965                    total, elapsed_str, succeeded, failed
966                );
967            }
968        }
969
970        if self.sandbox.enabled() {
971            self.run_post_build()?;
972        }
973
974        // Guard dropped here, destroys sandbox
975        if interrupted {
976            return Ok(true);
977        }
978
979        Ok(false)
980    }
981
982    /// Run post-build script if configured.
983    fn run_post_build(&self) -> anyhow::Result<()> {
984        if !self.sandbox.run_post_build(
985            0,
986            &self.config,
987            self.config.script_env(self.pkgsrc_env.as_ref()),
988        )? {
989            warn!("post-build script failed");
990        }
991        Ok(())
992    }
993
994    /// Returns scan failures as formatted error strings.
995    pub fn scan_errors(&self) -> impl Iterator<Item = &str> {
996        self.scan_failures.iter().map(|(_, e)| e.as_str())
997    }
998
999    /**
1000     * Scan a single PKGPATH, returning a [`Vec`] of [`ScanIndex`] results,
1001     * as multi-version packages may return multiple results.
1002     */
1003    pub fn scan_pkgpath(
1004        &self,
1005        pkgpath: &PkgPath,
1006    ) -> anyhow::Result<Vec<ScanIndex>> {
1007        static NO_SHUTDOWN: AtomicBool = AtomicBool::new(false);
1008        let scan_env: Vec<(String, String)> = self
1009            .pkgsrc_env
1010            .as_ref()
1011            .map(|e| {
1012                e.cachevars
1013                    .iter()
1014                    .map(|(k, v)| (k.clone(), v.clone()))
1015                    .collect()
1016            })
1017            .unwrap_or_default();
1018        Self::scan_pkgpath_with(
1019            &self.config,
1020            &self.sandbox,
1021            pkgpath,
1022            &scan_env,
1023            &NO_SHUTDOWN,
1024        )
1025    }
1026
1027    /*
1028     * Scan a single PKGPATH using provided config and sandbox references.
1029     * This allows scanning without borrowing all of `self`.
1030     */
1031    fn scan_pkgpath_with(
1032        config: &Config,
1033        sandbox: &Sandbox,
1034        pkgpath: &PkgPath,
1035        scan_env: &[(String, String)],
1036        shutdown: &AtomicBool,
1037    ) -> anyhow::Result<Vec<ScanIndex>> {
1038        let pkgpath_str = pkgpath.as_path().display().to_string();
1039        let span = info_span!("scan", pkgpath = %pkgpath_str);
1040        let _guard = span.enter();
1041        debug!("Scanning package");
1042
1043        let pkgsrcdir = config.pkgsrc().display().to_string();
1044        let workdir = format!("{}/{}", pkgsrcdir, pkgpath_str);
1045
1046        trace!(
1047            workdir = %workdir,
1048            scan_env = ?scan_env,
1049            "Executing pkg-scan"
1050        );
1051        let child = sandbox.execute_command(
1052            0,
1053            config.make(),
1054            ["-C", &workdir, "pbulk-index"],
1055            scan_env.to_vec(),
1056        )?;
1057        let output = wait_output_with_shutdown(child, shutdown)?;
1058
1059        if !output.status.success() {
1060            let stderr = String::from_utf8_lossy(&output.stderr);
1061            error!(
1062                exit_code = ?output.status.code(),
1063                stderr = %stderr,
1064                "pkg-scan script failed"
1065            );
1066            let stderr = stderr.trim();
1067            let msg = if stderr.is_empty() {
1068                format!("Scan failed for {}", pkgpath_str)
1069            } else {
1070                format!("Scan failed for {}: {}", pkgpath_str, stderr)
1071            };
1072            bail!(msg);
1073        }
1074
1075        let stdout_str = String::from_utf8_lossy(&output.stdout);
1076        trace!(
1077            stdout_len = stdout_str.len(),
1078            stdout = %stdout_str,
1079            "pkg-scan script output"
1080        );
1081
1082        let reader = BufReader::new(&output.stdout[..]);
1083        let all_results: Vec<ScanIndex> =
1084            ScanIndex::from_reader(reader).collect::<Result<_, _>>()?;
1085
1086        /*
1087         * Filter to keep only the first occurrence of each PKGNAME.
1088         * For multi-version packages, pbulk-index returns the *_DEFAULT
1089         * version first, which is the one we want.
1090         */
1091        let mut seen_pkgnames = HashSet::new();
1092        let mut index: Vec<ScanIndex> = Vec::new();
1093        for pkg in all_results {
1094            if seen_pkgnames.insert(pkg.pkgname.clone()) {
1095                index.push(pkg);
1096            }
1097        }
1098
1099        info!(packages_found = index.len(), "Scan complete");
1100
1101        /*
1102         * Set PKGPATH (PKG_LOCATION) as for some reason pbulk-index doesn't.
1103         */
1104        for pkg in &mut index {
1105            pkg.pkg_location = Some(pkgpath.clone());
1106            debug!(
1107                pkgname = %pkg.pkgname.pkgname(),
1108                skip_reason = ?pkg.pkg_skip_reason,
1109                fail_reason = ?pkg.pkg_fail_reason,
1110                depends_count = pkg.all_depends.as_ref().map_or(0, |v| v.len()),
1111                "Found package in scan"
1112            );
1113        }
1114
1115        Ok(index)
1116    }
1117
1118    /// Resolve the list of scanned packages, matching dependency patterns to
1119    /// specific packages and verifying no circular dependencies exist.
1120    ///
1121    /// Returns a [`ScanSummary`] containing all packages with their outcomes.
1122    /// Also stores resolved dependencies in the database for fast reverse lookups.
1123    pub fn resolve(&mut self, db: &crate::db::Database) -> Result<ScanSummary> {
1124        info!(
1125            done_pkgpaths = self.done.len(),
1126            "Starting dependency resolution"
1127        );
1128
1129        // Load all scan data in one query
1130        let all_scan_data = db.get_all_scan_indexes()?;
1131
1132        // Track package_id for storing resolved dependencies
1133        let mut pkgname_to_id: HashMap<PkgName, i64> = HashMap::new();
1134
1135        // Track skip reasons (packages not in this map are buildable)
1136        let mut skip_reasons: HashMap<PkgName, SkipReason> = HashMap::new();
1137        let mut depends: HashMap<PkgName, Vec<PkgName>> = HashMap::new();
1138
1139        // Process all scan data
1140        for (pkg_id, pkg) in all_scan_data {
1141            // Skip duplicate PKGNAMEs - keep only the first (preferred) variant
1142            if self.packages.contains_key(&pkg.pkgname) {
1143                debug!(pkgname = %pkg.pkgname.pkgname(), "Skipping duplicate PKGNAME");
1144                continue;
1145            }
1146
1147            // Track skip/fail reasons
1148            if let Some(reason) = &pkg.pkg_skip_reason {
1149                if !reason.is_empty() {
1150                    info!(pkgname = %pkg.pkgname.pkgname(), reason = %reason, "PKG_SKIP_REASON");
1151                    skip_reasons.insert(
1152                        pkg.pkgname.clone(),
1153                        SkipReason::PkgSkip(reason.clone()),
1154                    );
1155                }
1156            }
1157            if let Some(reason) = &pkg.pkg_fail_reason {
1158                if !reason.is_empty()
1159                    && !skip_reasons.contains_key(&pkg.pkgname)
1160                {
1161                    info!(pkgname = %pkg.pkgname.pkgname(), reason = %reason, "PKG_FAIL_REASON");
1162                    skip_reasons.insert(
1163                        pkg.pkgname.clone(),
1164                        SkipReason::PkgFail(reason.clone()),
1165                    );
1166                }
1167            }
1168
1169            pkgname_to_id.insert(pkg.pkgname.clone(), pkg_id);
1170            depends.insert(pkg.pkgname.clone(), Vec::new());
1171            self.packages.insert(pkg.pkgname.clone(), pkg);
1172        }
1173
1174        info!(packages = self.packages.len(), "Loaded packages");
1175
1176        // Collect pkgnames for lookups (owned to avoid borrow issues)
1177        let pkgnames: Vec<PkgName> = self.packages.keys().cloned().collect();
1178
1179        // Build pkgbase -> Vec<&PkgName> for efficient lookups
1180        let pkgbase_map: HashMap<&str, Vec<&PkgName>> = {
1181            let mut map: HashMap<&str, Vec<&PkgName>> = HashMap::new();
1182            for pkgname in &pkgnames {
1183                map.entry(pkgname.pkgbase()).or_default().push(pkgname);
1184            }
1185            map
1186        };
1187
1188        // Cache of best Depend => PkgName matches
1189        let mut match_cache: HashMap<Depend, PkgName> = HashMap::new();
1190
1191        // Helper to check if a dependency pattern is already satisfied
1192        let is_satisfied = |deps: &[PkgName], pattern: &pkgsrc::Pattern| {
1193            deps.iter().any(|existing| pattern.matches(existing.pkgname()))
1194        };
1195
1196        // Resolve dependencies for each package
1197        for pkg in self.packages.values_mut() {
1198            let all_deps = match pkg.all_depends.take() {
1199                Some(deps) => deps,
1200                None => continue,
1201            };
1202            let pkg_depends = depends.get_mut(&pkg.pkgname).unwrap();
1203
1204            for depend in all_deps.iter() {
1205                // Check cache first
1206                if let Some(pkgname) = match_cache.get(depend) {
1207                    if !is_satisfied(pkg_depends, depend.pattern())
1208                        && !pkg_depends.contains(pkgname)
1209                    {
1210                        pkg_depends.push(pkgname.clone());
1211                    }
1212                    continue;
1213                }
1214
1215                // Find candidates matching the pattern
1216                let candidates: Vec<&PkgName> = if let Some(base) =
1217                    depend.pattern().pkgbase()
1218                {
1219                    pkgbase_map.get(base).map_or(Vec::new(), |v| {
1220                        v.iter()
1221                            .filter(|c| depend.pattern().matches(c.pkgname()))
1222                            .copied()
1223                            .collect()
1224                    })
1225                } else {
1226                    pkgnames
1227                        .iter()
1228                        .filter(|c| depend.pattern().matches(c.pkgname()))
1229                        .collect()
1230                };
1231
1232                // Find best match using pbulk algorithm
1233                let mut best: Option<&PkgName> = None;
1234                let mut match_error: Option<pkgsrc::PatternError> = None;
1235                for candidate in candidates {
1236                    best = match best {
1237                        None => Some(candidate),
1238                        Some(current) => {
1239                            match depend.pattern().best_match_pbulk(
1240                                current.pkgname(),
1241                                candidate.pkgname(),
1242                            ) {
1243                                Ok(Some(m)) if m == candidate.pkgname() => {
1244                                    Some(candidate)
1245                                }
1246                                Ok(_) => Some(current),
1247                                Err(e) => {
1248                                    match_error = Some(e);
1249                                    break;
1250                                }
1251                            }
1252                        }
1253                    };
1254                }
1255
1256                if let Some(e) = match_error {
1257                    let reason = format!(
1258                        "{}: pattern error for {}: {}",
1259                        pkg.pkgname.pkgname(),
1260                        depend.pattern().pattern(),
1261                        e
1262                    );
1263                    if !skip_reasons.contains_key(&pkg.pkgname) {
1264                        skip_reasons.insert(
1265                            pkg.pkgname.clone(),
1266                            SkipReason::PkgFail(reason),
1267                        );
1268                    }
1269                    continue;
1270                }
1271
1272                if let Some(pkgname) = best {
1273                    if !is_satisfied(pkg_depends, depend.pattern())
1274                        && !pkg_depends.contains(pkgname)
1275                    {
1276                        pkg_depends.push(pkgname.clone());
1277                    }
1278                    match_cache.insert(depend.clone(), pkgname.clone());
1279                } else {
1280                    // Unresolved dependency - set pkg_fail_reason for output
1281                    // and store in outcomes for error reporting
1282                    let pattern = depend.pattern().pattern();
1283                    // pbulk format includes outer quotes: "could not resolve dependency "pattern""
1284                    let fail_reason = format!(
1285                        "\"could not resolve dependency \"{}\"\"",
1286                        pattern
1287                    );
1288                    pkg.pkg_fail_reason = Some(fail_reason);
1289                    let msg = format!(
1290                        "No match found for dependency {} of package {}",
1291                        pattern,
1292                        pkg.pkgname.pkgname()
1293                    );
1294                    match skip_reasons.get_mut(&pkg.pkgname) {
1295                        Some(SkipReason::UnresolvedDep(existing)) => {
1296                            existing.push('\n');
1297                            existing.push_str(&msg);
1298                        }
1299                        None => {
1300                            skip_reasons.insert(
1301                                pkg.pkgname.clone(),
1302                                SkipReason::UnresolvedDep(msg),
1303                            );
1304                        }
1305                        _ => {}
1306                    }
1307                }
1308            }
1309            pkg.all_depends = Some(all_deps);
1310        }
1311
1312        // Propagate failures: if A depends on B and B is failed/skipped, A is indirect-failed/skipped
1313        loop {
1314            let mut new_skip_reasons: Vec<(PkgName, SkipReason)> = Vec::new();
1315            for (pkgname, pkg_depends) in &depends {
1316                if skip_reasons.contains_key(pkgname) {
1317                    continue;
1318                }
1319                for dep in pkg_depends {
1320                    if let Some(dep_reason) = skip_reasons.get(dep) {
1321                        // Use indirect variants, preserving skip vs fail distinction
1322                        let reason = match dep_reason {
1323                            SkipReason::PkgSkip(_)
1324                            | SkipReason::IndirectSkip(_) => {
1325                                SkipReason::IndirectSkip(format!(
1326                                    "dependency {} skipped",
1327                                    dep.pkgname()
1328                                ))
1329                            }
1330                            _ => SkipReason::IndirectFail(format!(
1331                                "dependency {} failed",
1332                                dep.pkgname()
1333                            )),
1334                        };
1335                        new_skip_reasons.push((pkgname.clone(), reason));
1336                        break;
1337                    }
1338                }
1339            }
1340            if new_skip_reasons.is_empty() {
1341                break;
1342            }
1343            for (pkgname, reason) in new_skip_reasons {
1344                skip_reasons.insert(pkgname, reason);
1345            }
1346        }
1347
1348        // Build final packages list
1349        let mut packages: Vec<ScanResult> = Vec::new();
1350        let mut count_buildable = 0;
1351
1352        for (pkgname, index) in std::mem::take(&mut self.packages) {
1353            let Some(pkgpath) = index.pkg_location.clone() else {
1354                error!(pkgname = %pkgname, "Package missing PKG_LOCATION, skipping");
1355                continue;
1356            };
1357            let resolved_depends = depends.remove(&pkgname).unwrap_or_default();
1358            let result = match skip_reasons.remove(&pkgname) {
1359                Some(reason) => ScanResult::Skipped {
1360                    pkgpath,
1361                    reason,
1362                    index: Some(index),
1363                    resolved_depends,
1364                },
1365                None => {
1366                    count_buildable += 1;
1367                    ScanResult::Buildable(ResolvedPackage {
1368                        index,
1369                        pkgpath,
1370                        resolved_depends,
1371                    })
1372                }
1373            };
1374            packages.push(result);
1375        }
1376
1377        // Add scan failures (these don't have a ScanIndex, just pkgpath)
1378        for (pkgpath, error) in &self.scan_failures {
1379            packages.push(ScanResult::ScanFail {
1380                pkgpath: pkgpath.clone(),
1381                error: error.clone(),
1382            });
1383        }
1384
1385        // Verify no circular dependencies (only for buildable packages)
1386        debug!(count_buildable, "Checking for circular dependencies");
1387        let mut graph = DiGraphMap::new();
1388        for pkg in &packages {
1389            if let ScanResult::Buildable(resolved) = pkg {
1390                for dep in &resolved.resolved_depends {
1391                    graph.add_edge(
1392                        dep.pkgname(),
1393                        resolved.pkgname().pkgname(),
1394                        (),
1395                    );
1396                }
1397            }
1398        }
1399        if let Some(cycle) = find_cycle(&graph) {
1400            let mut err = "Circular dependencies detected:\n".to_string();
1401            for n in cycle.iter().rev() {
1402                err.push_str(&format!("\t{}\n", n));
1403            }
1404            err.push_str(&format!("\t{}", cycle.last().unwrap()));
1405            error!(cycle = ?cycle, "Circular dependency detected");
1406            bail!(err);
1407        }
1408
1409        info!(
1410            count_buildable,
1411            count_preskip = packages
1412                .iter()
1413                .filter(|p| matches!(
1414                    p,
1415                    ScanResult::Skipped { reason: SkipReason::PkgSkip(_), .. }
1416                ))
1417                .count(),
1418            count_prefail = packages
1419                .iter()
1420                .filter(|p| matches!(
1421                    p,
1422                    ScanResult::Skipped { reason: SkipReason::PkgFail(_), .. }
1423                ))
1424                .count(),
1425            count_unresolved = packages
1426                .iter()
1427                .filter(|p| matches!(
1428                    p,
1429                    ScanResult::Skipped {
1430                        reason: SkipReason::UnresolvedDep(_),
1431                        ..
1432                    }
1433                ))
1434                .count(),
1435            "Resolution complete"
1436        );
1437
1438        // Store resolved dependencies in database
1439        let mut resolved_deps: Vec<(i64, i64)> = Vec::new();
1440        for pkg in &packages {
1441            if let ScanResult::Buildable(resolved) = pkg {
1442                if let Some(&pkg_id) = pkgname_to_id.get(resolved.pkgname()) {
1443                    for dep in &resolved.resolved_depends {
1444                        if let Some(&dep_id) = pkgname_to_id.get(dep) {
1445                            resolved_deps.push((pkg_id, dep_id));
1446                        }
1447                    }
1448                }
1449            }
1450        }
1451        if !resolved_deps.is_empty() {
1452            db.store_resolved_dependencies_batch(&resolved_deps)?;
1453            debug!(count = resolved_deps.len(), "Stored resolved dependencies");
1454        }
1455
1456        Ok(ScanSummary { pkgpaths: self.done.len(), packages })
1457    }
1458}
1459
1460pub fn find_cycle<'a>(
1461    graph: &'a DiGraphMap<&'a str, ()>,
1462) -> Option<Vec<&'a str>> {
1463    let mut visited = HashSet::new();
1464    let mut in_stack = HashSet::new();
1465    let mut stack = Vec::new();
1466
1467    for node in graph.nodes() {
1468        if visited.contains(&node) {
1469            continue;
1470        }
1471        if let Some(cycle) =
1472            dfs(graph, node, &mut visited, &mut stack, &mut in_stack)
1473        {
1474            return Some(cycle);
1475        }
1476    }
1477    None
1478}
1479
1480fn dfs<'a>(
1481    graph: &'a DiGraphMap<&'a str, ()>,
1482    node: &'a str,
1483    visited: &mut HashSet<&'a str>,
1484    stack: &mut Vec<&'a str>,
1485    in_stack: &mut HashSet<&'a str>,
1486) -> Option<Vec<&'a str>> {
1487    visited.insert(node);
1488    stack.push(node);
1489    in_stack.insert(node);
1490    for neighbor in graph.neighbors(node) {
1491        if in_stack.contains(neighbor) {
1492            if let Some(pos) = stack.iter().position(|&n| n == neighbor) {
1493                return Some(stack[pos..].to_vec());
1494            }
1495        } else if !visited.contains(neighbor) {
1496            let cycle = dfs(graph, neighbor, visited, stack, in_stack);
1497            if cycle.is_some() {
1498                return cycle;
1499            }
1500        }
1501    }
1502    stack.pop();
1503    in_stack.remove(node);
1504    None
1505}