use serde_json::Value;
use crate::schema::Kind;
#[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)?;
let success = obj
.get("success")
.ok_or(ReportValidationError::MissingSuccess)?;
if !success.is_boolean() {
return Err(ReportValidationError::SuccessNotBoolean);
}
if let Some(v) = obj.get("summary") {
if !v.is_string() && !v.is_null() {
return Err(ReportValidationError::SummaryNotString);
}
}
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),
}
}
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_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() {
let obj = item
.as_object()
.ok_or(ReportValidationError::DiscussionItemNotObject { index: i })?;
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: i });
}
if let Some(sev) = obj.get("severity") {
if !sev.is_string() {
return Err(ReportValidationError::DiscussionItemSeverityNotString { index: i });
}
}
if let Some(opts) = obj.get("options") {
validate_string_array_at(opts, &format!("discussion_items[{i}].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() {
let obj = item
.as_object()
.ok_or(ReportValidationError::SpinoffProposalNotObject { index: i })?;
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: i });
}
let kind_str = obj
.get("proposed_kind")
.and_then(Value::as_str)
.ok_or(ReportValidationError::SpinoffProposalKindNotString { index: i })?;
if serde_json::from_value::<Kind>(Value::String(kind_str.to_string())).is_err() {
return Err(ReportValidationError::SpinoffProposalKindUnknown {
index: i,
kind: kind_str.to_string(),
});
}
if let Some(rationale) = obj.get("rationale") {
if !rationale.is_string() && !rationale.is_null() {
return Err(ReportValidationError::SpinoffProposalRationaleNotString { index: i });
}
}
}
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(())
}
#[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": "code", "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!([
"code",
"spinoff",
"orchestrated",
"research",
"technical-decision",
"make-skill",
"fan-out",
"bugfix",
"orchestrate",
]))
);
}
#[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 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, .. })
));
}
}