use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::manifest::CONFIG_DIR;
const CONFIG_NAME: &str = "config.yaml";
const CACHE_GITIGNORE: &str =
"# Throwaway mmz cache; the config beside it stays tracked.\n/cache/\n";
pub const TEMPLATE: &str = concat!(
"# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/mmz/v",
env!("CARGO_PKG_VERSION"),
"/schema/mmz.schema.json
# .mmz/config.yaml — memoized command runner config.
# Prefix a command with `mmz`; it is skipped when the matched rule's inputs are
# byte-for-byte unchanged since the command last succeeded.
# Named glob sets, declared once and referenced by commands. `*` stays within a
# directory; `**` crosses directories.
scopes:
rust: [\"**/*.rs\", \"Cargo.toml\", \"Cargo.lock\"]
# Ordered rules. The first whose name is a token-prefix of the command wins.
# Set `match: exact` on a rule to match only the bare command (no extra args).
commands:
- name: cargo test
inputs: [rust]
# Named inputs that are not whole files, hashed into the input digest of every
# rule naming them in `inputs` — how a rule depends on part of a file, or on
# something that is not a file. One namespace with scopes: a probe may not
# share a name with one. Weigh the trade before reaching for this: a wrong
# scope costs time, a wrong probe can lie.
#
# `file:` + `json:` is the shape to reach for first. mmz reads the file and
# runs the jq program in-process — no shell, nothing needed on PATH — and
# hashes its own canonical rendering of the result, so object key order is
# never an input and there is no `jq -S` to forget.
# probes:
# nixpkgs-input:
# file: flake.lock
# json: '.nodes[\"nixpkgs\"][\"locked\"][\"narHash\"]'
# commands:
# - name: nix flake check
# inputs: [nixpkgs-input]
#
# `run:` is for what genuinely is not on disk; it may carry a `json:` too, to
# select out of the command's stdout instead of a file. A non-zero exit, a
# failed spawn, an unreadable file, bytes that are not JSON, or a selection
# that measured nothing (unless `allow_empty: true`) is a hard error and
# records nothing. A `run:` line resolves through the caller's PATH; set
# top-level `probe_shell` (e.g. [\"direnv\", \"exec\", \".\", \"sh\", \"-c\"]) to pin
# the environment it is measured in.
# probes:
# fmt-recipe:
# run: just --dump --dump-format json
# json: '.recipes[\"fmt-check\"]'
# commands:
# - name: just fmt-check
# inputs: [rust, fmt-recipe]
# A single {scope} macro in a name fans the rule over that scope's files: one
# per-file cache record, each keyed by and scoped to its own file plus the
# shared `inputs` pins. Best for commands that read only the one file (a
# per-file lint/format); a whole-crate command under-declares this way.
# scopes:
# lint-targets: [\"src/**/*.rs\"]
# commands:
# - name: \"ruff check {lint-targets}\"
# Optional labels on a rule. `mmz --is-fresh --tag <tag>` (and
# `mmz --status --tag <tag>`) then gates only the rules carrying every listed
# tag, instead of every rule in the manifest — so one manifest can hold both
# a gating subset (wired into a pre-push hook or `just check`) and other
# memoized commands (e.g. bench) that a gate should ignore. Untagged rules
# never match a --tag filter; repeat --tag to require more than one.
# commands:
# - name: cargo test
# inputs: [rust]
# tags: [gate]
# - name: cargo bench
# inputs: [rust]
# tags: [bench]
# Printed to stderr when a command is skipped. `{cache:<field>}` pulls a field
# straight from the cache record (command, ran_at, input_digest, ...). Set it per
# command to override, or to \"\" to silence that one.
on_hit: \"mmz: skipped {cache:command} (inputs unchanged)\"
# Directory for throwaway cache records, relative to the project root (the
# directory holding .mmz). The generated .mmz/.gitignore already ignores it.
# cache_dir: .mmz/cache
# Skip git-ignored paths when expanding globs (default true).
# gitignore: true
# Runtime cases mmz errors on instead of falling back. Omit for all (the safe
# default); list a subset to relax the rest; use [] to fall back everywhere.
# strict: [no_match, no_inputs]
"
);
pub fn init(cwd: &Path) -> Result<PathBuf> {
let dir = cwd.join(CONFIG_DIR);
let path = dir.join(CONFIG_NAME);
if path.exists() {
return Err(Error::ManifestExists { path });
}
std::fs::create_dir_all(&dir).map_err(|source| init_write(&dir, source))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
std::fs::write(&ignore, CACHE_GITIGNORE).map_err(|source| init_write(&ignore, source))?;
}
std::fs::write(&path, TEMPLATE).map_err(|source| init_write(&path, source))?;
Ok(path)
}
fn init_write(path: &Path, source: std::io::Error) -> Error {
Error::InitWrite {
path: path.to_path_buf(),
source,
}
}
#[cfg(test)]
mod tests {
use super::{CACHE_GITIGNORE, CONFIG_NAME, TEMPLATE, init};
use crate::manifest::{CONFIG_DIR, Manifest};
#[test]
fn template_is_a_valid_manifest() {
let manifest: Manifest = serde_yaml_ng::from_str(TEMPLATE).expect("template parses");
manifest.validate().expect("template validates");
}
#[test]
fn template_pins_schema_to_this_version() {
let pinned = format!("mmz/v{}/schema/mmz.schema.json", env!("CARGO_PKG_VERSION"));
assert!(
TEMPLATE.contains(&pinned),
"schema URL pins the build version"
);
assert!(
!TEMPLATE.contains("/main/"),
"no floating main ref in the scaffolded schema URL"
);
}
#[test]
fn template_scaffolds_a_configured_on_hit() {
let manifest: Manifest = serde_yaml_ng::from_str(TEMPLATE).expect("template parses");
assert_eq!(
manifest.on_hit.as_deref(),
Some("mmz: skipped {cache:command} (inputs unchanged)"),
"init ships an on_hit notice using a cache macro"
);
}
#[test]
fn writes_then_refuses_to_clobber() {
let dir = tempfile::tempdir().expect("tempdir");
let path = init(dir.path()).expect("first init writes");
assert_eq!(path, dir.path().join(CONFIG_DIR).join(CONFIG_NAME));
assert!(path.is_file(), "config written");
assert!(init(dir.path()).is_err(), "second init refuses to clobber");
}
#[test]
fn writes_a_gitignore_that_covers_the_cache() {
let dir = tempfile::tempdir().expect("tempdir");
init(dir.path()).expect("init writes");
let ignore = dir.path().join(CONFIG_DIR).join(".gitignore");
let body = std::fs::read_to_string(&ignore).expect("gitignore written");
assert_eq!(body, CACHE_GITIGNORE, "ignores the cache subtree");
assert!(body.contains("/cache/"), "the default cache_dir is covered");
}
}