mmz 0.13.0

memoized command runner
Documentation
//! `mmz --is-fresh`: assert a rule's cache is fresh without running it.
//!
//! The memoization engine ([`crate::engine`]) is run-or-skip: a stale rule runs.
//! A gate wants the opposite — to confirm a command was already memoized fresh
//! and otherwise fail, never launching the (often slow) command itself. That is
//! this module: resolve a rule's inputs, compare their digest to the record, and
//! report the verdict. It executes nothing.
//!
//! The reach-for case is a git hook. A pre-push that must not boot a VM, yet must
//! refuse a push whose checks were never run, calls `mmz --is-fresh -- just
//! check` and trusts the exit code. With no command it gates every rule at once.

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

use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::manifest::Manifest;
use crate::status::{self, Assessment, Records, Shared, expansion_state};
use crate::{parametric, probe, selection};

/// Every kept rule's parametric matches, plus its pre-resolved shared
/// `inputs` (files and probe digests) keyed by rule name —
/// [`expand_matching`]'s result.
type Expanded<'a> = (Vec<parametric::Match<'a>>, BTreeMap<String, Shared>);

/// One expansion's freshness, the unit a gate reports on. For a parametric
/// rule this is one per-file expansion; for a static rule, the rule itself.
pub struct Verdict {
    /// The expansion's cache identity: the bare rule name for a static rule,
    /// or the rule name with its `{scope}` macro substituted for a
    /// parametric expansion.
    pub rule: String,
    assessed: Assessment,
}

impl Verdict {
    /// True when the rule is fresh — its inputs are unchanged since it last
    /// succeeded, every artifact it declared is still on disk, and the pass has
    /// not outlived the rule's `ttl`, so a gate over it passes.
    #[must_use]
    pub const fn is_fresh(&self) -> bool {
        self.assessed.state.is_fresh()
    }

    /// The rule's freshness label (`fresh`, `stale`, `never`, `failed`,
    /// `no-inputs`, `missing-output`, `expired`), matching `mmz --status`.
    #[must_use]
    pub const fn state(&self) -> &'static str {
        self.assessed.state.label()
    }

    /// Why the rule is not fresh, for a gate's message; `None` when it is
    /// fresh. A record voided by a gone artifact names that artifact, since
    /// "inputs changed" would send a reader to the wrong place entirely.
    #[must_use]
    pub fn reason(&self) -> Option<String> {
        self.assessed.reason()
    }

    /// True for the non-fresh states a recorded pass can clear (`stale`,
    /// `never`, `failed`, `missing-output`, `expired`). A gate prints its
    /// one-line remediation hint once when any verdict is remediable; a `no-inputs`
    /// verdict is not, since a wrapped run records nothing against an empty
    /// input set.
    #[must_use]
    pub const fn is_remediable(&self) -> bool {
        self.assessed.state.is_remediable()
    }
}

/// Evaluates freshness against the nearest manifest above `cwd`, running
/// nothing. A parametric rule (a `{scope}`-fanned `name`) expands exactly as
/// `mmz --status` does: one [`Verdict`] per file its scope resolves to,
/// keyed on that expansion's concrete identity rather than the rule's literal
/// template name.
///
/// With `argv` given and `tags` empty, resolves the single expansion `argv`
/// binds to (via [`parametric::resolve_matches`]) and returns its lone
/// verdict — for a parametric rule this gates the one per-file expansion
/// `argv` names, not the whole rule. With `tags` non-empty, expands every
/// rule that carries every listed tag (an AND filter — a rule with no tags
/// never matches) and returns one verdict per expansion; `argv` must be
/// `None` in that case, since a targeted command already resolves to a
/// single expansion. With both empty, expands every rule in manifest order
/// and returns one verdict per expansion, so a caller can gate the whole
/// manifest at once.
///
/// A selection that resolves to no expansion at all is refused rather than
/// returning an empty verdict list: the caller's gate is an assertion, and an
/// assertion over nothing passes without having checked anything.
///
/// # Errors
///
/// Returns [`Error::TagWithCommand`] when `tags` is non-empty and `argv` is
/// also given, [`Error::NoManifest`] when no manifest is found, a manifest
/// error when one cannot be loaded, [`Error::NoMatch`] when `argv` matches no
/// rule, [`Error::NoRules`], [`Error::NoTaggedRules`] or
/// [`Error::NoExpansions`] when the selection is empty,
/// [`Error::CollidingIdentity`] when two expansions share a cache identity,
/// [`Error::InvalidNow`] when `MMZ_NOW` is not a Unix epoch (a rule's `ttl` is
/// aged against it), or a resolution/hashing error when a rule's inputs cannot
/// be read.
pub fn evaluate(cwd: &Path, argv: Option<&[String]>, tags: &[String]) -> Result<Vec<Verdict>> {
    let now = Clock::resolve()?;
    let located = Manifest::locate(cwd)?;
    let manifest = &located.manifest;
    let base = located.root.as_path();
    let cache_dir = base.join(&manifest.cache_dir);
    let records = Records {
        dir: &cache_dir,
        now,
    };

    if !tags.is_empty() && argv.is_some() {
        return Err(Error::TagWithCommand);
    }

    if let Some(argv) = argv {
        let matches = parametric::resolve_matches(manifest, base, argv)?;
        parametric::detect_collision(&matches)?;
        let hit = matches.first().ok_or_else(|| Error::NoMatch {
            command: argv.join(" "),
        })?;
        let mut probes = probe::Resolver::new(manifest, base);
        let shared = status::shared_inputs(manifest, hit.rule, base, &mut probes)?;
        return Ok(vec![verdict_for(hit, &shared, base, records)?]);
    }

    let (matches, shared) = expand_matching(manifest, base, tags)?;
    let kept: Vec<&str> = shared.keys().map(String::as_str).collect();
    selection::ensure_gateable(manifest, &located.path, tags, &kept, matches.len())?;
    verdicts_for(&matches, &shared, base, records)
}

/// Expands every rule carrying every tag in `tags` into its parametric matches
/// (one per domain file, or the rule itself when static), then checks the
/// collected expansions for a colliding identity — the untargeted and
/// tag-filtered gates are one shape, since an empty `tags` keeps every rule
/// (`all` over no tags is true) rather than being a second code path. Also
/// resolves each kept rule's shared `inputs` once, keyed by rule name, so
/// every expansion a rule fans into reuses the same resolved file set
/// instead of re-walking the tree per expansion. One [`probe::Resolver`] spans
/// the whole sweep, so a probe many rules share costs one process for the
/// gate — the shape that matters, since a bare `mmz --is-fresh` gates every
/// rule and runs in git hooks.
fn expand_matching<'a>(
    manifest: &'a Manifest,
    base: &Path,
    tags: &[String],
) -> Result<Expanded<'a>> {
    let mut matches = Vec::new();
    let mut shared = BTreeMap::new();
    let mut probes = probe::Resolver::new(manifest, base);
    let kept = manifest
        .commands
        .iter()
        .filter(|rule| tags.iter().all(|tag| rule.tags.contains(tag)));
    for rule in kept {
        shared.insert(
            rule.name.clone(),
            status::shared_inputs(manifest, rule, base, &mut probes)?,
        );
        matches.extend(parametric::expand_rule(manifest, base, rule)?);
    }
    parametric::detect_collision(&matches)?;
    Ok((matches, shared))
}

/// Builds one [`Verdict`] per expansion in `matches`, looking each
/// expansion's rule up in `shared` for its pre-resolved shared inputs.
fn verdicts_for(
    matches: &[parametric::Match],
    shared: &BTreeMap<String, Shared>,
    base: &Path,
    records: Records,
) -> Result<Vec<Verdict>> {
    matches
        .iter()
        .map(|hit| {
            let inputs = shared
                .get(hit.rule.name.as_str())
                .expect("shared inputs resolved for every kept rule");
            verdict_for(hit, inputs, base, records)
        })
        .collect()
}

/// Builds one expansion's [`Verdict`] — the step every branch of [`evaluate`]
/// needs once it has settled on which expansion(s) to report and resolved
/// that expansion's rule's shared inputs.
fn verdict_for(
    hit: &parametric::Match,
    shared: &Shared,
    base: &Path,
    records: Records,
) -> Result<Verdict> {
    Ok(Verdict {
        rule: hit.exp.identity.clone(),
        assessed: expansion_state(hit, shared, base, records)?,
    })
}

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