Skip to main content

arch_toolkit/deps/
graph.rs

1//! Bounded, fixture-friendly dependency graph resolution.
2//!
3//! This module keeps graph expansion separate from the legacy synchronous host resolver so callers
4//! can inject verified `.SRCINFO` metadata without enabling the `aur` feature.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::time::{Duration, Instant};
8
9use crate::deps::parse::parse_dep_spec;
10use crate::deps::srcinfo::{GraphSrcinfoData, SrcinfoPackage, parse_srcinfo_graph};
11use crate::deps::version::{compare_versions, version_satisfies};
12use crate::error::{ArchToolkitError, Result};
13use crate::types::dependency::{
14    DependencyConstraintRange, DependencyGraphConfig, DependencyGraphDiagnostic,
15    DependencyGraphDiagnosticKind, DependencyGraphEdge, DependencyGraphNode,
16    DependencyGraphNodeStatus, DependencyGraphResolution, DependencyMetadata,
17    DependencyMetadataResponse, DependencyProvenance, DependencyVersionBound, PackageRef,
18};
19
20/// What: Fetch verified raw `.SRCINFO` metadata for graph-resolution requests.
21///
22/// Inputs:
23/// - A lexically sorted batch of requested package or virtual names.
24/// - A per-batch timeout supplied by `DependencyGraphConfig`.
25///
26/// Output:
27/// - Returns one `DependencyMetadataResponse` per request whenever possible.
28///
29/// Details:
30/// - The trait belongs to `deps` and has no AUR, HTTP, runtime, or helper dependency. Providers
31///   may batch internally and must honor the supplied timeout for cancellable I/O. The synchronous
32///   resolver issues batches serially, so it never has more than one provider call in flight.
33pub trait DependencyMetadataProvider: Send + Sync {
34    /// What: Retrieve raw metadata for a bounded batch of requested names.
35    ///
36    /// Inputs:
37    /// - `requested_names`: Lexically sorted, unique requested package or virtual names.
38    /// - `timeout`: Maximum time allocated to this provider batch.
39    ///
40    /// Output:
41    /// - Returns found, missing, or failed metadata responses.
42    ///
43    /// Details:
44    /// - Providers should return a response for every input. The resolver records a structured
45    ///   protocol diagnostic for omitted, duplicate, or unrequested responses.
46    fn fetch_metadata(
47        &self,
48        requested_names: &[String],
49        timeout: Duration,
50    ) -> Vec<DependencyMetadataResponse>;
51}
52
53/// What: Track one dependency request waiting for metadata processing.
54///
55/// Inputs:
56/// - Parent package, requested name, version requirement, depth, and active path.
57///
58/// Output:
59/// - Carries deterministic traversal state between bounded provider batches.
60///
61/// Details:
62/// - `path` contains actual selected package names, permitting cycle detection after virtual
63///   provider selection.
64#[derive(Clone, Debug)]
65struct PendingRequest {
66    /// Actual parent package, or `None` for a root request.
67    parent: Option<String>,
68    /// Requested package or virtual dependency name.
69    requested_name: String,
70    /// Requested version requirement.
71    version_req: String,
72    /// Edge depth from a root.
73    depth: usize,
74    /// Actual package names active on the traversal path.
75    path: Vec<String>,
76}
77
78/// What: Validate graph-resolution bounds before metadata lookup.
79///
80/// Inputs:
81/// - `config`: Caller-provided graph bounds.
82///
83/// Output:
84/// - Returns `Ok(())` for usable bounds or an actionable invalid-input error.
85///
86/// Details:
87/// - Zero node, timeout, or provider-batch limits are rejected rather than silently disabling a
88///   safety bound. A zero depth remains valid and resolves root metadata only.
89fn validate_graph_config(config: &DependencyGraphConfig) -> Result<()> {
90    if config.max_nodes == 0 {
91        return Err(ArchToolkitError::InvalidInput(
92            "dependency graph max_nodes must be greater than zero".to_string(),
93        ));
94    }
95    if config.metadata_timeout.is_zero() {
96        return Err(ArchToolkitError::InvalidInput(
97            "dependency graph metadata_timeout must be greater than zero".to_string(),
98        ));
99    }
100    if config.max_concurrency == 0 {
101        return Err(ArchToolkitError::InvalidInput(
102            "dependency graph max_concurrency must be greater than zero".to_string(),
103        ));
104    }
105    Ok(())
106}
107
108/// What: Sort pending requests into deterministic breadth-first lexical order.
109///
110/// Inputs:
111/// - `pending`: Requests waiting for cache lookup or metadata processing.
112///
113/// Output:
114/// - Updates the vector in depth, requested-name, parent, and constraint order.
115///
116/// Details:
117/// - Stable ordering ensures provider batch order, diagnostics, graph edges, and rendering do not
118///   depend on caller root order or provider response order.
119fn sort_pending(pending: &mut [PendingRequest]) {
120    pending.sort_by(|left, right| {
121        left.depth
122            .cmp(&right.depth)
123            .then_with(|| left.requested_name.cmp(&right.requested_name))
124            .then_with(|| left.parent.cmp(&right.parent))
125            .then_with(|| left.version_req.cmp(&right.version_req))
126    });
127}
128
129/// What: Return the requested name carried by a provider response.
130///
131/// Inputs:
132/// - `response`: One metadata provider response.
133///
134/// Output:
135/// - Returns the response's requested package or virtual name.
136///
137/// Details:
138/// - This allows the resolver to reject batch protocol mismatches before parsing metadata.
139fn response_requested_name(response: &DependencyMetadataResponse) -> &str {
140    match response {
141        DependencyMetadataResponse::Found(metadata) => &metadata.requested_name,
142        DependencyMetadataResponse::Missing { requested_name, .. }
143        | DependencyMetadataResponse::Failure { requested_name, .. } => requested_name,
144    }
145}
146
147/// What: Add a structured graph diagnostic.
148///
149/// Inputs:
150/// - `diagnostics`: Destination diagnostics.
151/// - `kind`: Stable event category.
152/// - `package`: Affected package or requested name.
153/// - `related_package`: Optional related package.
154/// - `message`: Actionable event detail.
155///
156/// Output:
157/// - Appends one diagnostic entry.
158///
159/// Details:
160/// - Final result sorting makes diagnostic order deterministic regardless of provider ordering.
161fn push_diagnostic(
162    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
163    kind: DependencyGraphDiagnosticKind,
164    package: impl Into<String>,
165    related_package: Option<String>,
166    message: impl Into<String>,
167) {
168    diagnostics.push(DependencyGraphDiagnostic {
169        kind,
170        package: package.into(),
171        related_package,
172        message: message.into(),
173    });
174}
175
176/// What: Cache one bounded provider batch and diagnose protocol violations.
177///
178/// Inputs:
179/// - `requests`: Unique requested names sent to the provider.
180/// - `responses`: Provider output for the batch.
181/// - `cache`: Per-run metadata response cache.
182/// - `diagnostics`: Resolution diagnostics.
183///
184/// Output:
185/// - Caches a response or deterministic synthetic failure for every request.
186///
187/// Details:
188/// - Duplicate and unrequested responses are diagnosed. Missing responses become failures so the
189///   resolver does not silently issue repeat requests or infer package provenance.
190fn cache_batch_responses(
191    requests: &[String],
192    responses: Vec<DependencyMetadataResponse>,
193    cache: &mut BTreeMap<String, DependencyMetadataResponse>,
194    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
195) {
196    let requested = requests.iter().collect::<BTreeSet<_>>();
197    let mut seen = BTreeSet::new();
198    for response in responses {
199        let response_name = response_requested_name(&response).to_string();
200        if !requested.contains(&response_name) {
201            push_diagnostic(
202                diagnostics,
203                DependencyGraphDiagnosticKind::MetadataProtocol,
204                response_name,
205                None,
206                "metadata provider returned a response for an unrequested name",
207            );
208            continue;
209        }
210        if !seen.insert(response_name.clone()) {
211            push_diagnostic(
212                diagnostics,
213                DependencyGraphDiagnosticKind::MetadataProtocol,
214                response_name,
215                None,
216                "metadata provider returned duplicate responses for one request",
217            );
218            continue;
219        }
220        cache.insert(response_name, response);
221    }
222    for request in requests {
223        if !seen.contains(request) {
224            push_diagnostic(
225                diagnostics,
226                DependencyGraphDiagnosticKind::MetadataProtocol,
227                request,
228                None,
229                "metadata provider omitted a response for the request",
230            );
231            cache.insert(
232                request.clone(),
233                DependencyMetadataResponse::Failure {
234                    requested_name: request.clone(),
235                    message: "metadata provider omitted a response".to_string(),
236                },
237            );
238        }
239    }
240}
241
242/// What: Insert a graph node without exceeding the configured per-run node bound.
243///
244/// Inputs:
245/// - `node`: Candidate graph node.
246/// - `nodes`: Nodes indexed by actual package name.
247/// - `config`: Graph-resolution bounds.
248/// - `diagnostics`: Resolution diagnostics.
249///
250/// Output:
251/// - Returns `true` when the node exists after the call and `false` when the node limit blocks it.
252///
253/// Details:
254/// - Existing nodes are always reusable. New-node rejection is explicit and leaves the branch
255///   unexpanded rather than exceeding the configured resource bound.
256fn insert_node_if_allowed(
257    node: DependencyGraphNode,
258    nodes: &mut BTreeMap<String, DependencyGraphNode>,
259    config: &DependencyGraphConfig,
260    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
261) -> bool {
262    if nodes.contains_key(&node.name) {
263        return true;
264    }
265    if nodes.len() >= config.max_nodes {
266        push_diagnostic(
267            diagnostics,
268            DependencyGraphDiagnosticKind::NodeLimit,
269            &node.name,
270            None,
271            format!("dependency graph node limit ({}) reached", config.max_nodes),
272        );
273        return false;
274    }
275    nodes.insert(node.name.clone(), node);
276    true
277}
278
279/// What: Build a missing graph node without inventing source provenance.
280///
281/// Inputs:
282/// - `name`: Requested package or virtual name.
283/// - `depth`: Traversal depth where metadata became unavailable.
284/// - `source`: Verified source if malformed metadata existed, otherwise `None`.
285///
286/// Output:
287/// - Returns a graph node in `Missing` state.
288///
289/// Details:
290/// - Missing nodes provide an edge target for partial graph consumers while retaining the policy
291///   that an unknown package is not automatically an AUR package.
292fn missing_node(
293    name: &str,
294    depth: usize,
295    source: Option<crate::types::dependency::DependencySource>,
296) -> DependencyGraphNode {
297    DependencyGraphNode {
298        name: name.to_string(),
299        pkgbase: None,
300        version: None,
301        provenance: DependencyProvenance {
302            requested_name: name.to_string(),
303            source,
304            provider: None,
305        },
306        status: DependencyGraphNodeStatus::Missing,
307        constraints: DependencyConstraintRange::default(),
308        provides: Vec::new(),
309        conflicts: Vec::new(),
310        depth,
311    }
312}
313
314/// What: Construct a package version retaining epoch and pkgrel semantics.
315///
316/// Inputs:
317/// - `data`: Parsed `.SRCINFO` metadata for the selected package base.
318///
319/// Output:
320/// - Returns an optional `epoch:pkgver-pkgrel` version string.
321///
322/// Details:
323/// - Empty package version metadata is left absent so constraints are not claimed to be verified.
324fn srcinfo_version(data: &GraphSrcinfoData) -> Option<String> {
325    if data.pkgver.is_empty() {
326        return None;
327    }
328    let epoch_prefix = if data.epoch.is_empty() {
329        String::new()
330    } else {
331        format!("{}:", data.epoch)
332    };
333    let pkgrel_suffix = if data.pkgrel.is_empty() {
334        String::new()
335    } else {
336        format!("-{}", data.pkgrel)
337    };
338    Some(format!("{epoch_prefix}{}{pkgrel_suffix}", data.pkgver))
339}
340
341/// What: Check that an actual provider output verifies a virtual requested name.
342///
343/// Inputs:
344/// - `package`: Selected package output from `.SRCINFO`.
345/// - `requested_name`: Original virtual dependency name.
346/// - `version_req`: Requested virtual version constraint.
347///
348/// Output:
349/// - Returns `true` when a matching `provides` entry verifies the request.
350///
351/// Details:
352/// - An unversioned provide satisfies only an unversioned request. A versioned provide is compared
353///   using the same epoch/pkgver/pkgrel comparator as ordinary dependency constraints.
354fn provider_satisfies_request(
355    package: &SrcinfoPackage,
356    requested_name: &str,
357    version_req: &str,
358) -> bool {
359    package.provides.iter().any(|provided| {
360        let provided_spec = parse_dep_spec(provided);
361        if provided_spec.name != requested_name {
362            return false;
363        }
364        if version_req.is_empty() {
365            return true;
366        }
367        let Some(version) = provided_spec.version_req.strip_prefix('=') else {
368            return false;
369        };
370        version_satisfies(version, version_req)
371    })
372}
373
374/// What: Parse and intersect one dependency requirement with a version range.
375///
376/// Inputs:
377/// - `range`: Existing compatible range.
378/// - `requirement`: A dependency operator and version, or an empty requirement.
379///
380/// Output:
381/// - Returns an intersected range or `None` when the requirement is malformed or incompatible.
382///
383/// Details:
384/// - The interval supports `=`, `>`, `>=`, `<`, and `<=`; exact constraints become equal inclusive
385///   lower and upper bounds. The function never inspects host package state.
386fn intersect_requirement(
387    range: &DependencyConstraintRange,
388    requirement: &str,
389) -> Option<DependencyConstraintRange> {
390    if requirement.is_empty() {
391        return Some(range.clone());
392    }
393    let (operator, version) = [">=", "<=", "=", ">", "<"].iter().find_map(|operator| {
394        requirement
395            .strip_prefix(operator)
396            .map(|version| (*operator, version))
397    })?;
398    if version.is_empty() {
399        return None;
400    }
401    let mut candidate = range.clone();
402    let bound = DependencyVersionBound {
403        version: version.to_string(),
404        inclusive: matches!(operator, ">=" | "<=" | "="),
405    };
406    match operator {
407        ">" | ">=" => update_lower(&mut candidate.lower, bound),
408        "<" | "<=" => update_upper(&mut candidate.upper, bound),
409        "=" => {
410            update_lower(&mut candidate.lower, bound.clone());
411            update_upper(&mut candidate.upper, bound);
412        }
413        _ => return None,
414    }
415    range_is_valid(&candidate).then_some(candidate)
416}
417
418/// What: Update an interval lower bound with the more restrictive requirement.
419///
420/// Inputs:
421/// - `current`: Existing lower bound.
422/// - `candidate`: New lower bound.
423///
424/// Output:
425/// - Keeps the larger version, with exclusive equality taking precedence.
426///
427/// Details:
428/// - This is pure constraint algebra and deliberately does not query installed packages.
429fn update_lower(current: &mut Option<DependencyVersionBound>, candidate: DependencyVersionBound) {
430    let should_replace = current.as_ref().is_none_or(|existing| {
431        matches!(
432            compare_versions(&candidate.version, &existing.version),
433            std::cmp::Ordering::Greater
434        ) || (candidate.version == existing.version && !candidate.inclusive && existing.inclusive)
435    });
436    if should_replace {
437        *current = Some(candidate);
438    }
439}
440
441/// What: Update an interval upper bound with the more restrictive requirement.
442///
443/// Inputs:
444/// - `current`: Existing upper bound.
445/// - `candidate`: New upper bound.
446///
447/// Output:
448/// - Keeps the smaller version, with exclusive equality taking precedence.
449///
450/// Details:
451/// - This is pure constraint algebra and deliberately does not query installed packages.
452fn update_upper(current: &mut Option<DependencyVersionBound>, candidate: DependencyVersionBound) {
453    let should_replace = current.as_ref().is_none_or(|existing| {
454        matches!(
455            compare_versions(&candidate.version, &existing.version),
456            std::cmp::Ordering::Less
457        ) || (candidate.version == existing.version && !candidate.inclusive && existing.inclusive)
458    });
459    if should_replace {
460        *current = Some(candidate);
461    }
462}
463
464/// What: Determine whether an intersected version interval contains any version.
465///
466/// Inputs:
467/// - `range`: Candidate lower and upper version bounds.
468///
469/// Output:
470/// - Returns `true` for compatible or unbounded ranges.
471///
472/// Details:
473/// - Equal bounds are incompatible whenever either side excludes equality.
474fn range_is_valid(range: &DependencyConstraintRange) -> bool {
475    let (Some(lower), Some(upper)) = (&range.lower, &range.upper) else {
476        return true;
477    };
478    match compare_versions(&lower.version, &upper.version) {
479        std::cmp::Ordering::Less => true,
480        std::cmp::Ordering::Greater => false,
481        std::cmp::Ordering::Equal => lower.inclusive && upper.inclusive,
482    }
483}
484
485/// What: Merge an edge constraint into a selected graph node.
486///
487/// Inputs:
488/// - `node`: Selected graph node.
489/// - `requirement`: Direct-package version requirement from an incoming edge.
490/// - `parent`: Parent package that declared the requirement.
491/// - `diagnostics`: Resolution diagnostics.
492///
493/// Output:
494/// - Updates the node's compatible range or emits an incompatibility diagnostic.
495///
496/// Details:
497/// - Virtual-provider constraints are validated against `provides` separately and are not applied to
498///   the provider's own package version interval.
499fn merge_node_requirement(
500    node: &mut DependencyGraphNode,
501    requirement: &str,
502    parent: Option<&str>,
503    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
504) {
505    if !requirement_is_well_formed(requirement) {
506        push_diagnostic(
507            diagnostics,
508            DependencyGraphDiagnosticKind::MalformedConstraint,
509            &node.name,
510            parent.map(str::to_string),
511            format!("requirement '{requirement}' has no supported operator and version"),
512        );
513        return;
514    }
515    let Some(range) = intersect_requirement(&node.constraints, requirement) else {
516        push_diagnostic(
517            diagnostics,
518            DependencyGraphDiagnosticKind::IncompatibleConstraints,
519            &node.name,
520            parent.map(str::to_string),
521            format!("requirement '{requirement}' has no compatible intersection"),
522        );
523        return;
524    };
525    node.constraints = range;
526}
527
528/// What: Validate the syntax accepted by graph constraint intersection.
529///
530/// Inputs:
531/// - `requirement`: Empty requirement or an operator-prefixed version requirement.
532///
533/// Output:
534/// - `true` for empty requirements and supported operators with a non-empty version.
535///
536/// Details:
537/// - Keeps malformed metadata diagnostics distinct from valid but incompatible intervals.
538fn requirement_is_well_formed(requirement: &str) -> bool {
539    requirement.is_empty()
540        || [">=", "<=", "=", ">", "<"]
541            .iter()
542            .find_map(|operator| requirement.strip_prefix(operator))
543            .is_some_and(|version| !version.is_empty())
544}
545
546/// What: Add a graph edge while preserving a stable duplicate-free result.
547///
548/// Inputs:
549/// - `edges`: Existing graph edges.
550/// - `parent`: Actual parent package.
551/// - `child`: Actual selected or missing child package.
552/// - `requested_name`: Dependency name written by the parent.
553/// - `version_req`: Requested version constraint.
554///
555/// Output:
556/// - Adds the edge only when an identical edge is not already present.
557///
558/// Details:
559/// - Multiple requirements for one package remain distinct edges when their constraints differ.
560fn add_edge(
561    edges: &mut Vec<DependencyGraphEdge>,
562    parent: Option<&str>,
563    child: &str,
564    requested_name: &str,
565    version_req: &str,
566) {
567    let Some(parent) = parent else {
568        return;
569    };
570    let edge = DependencyGraphEdge {
571        from: parent.to_string(),
572        to: child.to_string(),
573        requested_name: requested_name.to_string(),
574        version_req: version_req.to_string(),
575    };
576    if !edges.contains(&edge) {
577        edges.push(edge);
578    }
579}
580
581/// What: Process an unavailable or failed metadata response.
582///
583/// Inputs:
584/// - `pending`: Request being resolved.
585/// - `response`: Missing or failed provider response.
586/// - `nodes`, `edges`, `roots`, `config`, and `diagnostics`: Mutable graph state.
587///
588/// Output:
589/// - Adds a partial missing node and edge when the node bound permits it.
590///
591/// Details:
592/// - Provider errors remain structured diagnostics and do not abort unrelated resolution branches.
593fn process_unavailable_metadata(
594    pending: &PendingRequest,
595    response: &DependencyMetadataResponse,
596    nodes: &mut BTreeMap<String, DependencyGraphNode>,
597    edges: &mut Vec<DependencyGraphEdge>,
598    roots: &mut BTreeSet<String>,
599    config: &DependencyGraphConfig,
600    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
601) {
602    let (kind, message) = match response {
603        DependencyMetadataResponse::Missing { reason, .. } => (
604            DependencyGraphDiagnosticKind::MissingMetadata,
605            format!("metadata unavailable: {reason}"),
606        ),
607        DependencyMetadataResponse::Failure { message, .. } => (
608            DependencyGraphDiagnosticKind::MetadataFailure,
609            format!("metadata retrieval failed: {message}"),
610        ),
611        DependencyMetadataResponse::Found(_) => return,
612    };
613    push_diagnostic(
614        diagnostics,
615        kind,
616        &pending.requested_name,
617        pending.parent.clone(),
618        message,
619    );
620    let node = missing_node(&pending.requested_name, pending.depth, None);
621    if insert_node_if_allowed(node, nodes, config, diagnostics) {
622        add_edge(
623            edges,
624            pending.parent.as_deref(),
625            &pending.requested_name,
626            &pending.requested_name,
627            &pending.version_req,
628        );
629        if pending.parent.is_none() {
630            roots.insert(pending.requested_name.clone());
631        }
632    }
633}
634
635/// What: Select and validate a package output from injected `.SRCINFO` metadata.
636///
637/// Inputs:
638/// - `metadata`: Provider-returned raw `.SRCINFO` metadata.
639/// - `pending`: Dependency request that selected the metadata.
640/// - `diagnostics`: Resolution diagnostics.
641///
642/// Output:
643/// - Returns the parsed data and selected package output when validation succeeds.
644///
645/// Details:
646/// - Split packages are selected strictly by `metadata.package_name`. Virtual provider selections
647///   must prove the originally requested name through a matching `provides` entry.
648fn select_srcinfo_package(
649    metadata: &DependencyMetadata,
650    pending: &PendingRequest,
651    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
652) -> Option<(GraphSrcinfoData, String)> {
653    let data = parse_srcinfo_graph(&metadata.srcinfo);
654    if !data
655        .packages
656        .iter()
657        .any(|package| package.name == metadata.package_name)
658    {
659        push_diagnostic(
660            diagnostics,
661            DependencyGraphDiagnosticKind::MalformedSrcinfo,
662            &pending.requested_name,
663            pending.parent.clone(),
664            format!(
665                ".SRCINFO does not contain selected package output '{}'",
666                metadata.package_name
667            ),
668        );
669        return None;
670    }
671    let provider_verified = data
672        .packages
673        .iter()
674        .find(|package| package.name == metadata.package_name)
675        .is_some_and(|package| {
676            provider_satisfies_request(package, &pending.requested_name, &pending.version_req)
677        });
678    if metadata.package_name != pending.requested_name && !provider_verified {
679        push_diagnostic(
680            diagnostics,
681            DependencyGraphDiagnosticKind::MetadataProtocol,
682            &pending.requested_name,
683            pending.parent.clone(),
684            format!(
685                "selected provider '{}' does not verify requested virtual dependency",
686                metadata.package_name
687            ),
688        );
689        return None;
690    }
691    Some((data, metadata.package_name.clone()))
692}
693
694/// What: Collect included dependency fields from a selected `.SRCINFO` package.
695///
696/// Inputs:
697/// - `package`: Selected package-output metadata.
698/// - Inclusion flags from the legacy resolver configuration.
699///
700/// Output:
701/// - Returns deduplicated dependency specifications in lexical order.
702///
703/// Details:
704/// - Runtime dependencies are always included; optional, make, and check dependencies remain
705///   opt-in to preserve the old resolver's configuration semantics.
706fn selected_dependencies(
707    package: &SrcinfoPackage,
708    include_optdepends: bool,
709    include_makedepends: bool,
710    include_checkdepends: bool,
711) -> Vec<String> {
712    let mut dependencies = package.depends.clone();
713    if include_optdepends {
714        dependencies.extend(package.optdepends.iter().map(|dependency| {
715            dependency.split_once(':').map_or_else(
716                || dependency.clone(),
717                |(_, target)| target.trim().to_string(),
718            )
719        }));
720    }
721    if include_makedepends {
722        dependencies.extend(package.makedepends.clone());
723    }
724    if include_checkdepends {
725        dependencies.extend(package.checkdepends.clone());
726    }
727    dependencies.sort();
728    dependencies.dedup();
729    dependencies
730}
731
732/// What: Process verified metadata and enqueue its bounded child dependency requests.
733///
734/// Inputs:
735/// - `pending`: Request and active traversal path.
736/// - `metadata`: Verified provider metadata.
737/// - Resolution flags, bounds, and mutable graph state.
738///
739/// Output:
740/// - Adds a graph node/edge and queues selected child requests when allowed.
741///
742/// Details:
743/// - Cycles, depth limits, node limits, malformed metadata, and incompatible constraints become
744///   diagnostics while sibling traversal continues in lexical order.
745#[allow(clippy::too_many_arguments)]
746fn process_found_metadata(
747    pending: PendingRequest,
748    metadata: DependencyMetadata,
749    include_optdepends: bool,
750    include_makedepends: bool,
751    include_checkdepends: bool,
752    nodes: &mut BTreeMap<String, DependencyGraphNode>,
753    edges: &mut Vec<DependencyGraphEdge>,
754    roots: &mut BTreeSet<String>,
755    config: &DependencyGraphConfig,
756    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
757    pending_requests: &mut Vec<PendingRequest>,
758    expanded_depths: &mut BTreeMap<String, usize>,
759) {
760    let Some((data, selected_name)) = select_srcinfo_package(&metadata, &pending, diagnostics)
761    else {
762        let missing = missing_node(
763            &pending.requested_name,
764            pending.depth,
765            Some(metadata.source.clone()),
766        );
767        if insert_node_if_allowed(missing, nodes, config, diagnostics) {
768            add_edge(
769                edges,
770                pending.parent.as_deref(),
771                &pending.requested_name,
772                &pending.requested_name,
773                &pending.version_req,
774            );
775            if pending.parent.is_none() {
776                roots.insert(pending.requested_name);
777            }
778        }
779        return;
780    };
781    let is_provider = selected_name != pending.requested_name;
782    let node = DependencyGraphNode {
783        name: selected_name.clone(),
784        pkgbase: (!data.pkgbase.is_empty()).then_some(data.pkgbase.clone()),
785        version: srcinfo_version(&data),
786        provenance: DependencyProvenance {
787            requested_name: pending.requested_name.clone(),
788            source: Some(metadata.source),
789            provider: is_provider.then_some(selected_name.clone()),
790        },
791        status: DependencyGraphNodeStatus::Resolved,
792        constraints: DependencyConstraintRange::default(),
793        provides: data
794            .packages
795            .iter()
796            .find(|package| package.name == selected_name)
797            .map_or_else(Vec::new, |package| package.provides.clone()),
798        conflicts: data
799            .packages
800            .iter()
801            .find(|package| package.name == selected_name)
802            .map_or_else(Vec::new, |package| package.conflicts.clone()),
803        depth: pending.depth,
804    };
805    if !insert_node_if_allowed(node, nodes, config, diagnostics) {
806        return;
807    }
808    add_edge(
809        edges,
810        pending.parent.as_deref(),
811        &selected_name,
812        &pending.requested_name,
813        &pending.version_req,
814    );
815    if pending.parent.is_none() {
816        roots.insert(selected_name.clone());
817    }
818    let Some(node) = nodes.get_mut(&selected_name) else {
819        return;
820    };
821    node.depth = node.depth.min(pending.depth);
822    if !is_provider {
823        merge_node_requirement(
824            node,
825            &pending.version_req,
826            pending.parent.as_deref(),
827            diagnostics,
828        );
829    }
830    if pending.path.contains(&selected_name) {
831        push_diagnostic(
832            diagnostics,
833            DependencyGraphDiagnosticKind::Cycle,
834            &selected_name,
835            pending.parent,
836            "dependency cycle detected; branch expansion stopped",
837        );
838        return;
839    }
840    if !mark_expansion(expanded_depths, &selected_name, pending.depth) {
841        return;
842    }
843    if pending.depth >= config.max_depth {
844        let has_dependencies = data
845            .packages
846            .iter()
847            .find(|package| package.name == selected_name)
848            .is_some_and(|package| {
849                !selected_dependencies(
850                    package,
851                    include_optdepends,
852                    include_makedepends,
853                    include_checkdepends,
854                )
855                .is_empty()
856            });
857        if has_dependencies {
858            push_diagnostic(
859                diagnostics,
860                DependencyGraphDiagnosticKind::DepthLimit,
861                &selected_name,
862                None,
863                format!(
864                    "dependency graph depth limit ({}) reached",
865                    config.max_depth
866                ),
867            );
868        }
869        return;
870    }
871    let Some(package) = data
872        .packages
873        .iter()
874        .find(|package| package.name == selected_name)
875    else {
876        return;
877    };
878    let mut next_path = pending.path;
879    next_path.push(selected_name.clone());
880    for dependency in selected_dependencies(
881        package,
882        include_optdepends,
883        include_makedepends,
884        include_checkdepends,
885    ) {
886        let specification = parse_dep_spec(&dependency);
887        if specification.name.is_empty() {
888            continue;
889        }
890        pending_requests.push(PendingRequest {
891            parent: Some(selected_name.clone()),
892            requested_name: specification.name,
893            version_req: specification.version_req,
894            depth: pending.depth + 1,
895            path: next_path.clone(),
896        });
897    }
898}
899
900/// What: Record the shallowest expansion depth for one selected package.
901///
902/// Inputs:
903/// - `expanded_depths`: Package-to-shallowest-expanded-depth map.
904/// - `package`: Selected package identity.
905/// - `depth`: Incoming traversal depth.
906///
907/// Output:
908/// - `true` when children must be expanded, `false` for an already-expanded equal/deeper path.
909///
910/// Details:
911/// - Bounds traversal work by package/depth instead of the exponential number of distinct paths.
912/// - A later shallower path is allowed to expand because it can reach children hidden by the
913///   maximum-depth bound on an earlier path.
914fn mark_expansion(
915    expanded_depths: &mut BTreeMap<String, usize>,
916    package: &str,
917    depth: usize,
918) -> bool {
919    if expanded_depths
920        .get(package)
921        .is_some_and(|known_depth| *known_depth <= depth)
922    {
923        return false;
924    }
925    expanded_depths.insert(package.to_string(), depth);
926    true
927}
928
929/// What: Match a declared conflict against one resolved package or virtual provider.
930///
931/// Inputs:
932/// - `conflict`: Raw conflict specification.
933/// - `candidate`: Other resolved graph node.
934///
935/// Output:
936/// - Returns `true` when package or virtual identity and any version requirement match.
937///
938/// Details:
939/// - Versioned virtual provides use their declared `=version`; unversioned provides only satisfy
940///   unversioned conflicts, avoiding unverified version assumptions.
941fn conflict_matches_node(conflict: &str, candidate: &DependencyGraphNode) -> bool {
942    let conflict_spec = parse_dep_spec(conflict);
943    if conflict_spec.name.is_empty() {
944        return false;
945    }
946    if candidate.name == conflict_spec.name {
947        return conflict_spec.version_req.is_empty()
948            || candidate
949                .version
950                .as_deref()
951                .is_some_and(|version| version_satisfies(version, &conflict_spec.version_req));
952    }
953    candidate.provides.iter().any(|provided| {
954        let provided_spec = parse_dep_spec(provided);
955        if provided_spec.name != conflict_spec.name {
956            return false;
957        }
958        if conflict_spec.version_req.is_empty() {
959            return true;
960        }
961        provided_spec
962            .version_req
963            .strip_prefix('=')
964            .is_some_and(|version| version_satisfies(version, &conflict_spec.version_req))
965    })
966}
967
968/// What: Mark mutually conflicting graph nodes and preserve conflict diagnostics.
969///
970/// Inputs:
971/// - `nodes`: Resolved graph nodes indexed by package name.
972/// - `diagnostics`: Resolution diagnostics.
973///
974/// Output:
975/// - Updates conflicting node status and appends deterministic conflict events.
976///
977/// Details:
978/// - The check works for official, local, and AUR metadata because it relies only on verified
979///   injected source metadata and does not infer a source from missing system commands.
980fn apply_conflicts(
981    nodes: &mut BTreeMap<String, DependencyGraphNode>,
982    diagnostics: &mut Vec<DependencyGraphDiagnostic>,
983) {
984    let names = nodes.keys().cloned().collect::<Vec<_>>();
985    let mut matches = Vec::new();
986    for (index, left_name) in names.iter().enumerate() {
987        for right_name in names.iter().skip(index + 1) {
988            let (Some(left), Some(right)) = (nodes.get(left_name), nodes.get(right_name)) else {
989                continue;
990            };
991            if left.status != DependencyGraphNodeStatus::Resolved
992                || right.status != DependencyGraphNodeStatus::Resolved
993            {
994                continue;
995            }
996            if left
997                .conflicts
998                .iter()
999                .any(|conflict| conflict_matches_node(conflict, right))
1000                || right
1001                    .conflicts
1002                    .iter()
1003                    .any(|conflict| conflict_matches_node(conflict, left))
1004            {
1005                matches.push((left_name.clone(), right_name.clone()));
1006            }
1007        }
1008    }
1009    for (left_name, right_name) in matches {
1010        if let Some(left) = nodes.get_mut(&left_name) {
1011            left.status = DependencyGraphNodeStatus::Conflicting;
1012        }
1013        if let Some(right) = nodes.get_mut(&right_name) {
1014            right.status = DependencyGraphNodeStatus::Conflicting;
1015        }
1016        push_diagnostic(
1017            diagnostics,
1018            DependencyGraphDiagnosticKind::Conflict,
1019            left_name,
1020            Some(right_name),
1021            "declared package or virtual conflict matched another resolved graph node",
1022        );
1023    }
1024}
1025
1026/// What: Resolve a bounded dependency graph through injected `.SRCINFO` metadata.
1027///
1028/// Inputs:
1029/// - `packages`: Root package references; their declared source is not treated as metadata proof.
1030/// - `provider`: Mockable provider returning verified `.SRCINFO` metadata in batches.
1031/// - `config`: Depth, node, timeout, and provider-batch bounds.
1032/// - Inclusion flags: Optional, make, and check dependency controls from the legacy resolver.
1033///
1034/// Output:
1035/// - Returns a deterministic graph with partial nodes and diagnostics for non-fatal branch errors.
1036///
1037/// Details:
1038/// - Metadata is cached for the duration of one call, traversed breadth-first in lexical order, and
1039///   requested in bounded serial batches. This function never executes pacman, an AUR helper, or
1040///   network I/O itself, keeping `deps` independent from the `aur` feature.
1041#[allow(clippy::too_many_arguments)]
1042pub(super) fn resolve_dependency_graph<P: DependencyMetadataProvider>(
1043    packages: &[PackageRef],
1044    provider: &P,
1045    config: DependencyGraphConfig,
1046    include_optdepends: bool,
1047    include_makedepends: bool,
1048    include_checkdepends: bool,
1049) -> Result<DependencyGraphResolution> {
1050    validate_graph_config(&config)?;
1051    let mut pending_requests = packages
1052        .iter()
1053        .map(|package| PendingRequest {
1054            parent: None,
1055            requested_name: package.name.clone(),
1056            version_req: String::new(),
1057            depth: 0,
1058            path: Vec::new(),
1059        })
1060        .collect::<Vec<_>>();
1061    let mut cache = BTreeMap::new();
1062    let mut nodes = BTreeMap::new();
1063    let mut edges = Vec::new();
1064    let mut roots = BTreeSet::new();
1065    let mut diagnostics = Vec::new();
1066    let mut expanded_depths = BTreeMap::new();
1067
1068    while !pending_requests.is_empty() {
1069        sort_pending(&mut pending_requests);
1070        let request = pending_requests.remove(0);
1071        if !cache.contains_key(&request.requested_name) {
1072            let mut batch = Vec::new();
1073            for pending in std::iter::once(&request).chain(pending_requests.iter()) {
1074                if batch.len() == config.max_concurrency {
1075                    break;
1076                }
1077                if !cache.contains_key(&pending.requested_name)
1078                    && !batch.contains(&pending.requested_name)
1079                {
1080                    batch.push(pending.requested_name.clone());
1081                }
1082            }
1083            let started = Instant::now();
1084            let responses = provider.fetch_metadata(&batch, config.metadata_timeout);
1085            if started.elapsed() > config.metadata_timeout {
1086                for name in &batch {
1087                    push_diagnostic(
1088                        &mut diagnostics,
1089                        DependencyGraphDiagnosticKind::Timeout,
1090                        name,
1091                        None,
1092                        format!(
1093                            "metadata provider exceeded timeout of {:?}",
1094                            config.metadata_timeout
1095                        ),
1096                    );
1097                }
1098                cache_batch_responses(
1099                    &batch,
1100                    batch
1101                        .iter()
1102                        .map(|name| DependencyMetadataResponse::Failure {
1103                            requested_name: name.clone(),
1104                            message: "metadata provider timed out".to_string(),
1105                        })
1106                        .collect(),
1107                    &mut cache,
1108                    &mut diagnostics,
1109                );
1110            } else {
1111                cache_batch_responses(&batch, responses, &mut cache, &mut diagnostics);
1112            }
1113        }
1114        let Some(response) = cache.get(&request.requested_name).cloned() else {
1115            continue;
1116        };
1117        match response {
1118            DependencyMetadataResponse::Found(metadata) => process_found_metadata(
1119                request,
1120                metadata,
1121                include_optdepends,
1122                include_makedepends,
1123                include_checkdepends,
1124                &mut nodes,
1125                &mut edges,
1126                &mut roots,
1127                &config,
1128                &mut diagnostics,
1129                &mut pending_requests,
1130                &mut expanded_depths,
1131            ),
1132            unavailable => process_unavailable_metadata(
1133                &request,
1134                &unavailable,
1135                &mut nodes,
1136                &mut edges,
1137                &mut roots,
1138                &config,
1139                &mut diagnostics,
1140            ),
1141        }
1142    }
1143
1144    apply_conflicts(&mut nodes, &mut diagnostics);
1145    edges.sort_by(|left, right| {
1146        left.from
1147            .cmp(&right.from)
1148            .then_with(|| left.to.cmp(&right.to))
1149            .then_with(|| left.requested_name.cmp(&right.requested_name))
1150            .then_with(|| left.version_req.cmp(&right.version_req))
1151    });
1152    diagnostics.sort_by(|left, right| {
1153        format!("{:?}", left.kind)
1154            .cmp(&format!("{:?}", right.kind))
1155            .then_with(|| left.package.cmp(&right.package))
1156            .then_with(|| left.related_package.cmp(&right.related_package))
1157            .then_with(|| left.message.cmp(&right.message))
1158    });
1159    Ok(DependencyGraphResolution {
1160        roots: roots.into_iter().collect(),
1161        nodes: nodes.into_values().collect(),
1162        edges,
1163        diagnostics,
1164    })
1165}
1166
1167impl DependencyGraphResolution {
1168    /// What: Render an already-resolved graph as a stable plain-text dependency tree.
1169    ///
1170    /// Inputs:
1171    /// - `self`: A graph result returned by `DependencyResolver::resolve_graph`.
1172    ///
1173    /// Output:
1174    /// - Returns a deterministic newline-terminated tree without performing metadata resolution.
1175    ///
1176    /// Details:
1177    /// - Children are lexical, shared subgraphs are shown under each parent, and active-path cycles
1178    ///   use `↺` rather than recursing indefinitely. Rendering does not mutate graph state.
1179    #[must_use]
1180    pub fn render_tree(&self) -> String {
1181        let mut output = String::new();
1182        for (index, root) in self.roots.iter().enumerate() {
1183            let mut path = BTreeSet::new();
1184            render_tree_node(
1185                self,
1186                root,
1187                "",
1188                true,
1189                index + 1 < self.roots.len(),
1190                &mut path,
1191                &mut output,
1192            );
1193        }
1194        output
1195    }
1196}
1197
1198/// What: Render one tree node and its lexical descendants.
1199///
1200/// Inputs:
1201/// - Graph, node name, prefix state, active path, and output buffer.
1202///
1203/// Output:
1204/// - Appends the node and bounded descendants to the output buffer.
1205///
1206/// Details:
1207/// - This presentation helper consults only resolved graph data and uses an active-path set to
1208///   prevent cycle expansion while retaining deterministic shared-subgraph output.
1209#[allow(clippy::too_many_arguments)]
1210fn render_tree_node(
1211    graph: &DependencyGraphResolution,
1212    name: &str,
1213    prefix: &str,
1214    is_child: bool,
1215    has_next_root: bool,
1216    path: &mut BTreeSet<String>,
1217    output: &mut String,
1218) {
1219    let label = graph
1220        .nodes
1221        .iter()
1222        .find(|node| node.name == name)
1223        .map_or_else(
1224            || name.to_string(),
1225            |node| {
1226                if node.provenance.requested_name == node.name {
1227                    node.name.clone()
1228                } else {
1229                    format!("{} (for {})", node.name, node.provenance.requested_name)
1230                }
1231            },
1232        );
1233    if is_child {
1234        output.push_str(prefix);
1235        output.push_str(if has_next_root {
1236            "├── "
1237        } else {
1238            "└── "
1239        });
1240    }
1241    output.push_str(&label);
1242    output.push('\n');
1243    if !path.insert(name.to_string()) {
1244        output.push_str(prefix);
1245        output.push_str("    ↺\n");
1246        return;
1247    }
1248    let children = graph
1249        .edges
1250        .iter()
1251        .filter(|edge| edge.from == name)
1252        .map(|edge| edge.to.as_str())
1253        .collect::<BTreeSet<_>>();
1254    for (index, child) in children.iter().enumerate() {
1255        let child_prefix = if is_child {
1256            format!("{prefix}{}", if has_next_root { "│   " } else { "    " })
1257        } else {
1258            String::new()
1259        };
1260        render_tree_node(
1261            graph,
1262            child,
1263            &child_prefix,
1264            true,
1265            index + 1 < children.len(),
1266            path,
1267            output,
1268        );
1269    }
1270    path.remove(name);
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    /// What: Verify epoch and pkgrel-aware constraint intersection.
1278    ///
1279    /// Inputs:
1280    /// - Static compatible and incompatible requirement strings.
1281    ///
1282    /// Output:
1283    /// - Confirms lower and upper bounds use full package version semantics.
1284    ///
1285    /// Details:
1286    /// - The test is pure and does not require host package metadata or a provider.
1287    #[test]
1288    fn intersect_requirement_handles_epoch_and_pkgrel() {
1289        let range = intersect_requirement(&DependencyConstraintRange::default(), ">=1:2.0-3")
1290            .unwrap_or_default();
1291        let range = intersect_requirement(&range, "<=1:3.0-1").unwrap_or_default();
1292        assert_eq!(
1293            range.lower.as_ref().map(|bound| bound.version.as_str()),
1294            Some("1:2.0-3")
1295        );
1296        assert_eq!(
1297            range.upper.as_ref().map(|bound| bound.version.as_str()),
1298            Some("1:3.0-1")
1299        );
1300        assert!(intersect_requirement(&range, "<1:2.0-3").is_none());
1301    }
1302
1303    /// What: Verify graph configuration rejects disabled safety bounds.
1304    ///
1305    /// Inputs:
1306    /// - Default config with each mandatory bound set to zero in turn.
1307    ///
1308    /// Output:
1309    /// - Confirms invalid bounds return errors before provider I/O.
1310    ///
1311    /// Details:
1312    /// - A zero depth remains valid because it intentionally permits root-only metadata resolution.
1313    #[test]
1314    fn validate_graph_config_rejects_zero_mandatory_bounds() {
1315        assert!(
1316            validate_graph_config(&DependencyGraphConfig {
1317                max_nodes: 0,
1318                ..DependencyGraphConfig::default()
1319            })
1320            .is_err()
1321        );
1322        assert!(
1323            validate_graph_config(&DependencyGraphConfig {
1324                metadata_timeout: Duration::ZERO,
1325                ..DependencyGraphConfig::default()
1326            })
1327            .is_err()
1328        );
1329        assert!(
1330            validate_graph_config(&DependencyGraphConfig {
1331                max_concurrency: 0,
1332                ..DependencyGraphConfig::default()
1333            })
1334            .is_err()
1335        );
1336    }
1337
1338    /// What: Verify shared graph nodes expand once unless later reached by a shallower path.
1339    ///
1340    /// Inputs:
1341    /// - Repeated package/depth pairs against one expansion map.
1342    ///
1343    /// Output:
1344    /// - Equal/deeper repeats are skipped; a shallower repeat is accepted once.
1345    ///
1346    /// Details:
1347    /// - Prevents traversal work from scaling with the number of paths through dense shared graphs.
1348    #[test]
1349    fn expansion_memoization_bounds_shared_paths() {
1350        let mut expanded = BTreeMap::new();
1351        assert!(mark_expansion(&mut expanded, "shared", 4));
1352        assert!(!mark_expansion(&mut expanded, "shared", 4));
1353        assert!(!mark_expansion(&mut expanded, "shared", 6));
1354        assert!(mark_expansion(&mut expanded, "shared", 2));
1355        assert!(!mark_expansion(&mut expanded, "shared", 3));
1356    }
1357
1358    /// What: Verify malformed requirements are distinguished from valid incompatible intervals.
1359    ///
1360    /// Inputs:
1361    /// - Empty, valid operator-prefixed, operator-less, and empty-version requirements.
1362    ///
1363    /// Output:
1364    /// - Only syntax accepted by interval intersection is reported well formed.
1365    ///
1366    /// Details:
1367    /// - Keeps actionable metadata diagnostics from mislabeling malformed input as a range conflict.
1368    #[test]
1369    fn requirement_validation_distinguishes_malformed_constraints() {
1370        assert!(requirement_is_well_formed(""));
1371        assert!(requirement_is_well_formed(">=1.0"));
1372        assert!(!requirement_is_well_formed("1.0"));
1373        assert!(!requirement_is_well_formed(">="));
1374    }
1375}