# mmz
[](https://github.com/mlavrinenko/mmz/actions/workflows/ci.yml)
[](https://crates.io/crates/mmz)
[](LICENSE-MIT)
memoized command runner
Prefix any command with `mmz`. When the matched rule's declared inputs are
byte-for-byte unchanged since the command last succeeded, `mmz` skips it and
exits 0. Otherwise it runs the command, streams its output, and records the
result on success.
It is not a build system: no task ordering, no dependency graph, no
output/artifact tracking, no remote cache. It answers one question per
invocation — are this rule's inputs unchanged since it last passed? See
[Non-goals](#non-goals).
## Install
```bash
cargo install mmz
```
Or download a pre-built binary from the
[latest release](https://github.com/mlavrinenko/mmz/releases/latest).
## Usage
```
mmz <command> [args...] run a command, skipping it when its inputs are unchanged
mmz --init write a starter .mmz/config.yaml in the current directory
mmz --status [--tag t]... show each rule's freshness as a table
mmz --status=json the same as JSON, with each rule's inputs and hashes
mmz --status=json-schema print the JSON Schema for --status=json
mmz --is-fresh [--tag t]... [-- cmd] exit 0 if cmd's rule (or every/tagged rule) is fresh
mmz --prune delete cache records whose rule no longer exists
mmz --schema print the config JSON Schema
mmz --version print version
mmz --help print help
mmz -- <command> [args] run a command whose name begins with a dash
```
Scaffold a manifest, then wrap commands wherever memoization is wanted — a
Justfile recipe line, a shell, a git hook:
```bash
mmz --init # writes .mmz/config.yaml and .mmz/.gitignore
mmz cargo test # skipped when the rust inputs are unchanged
mmz --status # show each rule's freshness
```
`mmz` receives a clean argv vector (not a shell-expanded string), so matching is
robust and there is no nesting blind spot. Wrappers go outside it
(`chronic mmz cargo test`). There is no `just`/runner integration and no
`--no-memo`: a bare command without `mmz` is simply unmemoized.
## Manifest
`.mmz/config.yaml` — the nearest one, searching upward from the working
directory. Everything mmz needs lives under one `.mmz/` directory: the tracked
config plus a `.mmz/.gitignore` (written by `--init`) that ignores the cache,
so a project gains one entry and its root `.gitignore` stays untouched.
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/mmz/v0.1.0/schema/mmz.schema.json
scopes:
rust: ["**/*.rs", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]
commands:
- name: cargo test # matcher and cache identity
inputs: [rust]
# match: exact # match only the bare command, no trailing args (default prefix)
# on_hit: "tests fresh" # per-rule cache-hit note, overriding the global one below
# cache_dir: .mmz/cache # where throwaway records live (default; must be git-ignored)
# gitignore: true # skip git-ignored paths when expanding globs (default)
# strict: [no_match, no_inputs] # the default; list a subset to relax, [] to relax all
on_hit: "mmz: skipped {cache:command} (inputs unchanged)" # stderr note on a hit
```
- `scopes`: named glob sets, declared once and referenced by many commands, so a
shared input path lives in one place. Globs follow the common convention — `*`
stays within a directory, `**` crosses directories.
- `commands`: ordered rules. Each has a `name` (the matcher and cache identity),
`inputs` (scope names whose globs, unioned, are the rule's input set), and an
optional `match` (`prefix`, the default, or `exact`; see [Matching](#matching)).
- `gitignore` (default `true`): glob expansion skips git-ignored paths, so build
artifacts never enter an input set. Explicitly listed literal paths are always
kept. The `.git` directory is never traversed; symlinks are not followed.
- `cache_dir` (default `.mmz/cache`): directory for throwaway records, relative
to the project root (the directory holding `.mmz`). Keep it git-ignored; set it
to relocate state (e.g. `.cache/mmz`).
- `strict` (default: all): the runtime cases `mmz` errors on rather than falling
back — `no_match` (no rule matches) and `no_inputs` (a matched rule resolves to
zero files). Omit for both; list a subset to relax the rest; `[]` to relax all.
- `on_hit` (default: none): a line printed to stderr when a command is skipped.
Embed `{cache:<field>}` to pull a field straight from the cache record
(`command`, `ran_at`, `input_digest`, …). Set it per command to override the
global note, or to `""` to silence one. `mmz --init` scaffolds a default.
The manifest is validated at load: command names must be non-empty and unique,
every referenced scope must be defined, and `strict` names must be known. Run
`mmz --schema` for the full JSON Schema.
`mmz --init` pins the `$schema` URL to the `v{version}` tag of the mmz that wrote
it, not `main`, so a project keeps validating against the schema its mmz was
built for even when different projects pin different mmz versions. `mmz --help`
and `mmz --version` report the running version.
## Tags
A command rule can carry `tags: [..]`; `mmz --is-fresh --tag <tag>` and `mmz
--status --tag <tag>` then narrow to rules carrying every listed tag, instead
of every rule in the manifest. Repeat `--tag`/`-t` to require more than one —
repeats AND together (there is no OR: call `mmz --is-fresh` once per tag for
that). A rule with no `tags:` never matches a `--tag` filter; a bare `mmz
--is-fresh` (no `--tag`, no command) still gates every rule regardless of tags.
```yaml
commands:
- name: cargo test
inputs: [rust]
tags: [gate]
- name: cargo bench
inputs: [rust]
tags: [bench]
```
`mmz --is-fresh --tag gate` here checks only `cargo test`; a bare `mmz
--is-fresh` still checks both. One manifest can then hold a gating subset
(wired into `just check` or a pre-push hook) alongside other memoized commands
a gate should ignore, instead of splitting `.mmz/config.yaml` per concern.
Tags are case-faithful, trimmed, and blank entries are dropped; declaring the
same tag twice on one rule is a manifest error.
## Matching
A rule's `name` is split on whitespace into tokens, and it matches when those
tokens are a prefix of the invoked argv: `cargo test` matches `cargo test` and
`cargo test --workspace`, but not `cargo build` and not the bare `cargo`.
Matching is on whole tokens, so `car` does not match `cargo`.
Rules are tried in manifest order; the first match wins. Order specific rules
before general ones. The cache identity is the matched rule (its `name`), not
the full argv, so you control granularity by how specifically rules are written:
split a rule or narrow its matcher when one rule conflates commands with
different real inputs.
Set `match: exact` on a rule to match only the bare command, no trailing args —
so `cargo test` and `cargo test --release` become separate cache identities. It
only narrows a rule, never causing a wrongful skip: an unmatched invocation
falls to the `no_match` case (error, or passthrough when relaxed).
## Parametric rules (per-file fan)
A single `{scope}` macro in a rule's `name` fans it over that scope's files: one
per-file cache record per matched file, without hand-listing each as its own
rule.
```yaml
scopes:
lint-targets: ["src/**/*.rs"]
rust-pins: ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml"]
commands:
- name: "ruff check {lint-targets}" # {scope} macro ⇒ parametric rule
inputs: [rust-pins] # shared pins added to every record
```
`ruff check {lint-targets}` stands for `ruff check src/a.rs`, `ruff check
src/b.rs`, … — each a distinct cache identity. A record's inputs are the
`inputs` pins plus its own file, so editing `src/a.rs` busts only that file's
record. The macro is one whitespace token but may sit inside one
(`--file={lint-targets}`). `mmz` stays a prefix — you drive the loop
(`for f in src/**/*.rs; do mmz ruff check "$f"; done`).
The bound file must be a member of the scope (gitignore-filtered), so an
off-list path falls through to the no-match case rather than inventing a record.
`mmz --status` enumerates one row per expanded file, `mmz --prune` drops a record
once its file leaves the tree, and two rules resolving to the same expanded
identity is an error, not a silently picked winner.
Per-file scoping is only honest when the command depends on that one file plus
the pins (a per-file lint/format/typecheck). A whole-crate command like `cargo
mutants -f {scope}` compiles siblings, so a sibling edit can leave a file's
record wrongly fresh — use the fan there knowing you trade correctness for speed.
## Correctness contract
The governing asymmetry, because the failure is silent:
- Under-declaring a rule's inputs → `mmz` skips a command that should have run →
false green. Dangerous.
- Over-declaring inputs → an unnecessary re-run. Harmless.
So a rule's scopes must be a superset of every file any matching invocation
could depend on. When in doubt, broaden the scope. Toolchain sensitivity is
modeled as ordinary inputs: add `rust-toolchain.toml` or `flake.lock` to a scope
and a toolchain bump busts the cache. `mmz` trusts file content, not the ambient
environment.
`mmz` fails closed: a missing or invalid manifest always errors, and unmatched
commands or matched rules with no inputs error too unless `strict` relaxes them
(then they run unmemoized). What `mmz` never does is skip a command whose inputs
it has not confirmed unchanged.
## State and exit codes
Records live in a git-ignored cache directory (`.mmz/cache` by default), one YAML file
per rule, written atomically (temp file + rename) so a crash or concurrent writer
never leaves a truncated record. Derived, throwaway state — do not commit it. A
record is fresh only when its `status` is `ok` and its content digest, format,
algorithm, and command all still match; anything else re-runs.
`mmz --status` shows each rule's verdict and the age of its record;
`mmz --status=json` adds every resolved input and its content hash so you can
`jq` out what changed, and `mmz --status=json-schema` prints its schema. Renaming
or removing a rule orphans its record; `mmz --prune` sweeps the unclaimed ones.
`mmz --is-fresh -- <command>` gates a command on its cache without running it:
exit 0 when its rule is already fresh, exit 1 otherwise. With no command,
`mmz --is-fresh` gates every rule at once. It is the inverse of wrapping — where
`mmz <command>` runs a stale command, `mmz --is-fresh -- <command>` refuses it —
so a git hook can require that an expensive check was already run (and memoized)
without paying to run it on the spot:
```bash
# pre-push: refuse the push if the VM checks were not run, but never run them here
mmz --is-fresh -- just check-vm || { echo "stale: run 'just check-vm' first" >&2; exit 1; }
```
| 0 | fresh, skipped, or the command succeeded |
| 1 | `--is-fresh`: the targeted rule (or some rule) is not fresh |
| *n* | the wrapped command's own exit code |
| 2 | usage error (empty invocation, unknown option, `--init` over an existing manifest) |
| 3 | strict refusal (no matching rule, or a matched rule with no inputs) |
| 4 | manifest missing or invalid |
| 70 | internal error |
| 127 | command could not be spawned |
## Non-goals
`mmz` follows the Unix philosophy — one thing, done right — so these stay out of
scope:
- Task orchestration: no execution order or dependency graph; use a task runner.
- Output replay: only the exit code is cached, never stdout, stderr, or artifacts.
- Automatic dependency tracing: no strace; scopes are declared explicitly.
- Remote caching: state is strictly local and throwaway.
- Deep runner integration: no plugins or hooks; `mmz` is a dumb CLI prefix.
## Library
`mmz` is published as a library crate too; the binary is a thin wrapper.
`mmz::run(&argv, cwd)` memoizes one invocation and returns its exit code;
`mmz::status`, `mmz::prune`, and `mmz::Manifest` cover the rest.
```rust
let argv = vec!["cargo".to_owned(), "test".to_owned()];
std::process::exit(mmz::run(&argv, std::path::Path::new("."))?.into());
```
## Dogfooding
`mmz` memoizes its own checks: [`.mmz/config.yaml`](.mmz/config.yaml) declares the rules and the
[`Justfile`](Justfile) `check` recipe wraps `cargo test`, `cargo clippy`,
`cargo fmt`, and `cargo machete` with the locally built binary, so a no-op
`just check` skips them.
## Development
Prerequisites: [Nix](https://nixos.org/) with flakes enabled.
```bash
direnv allow # or: nix develop
just check # fmt + clippy + tests + file-size + drift check (memoized)
just build
just test
just cover # code coverage (70% minimum)
just fmt # format code
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for coding conventions.
## License
MIT