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
//! Why a probe produced no usable bytes: the runtime half of a probe's failure
//! surface, split out of `probe.rs` once `error.rs` reached its line cap.
//!
//! Every case here is one a probe reaches while *running* — a command that
//! failed, a file that would not read, a document that would not parse, a
//! selector that matched nothing — so every one of them shares an exit code
//! (`6`) and a consequence: mmz consumed no output and wrote no cache record.
//! The shape rules a probe is refused at load for are not here; they are
//! [`crate::error::Error::ProbeSource`], because a malformed probe is a
//! manifest defect (exit `4`) caught before anything runs.
use thiserror::Error;
use crate::ast::AstFailure;
use crate::error::Error;
/// Why a probe could not produce a digest, once it was asked to.
///
/// A sub-enum for the reason [`AstFailure`] is one: a family of related
/// refusals belongs beside the code that raises them, held in `Error` by the
/// single variant [`crate::error::Error::Probe`]. That variant supplies the
/// prefix naming the probe, so every message here is written to continue it
/// rather than to stand alone, and the rendered text is byte-for-byte what it
/// was when these were eight variants of `Error`: what moved is where they are
/// declared, not what anyone reads.
#[derive(Debug, Error)]
pub enum ProbeFailure {
/// A probe command exited non-zero, so its stdout is not a usable input.
#[error(
"failed (exit {code}); mmz consumed no output and wrote no cache record\n command: {run}\n stderr: {stderr}"
)]
Failed {
/// 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(
"could not be run; mmz consumed no output and wrote no cache record\n command: {run}\n {source}"
)]
Spawn {
/// 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(
"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}"
)]
Empty {
/// The `run` line, as the manifest spells it.
run: String,
},
/// A probe's `file:` could not be read, so there are no bytes to select
/// from. Named separately from any other unreadable file because a probe's
/// error must name the probe *and* the path: a bare "no such file" leaves a
/// reader hunting.
#[error(
"could not read `{path}`; mmz consumed no output and wrote no cache record\n {source}"
)]
FileUnreadable {
/// The path, as the manifest spells it.
path: std::path::PathBuf,
/// Underlying I/O error.
source: std::io::Error,
},
/// The bytes a `json:` probe was pointed at are not one JSON value — an
/// empty file, a tool that logged a line before its JSON, a truncated
/// write.
#[error(
"read {origin}, which is not one JSON value ({reason}); mmz consumed no output and wrote no cache record"
)]
JsonInput {
/// What was read, as the manifest points at it.
origin: String,
/// What the parser objected to.
reason: String,
},
/// A `json:` program did not compile, or raised while running. One variant
/// for both because the fix is the same edit — the program is wrong for
/// the document it was pointed at — and the reason says which half broke.
#[error(
"could not select from {origin} ({reason}); mmz consumed no output and wrote no cache record\n json: {program}"
)]
JsonFailed {
/// The `json:` program, as the manifest spells it.
program: String,
/// What it was run against.
origin: String,
/// What jaq objected to.
reason: String,
},
/// A `json:` selector yielded no value, or only `null`.
///
/// The same refusal [`ProbeFailure::Empty`] makes for stdout, at the place
/// the selection happens: a probe tracking `null` reports the same digest
/// whatever the document does, so the rule is permanently fresh against an
/// input nobody is measuring. `false` is a value and passes — jq's `-e`
/// conflates the two only because a shell exit code cannot tell them
/// apart, and mmz is under no such constraint.
#[error(
"selected nothing from {origin}; that digest would measure nothing, so the rule would be fresh forever — fix the selector, or set `allow_empty: true` if an absent value really is a valid input\n json: {program}"
)]
JsonEmpty {
/// The `json:` program, as the manifest spells it.
program: String,
/// What it was run against.
origin: String,
},
/// A probe's `ast:` key could not produce a digest. The detail — and which
/// refusal it is — lives one level further down again, with the matcher in
/// [`AstFailure`], because one of those cases is answered by a cargo
/// feature rather than a manifest edit and would not survive being
/// flattened into a string here.
#[error("{0}")]
Ast(#[source] Box<AstFailure>),
}
impl ProbeFailure {
/// Wraps this failure in the error that names the probe it came from.
///
/// The one way a `ProbeFailure` becomes an [`Error`], so the two halves of
/// a probe's message — whose it was, and what went wrong — can never be
/// assembled two different ways. This module knows the second half;
/// [`crate::probe`] and its digest step know the first.
pub(crate) fn named(self, name: &str) -> Error {
Error::Probe {
name: name.to_owned(),
source: Box::new(self),
}
}
}
#[cfg(test)]
mod tests {
use super::ProbeFailure;
use crate::ast::AstFailure;
/// One of every variant, so a new one added without a message reviewed here
/// is a compile error rather than a silent gap.
fn every_failure() -> Vec<ProbeFailure> {
vec![
ProbeFailure::Failed {
run: "just --dump".to_owned(),
code: 5,
stderr: "boom".to_owned(),
},
ProbeFailure::Spawn {
run: "rustc -vV".to_owned(),
source: std::io::Error::other("no such file"),
},
ProbeFailure::Empty {
run: "jq -c .missing".to_owned(),
},
ProbeFailure::FileUnreadable {
path: std::path::PathBuf::from("flake.lock"),
source: std::io::Error::other("permission denied"),
},
ProbeFailure::JsonInput {
origin: "flake.lock".to_owned(),
reason: "trailing characters".to_owned(),
},
ProbeFailure::JsonFailed {
program: ".nodes".to_owned(),
origin: "flake.lock".to_owned(),
reason: "cannot index".to_owned(),
},
ProbeFailure::JsonEmpty {
program: ".nodes".to_owned(),
origin: "flake.lock".to_owned(),
},
ProbeFailure::Ast(Box::new(AstFailure::NotText {
origin: "src/lib.rs".to_owned(),
lang: "rust".to_owned(),
})),
]
}
/// The wrapper supplies the prefix, so a message here must continue it. A
/// sub-message that restated it would render `probe `x` probe `x` …`, which
/// nothing else in the build would catch — the enum is not the thing a user
/// reads, and the CLI tests match on substrings.
#[test]
fn no_message_restates_the_prefix_its_wrapper_supplies() {
for failure in every_failure() {
let text = failure.to_string();
assert!(
!text.starts_with("probe "),
"the wrapper already named the probe: {text}"
);
}
}
/// And the wrapped form reads as one sentence about a named probe, which is
/// what these messages read as before the family moved out of `Error`.
#[test]
fn wrapping_names_the_probe_once() {
for failure in every_failure() {
let text = failure.named("fmt-recipe").to_string();
assert!(
text.starts_with("probe `fmt-recipe` "),
"names the probe first: {text}"
);
assert_eq!(
text.matches("probe `fmt-recipe`").count(),
1,
"and names it once: {text}"
);
}
}
}