pray-core 1.11.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::hashing::sha256_prefixed;
use crate::render::RenderedTarget;
use crate::{PrayError, PrayResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Lockfile {
    pub prayfile_lock: String,
    pub spec: String,
    pub generated_by: String,
    pub manifest_hash: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub environment: Option<String>,
    pub source: Vec<LockSource>,
    pub package: Vec<LockedPackage>,
    pub target: Vec<LockedTarget>,
    pub managed_span: Vec<ManagedSpanRecord>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provisioned: Vec<ProvisionedFileRecord>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockSource {
    pub name: String,
    pub kind: String,
    pub url: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub host_key_fingerprint: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedPackage {
    pub name: String,
    pub version: String,
    pub source: Option<String>,
    pub path: String,
    pub tree_hash: String,
    pub artifact_hash: String,
    pub artifact: String,
    pub exports: Vec<String>,
    pub dependencies: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signer_fingerprint: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedTarget {
    pub name: String,
    pub outputs: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManagedSpanRecord {
    pub id: String,
    pub target: String,
    pub open_line: usize,
    pub close_line: usize,
    pub ideal_checksum: String,
    pub package: String,
    pub export: String,
    pub source_checksum: String,
    pub silenced: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProvisionedFileRecord {
    pub path: String,
    pub content_hash: String,
    pub package: String,
    pub export: String,
}

impl Lockfile {
    pub fn canonicalized(&self) -> Self {
        let mut lockfile = self.clone();
        lockfile
            .source
            .sort_by(|left, right| left.name.cmp(&right.name));
        lockfile.package.sort_by(|left, right| {
            left.name
                .cmp(&right.name)
                .then(left.source.cmp(&right.source))
                .then(left.version.cmp(&right.version))
        });
        lockfile
            .target
            .sort_by(|left, right| left.name.cmp(&right.name));
        lockfile.managed_span.sort_by(|left, right| {
            left.target
                .cmp(&right.target)
                .then(left.open_line.cmp(&right.open_line))
                .then(left.id.cmp(&right.id))
        });
        lockfile.provisioned.sort_by(|left, right| {
            left.path
                .cmp(&right.path)
                .then(left.package.cmp(&right.package))
        });
        lockfile
    }

    pub fn serialized(&self) -> PrayResult<String> {
        let bytes = toml::to_string_pretty(&self.canonicalized())
            .map_err(|error| PrayError::Manifest(error.to_string()))?;
        Ok(bytes)
    }

    pub fn file_hash(&self) -> PrayResult<String> {
        let text = self.serialized()?;
        Ok(sha256_prefixed(text.as_bytes()))
    }

    pub fn equivalent_to(&self, other: &Self) -> bool {
        self == &other.canonicalized()
    }
}

pub fn lockfiles_equivalent(canonical: &Lockfile, other: &Lockfile) -> bool {
    canonical.equivalent_to(other)
}

pub fn write_lockfile_if_changed(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
    let serialized = lockfile.serialized()?;
    if path.exists() {
        if let Ok(existing) = fs::read(path) {
            if existing == serialized.as_bytes() {
                return Ok(());
            }
        }
    }
    fs::write(path, serialized)?;
    Ok(())
}

pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
    let serialized = lockfile.serialized()?;
    fs::write(path, serialized)?;
    Ok(())
}

pub fn read_lockfile(path: &Path) -> PrayResult<Lockfile> {
    let text = fs::read_to_string(path)?;
    let lockfile = toml::from_str(&text).map_err(|error| PrayError::Parse {
        kind: "lockfile",
        message: error.to_string(),
    })?;
    Ok(lockfile)
}

pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        project_root.join(path)
    };
    let normalized_root = lexical_normalize_path(project_root);
    let normalized_absolute = lexical_normalize_path(&absolute);
    let relative = normalized_absolute
        .strip_prefix(&normalized_root)
        .map(Path::to_path_buf)
        .unwrap_or_else(|_| {
            if path.is_absolute() {
                path.to_path_buf()
            } else {
                lexical_normalize_path(path)
            }
        });
    format_relative_lockfile_path(&relative)
}

fn format_relative_lockfile_path(relative: &Path) -> String {
    let text = relative.to_string_lossy().replace('\\', "/");
    if text == "." || text.starts_with("./") {
        text
    } else {
        format!("./{text}")
    }
}

fn lexical_normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
            Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
            Component::CurDir => {}
            Component::ParentDir => {
                let _ = normalized.pop();
            }
            Component::Normal(segment) => normalized.push(segment),
        }
    }
    normalized
}

fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
    if let Some(path_text) = artifact.strip_prefix("path:") {
        let path = Path::new(path_text);
        let relative = if path.is_absolute() {
            relative_lockfile_path(project_root, path)
        } else {
            relative_lockfile_path(project_root, package_root)
        };
        format!("path:{relative}")
    } else {
        artifact.to_string()
    }
}

#[allow(clippy::too_many_arguments)]
pub fn build_lockfile(
    manifest_hash: String,
    environment: Option<String>,
    project_root: &Path,
    manifest_sources: &[crate::manifest::ManifestSource],
    manifest_targets: &[crate::manifest::ManifestTarget],
    rendered: &[RenderedTarget],
    packages: &[crate::resolve::ResolvedPackage],
    source_revisions: &std::collections::BTreeMap<String, String>,
    source_host_keys: &std::collections::BTreeMap<String, String>,
) -> Lockfile {
    Lockfile {
        prayfile_lock: "1".to_string(),
        spec: "0.1".to_string(),
        generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
        manifest_hash,
        environment,
        source: manifest_sources
            .iter()
            .map(|source| LockSource {
                name: source.name.clone(),
                kind: source.kind.clone(),
                url: source.url.clone(),
                revision: source_revisions.get(&source.name).cloned(),
                host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
            })
            .collect(),
        package: packages
            .iter()
            .map(|package| LockedPackage {
                name: package.declaration.name.clone(),
                version: package.spec.version.clone(),
                source: package.declaration.source.clone(),
                path: relative_lockfile_path(project_root, &package.root),
                tree_hash: package.tree_hash.clone(),
                artifact_hash: package.artifact_hash.clone(),
                artifact: normalize_lockfile_artifact(
                    project_root,
                    &package.artifact,
                    &package.root,
                ),
                exports: package.selected_exports.clone(),
                dependencies: package
                    .spec
                    .dependencies
                    .iter()
                    .map(|dependency| dependency.name.clone())
                    .collect(),
                signer_fingerprint: package.signer_fingerprint.clone(),
            })
            .collect(),
        target: manifest_targets
            .iter()
            .map(|target| LockedTarget {
                name: target.name.clone(),
                outputs: target.outputs.clone(),
            })
            .collect(),
        managed_span: rendered
            .iter()
            .flat_map(|target| target.managed_spans.iter().cloned())
            .collect(),
        provisioned: Vec::new(),
    }
    .canonicalized()
}

#[cfg(test)]
#[path = "lockfile_unit.rs"]
mod lockfile_unit;