use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::schema::Kind;
pub const REPORT_ORIGIN_KEY: &str = "origin";
pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ReportOrigin {
Agent,
Supervisor,
RunMerge {
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
worker_oid: Option<String>,
},
}
impl ReportOrigin {
#[must_use]
pub fn from_report(report: &Value) -> Option<Self> {
let raw = report.get(REPORT_ORIGIN_KEY)?;
serde_json::from_value(raw.clone()).ok()
}
#[must_use]
pub fn report_is_confirmed_merge(report: &Value) -> bool {
let success = matches!(report.get("success"), Some(Value::Bool(true)));
let not_cancelled = matches!(
report.get("cancelled"),
None | Some(Value::Null | Value::Bool(false))
);
if !(success && not_cancelled) {
return false;
}
let is_run_merge_origin = matches!(
Self::from_report(report),
Some(ReportOrigin::RunMerge { .. })
);
let origin_present = report.get(REPORT_ORIGIN_KEY).is_some();
let legacy_via_merge = !origin_present
&& report.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
is_run_merge_origin || legacy_via_merge
}
pub fn stamp(&self, report: &mut Value) {
if let Some(obj) = report.as_object_mut() {
if let Ok(v) = serde_json::to_value(self) {
obj.insert(REPORT_ORIGIN_KEY.to_string(), v);
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ReportValidationError {
#[error("report payload must be a JSON object")]
NotObject,
#[error("report payload missing required field `success`")]
MissingSuccess,
#[error("field `success` must be a boolean")]
SuccessNotBoolean,
#[error("field `summary` must be a string")]
SummaryNotString,
#[error("field `cancelled` must be a boolean")]
CancelledNotBoolean,
#[error("field `reason` must be a string")]
ReasonNotString,
#[error("`cancelled: true` requires `success: false`")]
CancelledRequiresSuccessFalse,
#[error("`cancelled: true` requires a non-empty `reason` string")]
CancelledRequiresReason,
#[error("field `discussion_items` must be an array")]
DiscussionItemsNotArray,
#[error("discussion_items[{index}] must be a JSON object")]
DiscussionItemNotObject {
index: usize,
},
#[error("discussion_items[{index}].topic must be a non-empty string")]
DiscussionItemTopicMissing {
index: usize,
},
#[error("discussion_items[{index}].severity must be a string")]
DiscussionItemSeverityNotString {
index: usize,
},
#[error("field `spinoff_proposals` must be an array")]
SpinoffProposalsNotArray,
#[error("spinoff_proposals[{index}] must be a JSON object")]
SpinoffProposalNotObject {
index: usize,
},
#[error("spinoff_proposals[{index}].proposed_title must be a non-empty string")]
SpinoffProposalTitleMissing {
index: usize,
},
#[error("spinoff_proposals[{index}].proposed_kind must be a string")]
SpinoffProposalKindNotString {
index: usize,
},
#[error("spinoff_proposals[{index}].proposed_kind `{kind}` is not a known kind")]
SpinoffProposalKindUnknown {
index: usize,
kind: String,
},
#[error("spinoff_proposals[{index}].rationale must be a string")]
SpinoffProposalRationaleNotString {
index: usize,
},
#[error("field `{field}` must be an array")]
FieldNotArray {
field: String,
},
#[error("{field}[{index}] must be a string")]
FieldElementNotString {
field: String,
index: usize,
},
#[error("{path} must be an array")]
PathNotArray {
path: String,
},
#[error("{path}[{index}] must be a string")]
PathElementNotString {
path: String,
index: usize,
},
}
impl ReportValidationError {
#[must_use]
pub fn expected(&self) -> Option<Value> {
match self {
Self::MissingSuccess | Self::SuccessNotBoolean => {
Some(serde_json::json!({"field": "success", "type": "boolean"}))
}
Self::SpinoffProposalKindUnknown { .. } => Some(serde_json::json!(Kind::WIRE_NAMES)),
_ => None,
}
}
}
pub fn validate_report_payload(data: &Value) -> Result<(), ReportValidationError> {
let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
validate_required_fields(obj)?;
if let Some(v) = obj.get("summary") {
if !v.is_string() && !v.is_null() {
return Err(ReportValidationError::SummaryNotString);
}
}
validate_discussion_items(obj.get("discussion_items"))?;
validate_spinoff_proposals(obj.get("spinoff_proposals"))?;
validate_string_array(
obj.get("wrap_up_recommendations"),
"wrap_up_recommendations",
)?;
Ok(())
}
fn validate_required_fields(
obj: &serde_json::Map<String, Value>,
) -> Result<(), ReportValidationError> {
let success = obj
.get("success")
.ok_or(ReportValidationError::MissingSuccess)?;
if !success.is_boolean() {
return Err(ReportValidationError::SuccessNotBoolean);
}
let cancelled = match obj.get("cancelled") {
None | Some(Value::Null) => false,
Some(v) => v
.as_bool()
.ok_or(ReportValidationError::CancelledNotBoolean)?,
};
let reason = match obj.get("reason") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or(ReportValidationError::ReasonNotString)?),
};
if cancelled {
if success
.as_bool()
.expect("success validated as boolean above")
{
return Err(ReportValidationError::CancelledRequiresSuccessFalse);
}
match reason {
Some(s) if !s.trim().is_empty() => {}
_ => return Err(ReportValidationError::CancelledRequiresReason),
}
}
Ok(())
}
fn validate_discussion_items(v: Option<&Value>) -> Result<(), ReportValidationError> {
let arr = match v {
Some(Value::Array(a)) => a,
Some(_) => return Err(ReportValidationError::DiscussionItemsNotArray),
None => return Ok(()),
};
for (i, item) in arr.iter().enumerate() {
validate_discussion_item(item, i)?;
}
Ok(())
}
fn validate_discussion_item(item: &Value, index: usize) -> Result<(), ReportValidationError> {
let obj = item
.as_object()
.ok_or(ReportValidationError::DiscussionItemNotObject { index })?;
let topic = obj.get("topic").and_then(Value::as_str);
if topic.is_none_or(|t| t.trim().is_empty()) {
return Err(ReportValidationError::DiscussionItemTopicMissing { index });
}
if let Some(sev) = obj.get("severity") {
if !sev.is_string() {
return Err(ReportValidationError::DiscussionItemSeverityNotString { index });
}
}
if let Some(opts) = obj.get("options") {
validate_string_array_at(opts, &format!("discussion_items[{index}].options"))?;
}
Ok(())
}
fn validate_spinoff_proposals(v: Option<&Value>) -> Result<(), ReportValidationError> {
let arr = match v {
Some(Value::Array(a)) => a,
Some(_) => return Err(ReportValidationError::SpinoffProposalsNotArray),
None => return Ok(()),
};
for (i, item) in arr.iter().enumerate() {
validate_spinoff_proposal(item, i)?;
}
Ok(())
}
fn validate_spinoff_proposal(item: &Value, index: usize) -> Result<(), ReportValidationError> {
let obj = item
.as_object()
.ok_or(ReportValidationError::SpinoffProposalNotObject { index })?;
let title = obj.get("proposed_title").and_then(Value::as_str);
if title.is_none_or(|t| t.trim().is_empty()) {
return Err(ReportValidationError::SpinoffProposalTitleMissing { index });
}
let kind_str = obj
.get("proposed_kind")
.and_then(Value::as_str)
.ok_or(ReportValidationError::SpinoffProposalKindNotString { index })?;
if !Kind::WIRE_NAMES.contains(&kind_str) {
return Err(ReportValidationError::SpinoffProposalKindUnknown {
index,
kind: kind_str.to_string(),
});
}
if let Some(rationale) = obj.get("rationale") {
if !rationale.is_string() && !rationale.is_null() {
return Err(ReportValidationError::SpinoffProposalRationaleNotString { index });
}
}
Ok(())
}
fn validate_string_array_at(v: &Value, path: &str) -> Result<(), ReportValidationError> {
let arr = v
.as_array()
.ok_or_else(|| ReportValidationError::PathNotArray {
path: path.to_string(),
})?;
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
return Err(ReportValidationError::PathElementNotString {
path: path.to_string(),
index: i,
});
}
}
Ok(())
}
fn validate_string_array(v: Option<&Value>, field: &str) -> Result<(), ReportValidationError> {
let arr = match v {
Some(Value::Array(a)) => a,
Some(_) => {
return Err(ReportValidationError::FieldNotArray {
field: field.to_string(),
})
}
None => return Ok(()),
};
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
return Err(ReportValidationError::FieldElementNotString {
field: field.to_string(),
index: i,
});
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdvisoryWarning {
pub field: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<usize>,
pub reason: String,
}
impl AdvisoryWarning {
#[must_use]
pub fn to_message(&self) -> String {
match self.index {
Some(i) => format!("dropped {}[{i}]: {}", self.field, self.reason),
None => format!("dropped advisory field `{}`: {}", self.field, self.reason),
}
}
}
#[derive(Debug, Clone)]
pub struct SanitizedReport {
pub report: Value,
pub warnings: Vec<AdvisoryWarning>,
}
pub fn sanitize_report_advisory(data: &Value) -> Result<SanitizedReport, ReportValidationError> {
let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
validate_required_fields(obj)?;
let mut out = obj.clone();
let mut warnings = Vec::new();
if let Some(v) = obj.get("summary") {
if !v.is_string() && !v.is_null() {
out.remove("summary");
warnings.push(AdvisoryWarning {
field: "summary".to_string(),
index: None,
reason: ReportValidationError::SummaryNotString.to_string(),
});
}
}
sanitize_element_array(
&mut out,
"discussion_items",
ReportValidationError::DiscussionItemsNotArray,
validate_discussion_item,
&mut warnings,
);
sanitize_element_array(
&mut out,
"spinoff_proposals",
ReportValidationError::SpinoffProposalsNotArray,
validate_spinoff_proposal,
&mut warnings,
);
sanitize_string_array_field(&mut out, "wrap_up_recommendations", &mut warnings);
Ok(SanitizedReport {
report: Value::Object(out),
warnings,
})
}
fn sanitize_element_array(
obj: &mut serde_json::Map<String, Value>,
field: &str,
not_array_err: ReportValidationError,
validate: fn(&Value, usize) -> Result<(), ReportValidationError>,
warnings: &mut Vec<AdvisoryWarning>,
) {
let Some(v) = obj.get(field) else { return };
let Some(arr) = v.as_array() else {
obj.remove(field);
warnings.push(AdvisoryWarning {
field: field.to_string(),
index: None,
reason: not_array_err.to_string(),
});
return;
};
let mut kept = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
match validate(item, i) {
Ok(()) => kept.push(item.clone()),
Err(e) => warnings.push(AdvisoryWarning {
field: field.to_string(),
index: Some(i),
reason: e.to_string(),
}),
}
}
obj.insert(field.to_string(), Value::Array(kept));
}
fn sanitize_string_array_field(
obj: &mut serde_json::Map<String, Value>,
field: &str,
warnings: &mut Vec<AdvisoryWarning>,
) {
let Some(v) = obj.get(field) else { return };
let Some(arr) = v.as_array() else {
obj.remove(field);
warnings.push(AdvisoryWarning {
field: field.to_string(),
index: None,
reason: ReportValidationError::FieldNotArray {
field: field.to_string(),
}
.to_string(),
});
return;
};
let mut kept = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
if item.is_string() {
kept.push(item.clone());
} else {
warnings.push(AdvisoryWarning {
field: field.to_string(),
index: Some(i),
reason: ReportValidationError::FieldElementNotString {
field: field.to_string(),
index: i,
}
.to_string(),
});
}
}
obj.insert(field.to_string(), Value::Array(kept));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validates_minimal_success_payload() {
let v = json!({"success": true});
assert!(validate_report_payload(&v).is_ok());
}
#[test]
fn validates_full_success_payload() {
let v = json!({
"success": true,
"summary": "did the thing",
"discussion_items": [
{"topic": "naming", "severity": "discuss", "options": ["a", "b"]},
],
"spinoff_proposals": [
{"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
],
"wrap_up_recommendations": ["rebase", "squash"],
});
assert!(validate_report_payload(&v).is_ok());
}
#[test]
fn discussion_item_unknown_severity_accepted_for_forward_compat() {
let v = json!({
"success": true,
"discussion_items": [{"topic": "x", "severity": "info"}],
});
assert!(validate_report_payload(&v).is_ok());
}
#[test]
fn cancel_synthesized_report_shape_ok() {
let v = json!({
"success": false,
"cancelled": true,
"reason": "cancelled by user",
"summary": "Run cancelled before agent reported.",
"discussion_items": [],
"spinoff_proposals": [],
"wrap_up_recommendations": [],
});
assert!(validate_report_payload(&v).is_ok());
}
#[test]
fn non_object_root_rejected() {
let v = json!([1, 2, 3]);
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::NotObject)
));
}
#[test]
fn missing_success_rejected() {
let v = json!({"summary": "no success field"});
let err = validate_report_payload(&v).unwrap_err();
assert!(matches!(err, ReportValidationError::MissingSuccess));
assert_eq!(
err.expected(),
Some(json!({"field": "success", "type": "boolean"}))
);
}
#[test]
fn success_variants_carry_field_type_hint() {
let hint = Some(json!({"field": "success", "type": "boolean"}));
assert_eq!(ReportValidationError::MissingSuccess.expected(), hint);
assert_eq!(ReportValidationError::SuccessNotBoolean.expected(), hint);
}
#[test]
fn summary_must_be_string() {
let v = json!({"success": true, "summary": 42});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::SummaryNotString)
));
}
#[test]
fn discussion_item_options_non_array_rejected() {
let v = json!({
"success": true,
"discussion_items": [{"topic": "x", "options": "not-an-array"}],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::PathNotArray { .. })
));
}
#[test]
fn cancelled_requires_non_whitespace_reason() {
let v = json!({"success": false, "cancelled": true, "reason": " "});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::CancelledRequiresReason)
));
}
#[test]
fn non_boolean_success_rejected() {
let v = json!({"success": "yes"});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::SuccessNotBoolean)
));
}
#[test]
fn discussion_item_missing_topic_rejected() {
let v = json!({
"success": true,
"discussion_items": [{"severity": "discuss"}],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::DiscussionItemTopicMissing { index: 0 })
));
}
#[test]
fn discussion_item_non_string_severity_rejected() {
let v = json!({
"success": true,
"discussion_items": [{"topic": "x", "severity": 42}],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::DiscussionItemSeverityNotString { index: 0 })
));
}
#[test]
fn discussion_item_options_must_be_strings() {
let v = json!({
"success": true,
"discussion_items": [{"topic": "x", "options": [1, 2]}],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::PathElementNotString { index: 0, .. })
));
}
#[test]
fn spinoff_unknown_proposed_kind_rejected() {
let v = json!({
"success": true,
"spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
});
let err = validate_report_payload(&v).unwrap_err();
assert!(matches!(
err,
ReportValidationError::SpinoffProposalKindUnknown { index: 0, .. }
));
assert_eq!(err.expected(), Some(json!(crate::schema::Kind::WIRE_NAMES)));
assert_eq!(
err.expected(),
Some(json!([
"spinoff",
"research",
"technical-decision",
"fan-out"
]))
);
}
#[test]
fn spinoff_missing_kind_rejected() {
let v = json!({
"success": true,
"spinoff_proposals": [{"proposed_title": "x"}],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::SpinoffProposalKindNotString { index: 0 })
));
}
#[test]
fn cancelled_requires_success_false() {
let v = json!({"success": true, "cancelled": true, "reason": "x"});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::CancelledRequiresSuccessFalse)
));
}
#[test]
fn cancelled_requires_reason() {
let v = json!({"success": false, "cancelled": true});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::CancelledRequiresReason)
));
}
#[test]
fn report_origin_round_trips_through_a_report() {
let cases = [
ReportOrigin::Agent,
ReportOrigin::Supervisor,
ReportOrigin::RunMerge {
op_id: Some("op-123".into()),
worker_oid: Some("deadbeef".into()),
},
ReportOrigin::RunMerge {
op_id: None,
worker_oid: None,
},
];
for origin in cases {
let mut report = json!({ "success": true });
origin.stamp(&mut report);
assert_eq!(
ReportOrigin::from_report(&report),
Some(origin.clone()),
"round-trip: {origin:?}"
);
}
}
#[test]
fn report_origin_serializes_with_kind_tag() {
let mut report = json!({ "success": true });
ReportOrigin::Agent.stamp(&mut report);
assert_eq!(report["origin"], json!({ "kind": "agent" }));
let mut merge = json!({ "success": true });
ReportOrigin::RunMerge {
op_id: Some("op-9".into()),
worker_oid: Some("abc123".into()),
}
.stamp(&mut merge);
assert_eq!(
merge["origin"],
json!({ "kind": "run-merge", "op_id": "op-9", "worker_oid": "abc123" })
);
let mut bare = json!({ "success": true });
ReportOrigin::RunMerge {
op_id: None,
worker_oid: None,
}
.stamp(&mut bare);
assert_eq!(bare["origin"], json!({ "kind": "run-merge" }));
}
#[test]
fn report_origin_absent_or_malformed_is_none() {
assert_eq!(ReportOrigin::from_report(&json!({ "success": true })), None);
assert_eq!(
ReportOrigin::from_report(&json!({ "origin": "not-an-object" })),
None
);
assert_eq!(
ReportOrigin::from_report(&json!({ "origin": { "kind": "bogus" } })),
None
);
}
#[test]
fn report_origin_stamp_overwrites_a_supplied_value() {
let mut report = json!({
"success": true,
"origin": { "kind": "run-merge", "op_id": "spoofed" }
});
ReportOrigin::Agent.stamp(&mut report);
assert_eq!(
ReportOrigin::from_report(&report),
Some(ReportOrigin::Agent)
);
}
#[test]
fn report_is_confirmed_merge_prefers_typed_origin() {
let mut merged = json!({ "success": true });
ReportOrigin::RunMerge {
op_id: Some("op-1".into()),
worker_oid: Some("abc".into()),
}
.stamp(&mut merged);
assert!(ReportOrigin::report_is_confirmed_merge(&merged));
let mut bare = json!({ "success": true });
ReportOrigin::RunMerge {
op_id: None,
worker_oid: None,
}
.stamp(&mut bare);
assert!(ReportOrigin::report_is_confirmed_merge(&bare));
}
#[test]
fn report_is_confirmed_merge_legacy_via_only_when_origin_absent() {
assert!(ReportOrigin::report_is_confirmed_merge(&json!({
"success": true, "via": "explicit-merge"
})));
let mut agent = json!({ "success": true, "via": "explicit-merge" });
ReportOrigin::Agent.stamp(&mut agent);
assert!(
!ReportOrigin::report_is_confirmed_merge(&agent),
"an Agent-origin report must not be a merge even with a forged via"
);
assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
"success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
})));
assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
"success": true, "via": "explicit-merge", "origin": { "kind": "bogus" }
})));
}
#[test]
fn report_is_confirmed_merge_requires_success_and_not_cancelled() {
assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
"success": false, "via": "explicit-merge"
})));
let mut neg = json!({ "success": false });
ReportOrigin::RunMerge {
op_id: None,
worker_oid: None,
}
.stamp(&mut neg);
assert!(!ReportOrigin::report_is_confirmed_merge(&neg));
let mut cancelled = json!({ "success": false, "cancelled": true, "reason": "x" });
ReportOrigin::RunMerge {
op_id: None,
worker_oid: None,
}
.stamp(&mut cancelled);
assert!(!ReportOrigin::report_is_confirmed_merge(&cancelled));
assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
"success": "true", "via": "explicit-merge"
})));
}
#[test]
fn report_origin_stamp_on_non_object_is_noop() {
let mut not_obj = json!([1, 2, 3]);
ReportOrigin::Agent.stamp(&mut not_obj);
assert_eq!(not_obj, json!([1, 2, 3]));
}
#[test]
fn wrap_up_must_be_string_array() {
let v = json!({
"success": true,
"wrap_up_recommendations": ["ok", 42],
});
assert!(matches!(
validate_report_payload(&v),
Err(ReportValidationError::FieldElementNotString { index: 1, .. })
));
}
#[test]
fn sanitize_clean_report_has_no_warnings() {
let v = json!({
"success": true,
"summary": "did the thing",
"discussion_items": [{"topic": "naming", "severity": "discuss"}],
"spinoff_proposals": [
{"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
],
"wrap_up_recommendations": ["rebase"],
});
let out = sanitize_report_advisory(&v).unwrap();
assert!(out.warnings.is_empty());
assert_eq!(out.report, v);
}
#[test]
fn sanitize_drops_typoed_spinoff_proposal_with_warning() {
let v = json!({
"success": true,
"summary": "green, reviewed, committed",
"spinoff_proposals": [{"title": "do X later", "detail": "because Y"}],
});
assert!(validate_report_payload(&v).is_err());
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1);
assert_eq!(out.warnings[0].field, "spinoff_proposals");
assert_eq!(out.warnings[0].index, Some(0));
assert_eq!(out.report["spinoff_proposals"], json!([]));
assert_eq!(out.report["success"], json!(true));
assert_eq!(out.report["summary"], json!("green, reviewed, committed"));
}
#[test]
fn sanitize_keeps_valid_siblings_drops_only_bad_element() {
let v = json!({
"success": true,
"spinoff_proposals": [
{"proposed_title": "keep me", "proposed_kind": "spinoff"},
{"title": "typo, drop me"},
{"proposed_title": "keep me too", "proposed_kind": "research"},
],
});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1);
assert_eq!(out.warnings[0].index, Some(1));
let kept = out.report["spinoff_proposals"].as_array().unwrap();
assert_eq!(kept.len(), 2);
assert_eq!(kept[0]["proposed_title"], json!("keep me"));
assert_eq!(kept[1]["proposed_title"], json!("keep me too"));
}
#[test]
fn sanitize_drops_non_array_advisory_field_whole() {
let v = json!({
"success": true,
"discussion_items": "not-an-array",
"wrap_up_recommendations": {"oops": true},
});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 2);
assert!(out.report.get("discussion_items").is_none());
assert!(out.report.get("wrap_up_recommendations").is_none());
let fields: Vec<&str> = out.warnings.iter().map(|w| w.field.as_str()).collect();
assert!(fields.contains(&"discussion_items"));
assert!(fields.contains(&"wrap_up_recommendations"));
assert!(out.warnings.iter().all(|w| w.index.is_none()));
}
#[test]
fn sanitize_drops_non_string_wrap_up_element() {
let v = json!({
"success": true,
"wrap_up_recommendations": ["rebase", 42, "squash"],
});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1);
assert_eq!(out.warnings[0].index, Some(1));
assert_eq!(
out.report["wrap_up_recommendations"],
json!(["rebase", "squash"])
);
}
#[test]
fn sanitize_drops_malformed_summary() {
let v = json!({"success": true, "summary": 42});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1);
assert_eq!(out.warnings[0].field, "summary");
assert!(out.report.get("summary").is_none());
}
#[test]
fn sanitize_still_rejects_missing_required_success() {
let v = json!({"summary": "no success field"});
assert!(matches!(
sanitize_report_advisory(&v),
Err(ReportValidationError::MissingSuccess)
));
}
#[test]
fn sanitize_still_rejects_non_boolean_success() {
let v = json!({"success": "yes", "spinoff_proposals": [{"title": "x"}]});
assert!(matches!(
sanitize_report_advisory(&v),
Err(ReportValidationError::SuccessNotBoolean)
));
}
#[test]
fn sanitize_still_rejects_cancelled_contradiction() {
let v = json!({"success": true, "cancelled": true, "reason": "x"});
assert!(matches!(
sanitize_report_advisory(&v),
Err(ReportValidationError::CancelledRequiresSuccessFalse)
));
}
#[test]
fn sanitize_rejects_non_object_root() {
let v = json!([1, 2, 3]);
assert!(matches!(
sanitize_report_advisory(&v),
Err(ReportValidationError::NotObject)
));
}
#[test]
fn sanitize_preserves_unknown_and_provenance_fields() {
let v = json!({
"success": true,
"origin": {"kind": "agent"},
"via": "explicit-merge",
"custom_agent_key": {"nested": [1, 2, 3]},
"spinoff_proposals": [{"title": "typo, drop me"}],
});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1, "only the bad proposal is dropped");
assert_eq!(out.report["origin"], json!({"kind": "agent"}));
assert_eq!(out.report["via"], json!("explicit-merge"));
assert_eq!(out.report["custom_agent_key"], json!({"nested": [1, 2, 3]}));
}
#[test]
fn sanitize_nested_options_drops_whole_discussion_item() {
let v = json!({
"success": true,
"discussion_items": [
{"topic": "keep", "severity": "discuss"},
{"topic": "drop me", "options": ["ok", 42]},
],
});
let out = sanitize_report_advisory(&v).unwrap();
assert_eq!(out.warnings.len(), 1);
assert_eq!(out.warnings[0].index, Some(1));
let kept = out.report["discussion_items"].as_array().unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0]["topic"], json!("keep"));
}
#[test]
fn advisory_warning_message_shapes() {
let elem = AdvisoryWarning {
field: "spinoff_proposals".to_string(),
index: Some(2),
reason: "boom".to_string(),
};
assert_eq!(elem.to_message(), "dropped spinoff_proposals[2]: boom");
let whole = AdvisoryWarning {
field: "discussion_items".to_string(),
index: None,
reason: "not an array".to_string(),
};
assert_eq!(
whole.to_message(),
"dropped advisory field `discussion_items`: not an array"
);
}
}