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, Scan};
44//! use pkgsrc::PkgPath;
45//!
46//! let config = Config::load(None, false)?;
47//! let mut scan = Scan::new(&config);
48//!
49//! scan.add(&PkgPath::new("mail/mutt")?);
50//! scan.add(&PkgPath::new("www/curl")?);
51//!
52//! scan.start()?;  // Discover dependencies
53//! let result = scan.resolve()?;
54//!
55//! println!("Buildable: {}", result.buildable.len());
56//! println!("Skipped: {}", result.skipped.len());
57//! # Ok::<(), anyhow::Error>(())
58//! ```
59
60use crate::tui::MultiProgress;
61use crate::{Config, Sandbox};
62use anyhow::{Context, Result, bail};
63use petgraph::graphmap::DiGraphMap;
64use pkgsrc::{Depend, PkgName, PkgPath, ScanIndex};
65use rayon::prelude::*;
66use std::collections::{HashMap, HashSet};
67use std::io::BufReader;
68use std::sync::atomic::{AtomicBool, Ordering};
69use std::sync::{Arc, Mutex};
70use std::time::Duration;
71use tracing::{debug, error, info, trace};
72
73/// Reason why a package was excluded from the build.
74///
75/// Packages with skip or fail reasons set in pkgsrc are not built.
76#[derive(Clone, Debug)]
77pub enum SkipReason {
78    /// Package has `PKG_SKIP_REASON` set.
79    ///
80    /// This typically indicates the package cannot be built on the current
81    /// platform (e.g., architecture-specific code, missing dependencies).
82    PkgSkipReason(String),
83    /// Package has `PKG_FAIL_REASON` set.
84    ///
85    /// This indicates the package is known to fail on the current platform
86    /// and should not be attempted.
87    PkgFailReason(String),
88}
89
90/// Information about a package that was skipped during scanning.
91#[derive(Clone, Debug)]
92pub struct SkippedPackage {
93    /// Package name with version.
94    pub pkgname: PkgName,
95    /// Package path in pkgsrc.
96    pub pkgpath: Option<PkgPath>,
97    /// Reason the package was skipped.
98    pub reason: SkipReason,
99}
100
101/// Result of scanning and resolving packages.
102///
103/// Returned by [`Scan::resolve`], contains the packages that can be built
104/// and those that were skipped.
105#[derive(Clone, Debug, Default)]
106pub struct ScanResult {
107    /// Packages that can be built, indexed by package name.
108    ///
109    /// These packages have all dependencies resolved and no skip/fail reasons.
110    pub buildable: HashMap<PkgName, ScanIndex>,
111    /// Packages that were skipped due to skip/fail reasons.
112    pub skipped: Vec<SkippedPackage>,
113}
114
115/// Package dependency scanner.
116///
117/// Discovers all dependencies for a set of packages and resolves them into
118/// a buildable set with proper ordering.
119///
120/// # Usage
121///
122/// 1. Create a `Scan` with [`Scan::new`]
123/// 2. Add packages to scan with [`Scan::add`]
124/// 3. Run the scan with [`Scan::start`]
125/// 4. Resolve dependencies with [`Scan::resolve`]
126///
127/// # Example
128///
129/// ```no_run
130/// # use bob::{Config, Scan};
131/// # use pkgsrc::PkgPath;
132/// # fn example() -> anyhow::Result<()> {
133/// let config = Config::load(None, false)?;
134/// let mut scan = Scan::new(&config);
135///
136/// scan.add(&PkgPath::new("mail/mutt")?);
137/// scan.start()?;
138///
139/// let result = scan.resolve()?;
140/// println!("Found {} buildable packages", result.buildable.len());
141/// # Ok(())
142/// # }
143/// ```
144#[derive(Debug, Default)]
145pub struct Scan {
146    config: Config,
147    sandbox: Sandbox,
148    incoming: HashSet<PkgPath>,
149    done: HashMap<PkgPath, Vec<ScanIndex>>,
150    resolved: HashMap<PkgName, ScanIndex>,
151}
152
153impl Scan {
154    pub fn new(config: &Config) -> Scan {
155        let sandbox = Sandbox::new(config);
156        debug!(pkgsrc = %config.pkgsrc().display(),
157            make = %config.make().display(),
158            scan_threads = config.scan_threads(),
159            "Created new Scan instance"
160        );
161        Scan { config: config.clone(), sandbox, ..Default::default() }
162    }
163
164    pub fn add(&mut self, pkgpath: &PkgPath) {
165        info!(pkgpath = %pkgpath.as_path().display(), "Adding package to scan queue");
166        self.incoming.insert(pkgpath.clone());
167    }
168
169    pub fn start(&mut self) -> anyhow::Result<()> {
170        info!(
171            incoming_count = self.incoming.len(),
172            sandbox_enabled = self.sandbox.enabled(),
173            "Starting package scan"
174        );
175
176        let pool = rayon::ThreadPoolBuilder::new()
177            .num_threads(self.config.scan_threads())
178            .build()
179            .context("Failed to build scan thread pool")?;
180
181        /*
182         * Only a single sandbox is required, 'make pbulk-index' can safely be
183         * run in parallel inside one sandbox.
184         */
185        let script_envs = self.config.script_env();
186
187        if self.sandbox.enabled() {
188            println!("Creating sandbox...");
189            if let Err(e) = self.sandbox.create(0) {
190                if let Err(destroy_err) = self.sandbox.destroy(0) {
191                    eprintln!(
192                        "Warning: failed to destroy sandbox: {}",
193                        destroy_err
194                    );
195                }
196                return Err(e);
197            }
198
199            // Run pre-build script if defined
200            if let Some(pre_build) = self.config.script("pre-build") {
201                debug!("Running pre-build script");
202                let child = self.sandbox.execute(
203                    0,
204                    pre_build,
205                    script_envs.clone(),
206                    None,
207                    None,
208                )?;
209                let output = child
210                    .wait_with_output()
211                    .context("Failed to wait for pre-build")?;
212                if !output.status.success() {
213                    let stderr = String::from_utf8_lossy(&output.stderr);
214                    error!(exit_code = ?output.status.code(), stderr = %stderr, "pre-build script failed");
215                }
216            }
217        }
218
219        println!("Scanning packages...");
220
221        // Set up multi-line progress display using ratatui inline viewport
222        let progress = Arc::new(Mutex::new(
223            MultiProgress::new(
224                "Scanning",
225                "Scanned",
226                self.incoming.len(),
227                self.config.scan_threads(),
228                false,
229            )
230            .expect("Failed to initialize progress display"),
231        ));
232
233        // Flag to stop the refresh thread
234        let stop_refresh = Arc::new(AtomicBool::new(false));
235
236        // Spawn a thread to periodically refresh the display (for timer updates)
237        let progress_refresh = Arc::clone(&progress);
238        let stop_flag = Arc::clone(&stop_refresh);
239        let refresh_thread = std::thread::spawn(move || {
240            while !stop_flag.load(Ordering::Relaxed) {
241                if let Ok(mut p) = progress_refresh.lock() {
242                    let _ = p.render();
243                }
244                std::thread::sleep(Duration::from_millis(100));
245            }
246        });
247
248        /*
249         * Continuously iterate over incoming queue, moving to done once
250         * processed, and adding any dependencies to incoming to be processed
251         * next.
252         */
253        let mut scan_errors: Vec<String> = Vec::new();
254        loop {
255            /*
256             * Convert the incoming HashSet into a Vec for parallel processing.
257             */
258            let mut parpaths: Vec<(PkgPath, Result<Vec<ScanIndex>>)> = vec![];
259            for pkgpath in &self.incoming {
260                parpaths.push((pkgpath.clone(), Ok(vec![])));
261            }
262
263            let progress_clone = Arc::clone(&progress);
264            pool.install(|| {
265                parpaths.par_iter_mut().for_each(|pkg| {
266                    let (pkgpath, result) = pkg;
267                    let pathname =
268                        pkgpath.as_path().to_string_lossy().to_string();
269
270                    // Get rayon thread index for progress tracking
271                    let thread_id = rayon::current_thread_index().unwrap_or(0);
272
273                    // Update progress - show current package for this thread
274                    if let Ok(mut p) = progress_clone.lock() {
275                        p.state_mut().set_worker_active(thread_id, &pathname);
276                    }
277
278                    *result = self.scan_pkgpath(pkgpath);
279
280                    // Mark thread idle (counting happens in result processing)
281                    if let Ok(mut p) = progress_clone.lock() {
282                        p.state_mut().set_worker_idle(thread_id);
283                    }
284                });
285            });
286
287            /*
288             * Look through the results we just processed for any new PKGPATH
289             * entries in DEPENDS that we have not seen before (neither in
290             * done nor incoming).
291             */
292            let mut new_incoming: HashSet<PkgPath> = HashSet::new();
293            for (pkgpath, scanpkgs) in parpaths.drain(..) {
294                let scanpkgs = match scanpkgs {
295                    Ok(pkgs) => {
296                        if let Ok(mut p) = progress.lock() {
297                            p.state_mut().increment_completed();
298                        }
299                        pkgs
300                    }
301                    Err(e) => {
302                        scan_errors.push(format!("{}", e));
303                        if let Ok(mut p) = progress.lock() {
304                            p.state_mut().increment_failed();
305                        }
306                        self.done.insert(pkgpath.clone(), vec![]);
307                        continue;
308                    }
309                };
310                self.done.insert(pkgpath.clone(), scanpkgs.clone());
311                for pkg in scanpkgs {
312                    for dep in pkg.all_depends {
313                        if !self.done.contains_key(dep.pkgpath())
314                            && !self.incoming.contains(dep.pkgpath())
315                            && new_incoming.insert(dep.pkgpath().clone())
316                        {
317                            // Update total count for new dependencies
318                            if let Ok(mut p) = progress.lock() {
319                                p.state_mut().total += 1;
320                            }
321                        }
322                    }
323                }
324            }
325
326            /*
327             * We're finished with the current incoming, replace it with the
328             * new incoming list.  If it is empty then we've already processed
329             * all known PKGPATHs and are done.
330             */
331            self.incoming = new_incoming;
332            if self.incoming.is_empty() {
333                break;
334            }
335        }
336
337        if self.sandbox.enabled() {
338            // Run post-build script if defined
339            if let Some(post_build) = self.config.script("post-build") {
340                debug!("Running post-build script");
341                let child = self.sandbox.execute(
342                    0,
343                    post_build,
344                    script_envs,
345                    None,
346                    None,
347                )?;
348                let output = child
349                    .wait_with_output()
350                    .context("Failed to wait for post-build")?;
351                if !output.status.success() {
352                    let stderr = String::from_utf8_lossy(&output.stderr);
353                    error!(exit_code = ?output.status.code(), stderr = %stderr, "post-build script failed");
354                }
355            }
356
357            self.sandbox.destroy(0)?;
358        }
359
360        // Stop the refresh thread and print final summary
361        stop_refresh.store(true, Ordering::Relaxed);
362        let _ = refresh_thread.join();
363
364        if let Ok(mut p) = progress.lock() {
365            let _ = p.finish();
366        }
367
368        if !scan_errors.is_empty() {
369            for err in &scan_errors {
370                eprintln!("{}", err);
371            }
372            bail!("{} package(s) failed to scan", scan_errors.len());
373        }
374
375        Ok(())
376    }
377
378    /**
379     * Scan a single PKGPATH, returning a [`Vec`] of [`ScanIndex`] results,
380     * as multi-version packages may return multiple results.
381     */
382    pub fn scan_pkgpath(
383        &self,
384        pkgpath: &PkgPath,
385    ) -> anyhow::Result<Vec<ScanIndex>> {
386        let pkgpath_str = pkgpath.as_path().display().to_string();
387        debug!(pkgpath = %pkgpath_str, "Scanning package");
388
389        let bmake = self.config.make().display().to_string();
390        let pkgsrcdir = self.config.pkgsrc().display().to_string();
391        let script = format!(
392            "cd {}/{} && {} pbulk-index\n",
393            pkgsrcdir, pkgpath_str, bmake
394        );
395
396        trace!(pkgpath = %pkgpath_str,
397            script = %script,
398            "Executing pkg-scan"
399        );
400        let child = self.sandbox.execute_script(0, &script, vec![])?;
401        let output = child.wait_with_output()?;
402
403        if !output.status.success() {
404            let stderr = String::from_utf8_lossy(&output.stderr);
405            error!(pkgpath = %pkgpath_str,
406                exit_code = ?output.status.code(),
407                stderr = %stderr,
408                "pkg-scan script failed"
409            );
410            let stderr = stderr.trim();
411            let msg = if stderr.is_empty() {
412                format!("Scan failed for {}", pkgpath_str)
413            } else {
414                format!("Scan failed for {}: {}", pkgpath_str, stderr)
415            };
416            bail!(msg);
417        }
418
419        let stdout_str = String::from_utf8_lossy(&output.stdout);
420        trace!(pkgpath = %pkgpath_str,
421            stdout_len = stdout_str.len(),
422            stdout = %stdout_str,
423            "pkg-scan script output"
424        );
425
426        let reader = BufReader::new(&output.stdout[..]);
427        let mut index = ScanIndex::from_reader(reader)?;
428
429        info!(pkgpath = %pkgpath_str,
430            packages_found = index.len(),
431            "Scan complete for pkgpath"
432        );
433
434        /*
435         * Set PKGPATH (PKG_LOCATION) as for some reason pbulk-index doesn't.
436         */
437        for pkg in &mut index {
438            pkg.pkg_location = Some(pkgpath.clone());
439            debug!(pkgpath = %pkgpath_str,
440                pkgname = %pkg.pkgname.pkgname(),
441                skip_reason = ?pkg.pkg_skip_reason,
442                fail_reason = ?pkg.pkg_fail_reason,
443                depends_count = pkg.all_depends.len(),
444                "Found package in scan"
445            );
446        }
447
448        Ok(index)
449    }
450
451    /// Get all scanned packages (before resolution).
452    pub fn scanned(&self) -> impl Iterator<Item = &ScanIndex> {
453        self.done.values().flatten()
454    }
455
456    /// Write scan output to a file in FOO=bar format.
457    pub fn write_log(&self, path: &std::path::Path) -> anyhow::Result<()> {
458        let mut out = String::new();
459        for idx in self.scanned() {
460            out.push_str(&format!("PKGNAME={}\n", idx.pkgname.pkgname()));
461            if let Some(ref loc) = idx.pkg_location {
462                out.push_str(&format!(
463                    "PKG_LOCATION={}\n",
464                    loc.as_path().display()
465                ));
466            }
467            if !idx.all_depends.is_empty() {
468                let deps: Vec<String> = idx
469                    .all_depends
470                    .iter()
471                    .map(|d| d.pkgpath().as_path().display().to_string())
472                    .collect();
473                out.push_str(&format!("ALL_DEPENDS={}\n", deps.join(" ")));
474            }
475            if !idx.depends.is_empty() {
476                let deps: Vec<&str> =
477                    idx.depends.iter().map(|d| d.pkgname()).collect();
478                out.push_str(&format!("DEPENDS={}\n", deps.join(" ")));
479            }
480            if !idx.multi_version.is_empty() {
481                out.push_str(&format!(
482                    "MULTI_VERSION={}\n",
483                    idx.multi_version.join(" ")
484                ));
485            }
486            if let Some(ref v) = idx.pkg_skip_reason {
487                out.push_str(&format!("PKG_SKIP_REASON={}\n", v));
488            }
489            if let Some(ref v) = idx.pkg_fail_reason {
490                out.push_str(&format!("PKG_FAIL_REASON={}\n", v));
491            }
492            if let Some(ref v) = idx.categories {
493                out.push_str(&format!("CATEGORIES={}\n", v));
494            }
495            if let Some(ref v) = idx.maintainer {
496                out.push_str(&format!("MAINTAINER={}\n", v));
497            }
498            if let Some(ref v) = idx.bootstrap_pkg {
499                out.push_str(&format!("BOOTSTRAP_PKG={}\n", v));
500            }
501            if let Some(ref v) = idx.usergroup_phase {
502                out.push_str(&format!("USERGROUP_PHASE={}\n", v));
503            }
504            if let Some(ref v) = idx.use_destdir {
505                out.push_str(&format!("USE_DESTDIR={}\n", v));
506            }
507            if let Some(ref v) = idx.no_bin_on_ftp {
508                out.push_str(&format!("NO_BIN_ON_FTP={}\n", v));
509            }
510            if let Some(ref v) = idx.restricted {
511                out.push_str(&format!("RESTRICTED={}\n", v));
512            }
513            if let Some(ref v) = idx.pbulk_weight {
514                out.push_str(&format!("PBULK_WEIGHT={}\n", v));
515            }
516            out.push('\n');
517        }
518        std::fs::write(path, &out)?;
519        Ok(())
520    }
521
522    /**
523     * Resolve the list of scanned packages, by ensuring all of the [`Depend`]
524     * patterns in `all_depends` match a found package, and that there are no
525     * circular dependencies.  The best match for each is stored in the
526     * `depends` for the package in question.
527     *
528     * Return a [`ScanResult`] containing buildable packages and skipped packages.
529     */
530    pub fn resolve(&mut self) -> Result<ScanResult> {
531        info!(
532            done_pkgpaths = self.done.len(),
533            "Starting dependency resolution"
534        );
535
536        /*
537         * Populate the resolved hash.  This becomes our new working set,
538         * with a flat mapping of PKGNAME -> ScanIndex.
539         *
540         * self.done must no longer be used after this point, as its ScanIndex
541         * entries are out of date (do not have depends set, for example).
542         * Maybe at some point we'll handle lifetimes properly and just have
543         * one canonical index.
544         *
545         * Also create a simple HashSet for looking up known PKGNAME for
546         * matches.
547         */
548        let mut pkgnames: HashSet<PkgName> = HashSet::new();
549        let mut skipped: Vec<SkippedPackage> = Vec::new();
550
551        // Log what we have in self.done
552        for (pkgpath, index) in &self.done {
553            debug!(pkgpath = %pkgpath.as_path().display(),
554                packages_in_index = index.len(),
555                "Processing done entry"
556            );
557        }
558
559        for index in self.done.values() {
560            for pkg in index {
561                // Check for skip/fail reasons
562                if let Some(reason) = &pkg.pkg_skip_reason {
563                    if !reason.is_empty() {
564                        info!(pkgname = %pkg.pkgname.pkgname(),
565                            reason = %reason,
566                            "Skipping package due to PKG_SKIP_REASON"
567                        );
568                        skipped.push(SkippedPackage {
569                            pkgname: pkg.pkgname.clone(),
570                            pkgpath: pkg.pkg_location.clone(),
571                            reason: SkipReason::PkgSkipReason(reason.clone()),
572                        });
573                        continue;
574                    }
575                }
576                if let Some(reason) = &pkg.pkg_fail_reason {
577                    if !reason.is_empty() {
578                        info!(pkgname = %pkg.pkgname.pkgname(),
579                            reason = %reason,
580                            "Skipping package due to PKG_FAIL_REASON"
581                        );
582                        skipped.push(SkippedPackage {
583                            pkgname: pkg.pkgname.clone(),
584                            pkgpath: pkg.pkg_location.clone(),
585                            reason: SkipReason::PkgFailReason(reason.clone()),
586                        });
587                        continue;
588                    }
589                }
590
591                debug!(pkgname = %pkg.pkgname.pkgname(),
592                    "Adding package to resolved set"
593                );
594                pkgnames.insert(pkg.pkgname.clone());
595                self.resolved.insert(pkg.pkgname.clone(), pkg.clone());
596            }
597        }
598
599        info!(
600            resolved_count = self.resolved.len(),
601            skipped_count = skipped.len(),
602            "Initial resolution complete"
603        );
604
605        if !skipped.is_empty() {
606            println!(
607                "Skipping {} packages with PKG_SKIP_REASON or PKG_FAIL_REASON",
608                skipped.len()
609            );
610        }
611
612        /*
613         * Keep a cache of best Depend => PkgName matches we've already seen
614         * as it's likely the same patterns will be used in multiple places.
615         */
616        let mut match_cache: HashMap<Depend, PkgName> = HashMap::new();
617        let mut errors: Vec<String> = Vec::new();
618
619        for pkg in self.resolved.values_mut() {
620            for depend in &pkg.all_depends {
621                /*
622                 * Check for cached DEPENDS match first.  If found, use it.
623                 */
624                if let Some(pkgname) = match_cache.get(depend) {
625                    pkg.depends.push(pkgname.clone().clone());
626                    continue;
627                }
628                /*
629                 * Find best DEPENDS match out of all known PKGNAME.
630                 */
631                let mut best: Option<&PkgName> = None;
632                for candidate in &pkgnames {
633                    if depend.pattern().matches(candidate.pkgname()) {
634                        if let Some(current) = best {
635                            best = match depend.pattern().best_match(
636                                current.pkgname(),
637                                candidate.pkgname(),
638                            ) {
639                                Some(m) if m == current.pkgname() => {
640                                    Some(current)
641                                }
642                                Some(m) if m == candidate.pkgname() => {
643                                    Some(candidate)
644                                }
645                                Some(_) => todo!(),
646                                None => None,
647                            };
648                        } else {
649                            best = Some(candidate);
650                        }
651                    }
652                }
653                /*
654                 * If we found a match, save it and add to the cache,
655                 * otherwise collect the error (batch errors).
656                 */
657                if let Some(pkgname) = best {
658                    pkg.depends.push(pkgname.clone());
659                    match_cache.insert(depend.clone(), pkgname.clone());
660                } else {
661                    errors.push(format!(
662                        "No match found for {} in {}",
663                        depend.pattern().pattern(),
664                        pkg.pkgname.pkgname()
665                    ));
666                }
667            }
668        }
669
670        if !errors.is_empty() {
671            for err in &errors {
672                error!(error = %err, "Unresolved dependency");
673            }
674            bail!("Unresolved dependencies:\n  {}", errors.join("\n  "));
675        }
676
677        /*
678         * Verify that the graph is acyclic.
679         */
680        debug!(
681            resolved_count = self.resolved.len(),
682            "Checking for circular dependencies"
683        );
684        let mut graph = DiGraphMap::new();
685        for (pkgname, index) in &self.resolved {
686            for dep in &index.depends {
687                graph.add_edge(dep.pkgname(), pkgname.pkgname(), ());
688            }
689        }
690        if let Some(cycle) = find_cycle(&graph) {
691            let mut err = "Circular dependencies detected:\n".to_string();
692            for n in cycle.iter().rev() {
693                err.push_str(&format!("\t{}\n", n));
694            }
695            err.push_str(&format!("\t{}", cycle.last().unwrap()));
696            error!(cycle = ?cycle, "Circular dependency detected");
697            bail!(err);
698        }
699
700        info!(
701            buildable_count = self.resolved.len(),
702            skipped_count = skipped.len(),
703            "Resolution complete"
704        );
705
706        // Log all buildable packages
707        for pkgname in self.resolved.keys() {
708            debug!(pkgname = %pkgname.pkgname(), "Package is buildable");
709        }
710
711        Ok(ScanResult { buildable: self.resolved.clone(), skipped })
712    }
713}
714
715pub fn find_cycle<'a>(
716    graph: &'a DiGraphMap<&'a str, ()>,
717) -> Option<Vec<&'a str>> {
718    let mut visited = HashSet::new();
719    let mut in_stack = HashSet::new();
720    let mut stack = Vec::new();
721
722    for node in graph.nodes() {
723        if visited.contains(&node) {
724            continue;
725        }
726        let cycle = dfs(graph, node, &mut visited, &mut stack, &mut in_stack);
727        if cycle.is_some() {
728            return cycle;
729        }
730    }
731    None
732}
733
734fn dfs<'a>(
735    graph: &'a DiGraphMap<&'a str, ()>,
736    node: &'a str,
737    visited: &mut HashSet<&'a str>,
738    stack: &mut Vec<&'a str>,
739    in_stack: &mut HashSet<&'a str>,
740) -> Option<Vec<&'a str>> {
741    visited.insert(node);
742    stack.push(node);
743    in_stack.insert(node);
744    for neighbor in graph.neighbors(node) {
745        if in_stack.contains(neighbor) {
746            if let Some(pos) = stack.iter().position(|&n| n == neighbor) {
747                return Some(stack[pos..].to_vec());
748            }
749        } else if !visited.contains(neighbor) {
750            let cycle = dfs(graph, neighbor, visited, stack, in_stack);
751            if cycle.is_some() {
752                return cycle;
753            }
754        }
755    }
756    stack.pop();
757    in_stack.remove(node);
758    None
759}