dre-core 0.7.0

DRE core engine: project parsing, validation and the run path
Documentation
//! `dre.lock`: the exact plugin versions and package commits a project resolved, committed
//! alongside it.

use std::collections::BTreeMap;
use std::path::Path;

use semver::Version;
use serde::{Deserialize, Serialize};

use dre_protocol::PluginId;

pub const LOCK_FILE: &str = "dre.lock";

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Locked {
    pub version: Version,
    pub sha256: Checksums,
    /// Where it came from when not the default registry: `github:owner/repo` or
    /// `registry:<url>`. A different declared source re-resolves the plugin.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub from: Option<String>,
    /// The plugins the package provides, so a project can be checked without installing it.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provides: Vec<PluginId>,
}

/// A locked plugin's artifact checksums by platform (`macos-aarch64`, `linux-x86_64`, ...), so
/// one committed `dre.lock` installs the same builds on every machine. Written as a map; a lock
/// from before this holds one checksum for a platform it didn't name (see [`Checksums::legacy`]).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Checksums(BTreeMap<String, String>);

/// The key of a checksum whose platform isn't known.
const LEGACY: &str = "";

impl Checksums {
    pub fn new() -> Checksums {
        Checksums::default()
    }

    /// One checksum of unknown platform, as in a lock from before per-platform checksums.
    pub fn legacy_only(sha: &str) -> Checksums {
        let mut c = Checksums::new();
        c.0.insert(LEGACY.into(), sha.to_lowercase());
        c
    }

    pub fn get(&self, platform: &str) -> Option<&str> {
        self.0.get(platform).map(String::as_str)
    }

    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// Whether `sha` is one of the known platforms' checksums.
    pub fn contains(&self, sha: &str) -> bool {
        self.0
            .iter()
            .any(|(p, s)| p != LEGACY && s.eq_ignore_ascii_case(sha))
    }

    pub fn legacy(&self) -> Option<&str> {
        self.get(LEGACY)
    }

    pub fn insert(&mut self, platform: &str, sha: &str) {
        self.0.remove(LEGACY);
        self.0.insert(platform.into(), sha.to_lowercase());
    }

    /// Add the platforms `other` knows and this one doesn't.
    pub fn merge(&mut self, other: &Checksums) {
        for (p, s) in &other.0 {
            if p != LEGACY {
                self.0.entry(p.clone()).or_insert_with(|| s.clone());
            }
        }
    }
}

impl Serialize for Checksums {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        match self.legacy() {
            Some(sha) if self.0.len() == 1 => s.serialize_str(sha),
            _ => self.0.serialize(s),
        }
    }
}

impl<'de> Deserialize<'de> for Checksums {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl<'de> serde::de::Visitor<'de> for V {
            type Value = Checksums;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("checksums by platform, or one checksum")
            }
            fn visit_str<E>(self, sha: &str) -> Result<Checksums, E> {
                Ok(Checksums::legacy_only(sha))
            }
            // An unquoted checksum of digits reads as a number; it matches nothing either way.
            fn visit_u64<E>(self, n: u64) -> Result<Checksums, E> {
                Ok(Checksums::legacy_only(&n.to_string()))
            }
            fn visit_i64<E>(self, n: i64) -> Result<Checksums, E> {
                Ok(Checksums::legacy_only(&n.to_string()))
            }
            fn visit_f64<E>(self, n: f64) -> Result<Checksums, E> {
                Ok(Checksums::legacy_only(&n.to_string()))
            }
            fn visit_map<A: serde::de::MapAccess<'de>>(self, mut m: A) -> Result<Checksums, A::Error> {
                let mut out = BTreeMap::new();
                while let Some((k, v)) = m.next_entry::<String, String>()? {
                    out.insert(k, v.to_lowercase());
                }
                Ok(Checksums(out))
            }
        }
        d.deserialize_any(V)
    }
}

/// A git package pinned to the commit its declared revision resolved to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LockedPackage {
    pub git: String,
    pub revision: String,
    pub commit: String,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Lock {
    /// Plugin packages by name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub plugins: BTreeMap<String, Locked>,
    /// Macro packages by name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub packages: BTreeMap<String, LockedPackage>,
    /// `local:` plugin packages by name: the path they're used from. Nothing to pin.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub local: BTreeMap<String, String>,
}

impl Lock {
    /// The project's lock, or an empty one when there's no `dre.lock`.
    pub fn load(root: &Path) -> Result<Lock, String> {
        let p = root.join(LOCK_FILE);
        match std::fs::read_to_string(&p) {
            Ok(t) => serde_saphyr::from_str(&t).map_err(|e| format!("{LOCK_FILE}: {e}")),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Lock::default()),
            Err(e) => Err(format!("{LOCK_FILE}: {e}")),
        }
    }

    pub fn save(&self, root: &Path) -> Result<(), String> {
        let body = crate::config::to_yaml(self)?;
        let text = format!(
            "# Generated by DRE: the exact plugin versions and package commits this project uses. Commit this file.\n{body}"
        );
        crate::fs::write_atomic(&root.join(LOCK_FILE), text.as_bytes())
            .map_err(|e| format!("{LOCK_FILE}: {e}"))
    }

    /// A plugin package's pin.
    pub fn get(&self, package: &str) -> Option<&Locked> {
        self.plugins.get(package)
    }

    pub fn version(&self, package: &str) -> Option<Version> {
        self.get(package).map(|l| l.version.clone())
    }
}