Skip to main content

nova_pm/
lock.rs

1//! `nova.lock` (spec
2//! `docs/superpowers/specs/2026-10-09-phase-3-3b-index-and-publishing-design.md`
3//! ยง4.6): the registry packages a project builds with, and the index they
4//! came from.
5
6use std::ops::Range;
7
8use nova_diagnostics::{Diagnostic, FileId, Span};
9use semver::Version;
10use toml_edit::{ImDocument, Item};
11
12/// The lockfile's name, beside `nova.toml`.
13pub const LOCKFILE: &str = "nova.lock";
14
15/// A project's `nova.lock`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Lock {
18    /// The canonical form of the index its packages came from.
19    pub index: String,
20    /// Sorted by name.
21    pub packages: Vec<LockedPackage>,
22}
23
24/// One registry package of a [`Lock`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LockedPackage {
27    pub name: String,
28    pub version: Version,
29    /// The SHA-256 of its tarball, 64 lower-case hex digits.
30    pub checksum: String,
31    /// The names of its dependencies, sorted.
32    pub dependencies: Vec<String>,
33}
34
35impl Lock {
36    pub fn find(&self, name: &str) -> Option<&LockedPackage> {
37        self.packages.iter().find(|p| p.name == name)
38    }
39
40    /// The lock as nova writes it, with `\n` line endings.
41    pub fn to_text(&self) -> String {
42        let mut out = String::from("# Written by nova. Commit it for a program.\nversion = 1\n");
43        out.push_str(&format!("index = {}\n", quote(&self.index)));
44        for package in &self.packages {
45            out.push_str("\n[[package]]\n");
46            out.push_str(&format!("name = {}\n", quote(&package.name)));
47            out.push_str(&format!(
48                "version = {}\n",
49                quote(&package.version.to_string())
50            ));
51            out.push_str(&format!("checksum = {}\n", quote(&package.checksum)));
52            let names: Vec<String> = package.dependencies.iter().map(|n| quote(n)).collect();
53            out.push_str(&format!("dependencies = [{}]\n", names.join(", ")));
54        }
55        out
56    }
57}
58
59/// `text` as a TOML basic string.
60fn quote(text: &str) -> String {
61    let mut out = String::from("\"");
62    for c in text.chars() {
63        match c {
64            '"' => out.push_str("\\\""),
65            '\\' => out.push_str("\\\\"),
66            c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
67            c => out.push(c),
68        }
69    }
70    out.push('"');
71    out
72}
73
74/// Parse `text`, the contents of a `nova.lock` that a `FileDb` holds as
75/// `file`. Anything a lock written by nova would not hold is M0016.
76pub fn parse_lock(text: &str, file: FileId) -> Result<Lock, Diagnostic> {
77    let bad = |message: &str, at: Option<Range<usize>>| {
78        let at = at.unwrap_or(0..0);
79        Diagnostic::error("M0016", format!("nova.lock cannot be read: {message}"))
80            .with_primary_label(Span::new(at.start as u32, at.end as u32, file), "here")
81            .with_note("delete nova.lock, or run `nova update`")
82    };
83    let document = ImDocument::parse(text).map_err(|e| bad(e.message().trim(), e.span()))?;
84    let table = document.as_table();
85    if table.get("version").and_then(Item::as_integer) != Some(1) {
86        return Err(bad(
87            "its `version` is not 1",
88            table.get("version").and_then(Item::span),
89        ));
90    }
91    let index = table
92        .get("index")
93        .and_then(Item::as_str)
94        .ok_or_else(|| bad("it has no `index`", None))?
95        .to_string();
96    let mut packages = Vec::new();
97    if let Some(item) = table.get("package") {
98        let tables = item
99            .as_array_of_tables()
100            .ok_or_else(|| bad("`package` is not a list of tables", item.span()))?;
101        for package in tables.iter() {
102            let field = |key: &str| package.get(key).and_then(Item::as_str);
103            let (Some(name), Some(version), Some(checksum)) =
104                (field("name"), field("version"), field("checksum"))
105            else {
106                return Err(bad(
107                    "a package lacks its name, version or checksum",
108                    package.span(),
109                ));
110            };
111            let version = Version::parse(version)
112                .map_err(|e| bad(&format!("version `{version}`: {e}"), package.span()))?;
113            let dependencies = match package.get("dependencies") {
114                None => Vec::new(),
115                Some(item) => item
116                    .as_array()
117                    .and_then(|array| {
118                        array
119                            .iter()
120                            .map(|v| v.as_str().map(str::to_string))
121                            .collect::<Option<Vec<_>>>()
122                    })
123                    .ok_or_else(|| bad("`dependencies` is not a list of names", item.span()))?,
124            };
125            packages.push(LockedPackage {
126                name: name.to_string(),
127                version,
128                checksum: checksum.to_string(),
129                dependencies,
130            });
131        }
132    }
133    packages.sort_by(|a, b| a.name.cmp(&b.name));
134    Ok(Lock { index, packages })
135}