wsb 0.291.8591

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Whether a local render's `prepare` has to run again.
//!
//! `prepare` builds what a local render names outside the working copies —
//! a library's local distribution, installed into the environments the
//! render links to — and `wsb local on` and every `wsb checkpoint` going
//! local again ran it each time. When nothing it builds from had changed
//! that was minutes redone for nothing. Skipping it on a flag would leave the
//! render naming a build that is stale or gone, which is what the step is
//! there to prevent.
//!
//! So a `prepare` that declares its inputs ([`HookInputs`]) is run when they
//! changed: after a run that succeeded, a fingerprint of them is kept in the
//! worktree's state folder, and the next render compares. It runs when the
//! fingerprint differs, when there is none, and when something it declares
//! it produces is gone. A run that fails or stops records nothing, and `local
//! off` drops the record, because releasing lets the build be swept. One that
//! declares no inputs runs every time.

use std::collections::BTreeMap;

use sha2::{Digest, Sha256};

use crate::prelude::*;
use crate::state::stamp_config::{Hook, HookInputs};

/// The record's file in the worktree's state folder.
pub const RECORD: &str = "local-prepare.json";

/// What the last run that succeeded built from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prepared {
    /// Each input, named (`repository lib`, `file deps.lock`, `program
    /// python3`, `command`), and what it was.
    pub fingerprint: BTreeMap<String, String>,
    pub prepared_at: String,
}

/// Whether to run it, and why.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
    /// It declares no inputs: run every time.
    Always,
    /// Nothing it builds from changed since this run.
    Unchanged { since: String },
    /// Run, for this reason.
    Run { reason: String },
}

/// Decide for `hook` in the tree at `scan_root`, the record kept in
/// `state_dir`, and the fingerprint to record if it then runs and succeeds.
pub fn decide(hook: &Hook, scan_root: &Path, state_dir: &Path) -> Result<(Decision, Option<BTreeMap<String, String>>)> {
    let Some(inputs) = hook.inputs() else {
        return Ok((Decision::Always, None));
    };
    let now = fingerprint(hook, inputs, scan_root)?;
    let decision = match read(state_dir)? {
        None => Decision::Run { reason: "not prepared here since the tree was last released".to_string() },
        Some(record) => match changed(&record.fingerprint, &now) {
            Some(reason) => Decision::Run { reason },
            None => match hook.produces().iter().find(|path| !scan_root.join(path).exists()) {
                Some(gone) => Decision::Run { reason: format!("{} is not there", gone.display()) },
                None => Decision::Unchanged { since: record.prepared_at },
            },
        },
    };
    Ok((decision, Some(now)))
}

/// What differs between two fingerprints, said as what changed.
fn changed(before: &BTreeMap<String, String>, now: &BTreeMap<String, String>) -> Option<String> {
    let mut changed: Vec<&str> = now
        .iter()
        .filter(|(input, value)| before.get(*input) != Some(*value))
        .map(|(input, _)| input.as_str())
        .collect();
    changed.extend(before.keys().filter(|input| !now.contains_key(*input)).map(String::as_str));
    if changed.is_empty() {
        return None;
    }
    let shown: Vec<&str> = changed.iter().take(3).copied().collect();
    let more = if changed.len() > 3 { format!(" and {} more", changed.len() - 3) } else { String::new() };
    Some(format!("{}{more} changed", shown.join(", ")))
}

/// Every declared input as it is now.
fn fingerprint(hook: &Hook, inputs: &HookInputs, scan_root: &Path) -> Result<BTreeMap<String, String>> {
    let mut found = BTreeMap::new();
    found.insert("command".to_string(), hook.argv().join("\u{1f}"));
    for repository in &inputs.repositories {
        let dir = scan_root.join(repository);
        found.insert(format!("repository {}", repository.display()), working_tree_id(&dir)?);
    }
    for file in &inputs.files {
        let path = scan_root.join(file);
        let value = match std::fs::read(&path) {
            Ok(bytes) => format!("{:x}", Sha256::digest(&bytes)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => "absent".to_string(),
            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
        };
        found.insert(format!("file {}", file.display()), value);
    }
    for program in &inputs.programs {
        let value = match crate::report::which(program) {
            None => "absent".to_string(),
            Some(path) => {
                // Where it resolves to, and what is there: a venv rebuilt in
                // place is another interpreter at the same path.
                let resolved = crate::platform::canonicalize(&path).unwrap_or_else(|_| PathBuf::from(&path));
                let meta = std::fs::metadata(&resolved).with_context(|| format!("reading {}", resolved.display()))?;
                let modified = meta
                    .modified()
                    .ok()
                    .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|since| since.as_nanos())
                    .unwrap_or_default();
                format!("{path} -> {} ({} bytes, {modified})", resolved.display(), meta.len())
            }
        };
        found.insert(format!("program {program}"), value);
    }
    Ok(found)
}

/// The tree a commit of everything in the repository's working copy would
/// have — tracked files as they are, untracked ones not ignored — without
/// touching its index: the same whether the changes are committed or not, so
/// a checkpoint that commits them does not make the step run again.
pub fn working_tree_id(repository: &Path) -> Result<String> {
    let git_dir = git_in(repository, &["rev-parse", "--path-format=absolute", "--git-dir"], None)
        .with_context(|| format!("{} is not a repository a prepare step can read", repository.display()))?;
    let scratch = tempfile::tempdir().context("a scratch folder for an index")?;
    let index = scratch.path().join("index");
    let real = PathBuf::from(git_dir.trim()).join("index");
    if real.is_file() {
        // A copy of the real index, so what has not changed is not hashed
        // again — with the real one's time. git trusts an entry's file times
        // only when they are older than its index, and a copy made now would
        // pass a file rewritten in the same second at the same size (a
        // version 1.7.0 rendered as 1.8.4) as unchanged.
        std::fs::copy(&real, &index).with_context(|| format!("copying {}", real.display()))?;
        let written = std::fs::metadata(&real)
            .and_then(|meta| meta.modified())
            .with_context(|| format!("reading {}", real.display()))?;
        std::fs::File::options()
            .write(true)
            .open(&index)
            .and_then(|file| file.set_modified(written))
            .with_context(|| format!("timing {}", index.display()))?;
    }
    git_in(repository, &["add", "--all", "--", "."], Some(&index))?;
    Ok(git_in(repository, &["write-tree"], Some(&index))?.trim().to_string())
}

fn git_in(dir: &Path, args: &[&str], index: Option<&Path>) -> Result<String> {
    let mut command = crate::git_backend::git_command();
    command.arg("-C").arg(dir).args(args);
    if let Some(index) = index {
        command.env("GIT_INDEX_FILE", index);
    }
    let output = command.output().with_context(|| format!("running git in {}", dir.display()))?;
    if !output.status.success() {
        bail!("git {} in {} failed: {}", args.join(" "), dir.display(), String::from_utf8_lossy(&output.stderr).trim());
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn read(state_dir: &Path) -> Result<Option<Prepared>> {
    let path = state_dir.join(RECORD);
    match std::fs::read_to_string(&path) {
        Ok(text) => Ok(serde_json::from_str(&text).ok()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
    }
}

/// Keep what a run that succeeded built from.
pub fn record(state_dir: &Path, fingerprint: BTreeMap<String, String>) -> Result<()> {
    std::fs::create_dir_all(state_dir).with_context(|| format!("creating {}", state_dir.display()))?;
    let path = state_dir.join(RECORD);
    let record = Prepared { fingerprint, prepared_at: chrono::Local::now().to_rfc3339() };
    let staged = path.with_extension("json.tmp");
    std::fs::write(&staged, serde_json::to_string_pretty(&record)?).with_context(|| format!("writing {}", staged.display()))?;
    std::fs::rename(&staged, &path).with_context(|| format!("writing {}", path.display()))
}

/// Drop the record: before a run, so one that fails or is stopped leaves
/// none, and when the tree is released.
pub fn forget(state_dir: &Path) -> Result<()> {
    match std::fs::remove_file(state_dir.join(RECORD)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e).with_context(|| format!("removing {}", state_dir.join(RECORD).display())),
    }
}

/// `since` as a person reads it: the time of day, or the date and time when
/// it was not today.
pub fn shown_time(since: &str) -> String {
    match chrono::DateTime::parse_from_rfc3339(since) {
        Ok(at) => {
            let at = at.with_timezone(&chrono::Local);
            if at.date_naive() == chrono::Local::now().date_naive() {
                at.format("%H:%M").to_string()
            } else {
                at.format("%Y-%m-%d %H:%M").to_string()
            }
        }
        Err(_) => since.to_string(),
    }
}

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