pray-core 1.22.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::paths::remove_path_if_exists;
use crate::resolve_git::{git_object_exists, is_local_distribution_root};
use crate::resolve_git_clone::catalog_sparse_cones;
use crate::resolve_git_command::run_git_command_with_env;
use crate::resolve_git_lock::acquire_git_cache_lock;
use crate::resolve_git_materialize_check::{
    annotate_git_materialize_failure, require_revision_present_offline,
};
use crate::resolve_git_materialize_markers::{
    catalog_root_markers, offline_git_env, GIT_DIR_MARKER, REVISION_MARKER,
};
use crate::resolve_git_materialize_size::{
    require_blob_within_limit, require_tree_prefix_within_limits,
};
use crate::resolve_git_materialize_unpack::unpack_bounded_catalog_tar;
use crate::{PrayError, PrayResult};
use std::fs;
use std::path::{Path, PathBuf};

pub(crate) fn materialize_catalog_tree(
    git_dir: &Path,
    dest: &Path,
    revision: &str,
    subdir: Option<&str>,
    refresh: bool,
    offline: bool,
) -> PrayResult<()> {
    require_revision_present_offline(git_dir, revision, offline)?;
    let _lock = acquire_git_cache_lock(dest)?;
    if !refresh && catalog_matches(dest, git_dir, revision) {
        return Ok(());
    }
    let staging = staging_catalog_path(dest);
    if staging.exists() {
        remove_path_if_exists(&staging)?;
    }
    fs::create_dir_all(&staging)?;
    let mut unpacked = false;
    for prefix in catalog_sparse_cones(subdir) {
        if unpack_archive_prefix(git_dir, &staging, revision, &prefix, offline)? {
            unpacked = true;
        }
    }
    if !unpacked {
        remove_path_if_exists(&staging)?;
        if offline {
            return Err(PrayError::Resolution(format!(
                "git catalog at revision {revision} is not materializable locally and offline mode is enabled"
            )));
        }
        return Err(PrayError::Resolution(format!(
            "no pray distribution root in git source at revision {revision}"
        )));
    }
    fs::write(staging.join(REVISION_MARKER), revision)?;
    let git_dir_text = git_dir.to_str().ok_or_else(|| {
        PrayError::Resolution(format!("invalid global git cache path: {:?}", git_dir))
    })?;
    fs::write(staging.join(GIT_DIR_MARKER), git_dir_text)?;
    publish_catalog_tree(dest, &staging)
}

pub(crate) fn materialize_git_catalog_file(
    source_root: &Path,
    relative: &Path,
    offline: bool,
) -> PrayResult<()> {
    let full_path = source_root.join(relative);
    if full_path.is_file() {
        return Ok(());
    }
    let Some((git_dir, revision, catalog_root)) = catalog_root_markers(source_root) else {
        return Ok(());
    };
    let _lock = acquire_git_cache_lock(&catalog_root)?;
    if full_path.is_file() {
        return Ok(());
    }
    require_revision_present_offline(&git_dir, &revision, offline)?;
    let Ok(repo_relative) = full_path.strip_prefix(&catalog_root) else {
        return Ok(());
    };
    let Some(repo_relative) = repo_relative.to_str() else {
        return Ok(());
    };
    let object = format!("{revision}:{repo_relative}");
    require_blob_within_limit(&git_dir, &object, offline)
        .map_err(|error| annotate_git_materialize_failure(&git_dir, &revision, error))?;
    let output = run_git_command_with_env(&git_dir, &["show", &object], offline_git_env(offline))
        .map_err(|error| annotate_git_materialize_failure(&git_dir, &revision, error))?;
    if !output.status.success() {
        let error = crate::resolve_git_command::command_error("git show", output);
        if offline {
            return Err(PrayError::Resolution(format!(
                "git catalog blob {repo_relative} at revision {revision} is not available locally and offline mode is enabled"
            )));
        }
        return Err(annotate_git_materialize_failure(&git_dir, &revision, error));
    }
    write_catalog_blob_atomic(&full_path, &output.stdout)
}

fn write_catalog_blob_atomic(full_path: &Path, bytes: &[u8]) -> PrayResult<()> {
    if let Some(parent) = full_path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut partial = full_path.as_os_str().to_os_string();
    partial.push(".partial");
    let partial = PathBuf::from(partial);
    fs::write(&partial, bytes).map_err(|error| {
        PrayError::Resolution(format!(
            "failed to write git catalog file {}: {error}",
            full_path.display()
        ))
    })?;
    fs::rename(&partial, full_path).map_err(|error| {
        let _ = remove_path_if_exists(&partial);
        PrayError::Resolution(format!(
            "failed to publish git catalog file {}: {error}",
            full_path.display()
        ))
    })
}

fn catalog_matches(dest: &Path, git_dir: &Path, revision: &str) -> bool {
    let recorded = fs::read_to_string(dest.join(REVISION_MARKER)).unwrap_or_default();
    if recorded.trim() != revision {
        return false;
    }
    let recorded_dir = fs::read_to_string(dest.join(GIT_DIR_MARKER)).unwrap_or_default();
    if Path::new(recorded_dir.trim()) != git_dir {
        return false;
    }
    if !git_object_exists(git_dir, revision) {
        return false;
    }
    is_local_distribution_root(dest)
        || is_local_distribution_root(&dest.join("prayers"))
        || dest.read_dir().ok().is_some_and(|entries| {
            entries
                .flatten()
                .any(|entry| is_local_distribution_root(&entry.path()))
        })
}

fn unpack_archive_prefix(
    git_dir: &Path,
    dest: &Path,
    revision: &str,
    prefix: &str,
    offline: bool,
) -> PrayResult<bool> {
    if !require_tree_prefix_within_limits(git_dir, revision, prefix, offline)? {
        return Ok(false);
    }
    let output = run_git_command_with_env(
        git_dir,
        &["archive", "--format=tar", revision, "--", prefix],
        offline_git_env(offline),
    )?;
    if !output.status.success() || output.stdout.is_empty() {
        return Ok(false);
    }
    unpack_bounded_catalog_tar(&output.stdout, dest)?;
    Ok(true)
}

fn staging_catalog_path(dest: &Path) -> PathBuf {
    let mut staging = dest.as_os_str().to_os_string();
    staging.push(".partial");
    PathBuf::from(staging)
}

fn publish_catalog_tree(dest: &Path, staging: &Path) -> PrayResult<()> {
    if let Some(parent) = dest.parent() {
        fs::create_dir_all(parent)?;
    }
    if dest.exists() {
        let backup = {
            let mut path = dest.as_os_str().to_os_string();
            path.push(".previous");
            PathBuf::from(path)
        };
        if backup.exists() {
            remove_path_if_exists(&backup)?;
        }
        fs::rename(dest, &backup).map_err(|error| {
            PrayError::Resolution(format!(
                "failed to retire previous git catalog {}: {error}",
                dest.display()
            ))
        })?;
        if let Err(error) = fs::rename(staging, dest) {
            let _ = fs::rename(&backup, dest);
            let _ = remove_path_if_exists(staging);
            return Err(PrayError::Resolution(format!(
                "failed to publish git catalog {}: {error}",
                dest.display()
            )));
        }
        remove_path_if_exists(&backup)?;
        return Ok(());
    }
    fs::rename(staging, dest).map_err(|error| {
        PrayError::Resolution(format!(
            "failed to publish git catalog {}: {error}",
            dest.display()
        ))
    })
}