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}