rspyts-cli 1.0.0

Compiler and build orchestrator for rspyts
Documentation
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use anyhow::{Context, Result, bail};
use fs4::FileExt;
use serde::Serialize;
use tempfile::TempDir;

use crate::project::Project;

pub(super) struct ProjectLock(fs::File);

pub(super) fn project_lock(project: &Project) -> Result<ProjectLock> {
    let path = project.root.join(".rspyts-build.lock");
    let file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&path)
        .with_context(|| format!("failed to open {}", path.display()))?;
    FileExt::lock(&file).with_context(|| format!("failed to lock {}", path.display()))?;
    Ok(ProjectLock(file))
}

impl Drop for ProjectLock {
    fn drop(&mut self) {
        let _ = FileExt::unlock(&self.0);
    }
}

pub(super) fn replace_directory(temporary: &TempDir, output: &Path) -> Result<()> {
    if output.exists() {
        let metadata = fs::symlink_metadata(output)?;
        if metadata.file_type().is_symlink() || !metadata.is_dir() {
            bail!(
                "generated output must be a normal directory: {}",
                output.display()
            );
        }
        let backup = output.with_extension("rspyts-old");
        if backup.exists() {
            fs::remove_dir_all(&backup)?;
        }
        fs::rename(output, &backup)?;
        if let Err(error) = fs::rename(temporary.path(), output) {
            fs::rename(&backup, output)?;
            return Err(error).context("failed to publish generated packages");
        }
        fs::remove_dir_all(backup)?;
    } else {
        fs::rename(temporary.path(), output)?;
    }
    Ok(())
}

pub(super) fn file_tree(root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>> {
    let mut result = BTreeMap::new();
    collect_files(root, root, &mut result)?;
    Ok(result)
}

fn collect_files(
    root: &Path,
    current: &Path,
    result: &mut BTreeMap<PathBuf, Vec<u8>>,
) -> Result<()> {
    let mut entries = fs::read_dir(current)
        .with_context(|| format!("failed to read {}", current.display()))?
        .collect::<std::io::Result<Vec<_>>>()?;
    entries.sort_by_key(fs::DirEntry::file_name);
    for entry in entries {
        let path = entry.path();
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if matches!(name.as_ref(), "__pycache__" | "build")
            || name.ends_with(".egg-info")
            || path.extension().is_some_and(|value| value == "pyc")
        {
            continue;
        }
        let metadata = fs::symlink_metadata(&path)?;
        if metadata.file_type().is_symlink() {
            bail!("generated output contains a symlink: {}", path.display());
        }
        if metadata.is_dir() {
            collect_files(root, &path, result)?;
        } else if metadata.is_file() {
            result.insert(path.strip_prefix(root)?.to_path_buf(), fs::read(path)?);
        }
    }
    Ok(())
}

pub(super) fn source_state(root: &Path) -> Result<BTreeMap<PathBuf, (u64, Option<SystemTime>)>> {
    let mut result = BTreeMap::new();
    for path in source_files(root)? {
        let metadata = path.metadata()?;
        result.insert(path, (metadata.len(), metadata.modified().ok()));
    }
    Ok(result)
}

pub(super) fn source_fingerprint(root: &Path) -> Result<String> {
    const OFFSET: u128 = 144_066_263_297_769_815_596_495_629_667_062_367_629;
    const PRIME: u128 = 309_485_009_821_345_068_724_781_371;

    let mut fingerprint = OFFSET;
    for path in source_files(root)? {
        let relative = path
            .strip_prefix(root)?
            .to_string_lossy()
            .replace('\\', "/");
        for byte in relative
            .bytes()
            .chain([0])
            .chain(fs::read(path)?)
            .chain([0])
        {
            fingerprint ^= u128::from(byte);
            fingerprint = fingerprint.wrapping_mul(PRIME);
        }
    }
    for byte in env!("CARGO_PKG_VERSION").bytes() {
        fingerprint ^= u128::from(byte);
        fingerprint = fingerprint.wrapping_mul(PRIME);
    }
    Ok(format!("{fingerprint:032x}"))
}

fn source_files(root: &Path) -> Result<Vec<PathBuf>> {
    let mut result = Vec::new();
    collect_source_files(root, &mut result)?;
    result.sort();
    Ok(result)
}

fn collect_source_files(current: &Path, result: &mut Vec<PathBuf>) -> Result<()> {
    let entries = match fs::read_dir(current) {
        Ok(entries) => entries,
        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(error.into()),
    };
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        let metadata = entry.metadata()?;
        if metadata.is_dir() {
            if matches!(
                entry.file_name().to_str(),
                Some(".git" | "dist" | "node_modules" | "target")
            ) {
                continue;
            }
            collect_source_files(&path, result)?;
        } else if metadata.is_file() && is_source_file(&path) {
            result.push(path);
        }
    }
    Ok(())
}

fn is_source_file(path: &Path) -> bool {
    path.extension().is_some_and(|value| value == "rs")
        || path
            .file_name()
            .is_some_and(|value| matches!(value.to_str(), Some("Cargo.toml" | "Cargo.lock")))
}

pub(super) fn write(path: &Path, source: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .with_context(|| format!("generated file collision at {}", path.display()))?;
    file.write_all(source.as_bytes())?;
    Ok(())
}

pub(super) fn write_json(path: &Path, value: &impl Serialize) -> Result<()> {
    let mut source = serde_json::to_string_pretty(value)?;
    source.push('\n');
    write(path, &source)
}