use serde::{Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ScopeReason {
Diff,
ChangedSince,
ChangedFiles,
Workspace,
ChangedWorkspaces,
Scope,
File,
IssueTypeFilter,
Production,
}
impl ScopeReason {
const ALL: [Self; 9] = [
Self::Diff,
Self::ChangedSince,
Self::ChangedFiles,
Self::Workspace,
Self::ChangedWorkspaces,
Self::Scope,
Self::File,
Self::IssueTypeFilter,
Self::Production,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Diff => "diff",
Self::ChangedSince => "changed-since",
Self::ChangedFiles => "changed-files",
Self::Workspace => "workspace",
Self::ChangedWorkspaces => "changed-workspaces",
Self::Scope => "scope",
Self::File => "file",
Self::IssueTypeFilter => "issue-type-filter",
Self::Production => "production",
}
}
#[must_use]
pub const fn is_removable_by_rerun(self) -> bool {
match self {
Self::Diff
| Self::ChangedSince
| Self::ChangedFiles
| Self::Scope
| Self::File
| Self::IssueTypeFilter => true,
Self::Workspace | Self::ChangedWorkspaces | Self::Production => false,
}
}
const fn bit(self) -> u16 {
1 << (self as u16)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BaselineScopeReasons(u16);
const _: () = assert!(ScopeReason::ALL.len() <= u16::BITS as usize);
impl BaselineScopeReasons {
#[must_use]
pub const fn empty() -> Self {
Self(0)
}
#[must_use]
pub const fn with(self, reason: ScopeReason) -> Self {
Self(self.0 | reason.bit())
}
#[must_use]
pub const fn insert_if(self, active: bool, reason: ScopeReason) -> Self {
if active { self.with(reason) } else { self }
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn contains(self, reason: ScopeReason) -> bool {
self.0 & reason.bit() != 0
}
pub fn iter(self) -> impl Iterator<Item = ScopeReason> {
ScopeReason::ALL
.into_iter()
.filter(move |reason| self.contains(*reason))
}
#[must_use]
pub fn all_removable_by_rerun(self) -> bool {
self.iter().all(ScopeReason::is_removable_by_rerun)
}
#[must_use]
pub fn join(self) -> String {
self.iter()
.map(ScopeReason::as_str)
.collect::<Vec<_>>()
.join(", ")
}
}
impl Serialize for BaselineScopeReasons {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.iter())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum BaselineStalenessAdvisory {
None,
ZeroOverlap,
Partial,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BaselineStaleness {
pub baseline_entries: usize,
pub matched_entries: usize,
pub stale_entries: usize,
pub current_findings: usize,
pub change_scoped: bool,
pub stale: bool,
pub warning: BaselineStalenessAdvisory,
pub gate_trips: bool,
pub moved_entries: usize,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unrecognised_format: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schema", schemars(with = "String"))]
pub saved_by: Option<&'static str>,
#[serde(default, skip_serializing_if = "BaselineScopeReasons::is_empty")]
#[cfg_attr(
feature = "schema",
schemars(with = "std::collections::BTreeSet<ScopeReason>")
)]
pub scope_reasons: BaselineScopeReasons,
}
#[cfg(test)]
mod tests {
use super::{BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory, ScopeReason};
fn staleness(scope_reasons: BaselineScopeReasons) -> BaselineStaleness {
BaselineStaleness {
baseline_entries: 8,
matched_entries: 0,
stale_entries: 8,
current_findings: 0,
change_scoped: !scope_reasons.is_empty(),
stale: false,
warning: BaselineStalenessAdvisory::None,
gate_trips: false,
moved_entries: 0,
unrecognised_format: false,
saved_by: None,
scope_reasons,
}
}
#[test]
fn reasons_serialize_as_a_kebab_case_array() {
let value = serde_json::to_value(staleness(
BaselineScopeReasons::empty()
.with(ScopeReason::Production)
.with(ScopeReason::ChangedSince),
))
.expect("staleness serializes");
assert_eq!(
value.get("scope_reasons"),
Some(&serde_json::json!(["changed-since", "production"]))
);
}
#[test]
fn reasons_serialize_in_declaration_order_whatever_the_insertion_order() {
let forwards = BaselineScopeReasons::empty()
.with(ScopeReason::Diff)
.with(ScopeReason::IssueTypeFilter)
.with(ScopeReason::Production);
let backwards = BaselineScopeReasons::empty()
.with(ScopeReason::Production)
.with(ScopeReason::IssueTypeFilter)
.with(ScopeReason::Diff);
let expected = serde_json::json!(["diff", "issue-type-filter", "production"]);
assert_eq!(
serde_json::to_value(forwards).expect("reasons serialize"),
expected
);
assert_eq!(
serde_json::to_value(backwards).expect("reasons serialize"),
expected
);
}
#[test]
fn an_unscoped_run_keeps_the_member_off_the_wire() {
let value = serde_json::to_value(staleness(BaselineScopeReasons::empty()))
.expect("staleness serializes");
assert!(
value.get("scope_reasons").is_none(),
"a whole-project run must stay byte-identical to a pre-change run"
);
assert_eq!(value.get("change_scoped"), Some(&serde_json::json!(false)));
}
#[test]
fn the_member_is_non_empty_exactly_when_the_run_was_narrowed() {
for reason in [
ScopeReason::Diff,
ScopeReason::ChangedSince,
ScopeReason::ChangedFiles,
ScopeReason::Workspace,
ScopeReason::ChangedWorkspaces,
ScopeReason::Scope,
ScopeReason::File,
ScopeReason::IssueTypeFilter,
ScopeReason::Production,
] {
let reasons = BaselineScopeReasons::empty().with(reason);
assert!(reasons.contains(reason), "{reason:?} must round-trip");
assert!(!reasons.is_empty());
assert_eq!(reasons.join(), reason.as_str());
}
}
#[test]
fn removable_channels_are_the_ones_a_repeat_can_drop() {
let removable: Vec<&str> = ScopeReason::ALL
.into_iter()
.filter(|reason| reason.is_removable_by_rerun())
.map(ScopeReason::as_str)
.collect();
assert_eq!(
removable,
[
"diff",
"changed-since",
"changed-files",
"scope",
"file",
"issue-type-filter"
]
);
}
#[test]
fn a_set_is_removable_only_when_every_channel_in_it_is() {
let removable = BaselineScopeReasons::empty()
.with(ScopeReason::ChangedSince)
.with(ScopeReason::Scope);
assert!(removable.all_removable_by_rerun());
assert!(
!removable
.with(ScopeReason::Production)
.all_removable_by_rerun(),
"a repeat that drops the base ref still runs in production mode"
);
}
#[test]
fn insert_if_is_the_only_gate_on_membership() {
let reasons = BaselineScopeReasons::empty()
.insert_if(false, ScopeReason::Diff)
.insert_if(true, ScopeReason::Scope);
assert!(!reasons.contains(ScopeReason::Diff));
assert!(reasons.contains(ScopeReason::Scope));
}
#[test]
fn every_reason_has_a_distinct_bit_and_a_distinct_name() {
let mut combined = BaselineScopeReasons::empty();
for reason in ScopeReason::ALL {
combined = combined.with(reason);
}
assert_eq!(combined.iter().count(), ScopeReason::ALL.len());
let names = ScopeReason::ALL.map(ScopeReason::as_str);
let mut sorted = names.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len());
}
#[test]
fn the_kebab_name_matches_what_serde_emits() {
for reason in ScopeReason::ALL {
assert_eq!(
serde_json::to_value(reason).expect("reason serializes"),
serde_json::json!(reason.as_str()),
);
}
}
}