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
//! Error types for the `mmz` library.
use std::path::PathBuf;
use thiserror::Error;
/// Errors produced while loading the manifest, resolving inputs, hashing
/// files, or spawning the wrapped command.
#[derive(Debug, Error)]
pub enum Error {
/// An I/O operation failed.
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
/// No manifest was found searching upward from the working directory.
#[error("no .mmz/config.yaml found in `{start}` or any parent; create one with `mmz --init`")]
NoManifest {
/// Directory the upward search started from.
start: PathBuf,
},
/// No rule matched the invoked command and `no_match` strictness is on.
#[error(
"no rule matches `{command}`; add a matching rule to .mmz/config.yaml or relax `strict`"
)]
NoMatch {
/// The invoked command, joined for display.
command: String,
},
/// A matched rule resolved to zero input files and `no_inputs` strictness
/// is on.
#[error("rule `{rule}` matched no input files; fix its scopes or relax `strict`")]
NoInputs {
/// Name of the rule that resolved to nothing.
rule: String,
},
/// The manifest failed to parse.
#[error("failed to parse manifest {path}: {source}")]
ManifestParse {
/// Path of the offending manifest.
path: PathBuf,
/// Underlying parser error.
source: Box<serde_yaml_ng::Error>,
},
/// A command rule's `{scope}` fan macro names a scope the manifest does not
/// define. A macro fans over files, so a probe cannot stand in for it.
#[error("command `{command}` references unknown scope `{scope}`")]
UnknownScope {
/// Name of the command rule.
command: String,
/// The missing scope name.
scope: String,
},
/// A command rule's `inputs` names something the manifest declares neither
/// as a scope nor as a probe. One namespace, so one error.
#[error(
"command `{command}` references unknown input `{input}`; declare it under `scopes:` or `probes:`"
)]
UnknownInput {
/// Name of the command rule.
command: String,
/// The unresolvable `inputs` entry.
input: String,
},
/// A probe and a scope share a name, so an `inputs:` entry naming it would
/// be ambiguous.
#[error(
"`{name}` is declared as both a scope and a probe; `inputs:` has one namespace, so a name must be one or the other"
)]
NameCollision {
/// The name claimed twice.
name: String,
},
/// A probe command exited non-zero, so its stdout is not a usable input.
#[error(
"probe `{name}` failed (exit {code}); mmz consumed no output and wrote no cache record\n command: {run}\n stderr: {stderr}"
)]
ProbeFailed {
/// Name of the offending probe.
name: String,
/// The `run` line, as the manifest spells it.
run: String,
/// The probe's exit code (1 for a signal death).
code: i32,
/// What the probe wrote to stderr, trimmed and capped.
stderr: String,
},
/// A probe command could not be spawned at all — the same hard stop as a
/// probe that ran and failed.
#[error(
"probe `{name}` could not be run; mmz consumed no output and wrote no cache record\n command: {run}\n {source}"
)]
ProbeSpawn {
/// Name of the offending probe.
name: String,
/// The `run` line, as the manifest spells it.
run: String,
/// Underlying spawn error.
source: std::io::Error,
},
/// A probe printed nothing and did not opt into that with `allow_empty`.
#[error(
"probe `{name}` produced no output; that is almost always a selector that matched nothing — set `allow_empty: true` on the probe if empty really is a valid input\n command: {run}"
)]
ProbeEmpty {
/// Name of the offending probe.
name: String,
/// The `run` line, as the manifest spells it.
run: String,
},
/// A command rule has a blank `name`.
#[error("command #{0} has an empty `name`; every command must declare a name")]
EmptyCommandName(usize),
/// Two command rules share the same name (the cache identity).
#[error("duplicate command name: {0} (command names must be unique)")]
DuplicateCommand(String),
/// A command rule declares an output that is not a usable literal path.
#[error("command `{command}` declares invalid output `{path}`: {reason}")]
InvalidOutput {
/// Name of the offending command.
command: String,
/// The offending output path, as written.
path: String,
/// Why it cannot be used as an output.
reason: String,
},
/// A rule's command exited 0 without producing a declared output, so no
/// record was written: a record here would claim an artifact that is not
/// on disk, and every later invocation would skip on that claim.
#[error(
"`{rule}` succeeded without producing its declared output `{path}`; no cache record was written, so the rule stays stale"
)]
MissingOutput {
/// Cache identity of the rule that ran.
rule: String,
/// The declared output that never appeared.
path: String,
},
/// A command rule declares the same tag twice.
#[error("command `{command}` declares tag `{tag}` twice")]
DuplicateTag {
/// Name of the offending command.
command: String,
/// The duplicated tag.
tag: String,
},
/// `--is-fresh` (or another tag-filtered action) was given both a `--tag`
/// filter and a specific command to target.
#[error(
"`--tag` cannot be combined with a command; a command already resolves to a single rule"
)]
TagWithCommand,
/// A command rule's `name` carries a malformed `{scope}` fan macro.
#[error("command `{name}` has a malformed `{{scope}}` macro: {reason}")]
MacroSyntax {
/// The offending rule name.
name: String,
/// What is wrong with the macro.
reason: String,
},
/// Two rules resolve to the same cache identity, so which one owns the
/// record — and its inputs — is ambiguous.
#[error(
"cache identity `{identity}` is claimed by multiple rules ({rules}); make their file sets or names disjoint"
)]
CollidingIdentity {
/// The shared expanded identity.
identity: String,
/// The colliding rule names, for the operator to reconcile.
rules: String,
},
/// A glob pattern was invalid.
#[error("invalid pattern `{pattern}`: {source}")]
Pattern {
/// The offending pattern.
pattern: String,
/// Underlying glob error.
source: globset::Error,
},
/// `MMZ_NOW` is set to something that is not a Unix epoch in seconds.
///
/// Refused rather than ignored: falling back to the system clock would hide
/// the misconfiguration and quietly restore the non-determinism the pin
/// exists to remove.
#[error(
"`MMZ_NOW` is set to `{value}`, which is not a Unix epoch in seconds; set it to a whole number of seconds (e.g. `date +%s`) or unset it to use the system clock"
)]
InvalidNow {
/// The offending value, as the environment spells it.
value: String,
},
/// A cache record could not be serialized.
#[error("failed to serialize cache record: {0}")]
Serialize(Box<serde_yaml_ng::Error>),
/// The wrapped command could not be spawned.
#[error("failed to run `{program}`: {source}")]
Spawn {
/// The program mmz tried to execute.
program: String,
/// Underlying spawn error.
source: std::io::Error,
},
/// mmz was invoked with no command to run.
#[error("no command given")]
EmptyCommand,
/// `mmz --init` found a manifest already in place.
#[error("{path} already exists; remove it first or edit it directly")]
ManifestExists {
/// Path of the existing manifest.
path: PathBuf,
},
/// An invariant that should hold by construction did not.
#[error("internal error: {0}")]
Internal(String),
}
/// Convenience alias for fallible operations in this crate.
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::Error;
#[test]
fn messages_are_actionable() {
let scope = Error::UnknownScope {
command: "cargo test".to_owned(),
scope: "rust".to_owned(),
};
assert!(scope.to_string().contains("unknown scope `rust`"));
let dup = Error::DuplicateCommand("sh".to_owned());
assert!(dup.to_string().contains("duplicate command name"));
let blank = Error::EmptyCommandName(2);
assert!(blank.to_string().contains("empty `name`"));
let no_match = Error::NoMatch {
command: "cargo build".to_owned(),
};
assert!(
no_match
.to_string()
.contains("no rule matches `cargo build`")
);
let no_inputs = Error::NoInputs {
rule: "cargo test".to_owned(),
};
assert!(no_inputs.to_string().contains("matched no input files"));
let no_manifest = Error::NoManifest {
start: std::path::PathBuf::from("/tmp/x"),
};
assert!(
no_manifest
.to_string()
.contains("no .mmz/config.yaml found")
);
let bad_output = Error::InvalidOutput {
command: "just cover".to_owned(),
path: "target/*.info".to_owned(),
reason: "outputs are literal paths, not patterns".to_owned(),
};
assert!(
bad_output
.to_string()
.contains("declares invalid output `target/*.info`")
);
let missing = Error::MissingOutput {
rule: "just cover".to_owned(),
path: "target/coverage/lcov.info".to_owned(),
};
let text = missing.to_string();
assert!(
text.contains("target/coverage/lcov.info"),
"the missing artifact is named: {text}"
);
assert!(
text.contains("no cache record was written"),
"and the consequence is spelled out: {text}"
);
}
#[test]
fn probe_messages_name_the_probe_and_the_consequence() {
let failed = Error::ProbeFailed {
name: "fmt-recipe".to_owned(),
run: "just --dump | jq .recipes".to_owned(),
code: 5,
stderr: "jq: error: no such key".to_owned(),
};
let text = failed.to_string();
assert!(
text.contains("probe `fmt-recipe`"),
"names the probe: {text}"
);
assert!(text.contains("exit 5"), "names the exit code: {text}");
assert!(text.contains("jq: error"), "carries stderr: {text}");
assert!(
text.contains("wrote no cache record"),
"a failed probe never reaches the hasher, and says so: {text}"
);
let spawn = Error::ProbeSpawn {
name: "toolchain".to_owned(),
run: "rustc -vV".to_owned(),
source: std::io::Error::other("no such file"),
};
let text = spawn.to_string();
assert!(
text.contains("probe `toolchain`"),
"names the probe: {text}"
);
assert!(
text.contains("wrote no cache record"),
"an unspawnable probe is the same hard stop: {text}"
);
let empty = Error::ProbeEmpty {
name: "selector".to_owned(),
run: "jq -c .missing".to_owned(),
};
let text = empty.to_string();
assert!(text.contains("probe `selector`"), "names the probe: {text}");
assert!(
text.contains("allow_empty"),
"points at the opt-in rather than leaving it a dead end: {text}"
);
}
#[test]
fn input_namespace_messages_are_actionable() {
let unknown = Error::UnknownInput {
command: "cargo test".to_owned(),
input: "ghost".to_owned(),
};
let text = unknown.to_string();
assert!(
text.contains("unknown input `ghost`"),
"names the entry: {text}"
);
assert!(
text.contains("`scopes:` or `probes:`"),
"names both places it could be declared: {text}"
);
let clash = Error::NameCollision {
name: "rust".to_owned(),
};
assert!(
clash.to_string().contains("one namespace"),
"the collision explains why one name cannot be both"
);
}
}