Skip to main content

ridl_core/
lock.rs

1//! The `ridl.lock` lockfile (docs/ROADMAP.md epic E1.6, ADR-0002 §7).
2//!
3//! A [`Lockfile`] pins every remote import URL to the SHA-256 content hash of
4//! the artifact it resolved to. It lives at the workspace root — or the package
5//! root for a standalone package — as `ridl.lock`, is regenerated on every
6//! successful resolution, and is verified strictly under `ridlc --frozen`
7//! (ADR-0002 §7). The regeneration and verification live in
8//! [`materialize_imports`](crate::fetch::materialize_imports); this module only
9//! reads and writes the file.
10//!
11//! The on-disk form is TOML, keyed by URL:
12//!
13//! ```toml
14//! [entries."https://registry.example.com/veh/common@v1.2.0"]
15//! sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
16//! ```
17//!
18//! This module sits behind the `fs` feature: reading and writing the lockfile
19//! touches the filesystem, and the `wasm32-unknown-unknown` build
20//! (`--no-default-features`) has no lockfile.
21
22use std::collections::BTreeMap;
23use std::fs;
24use std::io;
25use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28
29use crate::diag::{Diagnostic, FileId, Severity, Span};
30
31/// A comment header written above the generated table so a human opening the
32/// file sees it is machine-owned. TOML comments are ignored on read, so the
33/// header round-trips transparently.
34const LOCKFILE_HEADER: &str = "\
35# ridl.lock — generated by ridlc; do not edit by hand.
36# Regenerated on every successful resolution (ADR-0002 §7).
37
38";
39
40/// The parsed `ridl.lock`: every remote import URL mapped to its pinned entry.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Lockfile {
43    /// URL to pinned entry. A [`BTreeMap`] so the serialized form is stable
44    /// (sorted by URL), which keeps the file's diff minimal across
45    /// regenerations.
46    #[serde(default)]
47    pub entries: BTreeMap<String, LockEntry>,
48}
49
50/// One lockfile entry: the SHA-256 content hash of the artifact a URL resolved
51/// to, as a lowercase hex string.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct LockEntry {
54    pub sha256: String,
55}
56
57/// Reads and parses `ridl.lock` at `path`.
58///
59/// A missing file is not an error — resolution simply regenerates the lockfile
60/// — so it returns `(None, [])`. A file that cannot be read or is not a valid
61/// lockfile returns `(None, [warning])`: the malformed lockfile is discarded
62/// and will be regenerated on a non-frozen build, and the warning tells the
63/// user why. The warning is detached ([`FileId::DETACHED`]) because a corrupt
64/// lockfile has no useful byte span to point at.
65pub fn read_lockfile(path: &Path) -> (Option<Lockfile>, Vec<Diagnostic>) {
66    let text = match fs::read_to_string(path) {
67        Ok(text) => text,
68        Err(err) if err.kind() == io::ErrorKind::NotFound => return (None, Vec::new()),
69        Err(err) => {
70            let message = format!(
71                "cannot read `{}`: {err}; the lockfile will be regenerated",
72                path.display()
73            );
74            return (None, vec![warning(message)]);
75        }
76    };
77    match toml::from_str::<Lockfile>(&text) {
78        Ok(lock) => (Some(lock), Vec::new()),
79        Err(err) => {
80            let rendered = err.to_string();
81            let reason = rendered.lines().last().unwrap_or("invalid lockfile").trim();
82            let message = format!(
83                "`{}` is not a valid lockfile ({reason}); it will be regenerated",
84                path.display()
85            );
86            (None, vec![warning(message)])
87        }
88    }
89}
90
91/// Serializes `lock` to TOML and writes it to `path`, replacing any existing
92/// file. The written file carries the `LOCKFILE_HEADER` comment.
93pub fn write_lockfile(path: &Path, lock: &Lockfile) -> io::Result<()> {
94    let body = toml::to_string_pretty(lock)
95        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
96    fs::write(path, format!("{LOCKFILE_HEADER}{body}"))
97}
98
99/// Builds a detached warning [`Diagnostic`]: no source span, since a corrupt or
100/// unreadable lockfile has no meaningful byte range.
101fn warning(message: String) -> Diagnostic {
102    Diagnostic {
103        code: crate::diag::DiagCode::NONE,
104        severity: Severity::Warning,
105        message,
106        primary: Span {
107            file: FileId::DETACHED,
108            range: rowan::TextRange::default(),
109        },
110        labels: Vec::new(),
111        fixits: Vec::new(),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use std::path::PathBuf;
118    use std::sync::atomic::{AtomicUsize, Ordering};
119
120    use super::*;
121
122    /// A unique directory under the system temp dir, removed on drop.
123    struct TempDir(PathBuf);
124
125    impl TempDir {
126        fn new(label: &str) -> Self {
127            static COUNTER: AtomicUsize = AtomicUsize::new(0);
128            let mut path = std::env::temp_dir();
129            path.push(format!(
130                "ridl-core-lock-{label}-{}-{}",
131                std::process::id(),
132                COUNTER.fetch_add(1, Ordering::SeqCst),
133            ));
134            fs::create_dir_all(&path).expect("create the temp dir");
135            Self(path)
136        }
137
138        fn path(&self) -> &Path {
139            &self.0
140        }
141    }
142
143    impl Drop for TempDir {
144        fn drop(&mut self) {
145            let _ = fs::remove_dir_all(&self.0);
146        }
147    }
148
149    fn entry(sha: &str) -> LockEntry {
150        LockEntry {
151            sha256: sha.to_string(),
152        }
153    }
154
155    /// Writing a lockfile then reading it back reconstructs the exact same
156    /// [`Lockfile`] — the SHA-256 pins survive the round trip.
157    #[test]
158    fn write_then_read_round_trips_the_pins() {
159        let dir = TempDir::new("round-trip");
160        let path = dir.path().join("ridl.lock");
161
162        let mut lock = Lockfile::default();
163        lock.entries.insert(
164            "https://registry.example.com/veh/common@v1.2.0".to_string(),
165            entry("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
166        );
167        lock.entries.insert(
168            "https://registry.example.com/veh/cluster@v0.3.0".to_string(),
169            entry("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"),
170        );
171
172        write_lockfile(&path, &lock).expect("the lockfile writes");
173        let (read, diags) = read_lockfile(&path);
174        assert!(
175            diags.is_empty(),
176            "a clean lockfile reads without diagnostics"
177        );
178        assert_eq!(read, Some(lock), "the round trip preserves every pin");
179    }
180
181    /// The written file carries the generated-by header comment.
182    #[test]
183    fn written_file_carries_the_header() {
184        let dir = TempDir::new("header");
185        let path = dir.path().join("ridl.lock");
186        write_lockfile(&path, &Lockfile::default()).expect("writes");
187        let text = fs::read_to_string(&path).expect("reads back");
188        assert!(
189            text.starts_with("# ridl.lock — generated by ridlc"),
190            "the header comment is present, got:\n{text}",
191        );
192    }
193
194    /// A missing lockfile is not an error: `(None, [])`, so a non-frozen build
195    /// simply regenerates it.
196    #[test]
197    fn a_missing_lockfile_is_none_without_diagnostics() {
198        let dir = TempDir::new("missing");
199        let path = dir.path().join("ridl.lock");
200        let (lock, diags) = read_lockfile(&path);
201        assert_eq!(lock, None);
202        assert!(diags.is_empty(), "a missing lockfile is not a diagnostic");
203    }
204
205    /// A malformed lockfile is discarded with a detached warning rather than
206    /// returning a parsed value.
207    #[test]
208    fn a_malformed_lockfile_warns_and_is_discarded() {
209        let dir = TempDir::new("malformed");
210        let path = dir.path().join("ridl.lock");
211        fs::write(&path, "this is = not [a valid lockfile").expect("write junk");
212
213        let (lock, diags) = read_lockfile(&path);
214        assert_eq!(lock, None, "a malformed lockfile yields no value");
215        assert_eq!(diags.len(), 1);
216        assert_eq!(diags[0].severity, Severity::Warning);
217        assert_eq!(
218            diags[0].primary.file,
219            FileId::DETACHED,
220            "a lockfile diagnostic is not tied to a source file",
221        );
222        assert!(diags[0].message.contains("not a valid lockfile"));
223    }
224}