1use serde_json::Value;
23
24pub fn advisory_line(envelope: &Value) -> Option<String> {
37 let root = envelope.as_object()?;
38 let sentences = fallow_types::envelope_sites::baseline_staleness_objects(root)
39 .filter_map(|(staleness, analysis)| advisory_sentence(staleness, analysis))
40 .collect::<Vec<_>>();
41 if sentences.is_empty() {
42 return None;
43 }
44 Some(sentences.join(" "))
45}
46
47pub fn advisory_line_for_staleness(
56 staleness: Option<&fallow_output::BaselineStaleness>,
57) -> Option<String> {
58 let staleness = staleness?;
59 advisory_line(&serde_json::json!({ "baseline_staleness": staleness }))
60}
61
62fn advisory_sentence(staleness: &Value, analysis: Option<&str>) -> Option<String> {
64 let warning = staleness
65 .get("warning")
66 .and_then(Value::as_str)
67 .unwrap_or("none");
68 let gate_trips = staleness
69 .get("gate_trips")
70 .and_then(Value::as_bool)
71 .unwrap_or(false);
72 let entries = count(staleness, "baseline_entries");
73 let stale = count(staleness, "stale_entries");
74 let subject = subject(analysis);
75 if staleness
82 .get("unrecognised_format")
83 .and_then(Value::as_bool)
84 .unwrap_or(false)
85 {
86 return Some(format!(
87 "**{subject} recognises nothing.** The baseline has no entries this command \
88 recognises. It may be a baseline saved by another command, or an empty file. Either \
89 way it suppresses nothing."
90 ));
91 }
92 match warning {
93 "partial" => Some(format!(
94 "**{subject} is partially stale.** {stale} of {entries} saved entries matched \
95 nothing this run, so the baseline protects less than what was saved. Re-save it \
96 from a whole-project run."
97 )),
98 "zero-overlap" => Some(format!(
99 "**{subject} matched nothing.** All {entries} saved entries went unmatched. Paths \
100 may have changed, or the baseline was saved elsewhere. Re-save it from a \
101 whole-project run."
102 )),
103 _ if gate_trips => Some(format!(
107 "**{subject} has stale entries.** {stale} of {entries} saved entries matched \
108 nothing this run. The project may be clean, or the baseline may no longer \
109 describe it."
110 )),
111 _ => None,
112 }
113}
114
115fn subject(analysis: Option<&str>) -> String {
121 let Some(analysis) = analysis else {
122 return "Baseline".to_owned();
123 };
124 format!("{} baseline", capitalize(analysis))
125}
126
127fn capitalize(value: &str) -> String {
130 let mut chars = value.chars();
131 match chars.next() {
132 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
133 None => String::new(),
134 }
135}
136
137fn count(staleness: &Value, key: &str) -> u64 {
138 staleness.get(key).and_then(Value::as_u64).unwrap_or(0)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use fallow_output::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory};
145
146 fn staleness(warning: &str, entries: u64, matched: u64, gate_trips: bool) -> Value {
147 serde_json::json!({
148 "baseline_entries": entries,
149 "matched_entries": matched,
150 "stale_entries": entries - matched,
151 "current_findings": 4,
152 "change_scoped": false,
153 "stale": warning != "none",
154 "warning": warning,
155 "gate_trips": gate_trips,
156 "moved_entries": 0
157 })
158 }
159
160 fn envelope(staleness: &Value) -> Value {
161 serde_json::json!({ "kind": "dead-code", "baseline_staleness": staleness })
162 }
163
164 #[test]
165 fn an_envelope_without_a_baseline_says_nothing() {
166 assert!(advisory_line(&serde_json::json!({ "kind": "dead-code" })).is_none());
167 }
168
169 #[test]
170 fn a_partially_stale_baseline_names_both_counts() {
171 assert_eq!(
172 advisory_line(&envelope(&staleness("partial", 8, 3, true)))
173 .expect("a baseline was loaded"),
174 "**Baseline is partially stale.** 5 of 8 saved entries matched nothing this run, \
175 so the baseline protects less than what was saved. Re-save it from a whole-project \
176 run."
177 );
178 }
179
180 #[test]
181 fn a_baseline_that_matched_nothing_says_so() {
182 assert_eq!(
183 advisory_line(&envelope(&staleness("zero-overlap", 8, 0, true)))
184 .expect("a baseline was loaded"),
185 "**Baseline matched nothing.** All 8 saved entries went unmatched. Paths may have \
186 changed, or the baseline was saved elsewhere. Re-save it from a whole-project run."
187 );
188 }
189
190 #[test]
195 fn a_tripped_gate_reports_even_with_no_advisory() {
196 assert_eq!(
197 advisory_line(&envelope(&staleness("none", 8, 0, true))).expect("the gate rule held"),
198 "**Baseline has stale entries.** 8 of 8 saved entries matched nothing this run. \
199 The project may be clean, or the baseline may no longer describe it."
200 );
201 }
202
203 #[test]
204 fn a_fresh_baseline_says_nothing() {
205 assert!(advisory_line(&envelope(&staleness("none", 8, 8, false))).is_none());
206 }
207
208 #[test]
214 fn a_baseline_nothing_recognises_gets_its_own_sentence() {
215 let mut object = staleness("none", 0, 0, true);
216 object["unrecognised_format"] = Value::Bool(true);
217 assert_eq!(
218 advisory_line(&envelope(&object)).expect("the file was not this command's"),
219 "**Baseline recognises nothing.** The baseline has no entries this command \
220 recognises. It may be a baseline saved by another command, or an empty file. Either \
221 way it suppresses nothing."
222 );
223 }
224
225 #[test]
229 fn an_empty_baseline_of_this_commands_own_says_nothing() {
230 assert!(advisory_line(&envelope(&staleness("none", 0, 0, false))).is_none());
231 }
232
233 #[test]
235 fn a_multi_section_envelope_names_the_baseline_nothing_recognises() {
236 let mut object = staleness("none", 0, 0, true);
237 object["unrecognised_format"] = Value::Bool(true);
238 let value = serde_json::json!({
239 "kind": "audit",
240 "complexity": { "summary": { "baseline_staleness": object } }
241 });
242 assert!(
243 advisory_line(&value)
244 .expect("a baseline was loaded")
245 .starts_with("**Complexity baseline recognises nothing.**"),
246 "{value}"
247 );
248 }
249
250 #[test]
254 fn a_change_scoped_run_says_nothing() {
255 let mut object = staleness("none", 8, 0, false);
256 object["change_scoped"] = Value::Bool(true);
257 assert!(advisory_line(&envelope(&object)).is_none());
258 }
259
260 #[test]
263 fn an_unrecognised_advisory_falls_back_to_the_gate_rule() {
264 let mut object = staleness("none", 8, 2, true);
265 object["warning"] = Value::String("some-future-advisory".to_owned());
266 assert!(advisory_line(&envelope(&object)).is_some());
267
268 object["gate_trips"] = Value::Bool(false);
269 assert!(advisory_line(&envelope(&object)).is_none());
270 }
271
272 #[test]
273 fn health_publishes_inside_summary_and_reads_the_same() {
274 let value = serde_json::json!({
275 "kind": "health",
276 "summary": { "baseline_staleness": staleness("partial", 4, 1, true) }
277 });
278 assert!(
279 advisory_line(&value)
280 .expect("a baseline was loaded")
281 .starts_with("**Baseline is partially stale.**")
282 );
283 }
284
285 #[test]
288 fn a_multi_section_envelope_names_each_baseline() {
289 let value = serde_json::json!({
290 "kind": "audit",
291 "dead_code": { "baseline_staleness": staleness("zero-overlap", 3, 0, true) },
292 "complexity": {
293 "summary": { "baseline_staleness": staleness("partial", 8, 2, true) }
294 }
295 });
296
297 let line = advisory_line(&value).expect("two baselines were loaded");
298
299 assert!(
300 line.contains("**Dead-code baseline matched nothing.**"),
301 "{line}"
302 );
303 assert!(
304 line.contains("**Complexity baseline is partially stale.**"),
305 "{line}"
306 );
307 }
308
309 #[test]
313 fn the_live_and_saved_advisories_agree() {
314 for (warning, matched, gate_trips, unrecognised_format) in [
315 (BaselineStalenessAdvisory::Partial, 3, true, false),
316 (BaselineStalenessAdvisory::ZeroOverlap, 0, true, false),
317 (BaselineStalenessAdvisory::None, 0, true, false),
318 (BaselineStalenessAdvisory::None, 8, false, false),
319 (BaselineStalenessAdvisory::None, 8, true, true),
320 ] {
321 let typed = BaselineStaleness {
322 baseline_entries: 8,
323 matched_entries: matched,
324 stale_entries: 8 - matched,
325 current_findings: 4,
326 change_scoped: false,
327 stale: !matches!(warning, BaselineStalenessAdvisory::None),
328 warning,
329 gate_trips,
330 moved_entries: 0,
331 unrecognised_format,
332 scope_reasons: BaselineScopeReasons::empty(),
333 };
334 let envelope = serde_json::json!({ "baseline_staleness": typed });
335
336 assert_eq!(
337 advisory_line_for_staleness(Some(&typed)),
338 advisory_line(&envelope),
339 "{warning:?} must render the same from both paths"
340 );
341 }
342 }
343
344 #[test]
345 fn a_live_run_without_a_baseline_says_nothing() {
346 assert!(advisory_line_for_staleness(None).is_none());
347 }
348}