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 if let Some(writer) = saved_by(staleness) {
87 return Some(format!(
88 "**{subject} recognises nothing.** `fallow {writer}` saved this baseline, so \
89 this command reads nothing from it and it suppresses nothing."
90 ));
91 }
92 return Some(format!(
93 "**{subject} recognises nothing.** The baseline has no entries this command \
94 recognises. It may be a baseline saved by another command, or an empty file. Either \
95 way it suppresses nothing."
96 ));
97 }
98 match warning {
99 "partial" => Some(format!(
100 "**{subject} is partially stale.** {stale} of {entries} saved entries matched \
101 nothing this run, so the baseline protects less than what was saved. Re-save it \
102 from a whole-project run."
103 )),
104 "zero-overlap" => Some(format!(
105 "**{subject} matched nothing.** All {entries} saved entries went unmatched. Paths \
106 may have changed, or the baseline was saved elsewhere. Re-save it from a \
107 whole-project run."
108 )),
109 _ if gate_trips => Some(format!(
113 "**{subject} has stale entries.** {stale} of {entries} saved entries matched \
114 nothing this run. The project may be clean, or the baseline may no longer \
115 describe it."
116 )),
117 _ => None,
118 }
119}
120
121fn saved_by(staleness: &Value) -> Option<&str> {
127 staleness
128 .get("saved_by")
129 .and_then(Value::as_str)
130 .filter(|token| {
131 !token.is_empty()
132 && token
133 .bytes()
134 .all(|byte| byte.is_ascii_lowercase() || byte == b'-')
135 })
136}
137
138fn subject(analysis: Option<&str>) -> String {
144 let Some(analysis) = analysis else {
145 return "Baseline".to_owned();
146 };
147 format!("{} baseline", capitalize(analysis))
148}
149
150fn capitalize(value: &str) -> String {
153 let mut chars = value.chars();
154 match chars.next() {
155 Some(first) => first.to_uppercase().chain(chars).collect(),
156 None => String::new(),
157 }
158}
159
160fn count(staleness: &Value, key: &str) -> u64 {
161 staleness.get(key).and_then(Value::as_u64).unwrap_or(0)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use fallow_output::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory};
168
169 fn staleness(warning: &str, entries: u64, matched: u64, gate_trips: bool) -> Value {
170 serde_json::json!({
171 "baseline_entries": entries,
172 "matched_entries": matched,
173 "stale_entries": entries - matched,
174 "current_findings": 4,
175 "change_scoped": false,
176 "stale": warning != "none",
177 "warning": warning,
178 "gate_trips": gate_trips,
179 "moved_entries": 0
180 })
181 }
182
183 fn envelope(staleness: &Value) -> Value {
184 serde_json::json!({ "kind": "dead-code", "baseline_staleness": staleness })
185 }
186
187 #[test]
188 fn an_envelope_without_a_baseline_says_nothing() {
189 assert!(advisory_line(&serde_json::json!({ "kind": "dead-code" })).is_none());
190 }
191
192 #[test]
193 fn a_partially_stale_baseline_names_both_counts() {
194 assert_eq!(
195 advisory_line(&envelope(&staleness("partial", 8, 3, true)))
196 .expect("a baseline was loaded"),
197 "**Baseline is partially stale.** 5 of 8 saved entries matched nothing this run, \
198 so the baseline protects less than what was saved. Re-save it from a whole-project \
199 run."
200 );
201 }
202
203 #[test]
204 fn a_baseline_that_matched_nothing_says_so() {
205 assert_eq!(
206 advisory_line(&envelope(&staleness("zero-overlap", 8, 0, true)))
207 .expect("a baseline was loaded"),
208 "**Baseline matched nothing.** All 8 saved entries went unmatched. Paths may have \
209 changed, or the baseline was saved elsewhere. Re-save it from a whole-project run."
210 );
211 }
212
213 #[test]
218 fn a_tripped_gate_reports_even_with_no_advisory() {
219 assert_eq!(
220 advisory_line(&envelope(&staleness("none", 8, 0, true))).expect("the gate rule held"),
221 "**Baseline has stale entries.** 8 of 8 saved entries matched nothing this run. \
222 The project may be clean, or the baseline may no longer describe it."
223 );
224 }
225
226 #[test]
227 fn a_fresh_baseline_says_nothing() {
228 assert!(advisory_line(&envelope(&staleness("none", 8, 8, false))).is_none());
229 }
230
231 #[test]
237 fn a_baseline_nothing_recognises_gets_its_own_sentence() {
238 let mut object = staleness("none", 0, 0, true);
239 object["unrecognised_format"] = Value::Bool(true);
240 assert_eq!(
241 advisory_line(&envelope(&object)).expect("the file was not this command's"),
242 "**Baseline recognises nothing.** The baseline has no entries this command \
243 recognises. It may be a baseline saved by another command, or an empty file. Either \
244 way it suppresses nothing."
245 );
246 }
247
248 #[test]
251 fn a_foreign_baseline_names_the_command_that_saved_it() {
252 let mut object = staleness("none", 0, 0, true);
253 object["unrecognised_format"] = Value::Bool(true);
254 object["saved_by"] = Value::String("health".to_owned());
255 assert_eq!(
256 advisory_line(&envelope(&object)).expect("the file was not this command's"),
257 "**Baseline recognises nothing.** `fallow health` saved this baseline, so this \
258 command reads nothing from it and it suppresses nothing."
259 );
260 object["saved_by"] = Value::String("<b>x</b>".to_owned());
261 assert!(
262 advisory_line(&envelope(&object))
263 .expect("the file was not this command's")
264 .contains("It may be a baseline saved by another command"),
265 );
266 }
267
268 #[test]
272 fn an_empty_baseline_of_this_commands_own_says_nothing() {
273 assert!(advisory_line(&envelope(&staleness("none", 0, 0, false))).is_none());
274 }
275
276 #[test]
278 fn a_multi_section_envelope_names_the_baseline_nothing_recognises() {
279 let mut object = staleness("none", 0, 0, true);
280 object["unrecognised_format"] = Value::Bool(true);
281 let value = serde_json::json!({
282 "kind": "audit",
283 "complexity": { "summary": { "baseline_staleness": object } }
284 });
285 assert!(
286 advisory_line(&value)
287 .expect("a baseline was loaded")
288 .starts_with("**Complexity baseline recognises nothing.**"),
289 "{value}"
290 );
291 }
292
293 #[test]
297 fn a_change_scoped_run_says_nothing() {
298 let mut object = staleness("none", 8, 0, false);
299 object["change_scoped"] = Value::Bool(true);
300 assert!(advisory_line(&envelope(&object)).is_none());
301 }
302
303 #[test]
306 fn an_unrecognised_advisory_falls_back_to_the_gate_rule() {
307 let mut object = staleness("none", 8, 2, true);
308 object["warning"] = Value::String("some-future-advisory".to_owned());
309 assert!(advisory_line(&envelope(&object)).is_some());
310
311 object["gate_trips"] = Value::Bool(false);
312 assert!(advisory_line(&envelope(&object)).is_none());
313 }
314
315 #[test]
316 fn health_publishes_inside_summary_and_reads_the_same() {
317 let value = serde_json::json!({
318 "kind": "health",
319 "summary": { "baseline_staleness": staleness("partial", 4, 1, true) }
320 });
321 assert!(
322 advisory_line(&value)
323 .expect("a baseline was loaded")
324 .starts_with("**Baseline is partially stale.**")
325 );
326 }
327
328 #[test]
331 fn a_multi_section_envelope_names_each_baseline() {
332 let value = serde_json::json!({
333 "kind": "audit",
334 "dead_code": { "baseline_staleness": staleness("zero-overlap", 3, 0, true) },
335 "complexity": {
336 "summary": { "baseline_staleness": staleness("partial", 8, 2, true) }
337 }
338 });
339
340 let line = advisory_line(&value).expect("two baselines were loaded");
341
342 assert!(
343 line.contains("**Dead-code baseline matched nothing.**"),
344 "{line}"
345 );
346 assert!(
347 line.contains("**Complexity baseline is partially stale.**"),
348 "{line}"
349 );
350 }
351
352 #[test]
356 fn the_live_and_saved_advisories_agree() {
357 for (warning, matched, gate_trips, unrecognised_format) in [
358 (BaselineStalenessAdvisory::Partial, 3, true, false),
359 (BaselineStalenessAdvisory::ZeroOverlap, 0, true, false),
360 (BaselineStalenessAdvisory::None, 0, true, false),
361 (BaselineStalenessAdvisory::None, 8, false, false),
362 (BaselineStalenessAdvisory::None, 8, true, true),
363 ] {
364 let typed = BaselineStaleness {
365 baseline_entries: 8,
366 matched_entries: matched,
367 stale_entries: 8 - matched,
368 current_findings: 4,
369 remaining_findings: None,
370 change_scoped: false,
371 stale: !matches!(warning, BaselineStalenessAdvisory::None),
372 warning,
373 gate_trips,
374 moved_entries: 0,
375 unrecognised_format,
376 saved_by: None,
377 format: None,
378 scope_reasons: BaselineScopeReasons::empty(),
379 };
380 let envelope = serde_json::json!({ "baseline_staleness": typed });
381
382 assert_eq!(
383 advisory_line_for_staleness(Some(&typed)),
384 advisory_line(&envelope),
385 "{warning:?} must render the same from both paths"
386 );
387 }
388 }
389
390 #[test]
391 fn a_live_run_without_a_baseline_says_nothing() {
392 assert!(advisory_line_for_staleness(None).is_none());
393 }
394}