use std::path::Path;
use std::process::{Command, ExitStatus};
use crate::error::{Error, Result};
use crate::manifest::{Command as Rule, Manifest, StrictCase};
use crate::{cache, hashing, matcher, notice, resolve};
pub fn run(argv: &[String], cwd: &Path) -> Result<u8> {
let located = Manifest::locate(cwd)?;
let manifest = &located.manifest;
let base = located.root.as_path();
match matcher::first_match(&manifest.commands, argv) {
Some(rule) => memoized(manifest, rule, base, argv, cwd),
None => no_match(manifest, argv, cwd),
}
}
fn no_match(manifest: &Manifest, argv: &[String], cwd: &Path) -> Result<u8> {
if manifest.strict.enforces(StrictCase::NoMatch) {
return Err(Error::NoMatch {
command: argv.join(" "),
});
}
log::debug!("mmz: no rule matches; running unmemoized");
exec(argv, cwd)
}
fn memoized(
manifest: &Manifest,
rule: &Rule,
base: &Path,
argv: &[String],
cwd: &Path,
) -> Result<u8> {
let Some(digest) = digest_inputs(manifest, rule, base)? else {
if manifest.strict.enforces(StrictCase::NoInputs) {
return Err(Error::NoInputs {
rule: rule.name.clone(),
});
}
log::warn!(
"mmz: `{}` matched no input files; running unmemoized",
rule.name
);
return exec(argv, cwd);
};
let cache_dir = base.join(&manifest.cache_dir);
if let Some(cached) = cache::read(&cache_dir, &rule.name) {
if cached.ok && cached.digest == digest {
log::info!("mmz: skip `{}` (inputs unchanged)", rule.name);
announce_hit(manifest, rule, &cached);
return Ok(0);
}
}
let code = exec(argv, cwd)?;
cache::write(&cache_dir, &rule.name, &digest, code == 0);
Ok(code)
}
fn announce_hit(manifest: &Manifest, rule: &Rule, cached: &cache::Cached) {
let Some(template) = rule.on_hit.as_deref().or(manifest.on_hit.as_deref()) else {
return;
};
if template.is_empty() {
return;
}
eprintln!("{}", notice::expand(template, &cached.fields));
}
fn digest_inputs(manifest: &Manifest, rule: &Rule, base: &Path) -> Result<Option<String>> {
let globs = manifest.globs_for(rule)?;
let files = resolve::expand(&globs, base, manifest.gitignore)?;
if files.is_empty() {
return Ok(None);
}
Ok(Some(hashing::digest_files(base, &files)?))
}
fn exec(argv: &[String], cwd: &Path) -> Result<u8> {
let Some((program, rest)) = argv.split_first() else {
return Err(Error::EmptyCommand);
};
let status = Command::new(program)
.args(rest)
.current_dir(cwd)
.status()
.map_err(|source| Error::Spawn {
program: program.clone(),
source,
})?;
Ok(exit_code(status))
}
fn exit_code(status: ExitStatus) -> u8 {
let code = status.code().unwrap_or(1);
u8::try_from(code).unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::run;
fn write_manifest(dir: &std::path::Path, body: &str) {
let cfg = dir.join(".mmz");
std::fs::create_dir_all(&cfg).expect("mkdir .mmz");
std::fs::write(cfg.join("config.yaml"), body).expect("write manifest");
}
#[test]
fn skips_second_run_when_inputs_unchanged() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
std::fs::write(base.join("a.txt"), b"one").expect("input");
write_manifest(
base,
"scopes:\n src: [\"*.txt\"]\ncommands:\n - name: sh\n inputs: [src]\n",
);
let argv = [
"sh".to_owned(),
"-c".to_owned(),
"printf x >> runs.log".to_owned(),
];
assert_eq!(run(&argv, base).expect("run"), 0);
assert_eq!(run(&argv, base).expect("run"), 0);
assert_eq!(
std::fs::read(base.join("runs.log")).expect("log").len(),
1,
"skipped once"
);
std::fs::write(base.join("a.txt"), b"two").expect("rewrite");
assert_eq!(run(&argv, base).expect("run"), 0);
assert_eq!(
std::fs::read(base.join("runs.log")).expect("log").len(),
2,
"input change re-runs"
);
}
fn run_twice(base: &std::path::Path) {
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
assert_eq!(run(&argv, base).expect("first run records"), 0);
assert_eq!(run(&argv, base).expect("second run is a hit"), 0);
}
#[test]
fn on_hit_paths_are_exercised_on_a_cache_hit() {
let rule = tempfile::tempdir().expect("tempdir");
std::fs::write(rule.path().join("a.txt"), b"one").expect("input");
write_manifest(
rule.path(),
"scopes:\n src: [\"*.txt\"]\ncommands:\n - name: sh\n inputs: [src]\n on_hit: \"skip {cache:command}\"\n",
);
run_twice(rule.path());
let blank = tempfile::tempdir().expect("tempdir");
std::fs::write(blank.path().join("a.txt"), b"one").expect("input");
write_manifest(
blank.path(),
"scopes:\n src: [\"*.txt\"]\non_hit: \"\"\ncommands:\n - name: sh\n inputs: [src]\n",
);
run_twice(blank.path());
}
#[test]
fn propagates_exit_code_and_reruns_after_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
std::fs::write(base.join("a.txt"), b"one").expect("input");
write_manifest(
base,
"scopes:\n src: [\"*.txt\"]\ncommands:\n - name: sh\n inputs: [src]\n",
);
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 3".to_owned()];
assert_eq!(run(&argv, base).expect("run"), 3, "exit code propagates");
assert_eq!(
run(&argv, base).expect("run"),
3,
"failure was not cached as fresh"
);
}
#[test]
fn missing_manifest_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
assert!(
matches!(run(&argv, dir.path()), Err(crate::Error::NoManifest { .. })),
"no manifest is fatal, not passthrough"
);
}
#[test]
fn invalid_manifest_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write_manifest(base, "commands:\n - name: sh\n inputs: [ghost]\n");
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
assert!(run(&argv, base).is_err(), "invalid manifest is fatal");
}
#[test]
fn no_match_errors_under_strict_but_passes_through_when_relaxed() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 5".to_owned()];
write_manifest(base, "commands:\n - name: cargo\n");
assert!(
matches!(run(&argv, base), Err(crate::Error::NoMatch { .. })),
"strict default errors on no match"
);
write_manifest(base, "commands:\n - name: cargo\nstrict: []\n");
assert_eq!(run(&argv, base).expect("run"), 5, "relaxed runs unmemoized");
}
#[test]
fn empty_input_set_errors_under_strict() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write_manifest(
base,
"scopes:\n none: [\"*.none\"]\ncommands:\n - name: sh\n inputs: [none]\n",
);
let argv = ["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
assert!(
matches!(run(&argv, base), Err(crate::Error::NoInputs { .. })),
"strict default errors on empty input set"
);
}
#[test]
fn empty_input_set_runs_every_time_when_relaxed() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write_manifest(
base,
"scopes:\n none: [\"*.none\"]\ncommands:\n - name: sh\n inputs: [none]\nstrict: [no_match]\n",
);
let argv = [
"sh".to_owned(),
"-c".to_owned(),
"printf x >> runs.log".to_owned(),
];
assert_eq!(run(&argv, base).expect("run"), 0);
assert_eq!(run(&argv, base).expect("run"), 0);
assert_eq!(
std::fs::read(base.join("runs.log")).expect("log").len(),
2,
"relaxed no-inputs never memoizes"
);
}
}