Skip to main content

fallow_output/
request_outcomes.rs

1//! What happened to every narrowing or shaping request a run received.
2//!
3//! One shape for every command that can be asked to scope or shape its report,
4//! so a consumer reads the same members whether the envelope came from
5//! `dead-code`, `dupes`, `health` or `security`. Carried as `request_outcomes`
6//! at the envelope root, absent whenever the run was asked for nothing.
7//!
8//! This is the mirror image of [`crate::GateOutcomes`]. That object answers
9//! "what did the run conclude"; this one answers "did the run do what it was
10//! asked". Both are keyed maps at the root with an open key set, so a consumer
11//! learns one reading rule for both, and a request added in a later release
12//! reaches an unchanged consumer.
13//!
14//! # Why the honoured case is published too
15//!
16//! An entry appears for every request the run RECEIVED, including the ones it
17//! honoured, exactly as `gate_outcomes` publishes gates that passed. A report
18//! that says "scoped to the change" positively is the reviewer question this
19//! object is actually about, and a reader who only ever sees failures cannot
20//! tell "the filter applied" from "nothing was asked for".
21//!
22//! # Why this is not a `workspace_diagnostics` entry
23//!
24//! `WorkspaceDiagnostic` requires a `path` naming the file or directory that
25//! triggered it, and an unresolvable git ref or an oversize diff has no such
26//! path. More decisively, `degrades_analysis` means "less reached the analysis
27//! than the user expected", and the shipped consumer sentence built from it
28//! says findings were computed over less than the whole project. These facts
29//! mean the opposite: MORE was reported than was asked for. Routing them
30//! through that array would make an existing warning state something false.
31//!
32//! # Why it carries prose when `gate_outcomes` deliberately does not
33//!
34//! A gate verdict is rendered from `status`, `observed` and `threshold`, so it
35//! needs no sentence. Here every reason carries a different remedy (fetch the
36//! ref, regenerate the diff from the repository root, check out with full
37//! history), the CLI already writes those sentences to stderr, and a rendered
38//! pull-request comment needs one of them in its body. Reproducing nine
39//! remedies in bash and in jq, twice, is how they drift.
40//! `WorkspaceDiagnostic::message` is the precedent.
41//!
42//! # Wire compatibility
43//!
44//! The key set and the `status` value set are both OPEN. A name or a status
45//! this build does not recognise means "some request" and "some outcome", not
46//! an error, the same tolerate-unknown-values contract
47//! `workspace_diagnostics[].kind` documents. The Rust types are closed enums so
48//! the emitter cannot drift, and a request added later is an additive optional
49//! key that bumps no `schema_version`.
50
51use std::collections::BTreeMap;
52
53use serde::Serialize;
54
55/// Which request an outcome describes.
56///
57/// Taken from the flags and environment channels a run can be narrowed or
58/// shaped by, rather than from any integration's input list, because a request
59/// a consumer cannot name is exactly the one whose fate goes missing.
60/// Serialized as kebab-case and published as an open set.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
62#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
63#[serde(rename_all = "kebab-case")]
64pub enum RequestName {
65    /// `--changed-since <ref>`: scope the analysis to the files that changed
66    /// since a git ref. Not applied means the report covers the whole project.
67    ChangedSince,
68    /// `--diff-file`, `--diff-stdin` or `$FALLOW_DIFF_FILE`: keep only the
69    /// findings a supplied unified diff touches. Not applied means every
70    /// finding is reported.
71    DiffFilter,
72    /// `workspaces.changedSince` in the config: scope the findings of each
73    /// mapped workspace package to the files that changed since its own ref.
74    /// Not applied means a key named no workspace or a mapped ref did not
75    /// resolve, so the report covers every package in full scope. A global changed-since ref overrides the
76    /// map, and the run then publishes no entry for it.
77    PackageBaselines,
78    /// `--sarif-file <path>`: also write the findings as a SARIF document at
79    /// `path`. Not applied means the file was never written, so a consumer
80    /// uploading it to code scanning has nothing to upload. The primary report
81    /// on stdout is unaffected, which is why the run neither fails nor says
82    /// anything else about it.
83    SarifFile,
84}
85
86impl RequestName {
87    /// What an unapplied request of this name means for the report.
88    #[must_use]
89    pub const fn affects(self) -> RequestEffect {
90        match self {
91            Self::ChangedSince | Self::DiffFilter | Self::PackageBaselines => RequestEffect::Scope,
92            Self::SarifFile => RequestEffect::Artifact,
93        }
94    }
95}
96
97/// What a request governs, and therefore what its failure means.
98///
99/// Published on every entry so a consumer selects on the class rather than on
100/// a name list. Without it the one sentence a consumer can write for the whole
101/// object ("the report is wider than requested") is false for any request that
102/// does not narrow, which is how a failed `--sarif-file` write came to be
103/// reported as an unscoped run. A request name added later carries its own
104/// class, so a consumer written today keeps saying the right thing about it.
105///
106/// The value set is OPEN, like the names and the statuses: read a class this
107/// build does not recognise as "some request", not as an error, and do not read
108/// it as `scope`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "kebab-case")]
112pub enum RequestEffect {
113    /// The request narrows what the report covers. Not applied means the
114    /// report that follows is complete, valid, and WIDER than what was asked
115    /// for, which is a reviewing problem rather than a build failure.
116    Scope,
117    /// The request produces a secondary file beside the report. Not applied
118    /// means that file was not written, so anything consuming it has nothing to
119    /// read. The report on stdout and the exit code are unaffected, and nothing
120    /// about the run's scope changed.
121    Artifact,
122}
123
124/// What became of one request on this run.
125///
126/// Two-valued today. The value set is OPEN so a later `partial` needs no bump,
127/// and it is deliberately not added now: nothing emits it, and a permanently
128/// unused value reads as a measurement nobody takes.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131#[serde(rename_all = "kebab-case")]
132pub enum RequestStatus {
133    /// The run did what it was asked. The report is scoped or shaped as
134    /// requested.
135    Applied,
136    /// The run could not do what it was asked and continued anyway. The report
137    /// that follows is valid and complete; what the failure cost is read off
138    /// `affects`, which says whether the report widened or a requested file was
139    /// never written.
140    NotApplied,
141}
142
143/// One request's fate on one run.
144///
145/// `reason` and `message` are present exactly when `status` is not `applied`,
146/// and absent otherwise, so a consumer that only wants to know whether a
147/// report is scoped reads `status` alone.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150pub struct RequestOutcome {
151    /// What became of the request.
152    pub status: RequestStatus,
153    /// What this request governs, and therefore what an unapplied one means.
154    /// Derived from the name, so the two can never disagree.
155    pub affects: RequestEffect,
156    /// What was asked, as the user spelled it: the git ref for
157    /// `changed-since`, the diff source label (`--diff-file pr.diff`,
158    /// `--diff-stdin`, `$FALLOW_DIFF_FILE build/pr.diff`, or
159    /// `diffFile pr.diff` for the programmatic option) for `diff-filter`,
160    /// `workspaces.changedSince` for `package-baselines`, the target path for
161    /// `sarif-file`. Echoed rather than normalised, so a
162    /// consumer must not join it to the project root the way it joins every
163    /// other path-shaped field.
164    pub requested: String,
165    /// How much this request left in scope, in the request's own unit, when the
166    /// run applied it AND measured that scope. Absent otherwise, including on
167    /// every unapplied entry: a request that stood down narrowed nothing, so a
168    /// number there would describe a scope nobody applied.
169    ///
170    /// The unit belongs to the name. `diff-filter` counts added lines, which is
171    /// what its filter keeps a finding for. `changed-since` counts changed
172    /// files that the run analyzed: a changed file that discovery or an ignore
173    /// rule dropped does not count, so a change to a README only gives `0`. A
174    /// combined run counts a file that any of its analyses kept, because a
175    /// per-analysis `production` setting can give its analyses different
176    /// files.
177    /// Read the unit off the name the entry is keyed under, never across names,
178    /// and read an absent member as "not measured" rather than as zero.
179    ///
180    /// The count is what the run INDEXED rather than the true total:
181    /// `diff-filter` indexes at most one million added lines and reports that
182    /// cap for a larger diff, so read any non-zero value as a lower bound.
183    ///
184    /// `0` is the case this member exists for: a request that applied over an
185    /// EMPTY scope. Every finding then filters out and the report reads clean,
186    /// so a consumer that sees no findings beside `scope_size: 0` learns that
187    /// nothing was analyzable rather than that the code is clean.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub scope_size: Option<u64>,
190    /// Why the request was not applied, as a kebab-case token. Present exactly
191    /// when `status` is not `applied`. The set is open per request name; the
192    /// names this build can emit are listed on [`RequestOutcomes`].
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub reason: Option<String>,
195    /// One sentence naming what was asked, what happened instead, and the next
196    /// step. Byte-identical to the stderr line for the same case, so a
197    /// consumer that renders this never contradicts a log a human read.
198    /// Present exactly when `status` is not `applied`.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub message: Option<String>,
201}
202
203impl RequestOutcome {
204    /// A request the run honoured.
205    ///
206    /// Takes the name rather than the class so no caller can file a request
207    /// under the wrong one.
208    #[must_use]
209    pub fn applied(name: RequestName, requested: impl Into<String>) -> Self {
210        Self {
211            status: RequestStatus::Applied,
212            affects: name.affects(),
213            requested: requested.into(),
214            scope_size: None,
215            reason: None,
216            message: None,
217        }
218    }
219
220    /// A request the run honoured, whose remaining scope it also measured.
221    ///
222    /// `size` is in the unit [`RequestOutcome::scope_size`] documents for this
223    /// name. Use [`Self::applied`] where the run applies a request without
224    /// measuring what it left, so the member stays absent rather than claiming
225    /// a zero nobody counted.
226    #[must_use]
227    pub fn applied_with_scope_size(
228        name: RequestName,
229        requested: impl Into<String>,
230        size: u64,
231    ) -> Self {
232        Self {
233            scope_size: Some(size),
234            ..Self::applied(name, requested)
235        }
236    }
237
238    /// A request the run could not honour, with the reason token and the
239    /// sentence the CLI also wrote to stderr.
240    #[must_use]
241    pub fn not_applied(
242        name: RequestName,
243        requested: impl Into<String>,
244        reason: impl Into<String>,
245        message: impl Into<String>,
246    ) -> Self {
247        Self {
248            status: RequestStatus::NotApplied,
249            affects: name.affects(),
250            requested: requested.into(),
251            scope_size: None,
252            reason: Some(reason.into()),
253            message: Some(message.into()),
254        }
255    }
256}
257
258/// Every narrowing or shaping request a run RECEIVED, keyed by name.
259///
260/// Received, not failed: a request the run honoured is published with
261/// `status: "applied"`, so a consumer can say "scoped to the change"
262/// positively. Read an absent object as "nothing was asked for", never as
263/// "nothing failed".
264///
265/// Absent from an envelope whenever it is empty, so a run that was asked for
266/// nothing is byte-identical to one produced before this object existed. An
267/// empty object is never emitted: it would assert that something was asked and
268/// all of it applied, which is a different and false claim.
269///
270/// The names this build can emit are `changed-since`, `diff-filter`,
271/// `package-baselines` and `sarif-file`. The reasons are `git-missing`,
272/// `not-a-repository`, `git-failed` and `invalid-ref` for `changed-since`,
273/// `unknown-workspace`, `git-missing`, `not-a-repository` and `git-failed`
274/// for `package-baselines`, `oversize`,
275/// `unreadable`, `not-utf8`, `foreign-namespace` and `ambiguous-base` for
276/// `diff-filter`, and `directory-create-failed`, `write-failed` and
277/// `serialize-failed` for `sarif-file`. Every set is OPEN: a name a consumer
278/// does not recognise means "some request", not an error.
279///
280/// `sarif-file` reports a SECONDARY artifact rather than the scope of the
281/// report it travels in, and it is in the same object for the same reason the
282/// others are: the run was asked to do something and did something else, and
283/// nothing in the primary report says so. Which of the two an entry is, every
284/// entry says for itself: `affects` is `scope` for the narrowing requests and
285/// `artifact` for this one. Select on it. A consumer that instead assumes the
286/// whole object narrows the report tells its reader an unwritten SARIF file
287/// widened the analysis, which is what `affects` exists to prevent.
288///
289/// `scope_size` is emitted for `diff-filter`, in added lines, and for
290/// `changed-since`, in changed files that the run analyzed. `package-baselines`
291/// and `sarif-file` measure no scope; the applied package refs travel in
292/// `package_baselines`. A consumer reads the unit off the name, so a name that
293/// starts to measure its own scope in a later release needs no change here.
294///
295/// `invalid-ref` is reachable only through the programmatic API. The
296/// `--changed-since` flag validates its value before a run starts and fails
297/// with exit 2 and an error document, which is the right side to err on: a
298/// malformed ref is invalid input rather than a report of the wrong scope.
299#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301#[serde(transparent)]
302pub struct RequestOutcomes(BTreeMap<RequestName, RequestOutcome>);
303
304impl RequestOutcomes {
305    /// An empty set, which serializes to nothing once [`Self::into_option`]
306    /// has been applied.
307    #[must_use]
308    pub fn new() -> Self {
309        Self(BTreeMap::new())
310    }
311
312    /// Record one request's outcome, replacing any previous entry for that
313    /// name.
314    pub fn insert(&mut self, name: RequestName, outcome: RequestOutcome) {
315        self.0.insert(name, outcome);
316    }
317
318    /// Record one request's outcome when the run received it at all.
319    pub fn insert_if(&mut self, name: RequestName, outcome: Option<RequestOutcome>) {
320        if let Some(outcome) = outcome {
321            self.0.insert(name, outcome);
322        }
323    }
324
325    /// Collapse an empty set to `None`, which is how the field stays absent on
326    /// a run that was asked for nothing.
327    #[must_use]
328    pub fn into_option(self) -> Option<Self> {
329        if self.0.is_empty() { None } else { Some(self) }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn empty_set_collapses_to_absent() {
339        assert!(RequestOutcomes::new().into_option().is_none());
340    }
341
342    #[test]
343    fn populated_set_survives_collapse() {
344        let mut requests = RequestOutcomes::new();
345        requests.insert(
346            RequestName::ChangedSince,
347            RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
348        );
349        assert!(requests.into_option().is_some());
350    }
351
352    /// An honoured request carries neither a reason nor a sentence, so a
353    /// consumer reading `message` never renders prose about a run that did
354    /// exactly what it was told.
355    #[test]
356    fn an_applied_request_carries_no_reason_and_no_message() {
357        let mut requests = RequestOutcomes::new();
358        requests.insert(
359            RequestName::DiffFilter,
360            RequestOutcome::applied(RequestName::DiffFilter, "--diff-file pr.diff"),
361        );
362        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
363        assert_eq!(
364            value,
365            serde_json::json!({
366                "diff-filter": {
367                    "status": "applied",
368                    "affects": "scope",
369                    "requested": "--diff-file pr.diff"
370                }
371            })
372        );
373    }
374
375    #[test]
376    fn names_and_statuses_serialize_as_kebab_case() {
377        let mut requests = RequestOutcomes::new();
378        requests.insert(
379            RequestName::ChangedSince,
380            RequestOutcome::not_applied(
381                RequestName::ChangedSince,
382                "origin/main",
383                "invalid-ref",
384                "Ignored.",
385            ),
386        );
387        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
388        assert_eq!(
389            value,
390            serde_json::json!({
391                "changed-since": {
392                    "status": "not-applied",
393                    "affects": "scope",
394                    "requested": "origin/main",
395                    "reason": "invalid-ref",
396                    "message": "Ignored."
397                }
398            })
399        );
400    }
401
402    /// Two requests with different fates travel in one object, so a consumer
403    /// reads the scope of each channel rather than one verdict for the run.
404    #[test]
405    fn two_channels_keep_their_own_status_in_one_object() {
406        let mut requests = RequestOutcomes::new();
407        requests.insert(
408            RequestName::ChangedSince,
409            RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
410        );
411        requests.insert(
412            RequestName::DiffFilter,
413            RequestOutcome::not_applied(
414                RequestName::DiffFilter,
415                "--diff-stdin",
416                "not-utf8",
417                "Ignored.",
418            ),
419        );
420        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
421        assert_eq!(value["changed-since"]["status"], "applied");
422        assert_eq!(value["diff-filter"]["status"], "not-applied");
423        assert_eq!(value["diff-filter"]["reason"], "not-utf8");
424    }
425
426    /// The class travels with the entry, so a consumer never has to keep a
427    /// name list to know whether an unapplied request widened the report.
428    #[test]
429    fn a_secondary_artifact_request_is_not_classed_as_scope() {
430        let mut requests = RequestOutcomes::new();
431        requests.insert(
432            RequestName::SarifFile,
433            RequestOutcome::not_applied(
434                RequestName::SarifFile,
435                "out.sarif",
436                "write-failed",
437                "Not written.",
438            ),
439        );
440        requests.insert(
441            RequestName::ChangedSince,
442            RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
443        );
444        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
445        assert_eq!(value["sarif-file"]["affects"], "artifact");
446        assert_eq!(value["changed-since"]["affects"], "scope");
447    }
448
449    /// The class is derived from the name at construction, so an entry filed
450    /// under one name cannot carry another's class.
451    #[test]
452    fn every_name_carries_its_own_class() {
453        assert_eq!(RequestName::ChangedSince.affects(), RequestEffect::Scope);
454        assert_eq!(RequestName::DiffFilter.affects(), RequestEffect::Scope);
455        assert_eq!(RequestName::SarifFile.affects(), RequestEffect::Artifact);
456    }
457
458    /// An applied request that measured an empty scope is the case the member
459    /// exists for: `status` stays `applied`, because the filter DID apply, and
460    /// the zero is what tells a consumer the clean report covered nothing.
461    #[test]
462    fn an_empty_measured_scope_stays_applied_and_publishes_its_zero() {
463        let mut requests = RequestOutcomes::new();
464        requests.insert(
465            RequestName::DiffFilter,
466            RequestOutcome::applied_with_scope_size(
467                RequestName::DiffFilter,
468                "--diff-file pr.diff",
469                0,
470            ),
471        );
472        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
473        assert_eq!(
474            value,
475            serde_json::json!({
476                "diff-filter": {
477                    "status": "applied",
478                    "affects": "scope",
479                    "requested": "--diff-file pr.diff",
480                    "scope_size": 0
481                }
482            })
483        );
484    }
485
486    /// Absent is not zero. A request the run applied without counting what it
487    /// left carries no member, so a consumer cannot read "not measured" as "the
488    /// scope was empty".
489    #[test]
490    fn a_request_that_measured_nothing_carries_no_scope_size() {
491        let mut requests = RequestOutcomes::new();
492        requests.insert(
493            RequestName::ChangedSince,
494            RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
495        );
496        requests.insert(
497            RequestName::DiffFilter,
498            RequestOutcome::not_applied(
499                RequestName::DiffFilter,
500                "--diff-stdin",
501                "oversize",
502                "Ignored.",
503            ),
504        );
505        let value = serde_json::to_value(&requests).expect("request outcomes serialize");
506        assert!(
507            value["changed-since"].get("scope_size").is_none(),
508            "an unmeasured applied request carries no member: {value}"
509        );
510        assert!(
511            value["diff-filter"].get("scope_size").is_none(),
512            "a request that stood down narrowed nothing: {value}"
513        );
514    }
515
516    #[test]
517    fn insert_if_skips_a_request_the_run_never_received() {
518        let mut requests = RequestOutcomes::new();
519        requests.insert_if(RequestName::ChangedSince, None);
520        assert!(requests.into_option().is_none());
521    }
522}