use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::diag::{Diagnostic, FileId, Severity, Span};
const LOCKFILE_HEADER: &str = "\
# ridl.lock — generated by ridlc; do not edit by hand.
# Regenerated on every successful resolution (ADR-0002 §7).
";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lockfile {
#[serde(default)]
pub entries: BTreeMap<String, LockEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
pub sha256: String,
}
pub fn read_lockfile(path: &Path) -> (Option<Lockfile>, Vec<Diagnostic>) {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return (None, Vec::new()),
Err(err) => {
let message = format!(
"cannot read `{}`: {err}; the lockfile will be regenerated",
path.display()
);
return (None, vec![warning(message)]);
}
};
match toml::from_str::<Lockfile>(&text) {
Ok(lock) => (Some(lock), Vec::new()),
Err(err) => {
let rendered = err.to_string();
let reason = rendered.lines().last().unwrap_or("invalid lockfile").trim();
let message = format!(
"`{}` is not a valid lockfile ({reason}); it will be regenerated",
path.display()
);
(None, vec![warning(message)])
}
}
}
pub fn write_lockfile(path: &Path, lock: &Lockfile) -> io::Result<()> {
let body = toml::to_string_pretty(lock)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
fs::write(path, format!("{LOCKFILE_HEADER}{body}"))
}
fn warning(message: String) -> Diagnostic {
Diagnostic {
code: crate::diag::DiagCode::NONE,
severity: Severity::Warning,
message,
primary: Span {
file: FileId::DETACHED,
range: rowan::TextRange::default(),
},
labels: Vec::new(),
fixits: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"ridl-core-lock-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
fs::create_dir_all(&path).expect("create the temp dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn entry(sha: &str) -> LockEntry {
LockEntry {
sha256: sha.to_string(),
}
}
#[test]
fn write_then_read_round_trips_the_pins() {
let dir = TempDir::new("round-trip");
let path = dir.path().join("ridl.lock");
let mut lock = Lockfile::default();
lock.entries.insert(
"https://registry.example.com/veh/common@v1.2.0".to_string(),
entry("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
);
lock.entries.insert(
"https://registry.example.com/veh/cluster@v0.3.0".to_string(),
entry("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"),
);
write_lockfile(&path, &lock).expect("the lockfile writes");
let (read, diags) = read_lockfile(&path);
assert!(
diags.is_empty(),
"a clean lockfile reads without diagnostics"
);
assert_eq!(read, Some(lock), "the round trip preserves every pin");
}
#[test]
fn written_file_carries_the_header() {
let dir = TempDir::new("header");
let path = dir.path().join("ridl.lock");
write_lockfile(&path, &Lockfile::default()).expect("writes");
let text = fs::read_to_string(&path).expect("reads back");
assert!(
text.starts_with("# ridl.lock — generated by ridlc"),
"the header comment is present, got:\n{text}",
);
}
#[test]
fn a_missing_lockfile_is_none_without_diagnostics() {
let dir = TempDir::new("missing");
let path = dir.path().join("ridl.lock");
let (lock, diags) = read_lockfile(&path);
assert_eq!(lock, None);
assert!(diags.is_empty(), "a missing lockfile is not a diagnostic");
}
#[test]
fn a_malformed_lockfile_warns_and_is_discarded() {
let dir = TempDir::new("malformed");
let path = dir.path().join("ridl.lock");
fs::write(&path, "this is = not [a valid lockfile").expect("write junk");
let (lock, diags) = read_lockfile(&path);
assert_eq!(lock, None, "a malformed lockfile yields no value");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Warning);
assert_eq!(
diags[0].primary.file,
FileId::DETACHED,
"a lockfile diagnostic is not tied to a source file",
);
assert!(diags[0].message.contains("not a valid lockfile"));
}
}