Skip to main content

aube_resolver/
direct_dep_info.rs

1//! Per-direct-dep packument facts the install summary printer surfaces
2//! inline with the `+ name@version` listing — currently deprecation
3//! status and the registry `latest` dist-tag when it differs from the
4//! resolved version. The data has to be snapshotted before the resolver
5//! (which owns the packument cache) is dropped at the end of resolution.
6
7use crate::Resolver;
8use aube_lockfile::LockfileGraph;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AgeGatedUpdate {
13    pub name: String,
14    pub version: String,
15}
16
17/// Subset of packument facts the install summary printer wants to
18/// render next to a direct-dependency line. Returned only for direct
19/// deps where at least one signal is set — the printer skips the badge
20/// column when [`Resolver::direct_dep_info`]'s map has no entry.
21///
22/// Deprecation is a bare flag (not the message string) by design: the
23/// full per-version `deprecated` text already surfaces via the WARN
24/// pipeline in [`crate::deprecations`][crate-deprecations] above the
25/// summary, so the badge column just signals "this direct dep is one
26/// of the WARN lines you saw" without duplicating the message.
27///
28/// [crate-deprecations]: https://github.com/jdx/aube/blob/main/crates/aube/src/deprecations.rs
29#[derive(Debug, Clone, Default)]
30pub struct DirectDepInfo {
31    /// True when the packument marks the *resolved* version as
32    /// deprecated. The actual message is intentionally not carried
33    /// here — see the struct docs.
34    pub deprecated: bool,
35    /// The registry's `dist-tags.latest` for this package, but only
36    /// when it differs from the resolved version. `None` when latest
37    /// matches the resolved version, when the registry omits `latest`
38    /// (common on private registries), when `latest` points to a
39    /// prerelease (we don't nudge users toward betas), or when the dep
40    /// wasn't resolved from a packument (git / file / link / remote
41    /// tarball).
42    pub latest: Option<String>,
43}
44
45impl Resolver {
46    /// Return direct updates that an ungated resolve would select but the
47    /// active `minimumReleaseAge` policy hides. The selected gated version
48    /// must match the resolved graph, which avoids mislabeling a difference
49    /// caused by an override, lockfile preference, or another resolver rule.
50    pub fn age_gated_updates(&self, graph: &LockfileGraph) -> Vec<AgeGatedUpdate> {
51        let Some(minimum_release_age) = self.minimum_release_age.as_ref() else {
52            return Vec::new();
53        };
54        let mut updates = Vec::new();
55        for deps in graph.importers.values() {
56            for dep in deps {
57                let Some(pkg) = graph.packages.get(&dep.dep_path) else {
58                    continue;
59                };
60                if pkg.local_source.is_some() {
61                    continue;
62                }
63                let Some(packument) = self.cache.get(pkg.registry_name()) else {
64                    continue;
65                };
66                let Some(range) = dep.specifier.as_deref() else {
67                    continue;
68                };
69                let crate::PickResult::Found(selected) = crate::pick_version_for_add(
70                    packument,
71                    pkg.registry_name(),
72                    range,
73                    Some(minimum_release_age),
74                ) else {
75                    continue;
76                };
77                if selected.version != pkg.version {
78                    continue;
79                }
80                let crate::PickResult::Found(ungated) =
81                    crate::pick_version_for_add(packument, pkg.registry_name(), range, None)
82                else {
83                    continue;
84                };
85                if is_newer(&ungated.version, &selected.version) {
86                    updates.push(AgeGatedUpdate {
87                        name: dep.name.clone(),
88                        version: ungated.version.clone(),
89                    });
90                }
91            }
92        }
93        updates
94    }
95
96    /// Snapshot per-direct-dep packument facts so the install summary
97    /// printer can render them inline after the resolver — and its
98    /// packument cache — is dropped. Keys are `DirectDep::dep_path`;
99    /// importer direct deps don't carry peer-context suffixes, so the
100    /// key matches the `LockfileGraph.packages` entry 1:1.
101    ///
102    /// Skips deps whose packument wasn't fetched (frozen-lockfile reuse,
103    /// non-registry sources) and deps whose registry didn't publish a
104    /// `latest` dist-tag. Returns only entries where at least one signal
105    /// is set so the caller's printer can use `get(dep_path)` as the
106    /// "should I render badges?" check.
107    pub fn direct_dep_info(&self, graph: &LockfileGraph) -> HashMap<String, DirectDepInfo> {
108        let mut out: HashMap<String, DirectDepInfo> = HashMap::new();
109        for deps in graph.importers.values() {
110            for dep in deps {
111                let Some(pkg) = graph.packages.get(&dep.dep_path) else {
112                    continue;
113                };
114                if pkg.local_source.is_some() {
115                    continue;
116                }
117                let Some(packument) = self.cache.get(pkg.registry_name()) else {
118                    continue;
119                };
120                let deprecated = packument
121                    .versions
122                    .get(&pkg.version)
123                    .is_some_and(|v| v.deprecated.is_some());
124                let latest = packument
125                    .dist_tags
126                    .get("latest")
127                    .filter(|l| l.as_str() != pkg.version.as_str())
128                    .filter(|l| !is_prerelease(l))
129                    .cloned();
130                if deprecated || latest.is_some() {
131                    out.insert(dep.dep_path.clone(), DirectDepInfo { deprecated, latest });
132                }
133            }
134        }
135        out
136    }
137}
138
139/// Whether a version string parses to a semver with a prerelease tag
140/// (e.g. `1.2.0-beta.3`, `2.0.0-rc.1`). Unparseable strings are treated
141/// as non-prerelease so a registry returning a non-semver `latest`
142/// (rare, but possible) still surfaces as an upgrade hint.
143fn is_prerelease(version: &str) -> bool {
144    node_semver::Version::parse(version)
145        .map(|v| !v.pre_release.is_empty())
146        .unwrap_or(false)
147}
148
149fn is_newer(candidate: &str, selected: &str) -> bool {
150    match (
151        node_semver::Version::parse(candidate),
152        node_semver::Version::parse(selected),
153    ) {
154        (Ok(candidate), Ok(selected)) => candidate > selected,
155        _ => candidate != selected,
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::is_prerelease;
162
163    #[test]
164    fn detects_prerelease_versions() {
165        assert!(is_prerelease("1.0.0-beta.1"));
166        assert!(is_prerelease("2.0.0-rc.0"));
167        assert!(is_prerelease("0.1.0-alpha"));
168    }
169
170    #[test]
171    fn stable_versions_are_not_prerelease() {
172        assert!(!is_prerelease("1.0.0"));
173        assert!(!is_prerelease("0.0.1"));
174        assert!(!is_prerelease("not-a-version"));
175    }
176}