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 pub fn stamp(&self, report: &mut Value) {
168 if let Some(obj) = report.as_object_mut() {
169 if let Ok(v) = serde_json::to_value(self) {
172 obj.insert(REPORT_ORIGIN_KEY.to_string(), v);
173 }
174 }
175 }
176}
177
178#[derive(Debug, thiserror::Error)]
184pub enum ReportValidationError {
185 #[error("report payload must be a JSON object")]
187 NotObject,
188
189 #[error("report payload missing required field `success`")]
191 MissingSuccess,
192
193 #[error("field `success` must be a boolean")]
195 SuccessNotBoolean,
196
197 #[error("field `summary` must be a string")]
199 SummaryNotString,
200
201 #[error("field `cancelled` must be a boolean")]
203 CancelledNotBoolean,
204
205 #[error("field `reason` must be a string")]
207 ReasonNotString,
208
209 #[error("`cancelled: true` requires `success: false`")]
211 CancelledRequiresSuccessFalse,
212
213 #[error("`cancelled: true` requires a non-empty `reason` string")]
215 CancelledRequiresReason,
216
217 #[error("field `discussion_items` must be an array")]
219 DiscussionItemsNotArray,
220
221 #[error("discussion_items[{index}] must be a JSON object")]
223 DiscussionItemNotObject {
224 index: usize,
226 },
227
228 #[error("discussion_items[{index}].topic must be a non-empty string")]
230 DiscussionItemTopicMissing {
231 index: usize,
233 },
234
235 #[error("discussion_items[{index}].severity must be a string")]
237 DiscussionItemSeverityNotString {
238 index: usize,
240 },
241
242 #[error("field `spinoff_proposals` must be an array")]
244 SpinoffProposalsNotArray,
245
246 #[error("spinoff_proposals[{index}] must be a JSON object")]
248 SpinoffProposalNotObject {
249 index: usize,
251 },
252
253 #[error("spinoff_proposals[{index}].proposed_title must be a non-empty string")]
255 SpinoffProposalTitleMissing {
256 index: usize,
258 },
259
260 #[error("spinoff_proposals[{index}].proposed_kind must be a string")]
262 SpinoffProposalKindNotString {
263 index: usize,
265 },
266
267 #[error("spinoff_proposals[{index}].proposed_kind `{kind}` is not a known kind")]
269 SpinoffProposalKindUnknown {
270 index: usize,
272 kind: String,
274 },
275
276 #[error("spinoff_proposals[{index}].rationale must be a string")]
278 SpinoffProposalRationaleNotString {
279 index: usize,
281 },
282
283 #[error("field `{field}` must be an array")]
285 FieldNotArray {
286 field: String,
288 },
289
290 #[error("{field}[{index}] must be a string")]
292 FieldElementNotString {
293 field: String,
295 index: usize,
297 },
298
299 #[error("{path} must be an array")]
301 PathNotArray {
302 path: String,
304 },
305
306 #[error("{path}[{index}] must be a string")]
308 PathElementNotString {
309 path: String,
311 index: usize,
313 },
314}
315
316impl ReportValidationError {
317 #[must_use]
322 pub fn expected(&self) -> Option<Value> {
323 match self {
324 Self::MissingSuccess | Self::SuccessNotBoolean => {
325 Some(serde_json::json!({"field": "success", "type": "boolean"}))
326 }
327 Self::SpinoffProposalKindUnknown { .. } => Some(serde_json::json!(Kind::WIRE_NAMES)),
331 _ => None,
332 }
333 }
334}
335
336pub fn validate_report_payload(data: &Value) -> Result<(), ReportValidationError> {
348 let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
349
350 validate_required_fields(obj)?;
351
352 if let Some(v) = obj.get("summary") {
353 if !v.is_string() && !v.is_null() {
354 return Err(ReportValidationError::SummaryNotString);
355 }
356 }
357
358 validate_discussion_items(obj.get("discussion_items"))?;
359 validate_spinoff_proposals(obj.get("spinoff_proposals"))?;
360 validate_string_array(
361 obj.get("wrap_up_recommendations"),
362 "wrap_up_recommendations",
363 )?;
364 Ok(())
365}
366
367fn validate_required_fields(
372 obj: &serde_json::Map<String, Value>,
373) -> Result<(), ReportValidationError> {
374 let success = obj
378 .get("success")
379 .ok_or(ReportValidationError::MissingSuccess)?;
380 if !success.is_boolean() {
381 return Err(ReportValidationError::SuccessNotBoolean);
382 }
383
384 let cancelled = match obj.get("cancelled") {
385 None | Some(Value::Null) => false,
386 Some(v) => v
387 .as_bool()
388 .ok_or(ReportValidationError::CancelledNotBoolean)?,
389 };
390 let reason = match obj.get("reason") {
391 None | Some(Value::Null) => None,
392 Some(v) => Some(v.as_str().ok_or(ReportValidationError::ReasonNotString)?),
393 };
394
395 if cancelled {
401 if success
405 .as_bool()
406 .expect("success validated as boolean above")
407 {
408 return Err(ReportValidationError::CancelledRequiresSuccessFalse);
409 }
410 match reason {
411 Some(s) if !s.trim().is_empty() => {}
412 _ => return Err(ReportValidationError::CancelledRequiresReason),
413 }
414 }
415 Ok(())
416}
417
418fn validate_discussion_items(v: Option<&Value>) -> Result<(), ReportValidationError> {
419 let arr = match v {
420 Some(Value::Array(a)) => a,
421 Some(_) => return Err(ReportValidationError::DiscussionItemsNotArray),
422 None => return Ok(()),
423 };
424 for (i, item) in arr.iter().enumerate() {
425 validate_discussion_item(item, i)?;
426 }
427 Ok(())
428}
429
430fn validate_discussion_item(item: &Value, index: usize) -> Result<(), ReportValidationError> {
434 let obj = item
435 .as_object()
436 .ok_or(ReportValidationError::DiscussionItemNotObject { index })?;
437 let topic = obj.get("topic").and_then(Value::as_str);
438 if topic.is_none_or(|t| t.trim().is_empty()) {
439 return Err(ReportValidationError::DiscussionItemTopicMissing { index });
440 }
441 if let Some(sev) = obj.get("severity") {
442 if !sev.is_string() {
443 return Err(ReportValidationError::DiscussionItemSeverityNotString { index });
444 }
445 }
452 if let Some(opts) = obj.get("options") {
453 validate_string_array_at(opts, &format!("discussion_items[{index}].options"))?;
454 }
455 Ok(())
456}
457
458fn validate_spinoff_proposals(v: Option<&Value>) -> Result<(), ReportValidationError> {
459 let arr = match v {
460 Some(Value::Array(a)) => a,
461 Some(_) => return Err(ReportValidationError::SpinoffProposalsNotArray),
462 None => return Ok(()),
463 };
464 for (i, item) in arr.iter().enumerate() {
465 validate_spinoff_proposal(item, i)?;
466 }
467 Ok(())
468}
469
470fn validate_spinoff_proposal(item: &Value, index: usize) -> Result<(), ReportValidationError> {
476 let obj = item
477 .as_object()
478 .ok_or(ReportValidationError::SpinoffProposalNotObject { index })?;
479 let title = obj.get("proposed_title").and_then(Value::as_str);
480 if title.is_none_or(|t| t.trim().is_empty()) {
481 return Err(ReportValidationError::SpinoffProposalTitleMissing { index });
482 }
483 let kind_str = obj
484 .get("proposed_kind")
485 .and_then(Value::as_str)
486 .ok_or(ReportValidationError::SpinoffProposalKindNotString { index })?;
487 if !Kind::WIRE_NAMES.contains(&kind_str) {
495 return Err(ReportValidationError::SpinoffProposalKindUnknown {
496 index,
497 kind: kind_str.to_string(),
498 });
499 }
500 if let Some(rationale) = obj.get("rationale") {
501 if !rationale.is_string() && !rationale.is_null() {
502 return Err(ReportValidationError::SpinoffProposalRationaleNotString { index });
503 }
504 }
505 Ok(())
506}
507
508fn validate_string_array_at(v: &Value, path: &str) -> Result<(), ReportValidationError> {
511 let arr = v
512 .as_array()
513 .ok_or_else(|| ReportValidationError::PathNotArray {
514 path: path.to_string(),
515 })?;
516 for (i, item) in arr.iter().enumerate() {
517 if !item.is_string() {
518 return Err(ReportValidationError::PathElementNotString {
519 path: path.to_string(),
520 index: i,
521 });
522 }
523 }
524 Ok(())
525}
526
527fn validate_string_array(v: Option<&Value>, field: &str) -> Result<(), ReportValidationError> {
528 let arr = match v {
529 Some(Value::Array(a)) => a,
530 Some(_) => {
531 return Err(ReportValidationError::FieldNotArray {
532 field: field.to_string(),
533 })
534 }
535 None => return Ok(()),
536 };
537 for (i, item) in arr.iter().enumerate() {
538 if !item.is_string() {
539 return Err(ReportValidationError::FieldElementNotString {
540 field: field.to_string(),
541 index: i,
542 });
543 }
544 }
545 Ok(())
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
558pub struct AdvisoryWarning {
559 pub field: String,
561 #[serde(skip_serializing_if = "Option::is_none")]
565 pub index: Option<usize>,
566 pub reason: String,
568}
569
570impl AdvisoryWarning {
571 #[must_use]
574 pub fn to_message(&self) -> String {
575 match self.index {
576 Some(i) => format!("dropped {}[{i}]: {}", self.field, self.reason),
577 None => format!("dropped advisory field `{}`: {}", self.field, self.reason),
578 }
579 }
580}
581
582#[derive(Debug, Clone)]
586pub struct SanitizedReport {
587 pub report: Value,
590 pub warnings: Vec<AdvisoryWarning>,
593}
594
595pub fn sanitize_report_advisory(data: &Value) -> Result<SanitizedReport, ReportValidationError> {
644 let obj = data.as_object().ok_or(ReportValidationError::NotObject)?;
645
646 validate_required_fields(obj)?;
648
649 let mut out = obj.clone();
650 let mut warnings = Vec::new();
651
652 if let Some(v) = obj.get("summary") {
654 if !v.is_string() && !v.is_null() {
655 out.remove("summary");
656 warnings.push(AdvisoryWarning {
657 field: "summary".to_string(),
658 index: None,
659 reason: ReportValidationError::SummaryNotString.to_string(),
660 });
661 }
662 }
663
664 sanitize_element_array(
665 &mut out,
666 "discussion_items",
667 ReportValidationError::DiscussionItemsNotArray,
668 validate_discussion_item,
669 &mut warnings,
670 );
671 sanitize_element_array(
672 &mut out,
673 "spinoff_proposals",
674 ReportValidationError::SpinoffProposalsNotArray,
675 validate_spinoff_proposal,
676 &mut warnings,
677 );
678 sanitize_string_array_field(&mut out, "wrap_up_recommendations", &mut warnings);
679
680 Ok(SanitizedReport {
681 report: Value::Object(out),
682 warnings,
683 })
684}
685
686fn sanitize_element_array(
692 obj: &mut serde_json::Map<String, Value>,
693 field: &str,
694 not_array_err: ReportValidationError,
695 validate: fn(&Value, usize) -> Result<(), ReportValidationError>,
696 warnings: &mut Vec<AdvisoryWarning>,
697) {
698 let Some(v) = obj.get(field) else { return };
699 let Some(arr) = v.as_array() else {
700 obj.remove(field);
701 warnings.push(AdvisoryWarning {
702 field: field.to_string(),
703 index: None,
704 reason: not_array_err.to_string(),
705 });
706 return;
707 };
708 let mut kept = Vec::with_capacity(arr.len());
709 for (i, item) in arr.iter().enumerate() {
710 match validate(item, i) {
711 Ok(()) => kept.push(item.clone()),
712 Err(e) => warnings.push(AdvisoryWarning {
713 field: field.to_string(),
714 index: Some(i),
715 reason: e.to_string(),
716 }),
717 }
718 }
719 obj.insert(field.to_string(), Value::Array(kept));
720}
721
722fn sanitize_string_array_field(
726 obj: &mut serde_json::Map<String, Value>,
727 field: &str,
728 warnings: &mut Vec<AdvisoryWarning>,
729) {
730 let Some(v) = obj.get(field) else { return };
731 let Some(arr) = v.as_array() else {
732 obj.remove(field);
733 warnings.push(AdvisoryWarning {
734 field: field.to_string(),
735 index: None,
736 reason: ReportValidationError::FieldNotArray {
737 field: field.to_string(),
738 }
739 .to_string(),
740 });
741 return;
742 };
743 let mut kept = Vec::with_capacity(arr.len());
744 for (i, item) in arr.iter().enumerate() {
745 if item.is_string() {
746 kept.push(item.clone());
747 } else {
748 warnings.push(AdvisoryWarning {
749 field: field.to_string(),
750 index: Some(i),
751 reason: ReportValidationError::FieldElementNotString {
752 field: field.to_string(),
753 index: i,
754 }
755 .to_string(),
756 });
757 }
758 }
759 obj.insert(field.to_string(), Value::Array(kept));
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use serde_json::json;
766
767 #[test]
770 fn validates_minimal_success_payload() {
771 let v = json!({"success": true});
772 assert!(validate_report_payload(&v).is_ok());
773 }
774
775 #[test]
776 fn validates_full_success_payload() {
777 let v = json!({
778 "success": true,
779 "summary": "did the thing",
780 "discussion_items": [
781 {"topic": "naming", "severity": "discuss", "options": ["a", "b"]},
782 ],
783 "spinoff_proposals": [
784 {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
785 ],
786 "wrap_up_recommendations": ["rebase", "squash"],
787 });
788 assert!(validate_report_payload(&v).is_ok());
789 }
790
791 #[test]
792 fn discussion_item_unknown_severity_accepted_for_forward_compat() {
793 let v = json!({
796 "success": true,
797 "discussion_items": [{"topic": "x", "severity": "info"}],
798 });
799 assert!(validate_report_payload(&v).is_ok());
800 }
801
802 #[test]
803 fn cancel_synthesized_report_shape_ok() {
804 let v = json!({
806 "success": false,
807 "cancelled": true,
808 "reason": "cancelled by user",
809 "summary": "Run cancelled before agent reported.",
810 "discussion_items": [],
811 "spinoff_proposals": [],
812 "wrap_up_recommendations": [],
813 });
814 assert!(validate_report_payload(&v).is_ok());
815 }
816
817 #[test]
820 fn non_object_root_rejected() {
821 let v = json!([1, 2, 3]);
822 assert!(matches!(
823 validate_report_payload(&v),
824 Err(ReportValidationError::NotObject)
825 ));
826 }
827
828 #[test]
829 fn missing_success_rejected() {
830 let v = json!({"summary": "no success field"});
831 let err = validate_report_payload(&v).unwrap_err();
832 assert!(matches!(err, ReportValidationError::MissingSuccess));
833 assert_eq!(
835 err.expected(),
836 Some(json!({"field": "success", "type": "boolean"}))
837 );
838 }
839
840 #[test]
841 fn success_variants_carry_field_type_hint() {
842 let hint = Some(json!({"field": "success", "type": "boolean"}));
844 assert_eq!(ReportValidationError::MissingSuccess.expected(), hint);
845 assert_eq!(ReportValidationError::SuccessNotBoolean.expected(), hint);
846 }
847
848 #[test]
849 fn summary_must_be_string() {
850 let v = json!({"success": true, "summary": 42});
851 assert!(matches!(
852 validate_report_payload(&v),
853 Err(ReportValidationError::SummaryNotString)
854 ));
855 }
856
857 #[test]
858 fn discussion_item_options_non_array_rejected() {
859 let v = json!({
860 "success": true,
861 "discussion_items": [{"topic": "x", "options": "not-an-array"}],
862 });
863 assert!(matches!(
864 validate_report_payload(&v),
865 Err(ReportValidationError::PathNotArray { .. })
866 ));
867 }
868
869 #[test]
870 fn cancelled_requires_non_whitespace_reason() {
871 let v = json!({"success": false, "cancelled": true, "reason": " "});
872 assert!(matches!(
873 validate_report_payload(&v),
874 Err(ReportValidationError::CancelledRequiresReason)
875 ));
876 }
877
878 #[test]
879 fn non_boolean_success_rejected() {
880 let v = json!({"success": "yes"});
881 assert!(matches!(
882 validate_report_payload(&v),
883 Err(ReportValidationError::SuccessNotBoolean)
884 ));
885 }
886
887 #[test]
888 fn discussion_item_missing_topic_rejected() {
889 let v = json!({
890 "success": true,
891 "discussion_items": [{"severity": "discuss"}],
892 });
893 assert!(matches!(
894 validate_report_payload(&v),
895 Err(ReportValidationError::DiscussionItemTopicMissing { index: 0 })
896 ));
897 }
898
899 #[test]
900 fn discussion_item_non_string_severity_rejected() {
901 let v = json!({
902 "success": true,
903 "discussion_items": [{"topic": "x", "severity": 42}],
904 });
905 assert!(matches!(
906 validate_report_payload(&v),
907 Err(ReportValidationError::DiscussionItemSeverityNotString { index: 0 })
908 ));
909 }
910
911 #[test]
912 fn discussion_item_options_must_be_strings() {
913 let v = json!({
914 "success": true,
915 "discussion_items": [{"topic": "x", "options": [1, 2]}],
916 });
917 assert!(matches!(
918 validate_report_payload(&v),
919 Err(ReportValidationError::PathElementNotString { index: 0, .. })
920 ));
921 }
922
923 #[test]
924 fn spinoff_unknown_proposed_kind_rejected() {
925 let v = json!({
926 "success": true,
927 "spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
928 });
929 let err = validate_report_payload(&v).unwrap_err();
930 assert!(matches!(
931 err,
932 ReportValidationError::SpinoffProposalKindUnknown { index: 0, .. }
933 ));
934 assert_eq!(err.expected(), Some(json!(crate::schema::Kind::WIRE_NAMES)));
937 assert_eq!(
938 err.expected(),
939 Some(json!([
940 "spinoff",
941 "research",
942 "technical-decision",
943 "fan-out"
944 ]))
945 );
946 }
947
948 #[test]
949 fn spinoff_missing_kind_rejected() {
950 let v = json!({
951 "success": true,
952 "spinoff_proposals": [{"proposed_title": "x"}],
953 });
954 assert!(matches!(
955 validate_report_payload(&v),
956 Err(ReportValidationError::SpinoffProposalKindNotString { index: 0 })
957 ));
958 }
959
960 #[test]
961 fn cancelled_requires_success_false() {
962 let v = json!({"success": true, "cancelled": true, "reason": "x"});
963 assert!(matches!(
964 validate_report_payload(&v),
965 Err(ReportValidationError::CancelledRequiresSuccessFalse)
966 ));
967 }
968
969 #[test]
970 fn cancelled_requires_reason() {
971 let v = json!({"success": false, "cancelled": true});
972 assert!(matches!(
973 validate_report_payload(&v),
974 Err(ReportValidationError::CancelledRequiresReason)
975 ));
976 }
977
978 #[test]
981 fn report_origin_round_trips_through_a_report() {
982 let cases = [
983 ReportOrigin::Agent,
984 ReportOrigin::Supervisor,
985 ReportOrigin::RunMerge {
986 op_id: Some("op-123".into()),
987 worker_oid: Some("deadbeef".into()),
988 },
989 ReportOrigin::RunMerge {
990 op_id: None,
991 worker_oid: None,
992 },
993 ];
994 for origin in cases {
995 let mut report = json!({ "success": true });
996 origin.stamp(&mut report);
997 assert_eq!(
998 ReportOrigin::from_report(&report),
999 Some(origin.clone()),
1000 "round-trip: {origin:?}"
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn report_origin_serializes_with_kind_tag() {
1007 let mut report = json!({ "success": true });
1008 ReportOrigin::Agent.stamp(&mut report);
1009 assert_eq!(report["origin"], json!({ "kind": "agent" }));
1010
1011 let mut merge = json!({ "success": true });
1012 ReportOrigin::RunMerge {
1013 op_id: Some("op-9".into()),
1014 worker_oid: Some("abc123".into()),
1015 }
1016 .stamp(&mut merge);
1017 assert_eq!(
1018 merge["origin"],
1019 json!({ "kind": "run-merge", "op_id": "op-9", "worker_oid": "abc123" })
1020 );
1021
1022 let mut bare = json!({ "success": true });
1024 ReportOrigin::RunMerge {
1025 op_id: None,
1026 worker_oid: None,
1027 }
1028 .stamp(&mut bare);
1029 assert_eq!(bare["origin"], json!({ "kind": "run-merge" }));
1030 }
1031
1032 #[test]
1033 fn report_origin_absent_or_malformed_is_none() {
1034 assert_eq!(ReportOrigin::from_report(&json!({ "success": true })), None);
1036 assert_eq!(
1039 ReportOrigin::from_report(&json!({ "origin": "not-an-object" })),
1040 None
1041 );
1042 assert_eq!(
1043 ReportOrigin::from_report(&json!({ "origin": { "kind": "bogus" } })),
1044 None
1045 );
1046 }
1047
1048 #[test]
1049 fn report_origin_stamp_overwrites_a_supplied_value() {
1050 let mut report = json!({
1053 "success": true,
1054 "origin": { "kind": "run-merge", "op_id": "spoofed" }
1055 });
1056 ReportOrigin::Agent.stamp(&mut report);
1057 assert_eq!(
1058 ReportOrigin::from_report(&report),
1059 Some(ReportOrigin::Agent)
1060 );
1061 }
1062
1063 #[test]
1064 fn report_is_confirmed_merge_prefers_typed_origin() {
1065 let mut merged = json!({ "success": true });
1067 ReportOrigin::RunMerge {
1068 op_id: Some("op-1".into()),
1069 worker_oid: Some("abc".into()),
1070 }
1071 .stamp(&mut merged);
1072 assert!(ReportOrigin::report_is_confirmed_merge(&merged));
1073
1074 let mut bare = json!({ "success": true });
1076 ReportOrigin::RunMerge {
1077 op_id: None,
1078 worker_oid: None,
1079 }
1080 .stamp(&mut bare);
1081 assert!(ReportOrigin::report_is_confirmed_merge(&bare));
1082 }
1083
1084 #[test]
1085 fn report_is_confirmed_merge_legacy_via_only_when_origin_absent() {
1086 assert!(ReportOrigin::report_is_confirmed_merge(&json!({
1088 "success": true, "via": "explicit-merge"
1089 })));
1090
1091 let mut agent = json!({ "success": true, "via": "explicit-merge" });
1094 ReportOrigin::Agent.stamp(&mut agent);
1095 assert!(
1096 !ReportOrigin::report_is_confirmed_merge(&agent),
1097 "an Agent-origin report must not be a merge even with a forged via"
1098 );
1099
1100 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1103 "success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
1104 })));
1105 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1106 "success": true, "via": "explicit-merge", "origin": { "kind": "bogus" }
1107 })));
1108 }
1109
1110 #[test]
1111 fn report_is_confirmed_merge_requires_success_and_not_cancelled() {
1112 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1114 "success": false, "via": "explicit-merge"
1115 })));
1116 let mut neg = json!({ "success": false });
1118 ReportOrigin::RunMerge {
1119 op_id: None,
1120 worker_oid: None,
1121 }
1122 .stamp(&mut neg);
1123 assert!(!ReportOrigin::report_is_confirmed_merge(&neg));
1124 let mut cancelled = json!({ "success": false, "cancelled": true, "reason": "x" });
1126 ReportOrigin::RunMerge {
1127 op_id: None,
1128 worker_oid: None,
1129 }
1130 .stamp(&mut cancelled);
1131 assert!(!ReportOrigin::report_is_confirmed_merge(&cancelled));
1132 assert!(!ReportOrigin::report_is_confirmed_merge(&json!({
1134 "success": "true", "via": "explicit-merge"
1135 })));
1136 }
1137
1138 #[test]
1139 fn report_origin_stamp_on_non_object_is_noop() {
1140 let mut not_obj = json!([1, 2, 3]);
1141 ReportOrigin::Agent.stamp(&mut not_obj);
1142 assert_eq!(not_obj, json!([1, 2, 3]));
1143 }
1144
1145 #[test]
1146 fn wrap_up_must_be_string_array() {
1147 let v = json!({
1148 "success": true,
1149 "wrap_up_recommendations": ["ok", 42],
1150 });
1151 assert!(matches!(
1152 validate_report_payload(&v),
1153 Err(ReportValidationError::FieldElementNotString { index: 1, .. })
1154 ));
1155 }
1156
1157 #[test]
1160 fn sanitize_clean_report_has_no_warnings() {
1161 let v = json!({
1162 "success": true,
1163 "summary": "did the thing",
1164 "discussion_items": [{"topic": "naming", "severity": "discuss"}],
1165 "spinoff_proposals": [
1166 {"proposed_title": "follow-up", "proposed_kind": "spinoff", "rationale": "later"},
1167 ],
1168 "wrap_up_recommendations": ["rebase"],
1169 });
1170 let out = sanitize_report_advisory(&v).unwrap();
1171 assert!(out.warnings.is_empty());
1172 assert_eq!(out.report, v);
1173 }
1174
1175 #[test]
1176 fn sanitize_drops_typoed_spinoff_proposal_with_warning() {
1177 let v = json!({
1182 "success": true,
1183 "summary": "green, reviewed, committed",
1184 "spinoff_proposals": [{"title": "do X later", "detail": "because Y"}],
1185 });
1186 assert!(validate_report_payload(&v).is_err());
1188 let out = sanitize_report_advisory(&v).unwrap();
1190 assert_eq!(out.warnings.len(), 1);
1191 assert_eq!(out.warnings[0].field, "spinoff_proposals");
1192 assert_eq!(out.warnings[0].index, Some(0));
1193 assert_eq!(out.report["spinoff_proposals"], json!([]));
1194 assert_eq!(out.report["success"], json!(true));
1196 assert_eq!(out.report["summary"], json!("green, reviewed, committed"));
1197 }
1198
1199 #[test]
1200 fn sanitize_keeps_valid_siblings_drops_only_bad_element() {
1201 let v = json!({
1202 "success": true,
1203 "spinoff_proposals": [
1204 {"proposed_title": "keep me", "proposed_kind": "spinoff"},
1205 {"title": "typo, drop me"},
1206 {"proposed_title": "keep me too", "proposed_kind": "research"},
1207 ],
1208 });
1209 let out = sanitize_report_advisory(&v).unwrap();
1210 assert_eq!(out.warnings.len(), 1);
1211 assert_eq!(out.warnings[0].index, Some(1));
1212 let kept = out.report["spinoff_proposals"].as_array().unwrap();
1213 assert_eq!(kept.len(), 2);
1214 assert_eq!(kept[0]["proposed_title"], json!("keep me"));
1215 assert_eq!(kept[1]["proposed_title"], json!("keep me too"));
1216 }
1217
1218 #[test]
1219 fn sanitize_drops_non_array_advisory_field_whole() {
1220 let v = json!({
1221 "success": true,
1222 "discussion_items": "not-an-array",
1223 "wrap_up_recommendations": {"oops": true},
1224 });
1225 let out = sanitize_report_advisory(&v).unwrap();
1226 assert_eq!(out.warnings.len(), 2);
1227 assert!(out.report.get("discussion_items").is_none());
1229 assert!(out.report.get("wrap_up_recommendations").is_none());
1230 let fields: Vec<&str> = out.warnings.iter().map(|w| w.field.as_str()).collect();
1231 assert!(fields.contains(&"discussion_items"));
1232 assert!(fields.contains(&"wrap_up_recommendations"));
1233 assert!(out.warnings.iter().all(|w| w.index.is_none()));
1235 }
1236
1237 #[test]
1238 fn sanitize_drops_non_string_wrap_up_element() {
1239 let v = json!({
1240 "success": true,
1241 "wrap_up_recommendations": ["rebase", 42, "squash"],
1242 });
1243 let out = sanitize_report_advisory(&v).unwrap();
1244 assert_eq!(out.warnings.len(), 1);
1245 assert_eq!(out.warnings[0].index, Some(1));
1246 assert_eq!(
1247 out.report["wrap_up_recommendations"],
1248 json!(["rebase", "squash"])
1249 );
1250 }
1251
1252 #[test]
1253 fn sanitize_drops_malformed_summary() {
1254 let v = json!({"success": true, "summary": 42});
1255 let out = sanitize_report_advisory(&v).unwrap();
1256 assert_eq!(out.warnings.len(), 1);
1257 assert_eq!(out.warnings[0].field, "summary");
1258 assert!(out.report.get("summary").is_none());
1259 }
1260
1261 #[test]
1262 fn sanitize_still_rejects_missing_required_success() {
1263 let v = json!({"summary": "no success field"});
1265 assert!(matches!(
1266 sanitize_report_advisory(&v),
1267 Err(ReportValidationError::MissingSuccess)
1268 ));
1269 }
1270
1271 #[test]
1272 fn sanitize_still_rejects_non_boolean_success() {
1273 let v = json!({"success": "yes", "spinoff_proposals": [{"title": "x"}]});
1274 assert!(matches!(
1275 sanitize_report_advisory(&v),
1276 Err(ReportValidationError::SuccessNotBoolean)
1277 ));
1278 }
1279
1280 #[test]
1281 fn sanitize_still_rejects_cancelled_contradiction() {
1282 let v = json!({"success": true, "cancelled": true, "reason": "x"});
1285 assert!(matches!(
1286 sanitize_report_advisory(&v),
1287 Err(ReportValidationError::CancelledRequiresSuccessFalse)
1288 ));
1289 }
1290
1291 #[test]
1292 fn sanitize_rejects_non_object_root() {
1293 let v = json!([1, 2, 3]);
1294 assert!(matches!(
1295 sanitize_report_advisory(&v),
1296 Err(ReportValidationError::NotObject)
1297 ));
1298 }
1299
1300 #[test]
1301 fn sanitize_preserves_unknown_and_provenance_fields() {
1302 let v = json!({
1307 "success": true,
1308 "origin": {"kind": "agent"},
1309 "via": "explicit-merge",
1310 "custom_agent_key": {"nested": [1, 2, 3]},
1311 "spinoff_proposals": [{"title": "typo, drop me"}],
1312 });
1313 let out = sanitize_report_advisory(&v).unwrap();
1314 assert_eq!(out.warnings.len(), 1, "only the bad proposal is dropped");
1315 assert_eq!(out.report["origin"], json!({"kind": "agent"}));
1316 assert_eq!(out.report["via"], json!("explicit-merge"));
1317 assert_eq!(out.report["custom_agent_key"], json!({"nested": [1, 2, 3]}));
1318 }
1319
1320 #[test]
1321 fn sanitize_nested_options_drops_whole_discussion_item() {
1322 let v = json!({
1325 "success": true,
1326 "discussion_items": [
1327 {"topic": "keep", "severity": "discuss"},
1328 {"topic": "drop me", "options": ["ok", 42]},
1329 ],
1330 });
1331 let out = sanitize_report_advisory(&v).unwrap();
1332 assert_eq!(out.warnings.len(), 1);
1333 assert_eq!(out.warnings[0].index, Some(1));
1334 let kept = out.report["discussion_items"].as_array().unwrap();
1335 assert_eq!(kept.len(), 1);
1336 assert_eq!(kept[0]["topic"], json!("keep"));
1337 }
1338
1339 #[test]
1340 fn advisory_warning_message_shapes() {
1341 let elem = AdvisoryWarning {
1342 field: "spinoff_proposals".to_string(),
1343 index: Some(2),
1344 reason: "boom".to_string(),
1345 };
1346 assert_eq!(elem.to_message(), "dropped spinoff_proposals[2]: boom");
1347 let whole = AdvisoryWarning {
1348 field: "discussion_items".to_string(),
1349 index: None,
1350 reason: "not an array".to_string(),
1351 };
1352 assert_eq!(
1353 whole.to_message(),
1354 "dropped advisory field `discussion_items`: not an array"
1355 );
1356 }
1357}