mmz 0.13.0

memoized command runner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Thin entry point: parse argv, init logging, dispatch to the engine or an
//! `mmz --…` action, and map errors to exit codes.

use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;

use mmz::error::Error;

/// This binary's version, embedded at build time from `Cargo.toml`.
const VERSION: &str = env!("CARGO_PKG_VERSION");

const USAGE: &str = concat!(
    "mmz ",
    env!("CARGO_PKG_VERSION"),
    " — memoized command runner

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 [--tag t]...    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; runs nothing
    mmz --prune                       delete cache records whose rule no longer exists
    mmz --import <dir>                copy cache records in from another checkout of this repo
    mmz --schema                      print the config JSON Schema
    mmz --schema=fragment             print the JSON Schema for an imported fragment
    mmz --dump-config                 print the merged manifest and where each entry came from
    mmz --dump-config=json            the same as JSON
    mmz --version                     print version and how many languages it parses
    mmz --help                        print this help
    mmz -- <command> [args]           run a command whose name begins with a dash

Config lives in .mmz/config.yaml (nearest one, searching upward). mmz errors when no
manifest is found, the manifest is invalid, no rule matches, or a matched rule
has no inputs; relax the last two per project with the `strict` list.

`--tag`/`-t <tag>` (repeatable) narrows --is-fresh/--status to rules carrying
every listed tag (AND, not OR); untagged rules never match. Combining --tag
with a targeted command is a usage error — a command already resolves to one rule.
A --is-fresh whose selection holds no rule is refused (exit 7) rather than passing
on the strength of having checked nothing; --status reports it and exits 0.

Environment:
    MMZ_NOW    pin \"now\" to a Unix epoch in seconds, so a record's ran_at and
               the AGE column read the same on every run. A malformed value is
               refused, never ignored. Unset, mmz reads the system clock.

Exit codes:
    0    fresh, skipped, or succeeded        6    a probe failed, could not run, or
    1    --is-fresh: not fresh                    printed nothing (nothing recorded)
    2    usage error                         7    --is-fresh: nothing to gate (the
    3    strict refusal (no rule / inputs)        selection holds no rule)
    4    manifest missing or invalid         8    a file mmz needed could not be
    5    declared output missing after a          read or written (nothing recorded)
         successful run (nothing recorded)   70   internal error
                                             127  command could not be spawned
    otherwise the wrapped command's own exit code"
);

fn main() -> ExitCode {
    env_logger::init();
    let args: Vec<String> = std::env::args().skip(1).collect();
    run_cli(&args)
}

/// Splits mmz's own actions from a wrapped command. The first token decides:
/// `--` forces the rest to be a command, a recognized `--action` runs mmz
/// itself, anything else is the command to memoize.
fn run_cli(args: &[String]) -> ExitCode {
    let Some((first, rest)) = args.split_first() else {
        eprintln!("{USAGE}");
        return ExitCode::from(2);
    };
    if first == "--" {
        return wrap(rest);
    }
    if let Some(code) = action(first, rest) {
        return code;
    }
    wrap(args)
}

/// Dispatches a recognized `mmz --…` action. Returns `None` when `first` is the
/// start of a wrapped command rather than an mmz action.
fn action(first: &str, rest: &[String]) -> Option<ExitCode> {
    match first {
        // The grammar count rides along because which grammars a build carries
        // is a compile-time choice, so one version number describes more than
        // one binary — and the release ships two of them. Without it, "mmz
        // 0.8.0 cannot parse Python" names a version that is true of both.
        "--version" | "-V" => Some(meta(
            rest,
            &format!("mmz {VERSION} ({})\n", mmz::ast::language_summary()),
        )),
        "--help" | "-h" => Some(meta(rest, &format!("{USAGE}\n"))),
        schema if schema == "--schema" || schema.starts_with("--schema=") => {
            Some(run_schema(schema, rest))
        }
        dump if dump == "--dump-config" || dump.starts_with("--dump-config=") => {
            Some(run_dump_config(dump, rest))
        }
        "--init" => Some(run_init(rest)),
        "--prune" => Some(run_prune(rest)),
        "--import" => Some(run_import(rest)),
        "--is-fresh" => Some(run_is_fresh(rest)),
        status if status == "--status" || status.starts_with("--status=") => {
            Some(run_status(status, rest))
        }
        other if other.starts_with('-') => Some(unknown_option(other)),
        _ => None,
    }
}

/// Emits a zero-argument meta action's output, or a usage error if given extras.
fn meta(rest: &[String], text: &str) -> ExitCode {
    if rest.is_empty() {
        emit(text)
    } else {
        usage("this option takes no arguments")
    }
}

fn run_init(rest: &[String]) -> ExitCode {
    if !rest.is_empty() {
        return usage("`--init` takes no arguments");
    }
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    match mmz::init::init(&cwd) {
        Ok(path) => emit(&format!("wrote {}\n", path.display())),
        Err(err) => report_error(&err),
    }
}

fn run_prune(rest: &[String]) -> ExitCode {
    if !rest.is_empty() {
        return usage("`--prune` takes no arguments");
    }
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    match mmz::prune::prune(&cwd) {
        Ok(text) => emit(&text),
        Err(err) => report_error(&err),
    }
}

/// Handles `mmz --import <dir>`: exactly one directory, another checkout.
fn run_import(rest: &[String]) -> ExitCode {
    let [dir] = rest else {
        return usage("`--import` takes exactly one directory");
    };
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    match mmz::import_records::import(&cwd, std::path::Path::new(dir)) {
        Ok(text) => emit(&text),
        Err(err) => report_error(&err),
    }
}

/// Handles `--schema` and `--schema=fragment`. `arg` is the full token, so its
/// `=suffix` selects which document prints: the config manifest schema, or
/// the narrower schema for a file its `imports:` names — see
/// `mmz::schema` for how the two are kept from drifting apart.
fn run_schema(arg: &str, rest: &[String]) -> ExitCode {
    if !rest.is_empty() {
        return usage("`--schema` takes no arguments");
    }
    let format = arg.strip_prefix("--schema").unwrap_or("");
    match format {
        "" => emit(mmz::schema::SCHEMA),
        "=fragment" => emit(mmz::schema::FRAGMENT_SCHEMA),
        other => usage(&format!(
            "unknown `--schema` format `{}`; use fragment, or omit the suffix for the config schema",
            other.trim_start_matches('=')
        )),
    }
}

/// Handles `--dump-config` and `--dump-config=json`. `arg` is the full token,
/// so its `=suffix` selects the rendering. There is no `=json-schema` arm —
/// unlike `--schema` and `--status`, this document's only consumer today is a
/// gate that can assert on keys directly (see `mmz::dump`), so a schema for
/// it is deferred until a second consumer needs one.
fn run_dump_config(arg: &str, rest: &[String]) -> ExitCode {
    if !rest.is_empty() {
        return usage("`--dump-config` takes no arguments");
    }
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    let format = arg.strip_prefix("--dump-config").unwrap_or("");
    let rendered = match format {
        "" => mmz::dump::dump(&cwd),
        "=json" => mmz::dump::dump_json(&cwd),
        other => {
            return usage(&format!(
                "unknown `--dump-config` format `{}`; use json, or omit the suffix for the human form",
                other.trim_start_matches('=')
            ));
        }
    };
    match rendered {
        Ok(text) => emit(&text),
        Err(err) => report_error(&err),
    }
}

/// Handles `--status`, `--status=json`, and `--status=json-schema`. `arg` is the
/// full token, so its `=suffix` selects the rendering.
fn run_status(arg: &str, rest: &[String]) -> ExitCode {
    let (tags, rest) = match parse_tags(rest) {
        Ok(parsed) => parsed,
        Err(code) => return code,
    };
    if !rest.is_empty() {
        return usage("`--status` takes no arguments");
    }
    let format = arg.strip_prefix("--status").unwrap_or("");
    match format {
        "=json-schema" => return emit(mmz::status::SCHEMA),
        "" | "=json" => {}
        other => {
            return usage(&format!(
                "unknown `--status` format `{}`; use json or json-schema",
                other.trim_start_matches('=')
            ));
        }
    }
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    let rendered = if format == "=json" {
        mmz::status::report_json(&cwd, &tags)
    } else {
        mmz::status::report(&cwd, &tags)
    };
    match rendered {
        Ok(text) => emit(&text),
        Err(err) => report_error(&err),
    }
}

/// Handles `mmz --is-fresh [--tag t]... [-- <command>]`: assert freshness
/// without running. A bare `--is-fresh` gates every rule; a `--tag` filter
/// (repeatable, `ANDed`) narrows that to rules carrying every listed tag; a
/// trailing command (optionally behind `--`, to allow a leading dash) gates
/// the one rule it matches — combining a command with `--tag` is a library
/// usage error. Exit 0 when fresh, 1 when not, or a library error's code.
fn run_is_fresh(rest: &[String]) -> ExitCode {
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    let (tags, rest) = match parse_tags(rest) {
        Ok(parsed) => parsed,
        Err(code) => return code,
    };
    let argv = strip_separator(rest);
    let target = if argv.is_empty() { None } else { Some(argv) };
    match mmz::freshness::evaluate(&cwd, target, &tags) {
        Ok(verdicts) => report_freshness(&verdicts),
        Err(err) => report_error(&err),
    }
}

/// Peels leading `-t`/`--tag <value>` pairs off `rest`, one tag per occurrence
/// (repeats AND together — a rule must carry every listed tag). Stops at the
/// first token that isn't a tag flag, leaving the remainder for
/// [`strip_separator`] to resolve into the wrapped command.
fn parse_tags(rest: &[String]) -> Result<(Vec<String>, &[String]), ExitCode> {
    let mut tags = Vec::new();
    let mut cursor = rest;
    while let Some((flag, tail)) = cursor.split_first() {
        if flag != "-t" && flag != "--tag" {
            break;
        }
        let Some((value, after)) = tail.split_first() else {
            return Err(usage(&format!("`{flag}` requires a value")));
        };
        tags.push(value.clone());
        cursor = after;
    }
    Ok((tags, cursor))
}

/// Drops a leading `--` separator, so `--is-fresh -- just check` and
/// `--is-fresh just check` both name the command `just check`.
fn strip_separator(rest: &[String]) -> &[String] {
    match rest.split_first() {
        Some((first, tail)) if first == "--" => tail,
        _ => rest,
    }
}

/// Reports a freshness gate: each not-fresh rule on stderr, then exit 0 when all
/// are fresh, 1 otherwise. The stale lines name the rule and why it would re-run,
/// and a single remediation hint follows them when any non-fresh verdict is
/// remediable, since mmz only observes a command it wraps — a standalone pass is
/// not tracked and leaves the rule stale or `never`.
fn report_freshness(verdicts: &[mmz::freshness::Verdict]) -> ExitCode {
    let mut fresh = true;
    let mut example: Option<&str> = None;
    for verdict in verdicts {
        if verdict.is_fresh() {
            continue;
        }
        fresh = false;
        let reason = verdict.reason().unwrap_or_else(|| "not fresh".to_owned());
        eprintln!("mmz: `{}` is {} ({reason})", verdict.rule, verdict.state());
        if example.is_none() && verdict.is_remediable() {
            example = Some(&verdict.rule);
        }
    }
    if fresh {
        return ExitCode::SUCCESS;
    }
    if let Some(rule) = example {
        eprintln!(
            "mmz: re-run each listed command under mmz (e.g. `mmz {rule}`) to record a pass; a standalone run is not tracked"
        );
    }
    ExitCode::from(1)
}

/// Writes `text` to stdout, treating a closed pipe (e.g. `mmz --schema | head`)
/// as success rather than panicking, since `unsafe` SIGPIPE reset is disallowed.
fn emit(text: &str) -> ExitCode {
    let mut out = std::io::stdout().lock();
    match out.write_all(text.as_bytes()).and_then(|()| out.flush()) {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("mmz: cannot write to stdout: {err}");
            ExitCode::from(70)
        }
    }
}

/// Runs `argv` through the memoization engine, mapping its result to a code.
fn wrap(argv: &[String]) -> ExitCode {
    let cwd = match current_dir() {
        Ok(dir) => dir,
        Err(code) => return code,
    };
    match mmz::run(argv, &cwd) {
        Ok(code) => ExitCode::from(code),
        Err(err) => report_error(&err),
    }
}

fn current_dir() -> Result<PathBuf, ExitCode> {
    std::env::current_dir().map_err(|err| {
        eprintln!("mmz: cannot determine working directory: {err}");
        ExitCode::from(70)
    })
}

fn unknown_option(opt: &str) -> ExitCode {
    eprintln!(
        "mmz: unknown option `{opt}`; run `mmz --help`, or `mmz -- {opt} …` to wrap a command starting with a dash"
    );
    ExitCode::from(2)
}

fn usage(message: &str) -> ExitCode {
    eprintln!("mmz: {message}");
    ExitCode::from(2)
}

fn report_error(err: &Error) -> ExitCode {
    eprintln!("mmz: {err}");
    ExitCode::from(exit_for(err))
}

/// Maps a library error to its documented exit code.
fn exit_for(err: &Error) -> u8 {
    match err {
        Error::EmptyCommand
        | Error::ManifestExists { .. }
        | Error::TagWithCommand
        | Error::InvalidNow { .. }
        | Error::ImportNotCheckout { .. }
        | Error::ImportSelf { .. } => 2,
        Error::NoMatch { .. } | Error::NoInputs { .. } => 3,
        Error::NoManifest { .. }
        | Error::ManifestParse { .. }
        | Error::ManifestUnreadable { .. }
        | Error::UnknownScope { .. }
        | Error::UnknownInput { .. }
        | Error::NameCollision { .. }
        | Error::EmptyCommandName(_)
        | Error::DuplicateCommand(_)
        | Error::DuplicateTag { .. }
        | Error::InvalidOutput { .. }
        | Error::MacroSyntax { .. }
        | Error::CollidingIdentity { .. }
        | Error::Pattern { .. }
        | Error::ImportMissing { .. }
        | Error::ImportNotReadable { .. }
        | Error::ImportCycle { .. }
        | Error::DuplicateScope { .. }
        | Error::DuplicateProbe { .. }
        | Error::DuplicateCommandAcrossFiles { .. }
        | Error::FragmentPolicyKey { .. }
        | Error::NullPolicyKey { .. }
        | Error::ProbeSource { .. }
        | Error::EmptyProbeShell => 4,
        Error::MissingOutput { .. } => 5,
        Error::Probe { .. } => 6,
        Error::NoRules { .. } | Error::NoTaggedRules { .. } | Error::NoExpansions { .. } => 7,
        Error::InputVanished { .. }
        | Error::InputUnreadable { .. }
        | Error::InitWrite { .. }
        | Error::CacheIo { .. } => 8,
        Error::Spawn { .. } => 127,
        Error::Serialize(_) | Error::Internal(_) => 70,
    }
}