1use serde_json::Value;
15
16use crate::schema::Kind;
17
18#[derive(Debug, thiserror::Error)]
24pub enum ReportValidationError {
25 #[error("report payload must be a JSON object")]
27 NotObject,
28
29 #[error("report payload missing required field `success`")]
31 MissingSuccess,
32
33 #[error("field `success` must be a boolean")]
35 SuccessNotBoolean,
36
37 #[error("field `summary` must be a string")]
39 SummaryNotString,
40
41 #[error("field `cancelled` must be a boolean")]
43 CancelledNotBoolean,
44
45 #[error("field `reason` must be a string")]
47 ReasonNotString,
48
49 #[error("`cancelled: true` requires `success: false`")]
51 CancelledRequiresSuccessFalse,
52
53 #[error("`cancelled: true` requires a non-empty `reason` string")]
55 CancelledRequiresReason,
56
57 #[error("field `discussion_items` must be an array")]
59 DiscussionItemsNotArray,
60
61 #[error("discussion_items[{index}] must be a JSON object")]
63 DiscussionItemNotObject {
64 index: usize,
66 },
67
68 #[error("discussion_items[{index}].topic must be a non-empty string")]
70 DiscussionItemTopicMissing {
71 index: usize,
73 },
74
75 #[error("discussion_items[{index}].severity must be a string")]
77 DiscussionItemSeverityNotString {
78 index: usize,
80 },
81
82 #[error("field `spinoff_proposals` must be an array")]
84 SpinoffProposalsNotArray,
85
86 #[error("spinoff_proposals[{index}] must be a JSON object")]
88 SpinoffProposalNotObject {
89 index: usize,
91 },
92
93 #[error("spinoff_proposals[{index}].proposed_title must be a non-empty string")]
95 SpinoffProposalTitleMissing {
96 index: usize,
98 },
99
100 #[error("spinoff_proposals[{index}].proposed_kind must be a string")]
102 SpinoffProposalKindNotString {
103 index: usize,
105 },
106
107 #[error("spinoff_proposals[{index}].proposed_kind `{kind}` is not a known kind")]
109 SpinoffProposalKindUnknown {
110 index: usize,
112 kind: String,
114 },
115
116 #[error("spinoff_proposals[{index}].rationale must be a string")]
118 SpinoffProposalRationaleNotString {
119 index: usize,
121 },
122
123 #[error("field `{field}` must be an array")]
125 FieldNotArray {
126 field: String,
128 },
129
130 #[error("{field}[{index}] must be a string")]
132 FieldElementNotString {
133 field: String,
135 index: usize,
137 },
138
139 #[error("{path} must be an array")]
141 PathNotArray {
142 path: String,
144 },
145
146 #[error("{path}[{index}] must be a string")]
148 PathElementNotString {
149 path: String,
151 index: usize,
153 },
154}
155
156impl ReportValidationError {
157 #[must_use]
162 pub fn expected(&self) -> Option<Value> {
163 match self {
164 Self::MissingSuccess | Self::SuccessNotBoolean => {
165 Some(serde_json::json!({"field": "success", "type": "boolean"}))
166 }
167 Self::SpinoffProposalKindUnknown { .. } => Some(serde_json::json!(Kind::WIRE_NAMES)),
171 _ => None,
172 }
173 }
174}
175
176pub fn validate_report_payload(data: &Value) -> Result<(), ReportValidationError> {
188 let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
189
190 let success = obj
194 .get("success")
195 .ok_or(ReportValidationError::MissingSuccess)?;
196 if !success.is_boolean() {
197 return Err(ReportValidationError::SuccessNotBoolean);
198 }
199
200 if let Some(v) = obj.get("summary") {
201 if !v.is_string() && !v.is_null() {
202 return Err(ReportValidationError::SummaryNotString);
203 }
204 }
205 let cancelled = match obj.get("cancelled") {
206 None | Some(Value::Null) => false,
207 Some(v) => v
208 .as_bool()
209 .ok_or(ReportValidationError::CancelledNotBoolean)?,
210 };
211 let reason = match obj.get("reason") {
212 None | Some(Value::Null) => None,
213 Some(v) => Some(v.as_str().ok_or(ReportValidationError::ReasonNotString)?),
214 };
215
216 if cancelled {
222 if success
226 .as_bool()
227 .expect("success validated as boolean above")
228 {
229 return Err(ReportValidationError::CancelledRequiresSuccessFalse);
230 }
231 match reason {
232 Some(s) if !s.trim().is_empty() => {}
233 _ => return Err(ReportValidationError::CancelledRequiresReason),
234 }
235 }
236
237 validate_discussion_items(obj.get("discussion_items"))?;
238 validate_spinoff_proposals(obj.get("spinoff_proposals"))?;
239 validate_string_array(
240 obj.get("wrap_up_recommendations"),
241 "wrap_up_recommendations",
242 )?;
243 Ok(())
244}
245
246fn validate_discussion_items(v: Option<&Value>) -> Result<(), ReportValidationError> {
247 let arr = match v {
248 Some(Value::Array(a)) => a,
249 Some(_) => return Err(ReportValidationError::DiscussionItemsNotArray),
250 None => return Ok(()),
251 };
252 for (i, item) in arr.iter().enumerate() {
253 let obj = item
254 .as_object()
255 .ok_or(ReportValidationError::DiscussionItemNotObject { index: i })?;
256 let topic = obj.get("topic").and_then(Value::as_str);
257 if topic.is_none_or(|t| t.trim().is_empty()) {
258 return Err(ReportValidationError::DiscussionItemTopicMissing { index: i });
259 }
260 if let Some(sev) = obj.get("severity") {
261 if !sev.is_string() {
262 return Err(ReportValidationError::DiscussionItemSeverityNotString { index: i });
263 }
264 }
271 if let Some(opts) = obj.get("options") {
272 validate_string_array_at(opts, &format!("discussion_items[{i}].options"))?;
273 }
274 }
275 Ok(())
276}
277
278fn validate_spinoff_proposals(v: Option<&Value>) -> Result<(), ReportValidationError> {
279 let arr = match v {
280 Some(Value::Array(a)) => a,
281 Some(_) => return Err(ReportValidationError::SpinoffProposalsNotArray),
282 None => return Ok(()),
283 };
284 for (i, item) in arr.iter().enumerate() {
285 let obj = item
286 .as_object()
287 .ok_or(ReportValidationError::SpinoffProposalNotObject { index: i })?;
288 let title = obj.get("proposed_title").and_then(Value::as_str);
289 if title.is_none_or(|t| t.trim().is_empty()) {
290 return Err(ReportValidationError::SpinoffProposalTitleMissing { index: i });
291 }
292 let kind_str = obj
293 .get("proposed_kind")
294 .and_then(Value::as_str)
295 .ok_or(ReportValidationError::SpinoffProposalKindNotString { index: i })?;
296 if serde_json::from_value::<Kind>(Value::String(kind_str.to_string())).is_err() {
301 return Err(ReportValidationError::SpinoffProposalKindUnknown {
302 index: i,
303 kind: kind_str.to_string(),
304 });
305 }
306 if let Some(rationale) = obj.get("rationale") {
307 if !rationale.is_string() && !rationale.is_null() {
308 return Err(ReportValidationError::SpinoffProposalRationaleNotString { index: i });
309 }
310 }
311 }
312 Ok(())
313}
314
315fn validate_string_array_at(v: &Value, path: &str) -> Result<(), ReportValidationError> {
318 let arr = v
319 .as_array()
320 .ok_or_else(|| ReportValidationError::PathNotArray {
321 path: path.to_string(),
322 })?;
323 for (i, item) in arr.iter().enumerate() {
324 if !item.is_string() {
325 return Err(ReportValidationError::PathElementNotString {
326 path: path.to_string(),
327 index: i,
328 });
329 }
330 }
331 Ok(())
332}
333
334fn validate_string_array(v: Option<&Value>, field: &str) -> Result<(), ReportValidationError> {
335 let arr = match v {
336 Some(Value::Array(a)) => a,
337 Some(_) => {
338 return Err(ReportValidationError::FieldNotArray {
339 field: field.to_string(),
340 })
341 }
342 None => return Ok(()),
343 };
344 for (i, item) in arr.iter().enumerate() {
345 if !item.is_string() {
346 return Err(ReportValidationError::FieldElementNotString {
347 field: field.to_string(),
348 index: i,
349 });
350 }
351 }
352 Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use serde_json::json;
359
360 #[test]
363 fn validates_minimal_success_payload() {
364 let v = json!({"success": true});
365 assert!(validate_report_payload(&v).is_ok());
366 }
367
368 #[test]
369 fn validates_full_success_payload() {
370 let v = json!({
371 "success": true,
372 "summary": "did the thing",
373 "discussion_items": [
374 {"topic": "naming", "severity": "discuss", "options": ["a", "b"]},
375 ],
376 "spinoff_proposals": [
377 {"proposed_title": "follow-up", "proposed_kind": "code", "rationale": "later"},
378 ],
379 "wrap_up_recommendations": ["rebase", "squash"],
380 });
381 assert!(validate_report_payload(&v).is_ok());
382 }
383
384 #[test]
385 fn discussion_item_unknown_severity_accepted_for_forward_compat() {
386 let v = json!({
389 "success": true,
390 "discussion_items": [{"topic": "x", "severity": "info"}],
391 });
392 assert!(validate_report_payload(&v).is_ok());
393 }
394
395 #[test]
396 fn cancel_synthesized_report_shape_ok() {
397 let v = json!({
399 "success": false,
400 "cancelled": true,
401 "reason": "cancelled by user",
402 "summary": "Run cancelled before agent reported.",
403 "discussion_items": [],
404 "spinoff_proposals": [],
405 "wrap_up_recommendations": [],
406 });
407 assert!(validate_report_payload(&v).is_ok());
408 }
409
410 #[test]
413 fn non_object_root_rejected() {
414 let v = json!([1, 2, 3]);
415 assert!(matches!(
416 validate_report_payload(&v),
417 Err(ReportValidationError::NotObject)
418 ));
419 }
420
421 #[test]
422 fn missing_success_rejected() {
423 let v = json!({"summary": "no success field"});
424 let err = validate_report_payload(&v).unwrap_err();
425 assert!(matches!(err, ReportValidationError::MissingSuccess));
426 assert_eq!(
428 err.expected(),
429 Some(json!({"field": "success", "type": "boolean"}))
430 );
431 }
432
433 #[test]
434 fn success_variants_carry_field_type_hint() {
435 let hint = Some(json!({"field": "success", "type": "boolean"}));
437 assert_eq!(ReportValidationError::MissingSuccess.expected(), hint);
438 assert_eq!(ReportValidationError::SuccessNotBoolean.expected(), hint);
439 }
440
441 #[test]
442 fn summary_must_be_string() {
443 let v = json!({"success": true, "summary": 42});
444 assert!(matches!(
445 validate_report_payload(&v),
446 Err(ReportValidationError::SummaryNotString)
447 ));
448 }
449
450 #[test]
451 fn discussion_item_options_non_array_rejected() {
452 let v = json!({
453 "success": true,
454 "discussion_items": [{"topic": "x", "options": "not-an-array"}],
455 });
456 assert!(matches!(
457 validate_report_payload(&v),
458 Err(ReportValidationError::PathNotArray { .. })
459 ));
460 }
461
462 #[test]
463 fn cancelled_requires_non_whitespace_reason() {
464 let v = json!({"success": false, "cancelled": true, "reason": " "});
465 assert!(matches!(
466 validate_report_payload(&v),
467 Err(ReportValidationError::CancelledRequiresReason)
468 ));
469 }
470
471 #[test]
472 fn non_boolean_success_rejected() {
473 let v = json!({"success": "yes"});
474 assert!(matches!(
475 validate_report_payload(&v),
476 Err(ReportValidationError::SuccessNotBoolean)
477 ));
478 }
479
480 #[test]
481 fn discussion_item_missing_topic_rejected() {
482 let v = json!({
483 "success": true,
484 "discussion_items": [{"severity": "discuss"}],
485 });
486 assert!(matches!(
487 validate_report_payload(&v),
488 Err(ReportValidationError::DiscussionItemTopicMissing { index: 0 })
489 ));
490 }
491
492 #[test]
493 fn discussion_item_non_string_severity_rejected() {
494 let v = json!({
495 "success": true,
496 "discussion_items": [{"topic": "x", "severity": 42}],
497 });
498 assert!(matches!(
499 validate_report_payload(&v),
500 Err(ReportValidationError::DiscussionItemSeverityNotString { index: 0 })
501 ));
502 }
503
504 #[test]
505 fn discussion_item_options_must_be_strings() {
506 let v = json!({
507 "success": true,
508 "discussion_items": [{"topic": "x", "options": [1, 2]}],
509 });
510 assert!(matches!(
511 validate_report_payload(&v),
512 Err(ReportValidationError::PathElementNotString { index: 0, .. })
513 ));
514 }
515
516 #[test]
517 fn spinoff_unknown_proposed_kind_rejected() {
518 let v = json!({
519 "success": true,
520 "spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
521 });
522 let err = validate_report_payload(&v).unwrap_err();
523 assert!(matches!(
524 err,
525 ReportValidationError::SpinoffProposalKindUnknown { index: 0, .. }
526 ));
527 assert_eq!(err.expected(), Some(json!(crate::schema::Kind::WIRE_NAMES)));
530 assert_eq!(
531 err.expected(),
532 Some(json!([
533 "code",
534 "spinoff",
535 "orchestrated",
536 "research",
537 "technical-decision",
538 "make-skill",
539 "fan-out",
540 "bugfix",
541 "orchestrate",
542 ]))
543 );
544 }
545
546 #[test]
547 fn spinoff_missing_kind_rejected() {
548 let v = json!({
549 "success": true,
550 "spinoff_proposals": [{"proposed_title": "x"}],
551 });
552 assert!(matches!(
553 validate_report_payload(&v),
554 Err(ReportValidationError::SpinoffProposalKindNotString { index: 0 })
555 ));
556 }
557
558 #[test]
559 fn cancelled_requires_success_false() {
560 let v = json!({"success": true, "cancelled": true, "reason": "x"});
561 assert!(matches!(
562 validate_report_payload(&v),
563 Err(ReportValidationError::CancelledRequiresSuccessFalse)
564 ));
565 }
566
567 #[test]
568 fn cancelled_requires_reason() {
569 let v = json!({"success": false, "cancelled": true});
570 assert!(matches!(
571 validate_report_payload(&v),
572 Err(ReportValidationError::CancelledRequiresReason)
573 ));
574 }
575
576 #[test]
577 fn wrap_up_must_be_string_array() {
578 let v = json!({
579 "success": true,
580 "wrap_up_recommendations": ["ok", 42],
581 });
582 assert!(matches!(
583 validate_report_payload(&v),
584 Err(ReportValidationError::FieldElementNotString { index: 1, .. })
585 ));
586 }
587}