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, resolve};
pub fn run(argv: &[String], cwd: &Path) -> Result<u8> {
let manifest_path = Manifest::discover(cwd).ok_or_else(|| Error::NoManifest {
start: cwd.to_path_buf(),
})?;
let manifest = Manifest::load(&manifest_path)?;
let base = manifest_path
.parent()
.ok_or_else(|| Error::Internal("manifest path has no parent".to_owned()))?;
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 cache::is_fresh(&cache_dir, &rule.name, &digest) {
log::info!("mmz: skip `{}` (inputs unchanged)", rule.name);
return Ok(0);
}
let code = exec(argv, cwd)?;
cache::write(&cache_dir, &rule.name, &digest, code == 0);
Ok(code)
}
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) {
std::fs::write(dir.join("mmz.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"
);
}
#[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"
);
}
}