1use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32use crate::schema::Kind;
33
34pub const REPORT_ORIGIN_KEY: &str = "origin";
37
38pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum ReportOrigin {
76 Agent,
79 Supervisor,
82 RunMerge {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 op_id: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 worker_oid: Option<String>,
95 },
96}
97
98impl ReportOrigin {
99 #[must_use]
113 pub fn from_report(report: &Value) -> Option<Self> {
114 let raw = report.get(REPORT_ORIGIN_KEY)?;
115 serde_json::from_value(raw.clone()).ok()
116 }
117
118 #[must_use]
142 pub fn report_is_confirmed_merge(report: &Value) -> bool {
143 let success = matches!(report.get("success"), Some(Value::Bool(true)));
144 let not_cancelled = matches!(
145 report.get("cancelled"),
146 None | Some(Value::Null | Value::Bool(false))
147 );
148 if !(success && not_cancelled) {
149 return false;
150 }
151 let is_run_merge_origin = matches!(
154 Self::from_report(report),
155 Some(ReportOrigin::RunMerge { .. })
156 );
157 let origin_present = report.get(REPORT_ORIGIN_KEY).is_some();
158 let legacy_via_merge = !origin_present
159 && report.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
160 is_run_merge_origin || legacy_via_merge
161 }
162
163 #[must_use]
171 pub fn permits_terminal_merge_recovery(status: crate::Status, report: &Value) -> bool {
172 matches!(status, crate::Status::Failed | crate::Status::Done)
173 && Self::report_is_confirmed_merge(report)
174 }
175
176 pub fn stamp(&self, report: &mut Value) {
181 if let Some(obj) = report.as_object_mut() {
182 if let Ok(v) = serde_json::to_value(self) {
185 obj.insert(REPORT_ORIGIN_KEY.to_string(), v);
186 }
187 }
188 }
189}
190
191#[derive(Debug, thiserror::Error)]
197pub enum ReportValidationError {
198 #[error("report payload must be a JSON object")]
200 NotObject,
201
202 #[error("report payload missing required field `success`")]
204 MissingSuccess,
205
206 #[error("field `success` must be a boolean")]
208 SuccessNotBoolean,
209
210 #[error("field `summary` must be a string")]
212 SummaryNotString,
213
214 #[error("field `cancelled` must be a boolean")]
216 CancelledNotBoolean,
217
218 #[error("field `reason` must be a string")]
220 ReasonNotString,
221
222 #[error("`cancelled: true` requires `success: false`")]
224 CancelledRequiresSuccessFalse,
225
226 #[error("`cancelled: true` requires a non-empty `reason` string")]
228 CancelledRequiresReason,
229
230 #[error("field `discussion_items` must be an array")]
232 DiscussionItemsNotArray,
233
234 #[error("discussion_items[{index}] must be a JSON object")]
236 DiscussionItemNotObject {
237 index: usize,
239 },
240
241 #[error("discussion_items[{index}].topic must be a non-empty string")]
243 DiscussionItemTopicMissing {
244 index: usize,
246 },
247
248 #[error("discussion_items[{index}].severity must be a string")]
250 DiscussionItemSeverityNotString {
251 index: usize,
253 },
254
255 #[error("field `spinoff_proposals` must be an array")]
257 SpinoffProposalsNotArray,
258
259 #[error("spinoff_proposals[{index}] must be a JSON object")]
261 SpinoffProposalNotObject {
262 index: usize,
264 },
265
266 #[error("spinoff_proposals[{index}].proposed_title must be a non-empty string")]
268 SpinoffProposalTitleMissing {
269 index: usize,
271 },
272
273 #[error("spinoff_proposals[{index}].proposed_kind must be a string")]
275 SpinoffProposalKindNotString {
276 index: usize,
278 },
279
280 #[error("spinoff_proposals[{index}].proposed_kind `{kind}` is not a known kind")]
282 SpinoffProposalKindUnknown {
283 index: usize,
285 kind: String,
287 },
288
289 #[error("spinoff_proposals[{index}].rationale must be a string")]
291 SpinoffProposalRationaleNotString {
292 index: usize,
294 },
295
296 #[error("field `{field}` must be an array")]
298 FieldNotArray {
299 field: String,
301 },
302
303 #[error("{field}[{index}] must be a string")]
305 FieldElementNotString {
306 field: String,
308 index: usize,
310 },
311
312 #[error("{path} must be an array")]
314 PathNotArray {
315 path: String,
317 },
318
319 #[error("{path}[{index}] must be a string")]
321 PathElementNotString {
322 path: String,
324 index: usize,
326 },
327}
328
329impl ReportValidationError {
330 #[must_use]
335 pub fn expected(&self) -> Option<Value> {
336 match self {
337 Self::MissingSuccess | Self::SuccessNotBoolean => {
338 Some(serde_json::json!({"field": "success", "type": "boolean"}))
339 }
340 Self::SpinoffProposalKindUnknown { .. } => Some(serde_json::json!(Kind::WIRE_NAMES)),
344 _ => None,
345 }
346 }
347}
348
349pub fn validate_report_payload(data: &Value) -> Result<(), ReportValidationError> {
361 let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
362
363 validate_required_fields(obj)?;
364
365 if let Some(v) = obj.get("summary") {
366 if !v.is_string() && !v.is_null() {
367 return Err(ReportValidationError::SummaryNotString);
368 }
369 }
370
371 validate_discussion_items(obj.get("discussion_items"))?;
372 validate_spinoff_proposals(obj.get("spinoff_proposals"))?;
373 validate_string_array(
374 obj.get("wrap_up_recommendations"),
375 "wrap_up_recommendations",
376 )?;
377 Ok(())
378}
379
380fn validate_required_fields(
385 obj: &serde_json::Map<String, Value>,
386) -> Result<(), ReportValidationError> {
387 let success = obj
391 .get("success")
392 .ok_or(ReportValidationError::MissingSuccess)?;
393 if !success.is_boolean() {
394 return Err(ReportValidationError::SuccessNotBoolean);
395 }
396
397 let cancelled = match obj.get("cancelled") {
398 None | Some(Value::Null) => false,
399 Some(v) => v
400 .as_bool()
401 .ok_or(ReportValidationError::CancelledNotBoolean)?,
402 };
403 let reason = match obj.get("reason") {
404 None | Some(Value::Null) => None,
405 Some(v) => Some(v.as_str().ok_or(ReportValidationError::ReasonNotString)?),
406 };
407
408 if cancelled {
414 if success
418 .as_bool()
419 .expect("success validated as boolean above")
420 {
421 return Err(ReportValidationError::CancelledRequiresSuccessFalse);
422 }
423 match reason {
424 Some(s) if !s.trim().is_empty() => {}
425 _ => return Err(ReportValidationError::CancelledRequiresReason),
426 }
427 }
428 Ok(())
429}
430
431fn validate_discussion_items(v: Option<&Value>) -> Result<(), ReportValidationError> {
432 let arr = match v {
433 Some(Value::Array(a)) => a,
434 Some(_) => return Err(ReportValidationError::DiscussionItemsNotArray),
435 None => return Ok(()),
436 };
437 for (i, item) in arr.iter().enumerate() {
438 validate_discussion_item(item, i)?;
439 }
440 Ok(())
441}
442
443fn validate_discussion_item(item: &Value, index: usize) -> Result<(), ReportValidationError> {
447 let obj = item
448 .as_object()
449 .ok_or(ReportValidationError::DiscussionItemNotObject { index })?;
450 let topic = obj.get("topic").and_then(Value::as_str);
451 if topic.is_none_or(|t| t.trim().is_empty()) {
452 return Err(ReportValidationError::DiscussionItemTopicMissing { index });
453 }
454 if let Some(sev) = obj.get("severity") {
455 if !sev.is_string() {
456 return Err(ReportValidationError::DiscussionItemSeverityNotString { index });
457 }
458 }
465 if let Some(opts) = obj.get("options") {
466 validate_string_array_at(opts, &format!("discussion_items[{index}].options"))?;
467 }
468 Ok(())
469}
470
471fn validate_spinoff_proposals(v: Option<&Value>) -> Result<(), ReportValidationError> {
472 let arr = match v {
473 Some(Value::Array(a)) => a,
474 Some(_) => return Err(ReportValidationError::SpinoffProposalsNotArray),
475 None => return Ok(()),
476 };
477 for (i, item) in arr.iter().enumerate() {
478 validate_spinoff_proposal(item, i)?;
479 }
480 Ok(())
481}
482
483fn validate_spinoff_proposal(item: &Value, index: usize) -> Result<(), ReportValidationError> {
489 let obj = item
490 .as_object()
491 .ok_or(ReportValidationError::SpinoffProposalNotObject { index })?;
492 let title = obj.get("proposed_title").and_then(Value::as_str);
493 if title.is_none_or(|t| t.trim().is_empty()) {
494 return Err(ReportValidationError::SpinoffProposalTitleMissing { index });
495 }
496 let kind_str = obj
497 .get("proposed_kind")
498 .and_then(Value::as_str)
499 .ok_or(ReportValidationError::SpinoffProposalKindNotString { index })?;
500 if !Kind::WIRE_NAMES.contains(&kind_str) {
508 return Err(ReportValidationError::SpinoffProposalKindUnknown {
509 index,
510 kind: kind_str.to_string(),
511 });
512 }
513 if let Some(rationale) = obj.get("rationale") {
514 if !rationale.is_string() && !rationale.is_null() {
515 return Err(ReportValidationError::SpinoffProposalRationaleNotString { index });
516 }
517 }
518 Ok(())
519}
520
521fn validate_string_array_at(v: &Value, path: &str) -> Result<(), ReportValidationError> {
524 let arr = v
525 .as_array()
526 .ok_or_else(|| ReportValidationError::PathNotArray {
527 path: path.to_string(),
528 })?;
529 for (i, item) in arr.iter().enumerate() {
530 if !item.is_string() {
531 return Err(ReportValidationError::PathElementNotString {
532 path: path.to_string(),
533 index: i,
534 });
535 }
536 }
537 Ok(())
538}
539
540fn validate_string_array(v: Option<&Value>, field: &str) -> Result<(), ReportValidationError> {
541 let arr = match v {
542 Some(Value::Array(a)) => a,
543 Some(_) => {
544 return Err(ReportValidationError::FieldNotArray {
545 field: field.to_string(),
546 })
547 }
548 None => return Ok(()),
549 };
550 for (i, item) in arr.iter().enumerate() {
551 if !item.is_string() {
552 return Err(ReportValidationError::FieldElementNotString {
553 field: field.to_string(),
554 index: i,
555 });
556 }
557 }
558 Ok(())
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
571pub struct AdvisoryWarning {
572 pub field: String,
574 #[serde(skip_serializing_if = "Option::is_none")]
578 pub index: Option<usize>,
579 pub reason: String,
581}
582
583impl AdvisoryWarning {
584 #[must_use]
587 pub fn to_message(&self) -> String {
588 match self.index {
589 Some(i) => format!("dropped {}[{i}]: {}", self.field, self.reason),
590 None => format!("dropped advisory field `{}`: {}", self.field, self.reason),
591 }
592 }
593}
594
595#[derive(Debug, Clone)]
599pub struct SanitizedReport {
600 pub report: Value,
603 pub warnings: Vec<AdvisoryWarning>,
606}
607
608pub fn sanitize_report_advisory(data: &Value) -> Result<SanitizedReport, ReportValidationError> {
657 let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
658
659 validate_required_fields(obj)?;
661
662 let mut out = obj.clone();
663 let mut warnings = Vec::new();
664
665 if let Some(v) = obj.get("summary") {
667 if !v.is_string() && !v.is_null() {
668 out.remove("summary");
669 warnings.push(AdvisoryWarning {
670 field: "summary".to_string(),
671 index: None,
672 reason: ReportValidationError::SummaryNotString.to_string(),
673 });
674 }
675 }
676
677 sanitize_element_array(
678 &mut out,
679 "discussion_items",
680 ReportValidationError::DiscussionItemsNotArray,
681 validate_discussion_item,
682 &mut warnings,
683 );
684 sanitize_element_array(
685 &mut out,
686 "spinoff_proposals",
687 ReportValidationError::SpinoffProposalsNotArray,
688 validate_spinoff_proposal,
689 &mut warnings,
690 );
691 sanitize_string_array_field(&mut out, "wrap_up_recommendations", &mut warnings);
692
693 Ok(SanitizedReport {
694 report: Value::Object(out),
695 warnings,
696 })
697}
698
699fn sanitize_element_array(
705 obj: &mut serde_json::Map<String, Value>,
706 field: &str,
707 not_array_err: ReportValidationError,
708 validate: fn(&Value, usize) -> Result<(), ReportValidationError>,
709 warnings: &mut Vec<AdvisoryWarning>,
710) {
711 let Some(v) = obj.get(field) else { return };
712 let Some(arr) = v.as_array() else {
713 obj.remove(field);
714 warnings.push(AdvisoryWarning {
715 field: field.to_string(),
716 index: None,
717 reason: not_array_err.to_string(),
718 });
719 return;
720 };
721 let mut kept = Vec::with_capacity(arr.len());
722 for (i, item) in arr.iter().enumerate() {
723 match validate(item, i) {
724 Ok(()) => kept.push(item.clone()),
725 Err(e) => warnings.push(AdvisoryWarning {
726 field: field.to_string(),
727 index: Some(i),
728 reason: e.to_string(),
729 }),
730 }
731 }
732 obj.insert(field.to_string(), Value::Array(kept));
733}
734
735fn sanitize_string_array_field(
739 obj: &mut serde_json::Map<String, Value>,
740 field: &str,
741 warnings: &mut Vec<AdvisoryWarning>,
742) {
743 let Some(v) = obj.get(field) else { return };
744 let Some(arr) = v.as_array() else {
745 obj.remove(field);
746 warnings.push(AdvisoryWarning {
747 field: field.to_string(),
748 index: None,
749 reason: ReportValidationError::FieldNotArray {
750 field: field.to_string(),
751 }
752 .to_string(),
753 });
754 return;
755 };
756 let mut kept = Vec::with_capacity(arr.len());
757 for (i, item) in arr.iter().enumerate() {
758 if item.is_string() {
759 kept.push(item.clone());
760 } else {
761 warnings.push(AdvisoryWarning {
762 field: field.to_string(),
763 index: Some(i),
764 reason: ReportValidationError::FieldElementNotString {
765 field: field.to_string(),
766 index: i,
767 }
768 .to_string(),
769 });
770 }
771 }
772 obj.insert(field.to_string(), Value::Array(kept));
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use serde_json::json;
779
780 #[test]
783 fn validates_minimal_success_payload() {
784 let v = json!({"success": true});
785 assert!(validate_report_payload(&v).is_ok());
786 }
787
788 #[test]
789 fn validates_full_success_payload() {
790 let v = json!({
791 "success": true,
792 "summary": "did the thing",
793 "discussion_items": [
794 {"topic": "naming", "severity": "discuss", "options": ["a", "b"]},
795 ],
796 "spinoff_proposals": [
797 {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
798 ],
799 "wrap_up_recommendations": ["rebase", "squash"],
800 });
801 assert!(validate_report_payload(&v).is_ok());
802 }
803
804 #[test]
805 fn discussion_item_unknown_severity_accepted_for_forward_compat() {
806 let v = json!({
809 "success": true,
810 "discussion_items": [{"topic": "x", "severity": "info"}],
811 });
812 assert!(validate_report_payload(&v).is_ok());
813 }
814
815 #[test]
816 fn cancel_synthesized_report_shape_ok() {
817 let v = json!({
819 "success": false,
820 "cancelled": true,
821 "reason": "cancelled by user",
822 "summary": "Run cancelled before agent reported.",
823 "discussion_items": [],
824 "spinoff_proposals": [],
825 "wrap_up_recommendations": [],
826 });
827 assert!(validate_report_payload(&v).is_ok());
828 }
829
830 #[test]
833 fn non_object_root_rejected() {
834 let v = json!([1, 2, 3]);
835 assert!(matches!(
836 validate_report_payload(&v),
837 Err(ReportValidationError::NotObject)
838 ));
839 }
840
841 #[test]
842 fn missing_success_rejected() {
843 let v = json!({"summary": "no success field"});
844 let err = validate_report_payload(&v).unwrap_err();
845 assert!(matches!(err, ReportValidationError::MissingSuccess));
846 assert_eq!(
848 err.expected(),
849 Some(json!({"field": "success", "type": "boolean"}))
850 );
851 }
852
853 #[test]
854 fn success_variants_carry_field_type_hint() {
855 let hint = Some(json!({"field": "success", "type": "boolean"}));
857 assert_eq!(ReportValidationError::MissingSuccess.expected(), hint);
858 assert_eq!(ReportValidationError::SuccessNotBoolean.expected(), hint);
859 }
860
861 #[test]
862 fn summary_must_be_string() {
863 let v = json!({"success": true, "summary": 42});
864 assert!(matches!(
865 validate_report_payload(&v),
866 Err(ReportValidationError::SummaryNotString)
867 ));
868 }
869
870 #[test]
871 fn discussion_item_options_non_array_rejected() {
872 let v = json!({
873 "success": true,
874 "discussion_items": [{"topic": "x", "options": "not-an-array"}],
875 });
876 assert!(matches!(
877 validate_report_payload(&v),
878 Err(ReportValidationError::PathNotArray { .. })
879 ));
880 }
881
882 #[test]
883 fn cancelled_requires_non_whitespace_reason() {
884 let v = json!({"success": false, "cancelled": true, "reason": " "});
885 assert!(matches!(
886 validate_report_payload(&v),
887 Err(ReportValidationError::CancelledRequiresReason)
888 ));
889 }
890
891 #[test]
892 fn non_boolean_success_rejected() {
893 let v = json!({"success": "yes"});
894 assert!(matches!(
895 validate_report_payload(&v),
896 Err(ReportValidationError::SuccessNotBoolean)
897 ));
898 }
899
900 #[test]
901 fn discussion_item_missing_topic_rejected() {
902 let v = json!({
903 "success": true,
904 "discussion_items": [{"severity": "discuss"}],
905 });
906 assert!(matches!(
907 validate_report_payload(&v),
908 Err(ReportValidationError::DiscussionItemTopicMissing { index: 0 })
909 ));
910 }
911
912 #[test]
913 fn discussion_item_non_string_severity_rejected() {
914 let v = json!({
915 "success": true,
916 "discussion_items": [{"topic": "x", "severity": 42}],
917 });
918 assert!(matches!(
919 validate_report_payload(&v),
920 Err(ReportValidationError::DiscussionItemSeverityNotString { index: 0 })
921 ));
922 }
923
924 #[test]
925 fn discussion_item_options_must_be_strings() {
926 let v = json!({
927 "success": true,
928 "discussion_items": [{"topic": "x", "options": [1, 2]}],
929 });
930 assert!(matches!(
931 validate_report_payload(&v),
932 Err(ReportValidationError::PathElementNotString { index: 0, .. })
933 ));
934 }
935
936 #[test]
937 fn spinoff_unknown_proposed_kind_rejected() {
938 let v = json!({
939 "success": true,
940 "spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
941 });
942 let err = validate_report_payload(&v).unwrap_err();
943 assert!(matches!(
944 err,
945 ReportValidationError::SpinoffProposalKindUnknown { index: 0, .. }
946 ));
947 assert_eq!(err.expected(), Some(json!(crate::schema::Kind::WIRE_NAMES)));
950 assert_eq!(
951 err.expected(),
952 Some(json!([
953 "spinoff",
954 "research",
955 "technical-decision",
956 "fan-out"
957 ]))
958 );
959 }
960
961 #[test]
962 fn spinoff_missing_kind_rejected() {
963 let v = json!({
964 "success": true,
965 "spinoff_proposals": [{"proposed_title": "x"}],
966 });
967 assert!(matches!(
968 validate_report_payload(&v),
969 Err(ReportValidationError::SpinoffProposalKindNotString { index: 0 })
970 ));
971 }
972
973 #[test]
974 fn cancelled_requires_success_false() {
975 let v = json!({"success": true, "cancelled": true, "reason": "x"});
976 assert!(matches!(
977 validate_report_payload(&v),
978 Err(ReportValidationError::CancelledRequiresSuccessFalse)
979 ));
980 }
981
982 #[test]
983 fn cancelled_requires_reason() {
984 let v = json!({"success": false, "cancelled": true});
985 assert!(matches!(
986 validate_report_payload(&v),
987 Err(ReportValidationError::CancelledRequiresReason)
988 ));
989 }
990
991 #[test]
994 fn report_origin_round_trips_through_a_report() {
995 let cases = [
996 ReportOrigin::Agent,
997 ReportOrigin::Supervisor,
998 ReportOrigin::RunMerge {
999 op_id: Some("op-123".into()),
1000 worker_oid: Some("deadbeef".into()),
1001 },
1002 ReportOrigin::RunMerge {
1003 op_id: None,
1004 worker_oid: None,
1005 },
1006 ];
1007 for origin in cases {
1008 let mut report = json!({ "success": true });
1009 origin.stamp(&mut report);
1010 assert_eq!(
1011 ReportOrigin::from_report(&report),
1012 Some(origin.clone()),
1013 "round-trip: {origin:?}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1019 fn report_origin_serializes_with_kind_tag() {
1020 let mut report = json!({ "success": true });
1021 ReportOrigin::Agent.stamp(&mut report);
1022 assert_eq!(report["origin"], json!({ "kind": "agent" }));
1023
1024 let mut merge = json!({ "success": true });
1025 ReportOrigin::RunMerge {
1026 op_id: Some("op-9".into()),
1027 worker_oid: Some("abc123".into()),
1028 }
1029 .stamp(&mut merge);
1030 assert_eq!(
1031 merge["origin"],
1032 json!({ "kind": "run-merge", "op_id": "op-9", "worker_oid": "abc123" })
1033 );
1034
1035 let mut bare = json!({ "success": true });
1037 ReportOrigin::RunMerge {
1038 op_id: None,
1039 worker_oid: None,
1040 }
1041 .stamp(&mut bare);
1042 assert_eq!(bare["origin"], json!({ "kind": "run-merge" }));
1043 }
1044
1045 #[test]
1046 fn report_origin_absent_or_malformed_is_none() {
1047 assert_eq!(ReportOrigin::from_report(&json!({ "success": true })), None);
1049 assert_eq!(
1052 ReportOrigin::from_report(&json!({ "origin": "not-an-object" })),
1053 None
1054 );
1055 assert_eq!(
1056 ReportOrigin::from_report(&json!({ "origin": { "kind": "bogus" } })),
1057 None
1058 );
1059 }
1060
1061 #[test]
1062 fn report_origin_stamp_overwrites_a_supplied_value() {
1063 let mut report = json!({
1066 "success": true,
1067 "origin": { "kind": "run-merge", "op_id": "spoofed" }
1068 });
1069 ReportOrigin::Agent.stamp(&mut report);
1070 assert_eq!(
1071 ReportOrigin::from_report(&report),
1072 Some(ReportOrigin::Agent)
1073 );
1074 }
1075
1076 #[test]
1077 fn report_is_confirmed_merge_prefers_typed_origin() {
1078 let mut merged = json!({ "success": true });
1080 ReportOrigin::RunMerge {
1081 op_id: Some("op-1".into()),
1082 worker_oid: Some("abc".into()),
1083 }
1084 .stamp(&mut merged);
1085 assert!(ReportOrigin::report_is_confirmed_merge(&merged));
1086
1087 let mut bare = json!({ "success": true });
1089 ReportOrigin::RunMerge {
1090 op_id: None,
1091 worker_oid: None,
1092 }
1093 .stamp(&mut bare);
1094 assert!(ReportOrigin::report_is_confirmed_merge(&bare));
1095 }
1096
1097 #[test]
1098 fn report_is_confirmed_merge_legacy_via_only_when_origin_absent() {
1099 assert!(ReportOrigin::report_is_confirmed_merge(&json!({
1101 "success": true, "via": "explicit-merge"
1102 })));
1103
1104 let mut agent = json!({ "success": true, "via": "explicit-merge" });
1107 ReportOrigin::Agent.stamp(&mut agent);
1108 assert!(
1109 !ReportOrigin::report_is_confirmed_merge(&agent),
1110 "an Agent-origin report must not be a merge even with a forged via"
1111 );
1112
1113 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1116 "success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
1117 })));
1118 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1119 "success": true, "via": "explicit-merge", "origin": { "kind": "bogus" }
1120 })));
1121 }
1122
1123 #[test]
1124 fn report_is_confirmed_merge_requires_success_and_not_cancelled() {
1125 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1127 "success": false, "via": "explicit-merge"
1128 })));
1129 let mut neg = json!({ "success": false });
1131 ReportOrigin::RunMerge {
1132 op_id: None,
1133 worker_oid: None,
1134 }
1135 .stamp(&mut neg);
1136 assert!(!ReportOrigin::report_is_confirmed_merge(&neg));
1137 let mut cancelled = json!({ "success": false, "cancelled": true, "reason": "x" });
1139 ReportOrigin::RunMerge {
1140 op_id: None,
1141 worker_oid: None,
1142 }
1143 .stamp(&mut cancelled);
1144 assert!(!ReportOrigin::report_is_confirmed_merge(&cancelled));
1145 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1147 "success": "true", "via": "explicit-merge"
1148 })));
1149 }
1150
1151 #[test]
1152 fn report_origin_stamp_on_non_object_is_noop() {
1153 let mut not_obj = json!([1, 2, 3]);
1154 ReportOrigin::Agent.stamp(&mut not_obj);
1155 assert_eq!(not_obj, json!([1, 2, 3]));
1156 }
1157
1158 #[test]
1159 fn wrap_up_must_be_string_array() {
1160 let v = json!({
1161 "success": true,
1162 "wrap_up_recommendations": ["ok", 42],
1163 });
1164 assert!(matches!(
1165 validate_report_payload(&v),
1166 Err(ReportValidationError::FieldElementNotString { index: 1, .. })
1167 ));
1168 }
1169
1170 #[test]
1173 fn sanitize_clean_report_has_no_warnings() {
1174 let v = json!({
1175 "success": true,
1176 "summary": "did the thing",
1177 "discussion_items": [{"topic": "naming", "severity": "discuss"}],
1178 "spinoff_proposals": [
1179 {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
1180 ],
1181 "wrap_up_recommendations": ["rebase"],
1182 });
1183 let out = sanitize_report_advisory(&v).unwrap();
1184 assert_eq!(out.warnings.len(), 0);
1185 assert_eq!(out.report, v);
1186 }
1187
1188 #[test]
1189 fn sanitize_drops_typoed_spinoff_proposal_with_warning() {
1190 let v = json!({
1195 "success": true,
1196 "summary": "green, reviewed, committed",
1197 "spinoff_proposals": [{"title": "do X later", "detail": "because Y"}],
1198 });
1199 assert!(validate_report_payload(&v).is_err());
1201 let out = sanitize_report_advisory(&v).unwrap();
1203 assert_eq!(out.warnings.len(), 1);
1204 assert_eq!(out.warnings[0].field, "spinoff_proposals");
1205 assert_eq!(out.warnings[0].index, Some(0));
1206 assert_eq!(out.report["spinoff_proposals"], json!([]));
1207 assert_eq!(out.report["success"], json!(true));
1209 assert_eq!(out.report["summary"], json!("green, reviewed, committed"));
1210 }
1211
1212 #[test]
1213 fn sanitize_keeps_valid_siblings_drops_only_bad_element() {
1214 let v = json!({
1215 "success": true,
1216 "spinoff_proposals": [
1217 {"proposed_title": "keep me", "proposed_kind": "spinoff"},
1218 {"title": "typo, drop me"},
1219 {"proposed_title": "keep me too", "proposed_kind": "research"},
1220 ],
1221 });
1222 let out = sanitize_report_advisory(&v).unwrap();
1223 assert_eq!(out.warnings.len(), 1);
1224 assert_eq!(out.warnings[0].index, Some(1));
1225 let kept = out.report["spinoff_proposals"].as_array().unwrap();
1226 assert_eq!(kept.len(), 2);
1227 assert_eq!(kept[0]["proposed_title"], json!("keep me"));
1228 assert_eq!(kept[1]["proposed_title"], json!("keep me too"));
1229 }
1230
1231 #[test]
1232 fn sanitize_drops_non_array_advisory_field_whole() {
1233 let v = json!({
1234 "success": true,
1235 "discussion_items": "not-an-array",
1236 "wrap_up_recommendations": {"oops": true},
1237 });
1238 let out = sanitize_report_advisory(&v).unwrap();
1239 assert_eq!(out.warnings.len(), 2);
1240 assert!(out.report.get("discussion_items").is_none());
1242 assert!(out.report.get("wrap_up_recommendations").is_none());
1243 let fields: Vec<&str> = out.warnings.iter().map(|w| w.field.as_str()).collect();
1244 assert!(fields.contains(&"discussion_items"));
1245 assert!(fields.contains(&"wrap_up_recommendations"));
1246 assert!(out.warnings.iter().all(|w| w.index.is_none()));
1248 }
1249
1250 #[test]
1251 fn sanitize_drops_non_string_wrap_up_element() {
1252 let v = json!({
1253 "success": true,
1254 "wrap_up_recommendations": ["rebase", 42, "squash"],
1255 });
1256 let out = sanitize_report_advisory(&v).unwrap();
1257 assert_eq!(out.warnings.len(), 1);
1258 assert_eq!(out.warnings[0].index, Some(1));
1259 assert_eq!(
1260 out.report["wrap_up_recommendations"],
1261 json!(["rebase", "squash"])
1262 );
1263 }
1264
1265 #[test]
1266 fn sanitize_drops_malformed_summary() {
1267 let v = json!({"success": true, "summary": 42});
1268 let out = sanitize_report_advisory(&v).unwrap();
1269 assert_eq!(out.warnings.len(), 1);
1270 assert_eq!(out.warnings[0].field, "summary");
1271 assert!(out.report.get("summary").is_none());
1272 }
1273
1274 #[test]
1275 fn sanitize_still_rejects_missing_required_success() {
1276 let v = json!({"summary": "no success field"});
1278 assert!(matches!(
1279 sanitize_report_advisory(&v),
1280 Err(ReportValidationError::MissingSuccess)
1281 ));
1282 }
1283
1284 #[test]
1285 fn sanitize_still_rejects_non_boolean_success() {
1286 let v = json!({"success": "yes", "spinoff_proposals": [{"title": "x"}]});
1287 assert!(matches!(
1288 sanitize_report_advisory(&v),
1289 Err(ReportValidationError::SuccessNotBoolean)
1290 ));
1291 }
1292
1293 #[test]
1294 fn sanitize_still_rejects_cancelled_contradiction() {
1295 let v = json!({"success": true, "cancelled": true, "reason": "x"});
1298 assert!(matches!(
1299 sanitize_report_advisory(&v),
1300 Err(ReportValidationError::CancelledRequiresSuccessFalse)
1301 ));
1302 }
1303
1304 #[test]
1305 fn sanitize_rejects_non_object_root() {
1306 let v = json!([1, 2, 3]);
1307 assert!(matches!(
1308 sanitize_report_advisory(&v),
1309 Err(ReportValidationError::NotObject)
1310 ));
1311 }
1312
1313 #[test]
1314 fn sanitize_preserves_unknown_and_provenance_fields() {
1315 let v = json!({
1320 "success": true,
1321 "origin": {"kind": "agent"},
1322 "via": "explicit-merge",
1323 "custom_agent_key": {"nested": [1, 2, 3]},
1324 "spinoff_proposals": [{"title": "typo, drop me"}],
1325 });
1326 let out = sanitize_report_advisory(&v).unwrap();
1327 assert_eq!(out.warnings.len(), 1, "only the bad proposal is dropped");
1328 assert_eq!(out.report["origin"], json!({"kind": "agent"}));
1329 assert_eq!(out.report["via"], json!("explicit-merge"));
1330 assert_eq!(out.report["custom_agent_key"], json!({"nested": [1, 2, 3]}));
1331 }
1332
1333 #[test]
1334 fn sanitize_nested_options_drops_whole_discussion_item() {
1335 let v = json!({
1338 "success": true,
1339 "discussion_items": [
1340 {"topic": "keep", "severity": "discuss"},
1341 {"topic": "drop me", "options": ["ok", 42]},
1342 ],
1343 });
1344 let out = sanitize_report_advisory(&v).unwrap();
1345 assert_eq!(out.warnings.len(), 1);
1346 assert_eq!(out.warnings[0].index, Some(1));
1347 let kept = out.report["discussion_items"].as_array().unwrap();
1348 assert_eq!(kept.len(), 1);
1349 assert_eq!(kept[0]["topic"], json!("keep"));
1350 }
1351
1352 #[test]
1353 fn advisory_warning_message_shapes() {
1354 let elem = AdvisoryWarning {
1355 field: "spinoff_proposals".to_string(),
1356 index: Some(2),
1357 reason: "boom".to_string(),
1358 };
1359 assert_eq!(elem.to_message(), "dropped spinoff_proposals[2]: boom");
1360 let whole = AdvisoryWarning {
1361 field: "discussion_items".to_string(),
1362 index: None,
1363 reason: "not an array".to_string(),
1364 };
1365 assert_eq!(
1366 whole.to_message(),
1367 "dropped advisory field `discussion_items`: not an array"
1368 );
1369 }
1370}