use std::collections::BTreeMap;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum RequestName {
ChangedSince,
DiffFilter,
SarifFile,
}
impl RequestName {
#[must_use]
pub const fn affects(self) -> RequestEffect {
match self {
Self::ChangedSince | Self::DiffFilter => RequestEffect::Scope,
Self::SarifFile => RequestEffect::Artifact,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum RequestEffect {
Scope,
Artifact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum RequestStatus {
Applied,
NotApplied,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RequestOutcome {
pub status: RequestStatus,
pub affects: RequestEffect,
pub requested: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl RequestOutcome {
#[must_use]
pub fn applied(name: RequestName, requested: impl Into<String>) -> Self {
Self {
status: RequestStatus::Applied,
affects: name.affects(),
requested: requested.into(),
scope_size: None,
reason: None,
message: None,
}
}
#[must_use]
pub fn applied_with_scope_size(
name: RequestName,
requested: impl Into<String>,
size: u64,
) -> Self {
Self {
scope_size: Some(size),
..Self::applied(name, requested)
}
}
#[must_use]
pub fn not_applied(
name: RequestName,
requested: impl Into<String>,
reason: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
status: RequestStatus::NotApplied,
affects: name.affects(),
requested: requested.into(),
scope_size: None,
reason: Some(reason.into()),
message: Some(message.into()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct RequestOutcomes(BTreeMap<RequestName, RequestOutcome>);
impl RequestOutcomes {
#[must_use]
pub fn new() -> Self {
Self(BTreeMap::new())
}
pub fn insert(&mut self, name: RequestName, outcome: RequestOutcome) {
self.0.insert(name, outcome);
}
pub fn insert_if(&mut self, name: RequestName, outcome: Option<RequestOutcome>) {
if let Some(outcome) = outcome {
self.0.insert(name, outcome);
}
}
#[must_use]
pub fn into_option(self) -> Option<Self> {
if self.0.is_empty() { None } else { Some(self) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_set_collapses_to_absent() {
assert!(RequestOutcomes::new().into_option().is_none());
}
#[test]
fn populated_set_survives_collapse() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::ChangedSince,
RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
);
assert!(requests.into_option().is_some());
}
#[test]
fn an_applied_request_carries_no_reason_and_no_message() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::DiffFilter,
RequestOutcome::applied(RequestName::DiffFilter, "--diff-file pr.diff"),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert_eq!(
value,
serde_json::json!({
"diff-filter": {
"status": "applied",
"affects": "scope",
"requested": "--diff-file pr.diff"
}
})
);
}
#[test]
fn names_and_statuses_serialize_as_kebab_case() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::ChangedSince,
RequestOutcome::not_applied(
RequestName::ChangedSince,
"origin/main",
"invalid-ref",
"Ignored.",
),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert_eq!(
value,
serde_json::json!({
"changed-since": {
"status": "not-applied",
"affects": "scope",
"requested": "origin/main",
"reason": "invalid-ref",
"message": "Ignored."
}
})
);
}
#[test]
fn two_channels_keep_their_own_status_in_one_object() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::ChangedSince,
RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
);
requests.insert(
RequestName::DiffFilter,
RequestOutcome::not_applied(
RequestName::DiffFilter,
"--diff-stdin",
"not-utf8",
"Ignored.",
),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert_eq!(value["changed-since"]["status"], "applied");
assert_eq!(value["diff-filter"]["status"], "not-applied");
assert_eq!(value["diff-filter"]["reason"], "not-utf8");
}
#[test]
fn a_secondary_artifact_request_is_not_classed_as_scope() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::SarifFile,
RequestOutcome::not_applied(
RequestName::SarifFile,
"out.sarif",
"write-failed",
"Not written.",
),
);
requests.insert(
RequestName::ChangedSince,
RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert_eq!(value["sarif-file"]["affects"], "artifact");
assert_eq!(value["changed-since"]["affects"], "scope");
}
#[test]
fn every_name_carries_its_own_class() {
assert_eq!(RequestName::ChangedSince.affects(), RequestEffect::Scope);
assert_eq!(RequestName::DiffFilter.affects(), RequestEffect::Scope);
assert_eq!(RequestName::SarifFile.affects(), RequestEffect::Artifact);
}
#[test]
fn an_empty_measured_scope_stays_applied_and_publishes_its_zero() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::DiffFilter,
RequestOutcome::applied_with_scope_size(
RequestName::DiffFilter,
"--diff-file pr.diff",
0,
),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert_eq!(
value,
serde_json::json!({
"diff-filter": {
"status": "applied",
"affects": "scope",
"requested": "--diff-file pr.diff",
"scope_size": 0
}
})
);
}
#[test]
fn a_request_that_measured_nothing_carries_no_scope_size() {
let mut requests = RequestOutcomes::new();
requests.insert(
RequestName::ChangedSince,
RequestOutcome::applied(RequestName::ChangedSince, "origin/main"),
);
requests.insert(
RequestName::DiffFilter,
RequestOutcome::not_applied(
RequestName::DiffFilter,
"--diff-stdin",
"oversize",
"Ignored.",
),
);
let value = serde_json::to_value(&requests).expect("request outcomes serialize");
assert!(
value["changed-since"].get("scope_size").is_none(),
"an unmeasured applied request carries no member: {value}"
);
assert!(
value["diff-filter"].get("scope_size").is_none(),
"a request that stood down narrowed nothing: {value}"
);
}
#[test]
fn insert_if_skips_a_request_the_run_never_received() {
let mut requests = RequestOutcomes::new();
requests.insert_if(RequestName::ChangedSince, None);
assert!(requests.into_option().is_none());
}
}