1use 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
31const 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Lockfile {
43 #[serde(default)]
47 pub entries: BTreeMap<String, LockEntry>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct LockEntry {
54 pub sha256: String,
55}
56
57pub 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
91pub 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
99fn 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 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 #[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 #[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 #[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 #[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}