1use serde::Serialize;
2
3use crate::domain::{Jurisdiction, Money};
4use crate::entity::{Entity, FieldValue};
5use crate::nulid_gen;
6use crate::parser::ParseError;
7use crate::relationship::Rel;
8use crate::writeback::{PendingId, WriteBackKind};
9
10#[derive(Debug, Serialize)]
12pub struct CaseOutput {
13 pub id: String,
15 pub case_id: String,
16 pub title: String,
17 #[serde(skip_serializing_if = "String::is_empty")]
18 pub summary: String,
19 #[serde(skip_serializing_if = "Vec::is_empty")]
20 pub tags: Vec<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub slug: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub case_type: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub status: Option<String>,
28 pub nodes: Vec<NodeOutput>,
29 pub relationships: Vec<RelOutput>,
30 pub sources: Vec<crate::parser::SourceEntry>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct NodeOutput {
36 pub id: String,
37 pub label: String,
38 pub name: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub slug: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub qualifier: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub description: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub thumbnail: Option<String>,
47 #[serde(skip_serializing_if = "Vec::is_empty")]
48 pub aliases: Vec<String>,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 pub urls: Vec<String>,
51 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub role: Vec<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub nationality: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub date_of_birth: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub place_of_birth: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub status: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub org_type: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub jurisdiction: Option<Jurisdiction>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub headquarters: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub founded_date: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub registration_number: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub event_type: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub occurred_at: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub severity: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub doc_type: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub issued_at: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub issuing_authority: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub case_number: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub case_type: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub asset_type: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub value: Option<Money>,
97 #[serde(skip_serializing_if = "Vec::is_empty")]
99 pub tags: Vec<String>,
100}
101
102#[derive(Debug, Serialize)]
104pub struct RelOutput {
105 pub id: String,
106 #[serde(rename = "type")]
107 pub rel_type: String,
108 pub source_id: String,
109 pub target_id: String,
110 #[serde(skip_serializing_if = "Vec::is_empty")]
111 pub source_urls: Vec<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub description: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub amount: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub currency: Option<String>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub valid_from: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub valid_until: Option<String>,
122}
123
124pub struct BuildResult {
126 pub output: CaseOutput,
127 pub case_pending: Vec<PendingId>,
129 pub registry_pending: Vec<(String, PendingId)>,
132}
133
134#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
143pub fn build_output(
144 case_id: &str,
145 case_nulid: &str,
146 title: &str,
147 summary: &str,
148 case_tags: &[String],
149 case_slug: Option<&str>,
150 case_type: Option<&str>,
151 case_status: Option<&str>,
152 sources: &[crate::parser::SourceEntry],
153 entities: &[Entity],
154 rels: &[Rel],
155 registry_entities: &[Entity],
156) -> Result<BuildResult, Vec<ParseError>> {
157 let mut errors = Vec::new();
158 let mut case_pending = Vec::new();
159 let mut registry_pending = Vec::new();
160
161 let mut entity_ids: Vec<(String, String)> = Vec::new();
163 let mut nodes = Vec::new();
164
165 for e in entities {
167 match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
168 Ok((id, generated)) => {
169 let id_str = id.to_string();
170 if generated {
171 case_pending.push(PendingId {
172 line: e.line,
173 id: id_str.clone(),
174 kind: WriteBackKind::InlineEvent,
175 });
176 }
177 entity_ids.push((e.name.clone(), id_str.clone()));
178 nodes.push(entity_to_node(&id_str, e));
179 }
180 Err(err) => errors.push(err),
181 }
182 }
183
184 for e in registry_entities {
186 match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
187 Ok((id, generated)) => {
188 let id_str = id.to_string();
189 if generated {
190 registry_pending.push((
191 e.name.clone(),
192 PendingId {
193 line: e.line,
194 id: id_str.clone(),
195 kind: WriteBackKind::EntityFrontMatter,
196 },
197 ));
198 }
199 entity_ids.push((e.name.clone(), id_str.clone()));
200 nodes.push(entity_to_node(&id_str, e));
201 }
202 Err(err) => errors.push(err),
203 }
204 }
205
206 let mut relationships = Vec::new();
207 for r in rels {
208 let source_id = entity_ids
209 .iter()
210 .find(|(name, _)| name == &r.source_name)
211 .map(|(_, id)| id.clone())
212 .unwrap_or_default();
213 let target_id = entity_ids
214 .iter()
215 .find(|(name, _)| name == &r.target_name)
216 .map(|(_, id)| id.clone())
217 .unwrap_or_default();
218
219 match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
220 Ok((id, generated)) => {
221 let id_str = id.to_string();
222 if generated && r.rel_type != "preceded_by" {
225 case_pending.push(PendingId {
226 line: r.line,
227 id: id_str.clone(),
228 kind: WriteBackKind::Relationship,
229 });
230 }
231 relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
232 }
233 Err(err) => errors.push(err),
234 }
235 }
236
237 if !errors.is_empty() {
238 return Err(errors);
239 }
240
241 let case_node = NodeOutput {
243 id: case_nulid.to_string(),
244 label: "case".to_string(),
245 name: title.to_string(),
246 slug: case_slug.map(String::from),
247 qualifier: None,
248 description: if summary.is_empty() {
249 None
250 } else {
251 Some(summary.to_string())
252 },
253 thumbnail: None,
254 aliases: Vec::new(),
255 urls: Vec::new(),
256 role: Vec::new(),
257 nationality: None,
258 date_of_birth: None,
259 place_of_birth: None,
260 status: case_status.map(String::from),
261 org_type: None,
262 jurisdiction: None,
263 headquarters: None,
264 founded_date: None,
265 registration_number: None,
266 event_type: None,
267 occurred_at: None,
268 severity: None,
269 doc_type: None,
270 issued_at: None,
271 issuing_authority: None,
272 case_number: None,
273 case_type: case_type.map(String::from),
274 asset_type: None,
275 value: None,
276 tags: case_tags.to_vec(),
277 };
278 nodes.push(case_node);
279
280 for (_, entity_id) in &entity_ids {
282 let involved_id = nulid_gen::generate().map_err(|e| {
283 vec![ParseError {
284 line: 0,
285 message: e,
286 }]
287 })?;
288 relationships.push(RelOutput {
289 id: involved_id,
290 rel_type: "involved_in".to_string(),
291 source_id: entity_id.clone(),
292 target_id: case_nulid.to_string(),
293 source_urls: Vec::new(),
294 description: None,
295 amount: None,
296 currency: None,
297 valid_from: None,
298 valid_until: None,
299 });
300 }
301
302 Ok(BuildResult {
303 output: CaseOutput {
304 id: case_nulid.to_string(),
305 case_id: case_id.to_string(),
306 title: title.to_string(),
307 summary: summary.to_string(),
308 tags: case_tags.to_vec(),
309 slug: case_slug.map(String::from),
310 case_type: case_type.map(String::from),
311 status: case_status.map(String::from),
312 nodes,
313 relationships,
314 sources: sources.to_vec(),
315 },
316 case_pending,
317 registry_pending,
318 })
319}
320
321fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
322 let label = entity.label.to_string();
323
324 let mut node = NodeOutput {
325 id: id.to_string(),
326 label,
327 name: entity.name.clone(),
328 slug: entity.slug.clone(),
329 qualifier: None,
330 description: None,
331 thumbnail: None,
332 aliases: Vec::new(),
333 urls: Vec::new(),
334 role: Vec::new(),
335 nationality: None,
336 date_of_birth: None,
337 place_of_birth: None,
338 status: None,
339 org_type: None,
340 jurisdiction: None,
341 headquarters: None,
342 founded_date: None,
343 registration_number: None,
344 event_type: None,
345 occurred_at: None,
346 severity: None,
347 doc_type: None,
348 issued_at: None,
349 issuing_authority: None,
350 case_number: None,
351 case_type: None,
352 asset_type: None,
353 value: None,
354 tags: entity.tags.clone(),
355 };
356
357 for (key, value) in &entity.fields {
358 match key.as_str() {
359 "qualifier" => node.qualifier = single(value),
360 "description" => node.description = single(value),
361 "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
362 "aliases" => node.aliases = list(value),
363 "urls" => node.urls = list(value),
364 "role" => node.role = list(value),
365 "nationality" => node.nationality = single(value),
366 "date_of_birth" => node.date_of_birth = single(value),
367 "place_of_birth" => node.place_of_birth = single(value),
368 "status" => node.status = single(value),
369 "org_type" => node.org_type = single(value),
370 "jurisdiction" => {
371 node.jurisdiction = single(value).and_then(|s| parse_jurisdiction(&s));
372 }
373 "headquarters" => node.headquarters = single(value),
374 "founded_date" => node.founded_date = single(value),
375 "registration_number" => node.registration_number = single(value),
376 "event_type" => node.event_type = single(value),
377 "occurred_at" => node.occurred_at = single(value),
378 "severity" => node.severity = single(value),
379 "doc_type" => node.doc_type = single(value),
380 "issued_at" => node.issued_at = single(value),
381 "issuing_authority" => node.issuing_authority = single(value),
382 "case_number" => node.case_number = single(value),
383 "asset_type" => node.asset_type = single(value),
384 "value" => node.value = single(value).and_then(|s| parse_money(&s)),
385 _ => {} }
387 }
388
389 node
390}
391
392fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
393 let mut output = RelOutput {
394 id: id.to_string(),
395 rel_type: rel.rel_type.clone(),
396 source_id: source_id.to_string(),
397 target_id: target_id.to_string(),
398 source_urls: rel.source_urls.clone(),
399 description: None,
400 amount: None,
401 currency: None,
402 valid_from: None,
403 valid_until: None,
404 };
405
406 for (key, value) in &rel.fields {
407 match key.as_str() {
408 "description" => output.description = Some(value.clone()),
409 "amount" => output.amount = Some(value.clone()),
410 "currency" => output.currency = Some(value.clone()),
411 "valid_from" => output.valid_from = Some(value.clone()),
412 "valid_until" => output.valid_until = Some(value.clone()),
413 _ => {}
414 }
415 }
416
417 output
418}
419
420fn single(value: &FieldValue) -> Option<String> {
421 match value {
422 FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
423 _ => None,
424 }
425}
426
427fn list(value: &FieldValue) -> Vec<String> {
428 match value {
429 FieldValue::List(items) => items.clone(),
430 FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
431 FieldValue::Single(_) => Vec::new(),
432 }
433}
434
435fn parse_jurisdiction(s: &str) -> Option<Jurisdiction> {
438 if s.is_empty() {
439 return None;
440 }
441 if let Some((country, subdivision)) = s.split_once('/') {
442 Some(Jurisdiction {
443 country: country.to_string(),
444 subdivision: Some(subdivision.to_string()),
445 })
446 } else {
447 Some(Jurisdiction {
448 country: s.to_string(),
449 subdivision: None,
450 })
451 }
452}
453
454fn parse_money(s: &str) -> Option<Money> {
457 let parts: Vec<&str> = s.splitn(3, ' ').collect();
458 if parts.len() < 3 {
459 return None;
460 }
461 let amount = parts[0].parse::<i64>().ok()?;
462 let currency = parts[1].to_string();
463 let display = parts[2]
465 .strip_prefix('"')
466 .and_then(|s| s.strip_suffix('"'))
467 .unwrap_or(parts[2])
468 .to_string();
469 Some(Money {
470 amount,
471 currency,
472 display,
473 })
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::entity::{FieldValue, Label};
480
481 fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
482 Entity {
483 name: name.to_string(),
484 label,
485 fields: fields
486 .into_iter()
487 .map(|(k, v)| (k.to_string(), v))
488 .collect(),
489 id: None,
490 line: 1,
491 tags: Vec::new(),
492 slug: None,
493 }
494 }
495
496 #[test]
497 fn build_minimal_output() {
498 let entities = vec![make_entity("Alice", Label::Person, vec![])];
499 let rels = vec![];
500 let result = build_output(
501 "test",
502 "01TEST00000000000000000000",
503 "Title",
504 "Summary",
505 &[],
506 None,
507 None,
508 None,
509 &[],
510 &entities,
511 &rels,
512 &[],
513 )
514 .unwrap();
515
516 assert_eq!(result.output.case_id, "test");
517 assert_eq!(result.output.title, "Title");
518 assert_eq!(result.output.summary, "Summary");
519 assert_eq!(result.output.nodes.len(), 2); assert_eq!(result.output.nodes[0].name, "Alice");
521 assert_eq!(result.output.nodes[0].label, "person");
522 assert!(!result.output.nodes[0].id.is_empty());
523 assert_eq!(result.case_pending.len(), 1);
525 }
526
527 #[test]
528 fn node_fields_populated() {
529 let entities = vec![make_entity(
530 "Mark",
531 Label::Person,
532 vec![
533 ("qualifier", FieldValue::Single("Kit Manager".into())),
534 ("nationality", FieldValue::Single("GB".into())),
535 ("role", FieldValue::Single("custom:Kit Manager".into())),
536 (
537 "aliases",
538 FieldValue::List(vec!["Marky".into(), "MB".into()]),
539 ),
540 ],
541 )];
542 let result = build_output(
543 "case",
544 "01TEST00000000000000000000",
545 "T",
546 "",
547 &[],
548 None,
549 None,
550 None,
551 &[],
552 &entities,
553 &[],
554 &[],
555 )
556 .unwrap();
557 let node = &result.output.nodes[0];
558
559 assert_eq!(node.qualifier, Some("Kit Manager".into()));
560 assert_eq!(node.nationality, Some("GB".into()));
561 assert_eq!(node.role, vec!["custom:Kit Manager"]);
562 assert_eq!(node.aliases, vec!["Marky", "MB"]);
563 assert!(node.org_type.is_none());
564 }
565
566 #[test]
567 fn relationship_output() {
568 let entities = vec![
569 make_entity("Alice", Label::Person, vec![]),
570 make_entity("Corp", Label::Organization, vec![]),
571 ];
572 let rels = vec![Rel {
573 source_name: "Alice".into(),
574 target_name: "Corp".into(),
575 rel_type: "employed_by".into(),
576 source_urls: vec!["https://example.com".into()],
577 fields: vec![("amount".into(), "EUR 50,000".into())],
578 id: None,
579 line: 10,
580 }];
581
582 let result = build_output(
583 "case",
584 "01TEST00000000000000000000",
585 "T",
586 "",
587 &[],
588 None,
589 None,
590 None,
591 &[],
592 &entities,
593 &rels,
594 &[],
595 )
596 .unwrap();
597 assert_eq!(result.output.relationships.len(), 3); let rel = &result.output.relationships[0];
600 assert_eq!(rel.rel_type, "employed_by");
601 assert_eq!(rel.source_urls, vec!["https://example.com"]);
602 assert_eq!(rel.amount, Some("EUR 50,000".into()));
603 assert_eq!(rel.source_id, result.output.nodes[0].id);
604 assert_eq!(rel.target_id, result.output.nodes[1].id);
605 assert!(
607 result
608 .case_pending
609 .iter()
610 .any(|p| matches!(p.kind, WriteBackKind::Relationship))
611 );
612 }
613
614 #[test]
615 fn empty_optional_fields_omitted_in_json() {
616 let entities = vec![make_entity("Test", Label::Person, vec![])];
617 let result = build_output(
618 "case",
619 "01TEST00000000000000000000",
620 "T",
621 "",
622 &[],
623 None,
624 None,
625 None,
626 &[],
627 &entities,
628 &[],
629 &[],
630 )
631 .unwrap();
632 let json = serde_json::to_string(&result.output).unwrap_or_default();
633
634 assert!(!json.contains("qualifier"));
635 assert!(!json.contains("description"));
636 assert!(!json.contains("aliases"));
637 assert!(!json.contains("summary"));
638 }
639
640 #[test]
641 fn json_roundtrip() {
642 let entities = vec![
643 make_entity(
644 "Alice",
645 Label::Person,
646 vec![("nationality", FieldValue::Single("Dutch".into()))],
647 ),
648 make_entity(
649 "Corp",
650 Label::Organization,
651 vec![("org_type", FieldValue::Single("corporation".into()))],
652 ),
653 ];
654 let rels = vec![Rel {
655 source_name: "Alice".into(),
656 target_name: "Corp".into(),
657 rel_type: "employed_by".into(),
658 source_urls: vec!["https://example.com".into()],
659 fields: vec![],
660 id: None,
661 line: 1,
662 }];
663 let sources = vec![crate::parser::SourceEntry::Url(
664 "https://example.com".into(),
665 )];
666
667 let result = build_output(
668 "test-case",
669 "01TEST00000000000000000000",
670 "Test Case",
671 "A summary.",
672 &[],
673 None,
674 None,
675 None,
676 &sources,
677 &entities,
678 &rels,
679 &[],
680 )
681 .unwrap();
682 let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
683
684 assert!(json.contains("\"case_id\": \"test-case\""));
685 assert!(json.contains("\"nationality\": \"Dutch\""));
686 assert!(json.contains("\"org_type\": \"corporation\""));
687 assert!(json.contains("\"type\": \"employed_by\""));
688 }
689
690 #[test]
691 fn no_pending_when_ids_present() {
692 let entities = vec![Entity {
693 name: "Alice".to_string(),
694 label: Label::Person,
695 fields: vec![],
696 id: Some("01JABC000000000000000000AA".to_string()),
697 line: 1,
698 tags: Vec::new(),
699 slug: None,
700 }];
701 let result = build_output(
702 "case",
703 "01TEST00000000000000000000",
704 "T",
705 "",
706 &[],
707 None,
708 None,
709 None,
710 &[],
711 &entities,
712 &[],
713 &[],
714 )
715 .unwrap();
716 assert!(result.case_pending.is_empty());
717 }
718
719 #[test]
720 fn jurisdiction_structured_output() {
721 let entities = vec![make_entity(
722 "KPK",
723 Label::Organization,
724 vec![
725 ("org_type", FieldValue::Single("government_agency".into())),
726 (
727 "jurisdiction",
728 FieldValue::Single("ID/South Sulawesi".into()),
729 ),
730 ],
731 )];
732 let result = build_output(
733 "case",
734 "01TEST00000000000000000000",
735 "T",
736 "",
737 &[],
738 None,
739 None,
740 None,
741 &[],
742 &entities,
743 &[],
744 &[],
745 )
746 .unwrap();
747 let node = &result.output.nodes[0];
748 let j = node
749 .jurisdiction
750 .as_ref()
751 .expect("jurisdiction should be set");
752 assert_eq!(j.country, "ID");
753 assert_eq!(j.subdivision.as_deref(), Some("South Sulawesi"));
754
755 let json = serde_json::to_string(&result.output).unwrap_or_default();
756 assert!(json.contains("\"country\":\"ID\""));
757 assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
758 }
759
760 #[test]
761 fn jurisdiction_country_only() {
762 let entities = vec![make_entity(
763 "KPK",
764 Label::Organization,
765 vec![("jurisdiction", FieldValue::Single("GB".into()))],
766 )];
767 let result = build_output(
768 "case",
769 "01TEST00000000000000000000",
770 "T",
771 "",
772 &[],
773 None,
774 None,
775 None,
776 &[],
777 &entities,
778 &[],
779 &[],
780 )
781 .unwrap();
782 let j = result.output.nodes[0].jurisdiction.as_ref().unwrap();
783 assert_eq!(j.country, "GB");
784 assert!(j.subdivision.is_none());
785 }
786
787 #[test]
788 fn money_structured_output() {
789 let entities = vec![make_entity(
790 "Bribe Fund",
791 Label::Asset,
792 vec![(
793 "value",
794 FieldValue::Single("500000000000 IDR \"Rp 500 billion\"".into()),
795 )],
796 )];
797 let result = build_output(
798 "case",
799 "01TEST00000000000000000000",
800 "T",
801 "",
802 &[],
803 None,
804 None,
805 None,
806 &[],
807 &entities,
808 &[],
809 &[],
810 )
811 .unwrap();
812 let node = &result.output.nodes[0];
813 let m = node.value.as_ref().expect("value should be set");
814 assert_eq!(m.amount, 500_000_000_000);
815 assert_eq!(m.currency, "IDR");
816 assert_eq!(m.display, "Rp 500 billion");
817
818 let json = serde_json::to_string(&result.output).unwrap_or_default();
819 assert!(json.contains("\"amount\":500000000000"));
820 assert!(json.contains("\"currency\":\"IDR\""));
821 assert!(json.contains("\"display\":\"Rp 500 billion\""));
822 }
823
824 #[test]
825 fn rel_temporal_fields_output() {
826 let entities = vec![
827 make_entity("Alice", Label::Person, vec![]),
828 make_entity("Corp", Label::Organization, vec![]),
829 ];
830 let rels = vec![Rel {
831 source_name: "Alice".into(),
832 target_name: "Corp".into(),
833 rel_type: "employed_by".into(),
834 source_urls: vec![],
835 fields: vec![
836 ("valid_from".into(), "2020-01".into()),
837 ("valid_until".into(), "2024-06".into()),
838 ],
839 id: None,
840 line: 1,
841 }];
842 let result = build_output(
843 "case",
844 "01TEST00000000000000000000",
845 "T",
846 "",
847 &[],
848 None,
849 None,
850 None,
851 &[],
852 &entities,
853 &rels,
854 &[],
855 )
856 .unwrap();
857 let rel = &result.output.relationships[0];
858 assert_eq!(rel.valid_from.as_deref(), Some("2020-01"));
859 assert_eq!(rel.valid_until.as_deref(), Some("2024-06"));
860
861 let json = serde_json::to_string(&result.output).unwrap_or_default();
862 assert!(json.contains("\"valid_from\":\"2020-01\""));
863 assert!(json.contains("\"valid_until\":\"2024-06\""));
864 assert!(!json.contains("effective_date"));
865 assert!(!json.contains("expiry_date"));
866 }
867}