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
//! The verdict vocabulary `mmz --status` and `mmz --is-fresh` share: the
//! [`State`] a rule is in, the [`Assessment`] carrying the detail behind it,
//! and [`verdict`], the one function that decides between them. Split out of
//! [`crate::status`] when `expired` arrived and that module hit its line cap;
//! it owns resolving and measuring a rule, this owns what the measurement
//! means. Everything here is re-exported there, so no path outside moved.
use std::collections::BTreeMap;
use serde::Serialize;
use super::CachedInfo;
use crate::probe;
use crate::ttl::Ttl;
/// A rule's freshness verdict.
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum State {
Fresh,
Stale,
Never,
Failed,
NoInputs,
MissingOutput,
Expired,
}
/// A verdict plus the detail behind it: the declared output that voided the
/// record, the probe whose output moved, or the ttl a pass outlived, when there
/// is one. The state alone cannot carry any of them — a reader told only
/// "stale" goes looking at the input files, which in every case is the wrong
/// place.
pub(crate) struct Assessment {
pub(crate) state: State,
pub(crate) missing_output: Option<String>,
pub(crate) changed_probe: Option<String>,
pub(crate) expired: Option<String>,
}
impl State {
/// The label used in the human table; matches the JSON enum spelling.
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Fresh => "fresh",
Self::Stale => "stale",
Self::Never => "never",
Self::Failed => "failed",
Self::NoInputs => "no-inputs",
Self::MissingOutput => "missing-output",
Self::Expired => "expired",
}
}
/// True only for [`State::Fresh`] — the sole state `mmz --is-fresh` passes.
pub(crate) const fn is_fresh(self) -> bool {
matches!(self, Self::Fresh)
}
/// True for the non-fresh states a recorded pass can clear: `Stale`,
/// `Never`, `Failed`, `MissingOutput` and `Expired` — re-running the
/// command under mmz regenerates the artifact (or asks the world again) and
/// records the pass. `NoInputs` never has inputs to digest, so a wrapped
/// run records nothing (or is refused under
/// the `no_inputs` strictness) and the rule stays `NoInputs` — its remedy
/// is fixing the manifest, not re-running under mmz.
pub(crate) const fn is_remediable(self) -> bool {
matches!(
self,
Self::Stale | Self::Never | Self::Failed | Self::MissingOutput | Self::Expired
)
}
/// Why a non-fresh rule would re-run, for the `--is-fresh` gate's message.
/// `None` when the rule is fresh. The `MissingOutput` wording here is the
/// fallback; the caller names the path (see [`Assessment`]).
pub(crate) const fn reason(self) -> Option<&'static str> {
match self {
Self::Fresh => None,
Self::Stale => Some("inputs changed since it last passed"),
Self::Never => Some("never run"),
Self::Failed => Some("last run failed"),
Self::NoInputs => Some("resolved no input files"),
Self::MissingOutput => Some("a declared output is missing"),
Self::Expired => Some("last pass is older than its ttl"),
}
}
}
impl Assessment {
/// The verdict's one-line explanation, naming the missing artifact when
/// that is what voided the record, the probe when that is what moved, or
/// the ttl when the pass is merely too old.
/// `None` when the rule is fresh.
pub(crate) fn reason(&self) -> Option<String> {
if let Some(path) = self.missing_output.as_deref() {
return Some(format!("declared output `{path}` is missing"));
}
if let Some(name) = self.changed_probe.as_deref() {
return Some(format!("probe `{name}` changed since it last passed"));
}
if let Some(ttl) = self.expired.as_deref() {
return Some(format!("last pass is older than its ttl `{ttl}`"));
}
self.state.reason().map(str::to_owned)
}
}
/// The freshness verdict for `digest` against a rule's stored record: fresh
/// only when the record is present, succeeded, its digest matches, every
/// output the rule declares is still on disk (`missing` is the first one that
/// is not), and it has not outlived the rule's `ttl` (`expired` is that ttl).
///
/// A missing artifact outranks a digest mismatch. Both would re-run the rule,
/// but only one of them is a fact a reader would otherwise never guess — an
/// input change is the assumption they already hold — so the verdict names the
/// gone artifact rather than sending them to diff the inputs. A stale verdict
/// gets the same treatment for probes: when the record's stored probe digests
/// show one moved, `probes` is compared against them and the culprit is named,
/// because no file changed and diffing the files would find nothing. Age ranks
/// last: `expired` means exactly "nothing moved, the pass is just too old", so
/// a record that is both old and stale reports the change.
pub(super) fn verdict(
cached: Option<&CachedInfo>,
digest: &str,
missing: Option<String>,
expired: Option<&Ttl>,
probes: &BTreeMap<String, String>,
) -> Assessment {
let state = match cached {
None => State::Never,
Some(record) if !record.ok => State::Failed,
Some(_) if missing.is_some() => State::MissingOutput,
Some(record) if record.digest != digest => State::Stale,
Some(_) if expired.is_some() => State::Expired,
Some(_) => State::Fresh,
};
let missing_output = match state {
State::MissingOutput => missing,
_ => None,
};
let changed_probe = match state {
State::Stale => cached.and_then(|record| probe::first_changed(&record.probes, probes)),
_ => None,
};
let expired = match state {
State::Expired => expired.map(|ttl| ttl.as_str().to_owned()),
_ => None,
};
Assessment {
state,
missing_output,
changed_probe,
expired,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{CachedInfo, State, verdict};
use crate::ttl::Ttl;
fn record(ok: bool, digest: &str) -> CachedInfo {
CachedInfo {
digest: digest.to_owned(),
ok,
ran_at: 0,
outputs: Vec::new(),
probes: BTreeMap::new(),
}
}
fn label(
cached: Option<&CachedInfo>,
missing: Option<&str>,
expired: Option<&Ttl>,
) -> &'static str {
let none = BTreeMap::new();
verdict(cached, "d1", missing.map(str::to_owned), expired, &none)
.state
.label()
}
#[test]
fn an_outlived_pass_with_unchanged_inputs_is_expired() {
let ttl = Ttl::parse("1h").expect("valid");
let ok = record(true, "d1");
assert_eq!(label(Some(&ok), None, None), "fresh");
assert_eq!(label(Some(&ok), None, Some(&ttl)), "expired");
let none = BTreeMap::new();
let assessed = verdict(Some(&ok), "d1", None, Some(&ttl), &none);
assert!(matches!(assessed.state, State::Expired));
assert!(
assessed.state.is_remediable(),
"a re-run records a new pass"
);
assert_eq!(
assessed.reason().as_deref(),
Some("last pass is older than its ttl `1h`")
);
}
#[test]
fn every_other_reason_to_rerun_outranks_age() {
let ttl = Ttl::parse("1h").expect("valid");
assert_eq!(label(None, None, Some(&ttl)), "never");
assert_eq!(
label(Some(&record(false, "d1")), None, Some(&ttl)),
"failed"
);
assert_eq!(
label(Some(&record(true, "d1")), Some("out.txt"), Some(&ttl)),
"missing-output"
);
assert_eq!(
label(Some(&record(true, "d0")), None, Some(&ttl)),
"stale",
"an input change is what re-runs it; age would only hide where to look"
);
}
#[test]
fn the_bare_state_reason_covers_expired_too() {
assert_eq!(
State::Expired.reason(),
Some("last pass is older than its ttl")
);
}
}