ridl-core 0.6.0

The RIDL family compiler core: the incremental database, diagnostics, and the package and workspace model.
Documentation
//! The `ridl.lock` lockfile (ADR-0002 §7).
//!
//! A [`Lockfile`] pins every remote import URL to the SHA-256 content hash of
//! the artifact it resolved to. It lives at the workspace root — or the package
//! root for a standalone package — as `ridl.lock`, is regenerated on every
//! successful resolution, and is verified strictly under `ridlc --frozen`
//! (ADR-0002 §7). The regeneration and verification live in
//! [`materialize_imports`](crate::fetch::materialize_imports); this module only
//! reads and writes the file.
//!
//! The on-disk form is TOML, keyed by URL:
//!
//! ```toml
//! [entries."https://registry.example.com/veh/common@v1.2.0"]
//! sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
//! ```
//!
//! This module sits behind the `fs` feature: reading and writing the lockfile
//! touches the filesystem, and the `wasm32-unknown-unknown` build
//! (`--no-default-features`) has no lockfile.

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};

/// A comment header written above the generated table so a human opening the
/// file sees it is machine-owned. TOML comments are ignored on read, so the
/// header round-trips transparently.
const LOCKFILE_HEADER: &str = "\
# ridl.lock — generated by ridlc; do not edit by hand.
# Regenerated on every successful resolution (ADR-0002 §7).

";

/// The parsed `ridl.lock`: every remote import URL mapped to its pinned entry.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lockfile {
    /// URL to pinned entry. A [`BTreeMap`] so the serialized form is stable
    /// (sorted by URL), which keeps the file's diff minimal across
    /// regenerations.
    #[serde(default)]
    pub entries: BTreeMap<String, LockEntry>,
}

/// One lockfile entry: the SHA-256 content hash of the artifact a URL resolved
/// to, as a lowercase hex string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
    pub sha256: String,
}

/// Reads and parses `ridl.lock` at `path`.
///
/// A missing file is not an error — resolution simply regenerates the lockfile
/// — so it returns `(None, [])`. A file that cannot be read or is not a valid
/// lockfile returns `(None, [warning])`: the malformed lockfile is discarded
/// and will be regenerated on a non-frozen build, and the warning tells the
/// user why. The warning is detached ([`FileId::DETACHED`]) because a corrupt
/// lockfile has no useful byte span to point at.
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)])
        }
    }
}

/// Serializes `lock` to TOML and writes it to `path`, replacing any existing
/// file. The written file carries the `LOCKFILE_HEADER` comment.
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}"))
}

/// Builds a detached warning [`Diagnostic`]: no source span, since a corrupt or
/// unreadable lockfile has no meaningful byte range.
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::*;

    /// A unique directory under the system temp dir, removed on drop.
    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(),
        }
    }

    /// Writing a lockfile then reading it back reconstructs the exact same
    /// [`Lockfile`] — the SHA-256 pins survive the round trip.
    #[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");
    }

    /// The written file carries the generated-by header comment.
    #[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}",
        );
    }

    /// A missing lockfile is not an error: `(None, [])`, so a non-frozen build
    /// simply regenerates it.
    #[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");
    }

    /// A malformed lockfile is discarded with a detached warning rather than
    /// returning a parsed value.
    #[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"));
    }
}