use bobbin_types::edges::{ExtractError, Record}; use jacquard_common::BosStr; use jacquard_common::types::nsid::Nsid; pub fn decode_record>( nsid: &Nsid, bytes: &[u8], ) -> Result { Record::from_json_bytes(nsid, bytes).or_else(|canon_err| { normalize_record_fields(bytes) .and_then(|normalized| Record::from_json_bytes(nsid, &normalized).ok()) .ok_or(canon_err) }) } pub fn normalize_record_fields(bytes: &[u8]) -> Option> { let value: serde_json::Value = serde_json::from_slice(bytes).ok()?; let reserialized = serde_json::to_vec(&value).ok()?; (reserialized.as_slice() != bytes).then_some(reserialized) } pub fn synthesize_created_at(bytes: &[u8], fallback_rfc3339: &str) -> Option> { let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?; let obj = value.as_object_mut()?; let needs_fill = match obj.get("createdAt") { None => true, Some(serde_json::Value::String(s)) if s.is_empty() => true, _ => false, }; if !needs_fill { return None; } obj.insert( "createdAt".to_owned(), serde_json::Value::String(fallback_rfc3339.to_owned()), ); serde_json::to_vec(&value).ok() } extern crate alloc; #[cfg(test)] mod tests { use super::*; use jacquard_common::DefaultStr; fn nsid(s: &'static str) -> Nsid { Nsid::new_static(s).unwrap() } #[test] fn decode_passes_through_for_unaffected_nsids() { let json = br#"{"$type":"org.tangled.graph.follow","createdAt":"2026-05-01T00:00:00Z"}"#; let decoded = decode_record(&nsid("org.tangled.graph.follow"), json) .expect("follow must decode canon"); assert!(matches!(decoded, Record::Follow(_))); } #[test] fn normalize_returns_none_for_invalid_json() { assert!(normalize_record_fields(b"{not json").is_none()); } #[test] fn normalize_drops_repeated_dollar_type_key() { let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","$type":"sh.tangled.repo.pull"}"#; let normalized = normalize_record_fields(json).expect("duplicate key must produce normalized output"); let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json"); let obj = value.as_object().expect("object"); assert_eq!(obj.len(), 2); assert_eq!( obj.get("$type"), Some(&serde_json::json!("sh.tangled.repo.pull")) ); assert_eq!(obj.get("title"), Some(&serde_json::json!("t"))); } #[test] fn normalize_keeps_last_value_for_repeated_keys() { let json = br#"{"$type":"sh.tangled.repo.issue","$type":"sh.tangled.repo.pull"}"#; let normalized = normalize_record_fields(json).expect("duplicate key must produce normalized output"); let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json"); assert_eq!( value.get("$type"), Some(&serde_json::json!("sh.tangled.repo.pull")), ); } #[test] fn synthesize_fills_empty_created_at_with_fallback() { let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow","createdAt":""}"#; let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z") .expect("empty createdAt must be filled"); let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json"); assert_eq!( value.get("createdAt"), Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")), ); } #[test] fn synthesize_fills_missing_created_at_with_fallback() { let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow"}"#; let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z") .expect("missing createdAt must be filled"); let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json"); assert_eq!( value.get("createdAt"), Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")), ); } #[test] fn synthesize_returns_none_when_created_at_already_set() { let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":"2026-05-01T00:00:00Z"}"#; assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none()); } #[test] fn synthesize_returns_none_for_non_string_created_at() { let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":null}"#; assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none()); } #[test] fn try_decode_recovers_canon_ticket_with_duplicate_dollar_type() { let json = br#"{"$type":"org.tangled.track.ticket","createdAt":"2026-05-01T00:00:00Z","title":{"$type":"org.tangled.markup.markdown#inline","text":"meow"},"state":"open","$type":"org.tangled.track.ticket"}"#; let decoded = decode_record(&nsid("org.tangled.track.ticket"), json) .expect("duplicate $type ticket must normalize-recover"); match decoded { Record::Ticket(t) => assert_eq!(t.title.text.as_str(), "meow"), other => panic!("expected ticket, got {other:?}"), } } }