bob/
scan.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//! 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//!
40//! # Example
41//!
42//! ```no_run
43//! use bob::{Config, RunContext, Scan};
44//! use pkgsrc::PkgPath;
45//! use std::sync::Arc;
46//! use std::sync::atomic::AtomicBool;
47//!
48//! let config = Config::load(None, false)?;
49//! let mut scan = Scan::new(&config);
50//!
51//! scan.add(&PkgPath::new("mail/mutt")?);
52//! scan.add(&PkgPath::new("www/curl")?);
53//!
54//! let ctx = RunContext::new(Arc::new(AtomicBool::new(false)));
55//! scan.start(&ctx)?;  // Discover dependencies
56//! let result = scan.resolve()?;
57//!
58//! println!("Buildable: {}", result.buildable.len());
59//! println!("Skipped: {}", result.skipped.len());
60//! # Ok::<(), anyhow::Error>(())
61//! ```
62
63use crate::tui::MultiProgress;
64use crate::{Config, RunContext, Sandbox};
65use anyhow::{Context, Result, bail};
66use indexmap::IndexMap;
67use petgraph::graphmap::DiGraphMap;
68use pkgsrc::{Depend, PkgName, PkgPath, ScanIndex};
69use rayon::prelude::*;
70use std::collections::{HashMap, HashSet};
71use std::io::BufReader;
72use std::sync::atomic::{AtomicBool, Ordering};
73use std::sync::{Arc, Mutex};
74use std::time::{Duration, Instant};
75use tracing::{debug, error, info, trace};
76
77/// Reason why a package was excluded from the build.
78///
79/// Packages with skip or fail reasons set in pkgsrc are not built.
80#[derive(Clone, Debug)]
81pub enum SkipReason {
82    /// Package has `PKG_SKIP_REASON` set.
83    ///
84    /// This typically indicates the package cannot be built on the current
85    /// platform (e.g., architecture-specific code, missing dependencies).
86    PkgSkipReason(String),
87    /// Package has `PKG_FAIL_REASON` set.
88    ///
89    /// This indicates the package is known to fail on the current platform
90    /// and should not be attempted.
91    PkgFailReason(String),
92}
93
94/// Information about a package that was skipped during scanning.
95#[derive(Clone, Debug)]
96pub struct SkippedPackage {
97    /// Package name with version.
98    pub pkgname: PkgName,
99    /// Package path in pkgsrc.
100    pub pkgpath: Option<PkgPath>,
101    /// Reason the package was skipped.
102    pub reason: SkipReason,
103    /// Full resolved index (for presolve output).
104    pub index: Option<ResolvedIndex>,
105}
106
107/// Information about a package that failed to scan.
108#[derive(Clone, Debug)]
109pub struct ScanFailure {
110    /// Package path in pkgsrc (e.g., `games/plib`).
111    pub pkgpath: PkgPath,
112    /// Error message from the scan failure.
113    pub error: String,
114}
115
116/// A resolved package index entry with dependency information.
117///
118/// This extends [`ScanIndex`] with resolved dependencies (`depends`).
119#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
120pub struct ResolvedIndex {
121    /// The underlying scan index data.
122    pub index: ScanIndex,
123    /// Resolved dependencies as package names.
124    pub depends: Vec<PkgName>,
125}
126
127impl ResolvedIndex {
128    /// Create from a ScanIndex with empty depends.
129    pub fn from_scan_index(index: ScanIndex) -> Self {
130        Self { index, depends: Vec::new() }
131    }
132}
133
134impl std::ops::Deref for ResolvedIndex {
135    type Target = ScanIndex;
136    fn deref(&self) -> &Self::Target {
137        &self.index
138    }
139}
140
141impl std::ops::DerefMut for ResolvedIndex {
142    fn deref_mut(&mut self) -> &mut Self::Target {
143        &mut self.index
144    }
145}
146
147impl std::fmt::Display for ResolvedIndex {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "{}", self.index)?;
150        // Only output DEPENDS= if there are dependencies
151        if !self.depends.is_empty() {
152            write!(f, "DEPENDS=")?;
153            for (i, d) in self.depends.iter().enumerate() {
154                if i > 0 {
155                    write!(f, " ")?;
156                }
157                write!(f, "{d}")?;
158            }
159            writeln!(f)?;
160        }
161        Ok(())
162    }
163}
164
165/// Result of scanning and resolving packages.
166///
167/// Returned by [`Scan::resolve`], contains the packages that can be built
168/// and those that were skipped.
169#[derive(Clone, Debug, Default)]
170pub struct ScanResult {
171    /// Packages that can be built, indexed by package name.
172    ///
173    /// These packages have all dependencies resolved and no skip/fail reasons.
174    /// Uses IndexMap to preserve insertion order from the original scan.
175    pub buildable: IndexMap<PkgName, ResolvedIndex>,
176    /// Packages that were skipped due to skip/fail reasons.
177    pub skipped: Vec<SkippedPackage>,
178    /// Packages that failed to scan (bmake pbulk-index failed).
179    pub scan_failed: Vec<ScanFailure>,
180    /// All packages in original order with their skip reason (if any).
181    /// Used for presolve output that needs to preserve original ordering.
182    pub all_ordered: Vec<(ResolvedIndex, Option<SkipReason>)>,
183}
184
185/// Package dependency scanner.
186///
187/// Discovers all dependencies for a set of packages and resolves them into
188/// a buildable set with proper ordering.
189///
190/// # Usage
191///
192/// 1. Create a `Scan` with [`Scan::new`]
193/// 2. Add packages to scan with [`Scan::add`]
194/// 3. Run the scan with [`Scan::start`]
195/// 4. Resolve dependencies with [`Scan::resolve`]
196///
197/// # Example
198///
199/// ```no_run
200/// # use bob::{Config, RunContext, Scan};
201/// # use pkgsrc::PkgPath;
202/// # use std::sync::Arc;
203/// # use std::sync::atomic::AtomicBool;
204/// # fn example() -> anyhow::Result<()> {
205/// let config = Config::load(None, false)?;
206/// let mut scan = Scan::new(&config);
207///
208/// scan.add(&PkgPath::new("mail/mutt")?);
209/// let ctx = RunContext::new(Arc::new(AtomicBool::new(false)));
210/// scan.start(&ctx)?;
211///
212/// let result = scan.resolve()?;
213/// println!("Found {} buildable packages", result.buildable.len());
214/// # Ok(())
215/// # }
216/// ```
217#[derive(Debug, Default)]
218pub struct Scan {
219    config: Config,
220    sandbox: Sandbox,
221    incoming: HashSet<PkgPath>,
222    done: IndexMap<PkgPath, Vec<ScanIndex>>,
223    /// Full cache from database for on-demand loading of dependencies.
224    cache: IndexMap<PkgPath, Vec<ScanIndex>>,
225    resolved: IndexMap<PkgName, ResolvedIndex>,
226    /// Full tree scan - discover all packages, skip recursive dependency discovery.
227    /// Defaults to true; set to false when packages are explicitly added.
228    full_tree: bool,
229    /// Packages that failed to scan (pkgpath, error message).
230    scan_failures: Vec<(PkgPath, String)>,
231}
232
233impl Scan {
234    pub fn new(config: &Config) -> Scan {
235        let sandbox = Sandbox::new(config);
236        debug!(pkgsrc = %config.pkgsrc().display(),
237            make = %config.make().display(),
238            scan_threads = config.scan_threads(),
239            "Created new Scan instance"
240        );
241        Scan {
242            config: config.clone(),
243            sandbox,
244            full_tree: true,
245            ..Default::default()
246        }
247    }
248
249    pub fn add(&mut self, pkgpath: &PkgPath) {
250        info!(pkgpath = %pkgpath.as_path().display(), "Adding package to scan queue");
251        self.full_tree = false;
252        self.incoming.insert(pkgpath.clone());
253    }
254
255    /// Load previously cached scan results.
256    ///
257    /// For limited scans, only loads cached packages reachable from the
258    /// configured pkgpaths initially, but keeps the full cache available
259    /// for on-demand loading when new dependencies are discovered.
260    ///
261    /// Returns the number of cached packages initially loaded.
262    pub fn load_cached(
263        &mut self,
264        cached: IndexMap<PkgPath, Vec<ScanIndex>>,
265    ) -> usize {
266        info!(cached_count = cached.len(), "Loading cached scan results");
267
268        // Keep full cache for on-demand loading during scan
269        self.cache = cached.clone();
270
271        if self.full_tree {
272            // For full tree scans, load everything
273            self.done = cached;
274        } else {
275            // For limited scans, only load cached data reachable from incoming
276            let mut relevant: HashSet<PkgPath> = self.incoming.clone();
277            let mut to_process: Vec<PkgPath> =
278                self.incoming.iter().cloned().collect();
279
280            // Walk dependency tree to find all relevant pkgpaths
281            while let Some(pkgpath) = to_process.pop() {
282                if let Some(indexes) = cached.get(&pkgpath) {
283                    for pkg in indexes {
284                        if let Some(ref all_deps) = pkg.all_depends {
285                            for dep in all_deps {
286                                if relevant.insert(dep.pkgpath().clone()) {
287                                    to_process.push(dep.pkgpath().clone());
288                                }
289                            }
290                        }
291                    }
292                }
293            }
294
295            // Only load relevant cached data
296            for (pkgpath, indexes) in cached {
297                if relevant.contains(&pkgpath) {
298                    self.done.insert(pkgpath, indexes);
299                }
300            }
301        }
302
303        // Rediscover dependencies that aren't cached
304        for indexes in self.done.values() {
305            for pkg in indexes {
306                if let Some(ref all_deps) = pkg.all_depends {
307                    for dep in all_deps {
308                        if !self.done.contains_key(dep.pkgpath()) {
309                            self.incoming.insert(dep.pkgpath().clone());
310                        }
311                    }
312                }
313            }
314        }
315
316        self.incoming.retain(|p| !self.done.contains_key(p));
317        self.done.len()
318    }
319
320    /// Access completed scan results.
321    pub fn completed(&self) -> &IndexMap<PkgPath, Vec<ScanIndex>> {
322        &self.done
323    }
324
325    /// Recursively load a pkgpath and all its cached dependencies.
326    /// Returns the count of packages loaded.
327    fn load_cached_recursive(&mut self, pkgpath: PkgPath) -> usize {
328        let mut count = 0;
329        let mut to_load = vec![pkgpath];
330
331        while let Some(path) = to_load.pop() {
332            if self.done.contains_key(&path) {
333                continue;
334            }
335            if let Some(indexes) = self.cache.get(&path).cloned() {
336                // Discover dependencies before inserting
337                for pkg in &indexes {
338                    if let Some(ref all_deps) = pkg.all_depends {
339                        for dep in all_deps {
340                            if !self.done.contains_key(dep.pkgpath()) {
341                                to_load.push(dep.pkgpath().clone());
342                            }
343                        }
344                    }
345                }
346                self.done.insert(path, indexes);
347                count += 1;
348            }
349        }
350
351        count
352    }
353
354    /// Discover all packages in pkgsrc tree.
355    fn discover_packages(&mut self) -> anyhow::Result<()> {
356        println!("Discovering packages...");
357        let pkgsrc = self.config.pkgsrc().display();
358        let make = self.config.make().display();
359
360        // Get top-level SUBDIR (categories + USER_ADDITIONAL_PKGS)
361        let script = format!(
362            "cd {} && {} show-subdir-var VARNAME=SUBDIR\n",
363            pkgsrc, make
364        );
365        let child = self.sandbox.execute_script(0, &script, vec![])?;
366        let output = child
367            .wait_with_output()
368            .context("Failed to run show-subdir-var")?;
369
370        if !output.status.success() {
371            let stderr = String::from_utf8_lossy(&output.stderr);
372            bail!("Failed to get categories: {}", stderr);
373        }
374
375        let stdout = String::from_utf8_lossy(&output.stdout);
376        let entries: Vec<&str> = stdout.split_whitespace().collect();
377
378        for entry in entries {
379            if entry.contains('/') {
380                // USER_ADDITIONAL_PKGS - add directly as pkgpath
381                if let Ok(pkgpath) = PkgPath::new(entry) {
382                    self.incoming.insert(pkgpath);
383                }
384            } else {
385                // Category - get packages within it
386                let script = format!(
387                    "cd {}/{} && {} show-subdir-var VARNAME=SUBDIR\n",
388                    pkgsrc, entry, make
389                );
390                let child = self.sandbox.execute_script(0, &script, vec![])?;
391                let cat_output = child.wait_with_output();
392
393                match cat_output {
394                    Ok(o) if o.status.success() => {
395                        let pkgs = String::from_utf8_lossy(&o.stdout);
396                        for pkg in pkgs.split_whitespace() {
397                            let path = format!("{}/{}", entry, pkg);
398                            if let Ok(pkgpath) = PkgPath::new(&path) {
399                                self.incoming.insert(pkgpath);
400                            }
401                        }
402                    }
403                    Ok(o) => {
404                        let stderr = String::from_utf8_lossy(&o.stderr);
405                        debug!(category = entry, stderr = %stderr,
406                            "Failed to get packages for category");
407                    }
408                    Err(e) => {
409                        debug!(category = entry, error = %e,
410                            "Failed to run make in category");
411                    }
412                }
413            }
414        }
415
416        info!(discovered = self.incoming.len(), "Package discovery complete");
417        println!("Discovered {} package paths", self.incoming.len());
418
419        Ok(())
420    }
421
422    pub fn start(&mut self, ctx: &RunContext) -> anyhow::Result<bool> {
423        info!(
424            incoming_count = self.incoming.len(),
425            sandbox_enabled = self.sandbox.enabled(),
426            "Starting package scan"
427        );
428
429        let pool = rayon::ThreadPoolBuilder::new()
430            .num_threads(self.config.scan_threads())
431            .build()
432            .context("Failed to build scan thread pool")?;
433
434        let shutdown_flag = Arc::clone(&ctx.shutdown);
435        let stats = ctx.stats.clone();
436
437        /*
438         * Only a single sandbox is required, 'make pbulk-index' can safely be
439         * run in parallel inside one sandbox.
440         */
441        let script_envs = self.config.script_env();
442
443        if self.sandbox.enabled() {
444            println!("Creating sandbox...");
445            if let Err(e) = self.sandbox.create(0) {
446                if let Err(destroy_err) = self.sandbox.destroy(0) {
447                    eprintln!(
448                        "Warning: failed to destroy sandbox: {}",
449                        destroy_err
450                    );
451                }
452                return Err(e);
453            }
454
455            // Run pre-build script if defined
456            if let Some(pre_build) = self.config.script("pre-build") {
457                debug!("Running pre-build script");
458                let child = self.sandbox.execute(
459                    0,
460                    pre_build,
461                    script_envs.clone(),
462                    None,
463                    None,
464                )?;
465                let output = child
466                    .wait_with_output()
467                    .context("Failed to wait for pre-build")?;
468                if !output.status.success() {
469                    let stderr = String::from_utf8_lossy(&output.stderr);
470                    error!(exit_code = ?output.status.code(), stderr = %stderr, "pre-build script failed");
471                }
472            }
473        }
474
475        // For full tree scans, discover all packages
476        if self.full_tree && self.incoming.is_empty() {
477            self.discover_packages()?;
478            self.incoming.retain(|p| !self.done.contains_key(p));
479        }
480
481        // Nothing to scan - all packages are cached
482        if self.incoming.is_empty() {
483            if !self.done.is_empty() {
484                println!(
485                    "All {} package paths already scanned",
486                    self.done.len()
487                );
488            }
489            return Ok(false);
490        }
491
492        println!("Scanning packages...");
493
494        // Set up multi-line progress display using ratatui inline viewport
495        // Include cached packages in total so progress shows full picture
496        let cached_count = self.done.len();
497        let total_count = cached_count + self.incoming.len();
498        let progress = Arc::new(Mutex::new(
499            MultiProgress::new(
500                "Scanning",
501                "Scanned",
502                total_count,
503                self.config.scan_threads(),
504            )
505            .expect("Failed to initialize progress display"),
506        ));
507
508        // Mark cached packages
509        if cached_count > 0 {
510            if let Ok(mut p) = progress.lock() {
511                p.state_mut().cached = cached_count;
512            }
513        }
514
515        // Flag to stop the refresh thread
516        let stop_refresh = Arc::new(AtomicBool::new(false));
517
518        // Spawn a thread to periodically refresh the display (for timer updates)
519        let progress_refresh = Arc::clone(&progress);
520        let stop_flag = Arc::clone(&stop_refresh);
521        let shutdown_for_refresh = Arc::clone(&shutdown_flag);
522        let refresh_thread = std::thread::spawn(move || {
523            while !stop_flag.load(Ordering::Relaxed)
524                && !shutdown_for_refresh.load(Ordering::SeqCst)
525            {
526                if let Ok(mut p) = progress_refresh.lock() {
527                    // Check for keyboard events (Ctrl+C raises SIGINT)
528                    let _ = p.poll_events();
529                    let _ = p.render();
530                }
531                std::thread::sleep(Duration::from_millis(50));
532            }
533        });
534
535        /*
536         * Continuously iterate over incoming queue, moving to done once
537         * processed, and adding any dependencies to incoming to be processed
538         * next.
539         */
540        let mut interrupted = false;
541        loop {
542            // Check for shutdown signal
543            if shutdown_flag.load(Ordering::Relaxed) {
544                // Immediately show interrupted message
545                stop_refresh.store(true, Ordering::Relaxed);
546                if let Ok(mut p) = progress.lock() {
547                    let _ = p.finish_interrupted();
548                }
549                interrupted = true;
550                break;
551            }
552
553            /*
554             * Convert the incoming HashSet into a Vec for parallel processing.
555             */
556            let mut parpaths: Vec<(PkgPath, Result<Vec<ScanIndex>>)> =
557                self.incoming.iter().map(|p| (p.clone(), Ok(vec![]))).collect();
558
559            let progress_clone = Arc::clone(&progress);
560            let shutdown_clone = Arc::clone(&shutdown_flag);
561            let stats_clone = stats.clone();
562            pool.install(|| {
563                parpaths.par_iter_mut().for_each(|pkg| {
564                    // Check for shutdown before starting each package
565                    if shutdown_clone.load(Ordering::Relaxed) {
566                        return;
567                    }
568
569                    let (pkgpath, result) = pkg;
570                    let pathname =
571                        pkgpath.as_path().to_string_lossy().to_string();
572
573                    // Get rayon thread index for progress tracking
574                    let thread_id = rayon::current_thread_index().unwrap_or(0);
575
576                    // Update progress - show current package for this thread
577                    if let Ok(mut p) = progress_clone.lock() {
578                        p.state_mut().set_worker_active(thread_id, &pathname);
579                    }
580
581                    let scan_start = Instant::now();
582                    *result = self.scan_pkgpath(pkgpath);
583                    let scan_duration = scan_start.elapsed();
584
585                    // Record stats if enabled
586                    if let Some(ref s) = stats_clone {
587                        s.scan(&pathname, scan_duration, result.is_ok());
588                    }
589
590                    // Update counter immediately after each package
591                    if let Ok(mut p) = progress_clone.lock() {
592                        p.state_mut().set_worker_idle(thread_id);
593                        if result.is_ok() {
594                            p.state_mut().increment_completed();
595                        } else {
596                            p.state_mut().increment_failed();
597                        }
598                    }
599                });
600            });
601
602            // Check if we were interrupted during parallel processing
603            if shutdown_flag.load(Ordering::Relaxed) {
604                // Immediately show interrupted message
605                stop_refresh.store(true, Ordering::Relaxed);
606                if let Ok(mut p) = progress.lock() {
607                    let _ = p.finish_interrupted();
608                }
609                interrupted = true;
610                break;
611            }
612
613            /*
614             * Look through the results we just processed for any new PKGPATH
615             * entries in DEPENDS that we have not seen before (neither in
616             * done nor incoming).
617             */
618            let mut new_incoming: HashSet<PkgPath> = HashSet::new();
619            let mut loaded_from_cache = 0usize;
620            for (pkgpath, scanpkgs) in parpaths.drain(..) {
621                let scanpkgs = match scanpkgs {
622                    Ok(pkgs) => pkgs,
623                    Err(e) => {
624                        self.scan_failures
625                            .push((pkgpath.clone(), e.to_string()));
626                        self.done.insert(pkgpath.clone(), vec![]);
627                        continue;
628                    }
629                };
630                self.done.insert(pkgpath.clone(), scanpkgs.clone());
631                // Discover dependencies not yet seen
632                for pkg in scanpkgs {
633                    if let Some(ref all_deps) = pkg.all_depends {
634                        for dep in all_deps {
635                            let dep_path = dep.pkgpath();
636                            if self.done.contains_key(dep_path)
637                                || self.incoming.contains(dep_path)
638                                || new_incoming.contains(dep_path)
639                            {
640                                continue;
641                            }
642                            // Check cache first - load on-demand if available
643                            if self.cache.contains_key(dep_path) {
644                                loaded_from_cache += self
645                                    .load_cached_recursive(dep_path.clone());
646                            } else {
647                                new_incoming.insert(dep_path.clone());
648                                if let Ok(mut p) = progress.lock() {
649                                    p.state_mut().total += 1;
650                                }
651                            }
652                        }
653                    }
654                }
655            }
656            if loaded_from_cache > 0 {
657                if let Ok(mut p) = progress.lock() {
658                    p.state_mut().total += loaded_from_cache;
659                    p.state_mut().cached += loaded_from_cache;
660                }
661            }
662
663            /*
664             * We're finished with the current incoming, replace it with the
665             * new incoming list.  If it is empty then we've already processed
666             * all known PKGPATHs and are done.
667             */
668            self.incoming = new_incoming;
669            if self.incoming.is_empty() {
670                break;
671            }
672        }
673
674        // Stop the refresh thread and print final summary
675        stop_refresh.store(true, Ordering::Relaxed);
676        let _ = refresh_thread.join();
677
678        // Only call finish() for normal completion; finish_interrupted()
679        // was already called immediately when interrupt was detected
680        if !interrupted {
681            if let Ok(mut p) = progress.lock() {
682                let _ = p.finish();
683            }
684        }
685
686        if self.sandbox.enabled() {
687            // Run post-build script if defined
688            if let Some(post_build) = self.config.script("post-build") {
689                debug!("Running post-build script");
690                let child = self.sandbox.execute(
691                    0,
692                    post_build,
693                    script_envs,
694                    None,
695                    None,
696                )?;
697                let output = child
698                    .wait_with_output()
699                    .context("Failed to wait for post-build")?;
700                if !output.status.success() {
701                    let stderr = String::from_utf8_lossy(&output.stderr);
702                    error!(exit_code = ?output.status.code(), stderr = %stderr, "post-build script failed");
703                }
704            }
705
706            self.sandbox.destroy(0)?;
707        }
708
709        if interrupted {
710            return Ok(true);
711        }
712
713        Ok(false)
714    }
715
716    /// Returns scan failures as formatted error strings.
717    pub fn scan_errors(&self) -> impl Iterator<Item = &str> {
718        self.scan_failures.iter().map(|(_, e)| e.as_str())
719    }
720
721    /// Returns scan failures with pkgpath information.
722    pub fn scan_failures(&self) -> &[(PkgPath, String)] {
723        &self.scan_failures
724    }
725
726    /**
727     * Scan a single PKGPATH, returning a [`Vec`] of [`ScanIndex`] results,
728     * as multi-version packages may return multiple results.
729     */
730    pub fn scan_pkgpath(
731        &self,
732        pkgpath: &PkgPath,
733    ) -> anyhow::Result<Vec<ScanIndex>> {
734        let pkgpath_str = pkgpath.as_path().display().to_string();
735        debug!(pkgpath = %pkgpath_str, "Scanning package");
736
737        let bmake = self.config.make().display().to_string();
738        let pkgsrcdir = self.config.pkgsrc().display().to_string();
739        let script = format!(
740            "cd {}/{} && {} pbulk-index\n",
741            pkgsrcdir, pkgpath_str, bmake
742        );
743
744        let scan_env = self.config.scan_env();
745        trace!(pkgpath = %pkgpath_str,
746            script = %script,
747            scan_env = ?scan_env,
748            "Executing pkg-scan"
749        );
750        let child = self.sandbox.execute_script(0, &script, scan_env)?;
751        let output = child.wait_with_output()?;
752
753        if !output.status.success() {
754            let stderr = String::from_utf8_lossy(&output.stderr);
755            error!(pkgpath = %pkgpath_str,
756                exit_code = ?output.status.code(),
757                stderr = %stderr,
758                "pkg-scan script failed"
759            );
760            let stderr = stderr.trim();
761            let msg = if stderr.is_empty() {
762                format!("Scan failed for {}", pkgpath_str)
763            } else {
764                format!("Scan failed for {}: {}", pkgpath_str, stderr)
765            };
766            bail!(msg);
767        }
768
769        let stdout_str = String::from_utf8_lossy(&output.stdout);
770        trace!(pkgpath = %pkgpath_str,
771            stdout_len = stdout_str.len(),
772            stdout = %stdout_str,
773            "pkg-scan script output"
774        );
775
776        let reader = BufReader::new(&output.stdout[..]);
777        let mut index: Vec<ScanIndex> =
778            ScanIndex::from_reader(reader).collect::<Result<_, _>>()?;
779
780        info!(pkgpath = %pkgpath_str,
781            packages_found = index.len(),
782            "Scan complete for pkgpath"
783        );
784
785        /*
786         * Set PKGPATH (PKG_LOCATION) as for some reason pbulk-index doesn't.
787         */
788        for pkg in &mut index {
789            pkg.pkg_location = Some(pkgpath.clone());
790            debug!(pkgpath = %pkgpath_str,
791                pkgname = %pkg.pkgname.pkgname(),
792                skip_reason = ?pkg.pkg_skip_reason,
793                fail_reason = ?pkg.pkg_fail_reason,
794                depends_count = pkg.all_depends.as_ref().map_or(0, |v| v.len()),
795                "Found package in scan"
796            );
797        }
798
799        Ok(index)
800    }
801
802    /// Get all scanned packages (before resolution).
803    pub fn scanned(&self) -> impl Iterator<Item = &ScanIndex> {
804        self.done.values().flatten()
805    }
806
807    /**
808     * Resolve the list of scanned packages, by ensuring all of the [`Depend`]
809     * patterns in `all_depends` match a found package, and that there are no
810     * circular dependencies.  The best match for each is stored in the
811     * `depends` for the package in question.
812     *
813     * Return a [`ScanResult`] containing buildable packages and skipped packages.
814     *
815     * Note: This method consumes the internal scan state. Calling it multiple
816     * times will return empty results on subsequent calls.
817     */
818    pub fn resolve(&mut self) -> Result<ScanResult> {
819        info!(
820            done_pkgpaths = self.done.len(),
821            "Starting dependency resolution"
822        );
823
824        /*
825         * Populate the resolved hash with ALL packages first, including those
826         * with skip/fail reasons. This allows us to resolve dependencies for
827         * all packages before separating them.
828         *
829         * self.done must no longer be used after this point, as its ScanIndex
830         * entries are out of date (do not have depends set, for example).
831         */
832        let mut pkgnames: indexmap::IndexSet<PkgName> =
833            indexmap::IndexSet::new();
834
835        // Track which packages have skip/fail reasons
836        let mut skip_reasons: HashMap<PkgName, SkipReason> = HashMap::new();
837
838        // Log what we have in self.done
839        for (pkgpath, index) in &self.done {
840            debug!(pkgpath = %pkgpath.as_path().display(),
841                packages_in_index = index.len(),
842                "Processing done entry"
843            );
844        }
845
846        for index in self.done.values() {
847            for pkg in index {
848                // Skip duplicate PKGNAMEs - keep only the first (preferred)
849                // variant for multi-version packages.
850                if pkgnames.contains(&pkg.pkgname) {
851                    debug!(pkgname = %pkg.pkgname.pkgname(),
852                        multi_version = ?pkg.multi_version,
853                        "Skipping duplicate PKGNAME"
854                    );
855                    continue;
856                }
857
858                // Track skip/fail reasons but still add to resolved
859                if let Some(reason) = &pkg.pkg_skip_reason {
860                    if !reason.is_empty() {
861                        info!(pkgname = %pkg.pkgname.pkgname(),
862                            reason = %reason,
863                            "Package has PKG_SKIP_REASON"
864                        );
865                        skip_reasons.insert(
866                            pkg.pkgname.clone(),
867                            SkipReason::PkgSkipReason(reason.clone()),
868                        );
869                    }
870                }
871                if let Some(reason) = &pkg.pkg_fail_reason {
872                    if !reason.is_empty()
873                        && !skip_reasons.contains_key(&pkg.pkgname)
874                    {
875                        info!(pkgname = %pkg.pkgname.pkgname(),
876                            reason = %reason,
877                            "Package has PKG_FAIL_REASON"
878                        );
879                        skip_reasons.insert(
880                            pkg.pkgname.clone(),
881                            SkipReason::PkgFailReason(reason.clone()),
882                        );
883                    }
884                }
885
886                debug!(pkgname = %pkg.pkgname.pkgname(),
887                    "Adding package to resolved set"
888                );
889                pkgnames.insert(pkg.pkgname.clone());
890                self.resolved.insert(
891                    pkg.pkgname.clone(),
892                    ResolvedIndex::from_scan_index(pkg.clone()),
893                );
894            }
895        }
896
897        info!(
898            resolved_count = self.resolved.len(),
899            skip_reasons_count = skip_reasons.len(),
900            "Initial resolution complete"
901        );
902
903        /*
904         * Keep a cache of best Depend => PkgName matches we've already seen
905         * as it's likely the same patterns will be used in multiple places.
906         */
907        let mut match_cache: HashMap<Depend, PkgName> = HashMap::new();
908
909        /*
910         * Track packages to skip due to skipped dependencies, and truly
911         * unresolved dependencies (errors).
912         */
913        let mut skip_due_to_dep: HashMap<PkgName, String> = HashMap::new();
914        let mut errors: Vec<(PkgName, String)> = Vec::new();
915
916        // Helper to check if a dependency pattern is already satisfied
917        let is_satisfied = |depends: &[PkgName], pattern: &pkgsrc::Pattern| {
918            depends.iter().any(|existing| pattern.matches(existing.pkgname()))
919        };
920
921        for pkg in self.resolved.values_mut() {
922            let all_deps = match pkg.all_depends.clone() {
923                Some(deps) => deps,
924                None => continue,
925            };
926            for depend in &all_deps {
927                // Check for cached DEPENDS match first. If found, use it
928                // (but only add if the pattern isn't already satisfied).
929                if let Some(pkgname) = match_cache.get(depend) {
930                    if !is_satisfied(&pkg.depends, depend.pattern())
931                        && !pkg.depends.contains(pkgname)
932                    {
933                        pkg.depends.push(pkgname.clone());
934                    }
935                    continue;
936                }
937                /*
938                 * Find best DEPENDS match out of all known PKGNAME.
939                 * Collect all candidates that match the pattern.
940                 */
941                let mut candidates: Vec<&PkgName> = Vec::new();
942                for candidate in &pkgnames {
943                    if depend.pattern().matches(candidate.pkgname()) {
944                        candidates.push(candidate);
945                    }
946                }
947
948                // Find best match among all candidates using pbulk algorithm:
949                // higher version wins, larger name on tie.
950                let mut best: Option<&PkgName> = None;
951                let mut match_error: Option<pkgsrc::PatternError> = None;
952                for candidate in candidates {
953                    best = match best {
954                        None => Some(candidate),
955                        Some(current) => {
956                            match depend.pattern().best_match_pbulk(
957                                current.pkgname(),
958                                candidate.pkgname(),
959                            ) {
960                                Ok(Some(m)) if m == candidate.pkgname() => {
961                                    Some(candidate)
962                                }
963                                Ok(_) => Some(current),
964                                Err(e) => {
965                                    match_error = Some(e);
966                                    break;
967                                }
968                            }
969                        }
970                    };
971                }
972                if let Some(e) = match_error {
973                    errors.push((
974                        pkg.index.pkgname.clone(),
975                        format!(
976                            "Pattern error for {} in {}: {}",
977                            depend.pattern().pattern(),
978                            pkg.index.pkgname.pkgname(),
979                            e
980                        ),
981                    ));
982                    continue;
983                }
984                // If found, save to cache and add to depends (if not already satisfied)
985                if let Some(pkgname) = best {
986                    if !is_satisfied(&pkg.depends, depend.pattern())
987                        && !pkg.depends.contains(pkgname)
988                    {
989                        pkg.depends.push(pkgname.clone());
990                    }
991                    match_cache.insert(depend.clone(), pkgname.clone());
992                } else {
993                    // No matching package exists
994                    errors.push((
995                        pkg.index.pkgname.clone(),
996                        format!(
997                            "No match found for {} in {}",
998                            depend.pattern().pattern(),
999                            pkg.index.pkgname.pkgname()
1000                        ),
1001                    ));
1002                }
1003            }
1004        }
1005
1006        /*
1007         * Iteratively propagate skips: if A depends on B, and B is now
1008         * marked to skip, then A should also be skipped.
1009         */
1010        loop {
1011            let mut new_skips: HashMap<PkgName, String> = HashMap::new();
1012
1013            for pkg in self.resolved.values() {
1014                if skip_due_to_dep.contains_key(&pkg.pkgname)
1015                    || skip_reasons.contains_key(&pkg.pkgname)
1016                {
1017                    continue;
1018                }
1019                for dep in &pkg.depends {
1020                    if skip_due_to_dep.contains_key(dep)
1021                        || skip_reasons.contains_key(dep)
1022                    {
1023                        // Our dependency is being skipped
1024                        new_skips.insert(
1025                            pkg.pkgname.clone(),
1026                            format!("Dependency {} skipped", dep.pkgname()),
1027                        );
1028                        break;
1029                    }
1030                }
1031            }
1032
1033            if new_skips.is_empty() {
1034                break;
1035            }
1036            skip_due_to_dep.extend(new_skips);
1037        }
1038
1039        // Merge skip_due_to_dep into skip_reasons
1040        for (pkgname, reason) in skip_due_to_dep.iter() {
1041            if !skip_reasons.contains_key(pkgname) {
1042                skip_reasons.insert(
1043                    pkgname.clone(),
1044                    SkipReason::PkgSkipReason(reason.clone()),
1045                );
1046            }
1047        }
1048
1049        // Filter out errors for packages that are being skipped anyway
1050        let errors: Vec<String> = errors
1051            .into_iter()
1052            .filter(|(pkgname, _)| !skip_reasons.contains_key(pkgname))
1053            .map(|(_, message)| message)
1054            .collect();
1055
1056        // Build all_ordered first to preserve original order, then separate
1057        let mut all_ordered: Vec<(ResolvedIndex, Option<SkipReason>)> =
1058            Vec::new();
1059        let mut buildable: IndexMap<PkgName, ResolvedIndex> = IndexMap::new();
1060        let mut skipped: Vec<SkippedPackage> = Vec::new();
1061
1062        for (pkgname, index) in std::mem::take(&mut self.resolved) {
1063            let reason = skip_reasons.remove(&pkgname);
1064            all_ordered.push((index.clone(), reason.clone()));
1065            if let Some(r) = reason {
1066                skipped.push(SkippedPackage {
1067                    pkgname: index.pkgname.clone(),
1068                    pkgpath: index.pkg_location.clone(),
1069                    reason: r,
1070                    index: Some(index),
1071                });
1072            } else {
1073                buildable.insert(pkgname, index);
1074            }
1075        }
1076
1077        /*
1078         * Verify that the graph is acyclic (only for buildable packages).
1079         */
1080        debug!(
1081            buildable_count = buildable.len(),
1082            "Checking for circular dependencies"
1083        );
1084        let mut graph = DiGraphMap::new();
1085        for (pkgname, index) in &buildable {
1086            for dep in &index.depends {
1087                graph.add_edge(dep.pkgname(), pkgname.pkgname(), ());
1088            }
1089        }
1090        let cycle_error = find_cycle(&graph).map(|cycle| {
1091            let mut err = "Circular dependencies detected:\n".to_string();
1092            for n in cycle.iter().rev() {
1093                err.push_str(&format!("\t{}\n", n));
1094            }
1095            err.push_str(&format!("\t{}", cycle.last().unwrap()));
1096            error!(cycle = ?cycle, "Circular dependency detected");
1097            err
1098        });
1099
1100        info!(
1101            buildable_count = buildable.len(),
1102            skipped_count = skipped.len(),
1103            "Resolution complete"
1104        );
1105
1106        // Log all buildable packages
1107        for pkgname in buildable.keys() {
1108            debug!(pkgname = %pkgname.pkgname(), "Package is buildable");
1109        }
1110
1111        // Convert scan failures to ScanFailure structs
1112        let scan_failed: Vec<ScanFailure> = self
1113            .scan_failures
1114            .iter()
1115            .map(|(pkgpath, error)| ScanFailure {
1116                pkgpath: pkgpath.clone(),
1117                error: error.clone(),
1118            })
1119            .collect();
1120
1121        let result =
1122            ScanResult { buildable, skipped, scan_failed, all_ordered };
1123
1124        // Now check for errors
1125        if !errors.is_empty() {
1126            for err in &errors {
1127                error!(error = %err, "Unresolved dependency");
1128            }
1129            bail!("Unresolved dependencies:\n  {}", errors.join("\n  "));
1130        }
1131
1132        if let Some(err) = cycle_error {
1133            bail!(err);
1134        }
1135
1136        Ok(result)
1137    }
1138}
1139
1140pub fn find_cycle<'a>(
1141    graph: &'a DiGraphMap<&'a str, ()>,
1142) -> Option<Vec<&'a str>> {
1143    let mut visited = HashSet::new();
1144    let mut in_stack = HashSet::new();
1145    let mut stack = Vec::new();
1146
1147    for node in graph.nodes() {
1148        if visited.contains(&node) {
1149            continue;
1150        }
1151        if let Some(cycle) =
1152            dfs(graph, node, &mut visited, &mut stack, &mut in_stack)
1153        {
1154            return Some(cycle);
1155        }
1156    }
1157    None
1158}
1159
1160fn dfs<'a>(
1161    graph: &'a DiGraphMap<&'a str, ()>,
1162    node: &'a str,
1163    visited: &mut HashSet<&'a str>,
1164    stack: &mut Vec<&'a str>,
1165    in_stack: &mut HashSet<&'a str>,
1166) -> Option<Vec<&'a str>> {
1167    visited.insert(node);
1168    stack.push(node);
1169    in_stack.insert(node);
1170    for neighbor in graph.neighbors(node) {
1171        if in_stack.contains(neighbor) {
1172            if let Some(pos) = stack.iter().position(|&n| n == neighbor) {
1173                return Some(stack[pos..].to_vec());
1174            }
1175        } else if !visited.contains(neighbor) {
1176            let cycle = dfs(graph, neighbor, visited, stack, in_stack);
1177            if cycle.is_some() {
1178                return cycle;
1179            }
1180        }
1181    }
1182    stack.pop();
1183    in_stack.remove(node);
1184    None
1185}