Skip to main content

nova_pm/
resolve.rs

1//! The resolver (spec
2//! `docs/superpowers/specs/2026-10-09-phase-3-3b-index-and-publishing-design.md`
3//! §4): one version of each package name per build, caret ranges, and a
4//! backtracking search that keeps locked versions.
5
6use std::collections::{BTreeMap, HashMap};
7
8use nova_diagnostics::{Diagnostic, Span};
9use semver::{Version, VersionReq};
10
11use crate::lock::{Lock, LockedPackage};
12
13/// One version of a package, as the index lists it.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Candidate {
16    pub version: Version,
17    /// Its `[dependencies]`: names and requirements.
18    pub deps: Vec<(String, VersionReq)>,
19    /// The SHA-256 of its tarball.
20    pub checksum: String,
21}
22
23/// What the resolver asks of an index.
24pub trait IndexView {
25    /// Every version of `name`, or `Ok(None)` when the index has no
26    /// package of that name. `Err` when the index cannot be read.
27    fn versions(&mut self, name: &str) -> Result<Option<Vec<Candidate>>, String>;
28}
29
30/// A requirement on a package, and where it came from.
31#[derive(Debug, Clone)]
32pub struct Requirement {
33    pub name: String,
34    pub req: VersionReq,
35    /// The package that made it, named in M0015.
36    pub by: String,
37    /// The root's manifest entry through which it is reached, where M0014
38    /// and M0015 point (spec §4.5).
39    pub span: Span,
40}
41
42/// What a resolution may change (spec §4.4).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Unlock {
45    /// Keep every locked version that still fits. When they cannot all
46    /// stay, resolve again as if there were no lock.
47    Nothing,
48    /// `nova update`: ignore the lock.
49    All,
50    /// `nova update <name>`: only that name may move.
51    One(String),
52}
53
54/// Why a resolution failed.
55#[derive(Debug)]
56pub enum ResolveError {
57    /// M0014 or M0015, ready to render.
58    Diagnostic(Diagnostic),
59    /// The index could not be read: why.
60    Index(String),
61}
62
63/// The packages `requirements` need, newest versions first, keeping
64/// `lock`'s versions as `unlock` says. Sorted by name, each with its
65/// dependencies' names.
66pub fn resolve(
67    requirements: &[Requirement],
68    lock: Option<&Lock>,
69    unlock: &Unlock,
70    index: &mut dyn IndexView,
71) -> Result<Vec<LockedPackage>, ResolveError> {
72    let pins = |except: Option<&str>| -> HashMap<String, Version> {
73        lock.map(|lock| {
74            lock.packages
75                .iter()
76                .filter(|p| Some(p.name.as_str()) != except)
77                .map(|p| (p.name.clone(), p.version.clone()))
78                .collect()
79        })
80        .unwrap_or_default()
81    };
82    let mut search = Search {
83        index,
84        known: HashMap::new(),
85        pinned: HashMap::new(),
86        strict: false,
87        failure: None,
88        conflicted: Vec::new(),
89    };
90    match unlock {
91        Unlock::All => search.run(requirements, HashMap::new(), false),
92        Unlock::One(name) => search
93            .run(requirements, pins(Some(name.as_str())), true)
94            .map_err(|error| match error {
95                ResolveError::Diagnostic(d) if d.code == "M0015" => ResolveError::Diagnostic(
96                    d.with_note("the other locked versions were kept; run `nova update` to let them move too"),
97                ),
98                other => other,
99            }),
100        Unlock::Nothing => {
101            // Keep every locked version that can stay (spec §4.3): when the
102            // pinned search meets a conflict, unpin only the packages it
103            // names and search again, until it succeeds or nothing is left
104            // pinned.
105            let mut pinned = pins(None);
106            loop {
107                match search.run(requirements, pinned.clone(), false) {
108                    Err(ResolveError::Diagnostic(d)) if d.code == "M0015" && !pinned.is_empty() => {
109                        let before = pinned.len();
110                        for name in &search.conflicted {
111                            pinned.remove(name);
112                        }
113                        // A conflict naming no pinned package: unpin them all.
114                        if pinned.len() == before {
115                            pinned.clear();
116                        }
117                    }
118                    result => return result,
119                }
120            }
121        }
122    }
123}
124
125/// Why a search failed: a package, and every requirement on it.
126struct Failure {
127    name: String,
128    reqs: Vec<Requirement>,
129}
130
131struct State {
132    reqs: Vec<Requirement>,
133    /// Package names in the order they were first reached.
134    order: Vec<String>,
135    chosen: BTreeMap<String, Candidate>,
136}
137
138struct Search<'a> {
139    index: &'a mut dyn IndexView,
140    /// Every version of each package asked for so far, newest first.
141    known: HashMap<String, Vec<Candidate>>,
142    /// Locked versions to keep.
143    pinned: HashMap<String, Version>,
144    /// Whether a pinned version that does not fit leaves no candidate
145    /// (`nova update <name>`) rather than letting its package move.
146    strict: bool,
147    /// The first conflict met, for M0015.
148    failure: Option<Failure>,
149    /// After a failed search: the package of its conflict, and every
150    /// package that made a requirement on it.
151    conflicted: Vec<String>,
152}
153
154impl Search<'_> {
155    fn run(
156        &mut self,
157        requirements: &[Requirement],
158        pinned: HashMap<String, Version>,
159        strict: bool,
160    ) -> Result<Vec<LockedPackage>, ResolveError> {
161        self.pinned = pinned;
162        self.strict = strict;
163        self.failure = None;
164        let mut order: Vec<String> = requirements.iter().map(|r| r.name.clone()).collect();
165        order.sort();
166        order.dedup();
167        let mut state = State {
168            reqs: requirements.to_vec(),
169            order,
170            chosen: BTreeMap::new(),
171        };
172        if self.solve(&mut state)? {
173            return Ok(state
174                .chosen
175                .into_iter()
176                .map(|(name, candidate)| {
177                    let mut dependencies: Vec<String> =
178                        candidate.deps.iter().map(|(n, _)| n.clone()).collect();
179                    dependencies.sort();
180                    dependencies.dedup();
181                    LockedPackage {
182                        name,
183                        version: candidate.version,
184                        checksum: candidate.checksum,
185                        dependencies,
186                    }
187                })
188                .collect());
189        }
190        let failure = self.failure.take().expect("a failed search records why");
191        self.conflicted = std::iter::once(failure.name.clone())
192            .chain(failure.reqs.iter().map(|r| r.by.clone()))
193            .collect();
194        Err(ResolveError::Diagnostic(conflict(&failure)))
195    }
196
197    /// Choose a version for the next unchosen package, depth first.
198    fn solve(&mut self, state: &mut State) -> Result<bool, ResolveError> {
199        let Some(name) = state
200            .order
201            .iter()
202            .find(|n| !state.chosen.contains_key(*n))
203            .cloned()
204        else {
205            return Ok(true);
206        };
207        let reqs: Vec<Requirement> = state
208            .reqs
209            .iter()
210            .filter(|r| r.name == name)
211            .cloned()
212            .collect();
213        let fitting: Vec<Candidate> = self
214            .versions(&name, &reqs)?
215            .into_iter()
216            .filter(|c| reqs.iter().all(|r| r.req.matches(&c.version)))
217            .collect();
218        let kept = self
219            .pinned
220            .get(&name)
221            .map(|version| fitting.iter().position(|c| &c.version == version));
222        let tried = match kept {
223            // A locked version that still fits is the only candidate.
224            Some(Some(i)) => vec![fitting[i].clone()],
225            Some(None) if self.strict => Vec::new(),
226            _ => fitting,
227        };
228        let via = reqs[0].span;
229        for candidate in tried {
230            let reqs_before = state.reqs.len();
231            let order_before = state.order.len();
232            let mut new_names = Vec::new();
233            for (dep, req) in &candidate.deps {
234                state.reqs.push(Requirement {
235                    name: dep.clone(),
236                    req: req.clone(),
237                    by: name.clone(),
238                    span: via,
239                });
240                if !state.order.contains(dep) && !new_names.contains(dep) {
241                    new_names.push(dep.clone());
242                }
243            }
244            new_names.sort();
245            state.order.extend(new_names);
246            // A new requirement on a package already chosen must fit it.
247            let clash = candidate.deps.iter().find(|(dep, req)| {
248                state
249                    .chosen
250                    .get(dep)
251                    .is_some_and(|chosen| !req.matches(&chosen.version))
252            });
253            match clash {
254                Some((dep, _)) => {
255                    let on_dep = state
256                        .reqs
257                        .iter()
258                        .filter(|r| &r.name == dep)
259                        .cloned()
260                        .collect();
261                    self.note(dep, on_dep);
262                }
263                None => {
264                    state.chosen.insert(name.clone(), candidate.clone());
265                    if self.solve(state)? {
266                        return Ok(true);
267                    }
268                    state.chosen.remove(&name);
269                }
270            }
271            state.order.truncate(order_before);
272            state.reqs.truncate(reqs_before);
273        }
274        self.note(&name, reqs);
275        Ok(false)
276    }
277
278    /// Every version of `name`, newest first; M0014 when there is none.
279    fn versions(
280        &mut self,
281        name: &str,
282        reqs: &[Requirement],
283    ) -> Result<Vec<Candidate>, ResolveError> {
284        if !self.known.contains_key(name) {
285            let Some(mut versions) = self.index.versions(name).map_err(ResolveError::Index)? else {
286                return Err(ResolveError::Diagnostic(
287                    Diagnostic::error("M0014", format!("the index has no package `{name}`"))
288                        .with_primary_label(reqs[0].span, "required here"),
289                ));
290            };
291            versions.sort_by(|a, b| b.version.cmp(&a.version));
292            self.known.insert(name.to_string(), versions);
293        }
294        Ok(self.known[name].clone())
295    }
296
297    /// Remember the first conflict met.
298    fn note(&mut self, name: &str, reqs: Vec<Requirement>) {
299        if self.failure.is_none() {
300            self.failure = Some(Failure {
301                name: name.to_string(),
302                reqs,
303            });
304        }
305    }
306}
307
308/// M0015: no version of a package meets every requirement on it.
309fn conflict(failure: &Failure) -> Diagnostic {
310    let wants: Vec<String> = failure
311        .reqs
312        .iter()
313        .map(|r| format!("`{}` from `{}`", r.req, r.by))
314        .collect();
315    Diagnostic::error(
316        "M0015",
317        format!(
318            "no version of `{}` meets every requirement: {}",
319            failure.name,
320            wants.join(", ")
321        ),
322    )
323    .with_primary_label(failure.reqs[0].span, "required here")
324}