use crate::Record;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BudgetSourceAnomaly {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bound_field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(
default,
deserialize_with = "number_or_numeric_string",
skip_serializing_if = "Option::is_none"
)]
pub reported_value: Option<f64>,
#[serde(
default,
deserialize_with = "number_or_numeric_string",
skip_serializing_if = "Option::is_none"
)]
pub served_value: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub likely_cause: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub affected_fields: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<BudgetSourceAnomalySource>,
#[serde(flatten, default)]
pub extra: HashMap<String, Value>,
}
pub fn budget_data_through_period(record: &Record) -> Option<u8> {
record
.get("data_through_period")
.and_then(Value::as_u64)
.and_then(|n| u8::try_from(n).ok())
}
pub(crate) fn number_or_numeric_string<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<Value>::deserialize(deserializer)? {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => Ok(n.as_f64()),
Some(Value::String(s)) => s
.trim()
.parse::<f64>()
.map(Some)
.map_err(serde::de::Error::custom),
Some(other) => Err(serde::de::Error::custom(format!(
"expected a number or numeric string, got {other}"
))),
}
}
impl BudgetSourceAnomaly {
pub fn from_record(record: &Record) -> serde_json::Result<Vec<Self>> {
match record.get("source_anomalies") {
None | Some(Value::Null) => Ok(Vec::new()),
Some(v) => serde_json::from_value(v.clone()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BudgetSourceAnomalySource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dataset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fiscal_year: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rows: Option<Vec<BudgetSourceAnomalyRow>>,
#[serde(flatten, default)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct BudgetSourceAnomalyRow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fiscal_period: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub piid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_piid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tas: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reporting_agency_id: Option<String>,
#[serde(
default,
deserialize_with = "number_or_numeric_string",
skip_serializing_if = "Option::is_none"
)]
pub transaction_obligated_amount: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_c_source: Option<String>,
#[serde(flatten, default)]
pub extra: HashMap<String, Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn record(v: Value) -> Record {
v.as_object().cloned().expect("object")
}
#[test]
fn decodes_a_capped_contract_anomaly_with_source_rows() {
let r = record(json!({
"federal_account_symbol": "020-4159",
"source_anomalies": [{
"code": "contract_exceeds_obligations",
"field": "contract_obligated",
"bound_field": "obligated_total",
"action": "capped",
"reported_value": 73_470_455_401.8,
"served_value": 4_748_670_061.77,
"likely_cause": "unit error",
"affected_fields": ["contract_obligated", "contract_share_of_obligated_capped"],
"message": "reported contracts exceed obligations",
"new_key": 1,
"source": {
"dataset": "file_c",
"fiscal_year": 2025,
"rows": [{
"fiscal_period": 6,
"piid": "P1",
"parent_piid": null,
"tas": "020-2025/2025-4159",
"reporting_agency_id": "020",
"transaction_obligated_amount": 68_721_785_340.03,
"file_c_source": "award_financial"
}]
}
}]
}));
let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
assert_eq!(got.len(), 1);
let a = &got[0];
assert_eq!(a.code.as_deref(), Some("contract_exceeds_obligations"));
assert_eq!(a.action.as_deref(), Some("capped"));
assert_eq!(a.reported_value, Some(73_470_455_401.8));
assert_eq!(a.served_value, Some(4_748_670_061.77));
assert_eq!(a.affected_fields.as_ref().map(Vec::len), Some(2));
assert_eq!(a.extra.get("new_key"), Some(&json!(1)));
let src = a.source.as_ref().expect("source");
assert_eq!(src.fiscal_year, Some(2025));
let row = &src.rows.as_ref().expect("rows")[0];
assert_eq!(row.fiscal_period, Some(6));
assert_eq!(row.parent_piid, None);
assert_eq!(row.reporting_agency_id.as_deref(), Some("020"));
assert_eq!(row.transaction_obligated_amount, Some(68_721_785_340.03));
}
#[test]
fn sparse_anomaly_and_unknown_code_decode() {
let r = record(json!({"source_anomalies": [{"code": "something_new"}]}));
let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
assert_eq!(got[0].code.as_deref(), Some("something_new"));
assert!(got[0].source.is_none());
}
#[test]
fn clean_absent_and_null_are_empty() {
for v in [
json!({"source_anomalies": []}),
json!({}),
json!({"source_anomalies": null}),
] {
assert!(BudgetSourceAnomaly::from_record(&record(v))
.expect("decode")
.is_empty());
}
}
#[test]
fn amounts_sent_as_strings_decode() {
let r = record(json!({"source_anomalies": [{
"reported_value": "73470455401.80",
"served_value": null,
"source": {"rows": [{"transaction_obligated_amount": "-12.5"}]}
}]}));
let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
assert_eq!(got[0].reported_value, Some(73_470_455_401.8));
assert_eq!(got[0].served_value, None);
let rows = got[0]
.source
.as_ref()
.and_then(|s| s.rows.as_ref())
.expect("rows");
assert_eq!(rows[0].transaction_obligated_amount, Some(-12.5));
}
#[test]
fn non_list_is_an_error() {
let r = record(json!({"source_anomalies": "oops"}));
assert!(BudgetSourceAnomaly::from_record(&r).is_err());
}
}