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