1use serde::Serialize;
2
3use crate::domain::{AmountEntry, 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 #[serde(skip_serializing_if = "Vec::is_empty")]
29 pub amounts: Vec<AmountEntry>,
30 pub nodes: Vec<NodeOutput>,
31 pub relationships: Vec<RelOutput>,
32 pub sources: Vec<crate::parser::SourceEntry>,
33}
34
35#[derive(Debug, Serialize)]
37pub struct NodeOutput {
38 pub id: String,
39 pub label: String,
40 pub name: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub slug: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub qualifier: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub thumbnail: Option<String>,
49 #[serde(skip_serializing_if = "Vec::is_empty")]
50 pub aliases: Vec<String>,
51 #[serde(skip_serializing_if = "Vec::is_empty")]
52 pub urls: Vec<String>,
53 #[serde(skip_serializing_if = "Vec::is_empty")]
55 pub role: Vec<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub nationality: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub date_of_birth: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub place_of_birth: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub status: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub org_type: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub jurisdiction: Option<Jurisdiction>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub headquarters: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub founded_date: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub registration_number: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub event_type: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub occurred_at: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub severity: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub doc_type: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub issued_at: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub issuing_authority: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub case_number: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub case_type: Option<String>,
94 #[serde(skip_serializing_if = "Vec::is_empty")]
95 pub amounts: Vec<AmountEntry>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub asset_type: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub value: Option<Money>,
101 #[serde(skip_serializing_if = "Vec::is_empty")]
103 pub tags: Vec<String>,
104}
105
106#[derive(Debug, Serialize)]
108pub struct RelOutput {
109 pub id: String,
110 #[serde(rename = "type")]
111 pub rel_type: String,
112 pub source_id: String,
113 pub target_id: String,
114 #[serde(skip_serializing_if = "Vec::is_empty")]
115 pub source_urls: Vec<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub description: Option<String>,
118 #[serde(skip_serializing_if = "Vec::is_empty")]
119 pub amounts: Vec<AmountEntry>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub valid_from: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub valid_until: Option<String>,
124}
125
126pub struct BuildResult {
128 pub output: CaseOutput,
129 pub case_pending: Vec<PendingId>,
131 pub registry_pending: Vec<(String, PendingId)>,
134}
135
136#[allow(
145 clippy::too_many_arguments,
146 clippy::too_many_lines,
147 clippy::implicit_hasher
148)]
149pub fn build_output(
150 case_id: &str,
151 case_nulid: &str,
152 title: &str,
153 summary: &str,
154 case_tags: &[String],
155 case_slug: Option<&str>,
156 case_type: Option<&str>,
157 case_status: Option<&str>,
158 case_amounts: Option<&str>,
159 sources: &[crate::parser::SourceEntry],
160 related_cases: &[crate::parser::RelatedCase],
161 case_nulid_map: &std::collections::HashMap<String, (String, String)>,
162 entities: &[Entity],
163 rels: &[Rel],
164 registry_entities: &[Entity],
165) -> Result<BuildResult, Vec<ParseError>> {
166 let mut errors = Vec::new();
167 let mut case_pending = Vec::new();
168 let mut registry_pending = Vec::new();
169
170 let mut entity_ids: Vec<(String, String)> = Vec::new();
172 let mut nodes = Vec::new();
173
174 for e in entities {
176 match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
177 Ok((id, generated)) => {
178 let id_str = id.to_string();
179 if generated {
180 case_pending.push(PendingId {
181 line: e.line,
182 id: id_str.clone(),
183 kind: WriteBackKind::InlineEvent,
184 });
185 }
186 entity_ids.push((e.name.clone(), id_str.clone()));
187 nodes.push(entity_to_node(&id_str, e));
188 }
189 Err(err) => errors.push(err),
190 }
191 }
192
193 let mut registry_entity_ids: Vec<String> = Vec::new();
195 for e in registry_entities {
196 match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
197 Ok((id, generated)) => {
198 let id_str = id.to_string();
199 if generated {
200 registry_pending.push((
201 e.name.clone(),
202 PendingId {
203 line: e.line,
204 id: id_str.clone(),
205 kind: WriteBackKind::EntityFrontMatter,
206 },
207 ));
208 }
209 entity_ids.push((e.name.clone(), id_str.clone()));
210 registry_entity_ids.push(id_str.clone());
211 nodes.push(entity_to_node(&id_str, e));
212 }
213 Err(err) => errors.push(err),
214 }
215 }
216
217 let mut relationships = Vec::new();
218 for r in rels {
219 let source_id = entity_ids
220 .iter()
221 .find(|(name, _)| name == &r.source_name)
222 .map(|(_, id)| id.clone())
223 .unwrap_or_default();
224 let target_id = entity_ids
225 .iter()
226 .find(|(name, _)| name == &r.target_name)
227 .map(|(_, id)| id.clone())
228 .unwrap_or_default();
229
230 match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
231 Ok((id, generated)) => {
232 let id_str = id.to_string();
233 if generated && r.rel_type != "preceded_by" {
236 case_pending.push(PendingId {
237 line: r.line,
238 id: id_str.clone(),
239 kind: WriteBackKind::Relationship,
240 });
241 }
242 relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
243 }
244 Err(err) => errors.push(err),
245 }
246 }
247
248 if !errors.is_empty() {
249 return Err(errors);
250 }
251
252 let case_amounts_parsed = match case_amounts {
254 Some(s) => AmountEntry::parse_dsl(s).unwrap_or_default(),
255 None => Vec::new(),
256 };
257
258 let case_node = NodeOutput {
260 id: case_nulid.to_string(),
261 label: "case".to_string(),
262 name: title.to_string(),
263 slug: case_slug.map(String::from),
264 qualifier: None,
265 description: if summary.is_empty() {
266 None
267 } else {
268 Some(summary.to_string())
269 },
270 thumbnail: None,
271 aliases: Vec::new(),
272 urls: Vec::new(),
273 role: Vec::new(),
274 nationality: None,
275 date_of_birth: None,
276 place_of_birth: None,
277 status: case_status.map(String::from),
278 org_type: None,
279 jurisdiction: None,
280 headquarters: None,
281 founded_date: None,
282 registration_number: None,
283 event_type: None,
284 occurred_at: None,
285 severity: None,
286 doc_type: None,
287 issued_at: None,
288 issuing_authority: None,
289 case_number: None,
290 case_type: case_type.map(String::from),
291 amounts: case_amounts_parsed.clone(),
292 asset_type: None,
293 value: None,
294 tags: case_tags.to_vec(),
295 };
296 nodes.push(case_node);
297
298 for entity_id in ®istry_entity_ids {
301 let involved_id = nulid_gen::generate().map_err(|e| {
302 vec![ParseError {
303 line: 0,
304 message: e,
305 }]
306 })?;
307 relationships.push(RelOutput {
308 id: involved_id,
309 rel_type: "involved_in".to_string(),
310 source_id: entity_id.clone(),
311 target_id: case_nulid.to_string(),
312 source_urls: Vec::new(),
313 description: None,
314 amounts: Vec::new(),
315 valid_from: None,
316 valid_until: None,
317 });
318 }
319
320 for rc in related_cases {
322 if let Some((target_nulid, target_title)) = case_nulid_map.get(&rc.case_path) {
323 let rel_id = nulid_gen::generate().map_err(|e| {
324 vec![ParseError {
325 line: 0,
326 message: e,
327 }]
328 })?;
329 relationships.push(RelOutput {
330 id: rel_id,
331 rel_type: "related_to".to_string(),
332 source_id: case_nulid.to_string(),
333 target_id: target_nulid.clone(),
334 source_urls: Vec::new(),
335 description: Some(rc.description.clone()),
336 amounts: Vec::new(),
337 valid_from: None,
338 valid_until: None,
339 });
340 nodes.push(NodeOutput {
342 id: target_nulid.clone(),
343 label: "case".to_string(),
344 name: target_title.clone(),
345 slug: Some(format!("cases/{}", rc.case_path)),
346 qualifier: None,
347 description: None,
348 thumbnail: None,
349 aliases: Vec::new(),
350 urls: Vec::new(),
351 role: Vec::new(),
352 nationality: None,
353 date_of_birth: None,
354 place_of_birth: None,
355 status: None,
356 org_type: None,
357 jurisdiction: None,
358 headquarters: None,
359 founded_date: None,
360 registration_number: None,
361 event_type: None,
362 occurred_at: None,
363 severity: None,
364 doc_type: None,
365 issued_at: None,
366 issuing_authority: None,
367 case_number: None,
368 case_type: None,
369 amounts: Vec::new(),
370 asset_type: None,
371 value: None,
372 tags: Vec::new(),
373 });
374 } else {
375 errors.push(ParseError {
376 line: 0,
377 message: format!("related case not found in index: {}", rc.case_path),
378 });
379 }
380 }
381
382 if !errors.is_empty() {
383 return Err(errors);
384 }
385
386 Ok(BuildResult {
387 output: CaseOutput {
388 id: case_nulid.to_string(),
389 case_id: case_id.to_string(),
390 title: title.to_string(),
391 summary: summary.to_string(),
392 tags: case_tags.to_vec(),
393 slug: case_slug.map(String::from),
394 case_type: case_type.map(String::from),
395 status: case_status.map(String::from),
396 amounts: case_amounts_parsed,
397 nodes,
398 relationships,
399 sources: sources.to_vec(),
400 },
401 case_pending,
402 registry_pending,
403 })
404}
405
406fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
407 let label = entity.label.to_string();
408
409 let mut node = NodeOutput {
410 id: id.to_string(),
411 label,
412 name: entity.name.clone(),
413 slug: entity.slug.clone(),
414 qualifier: None,
415 description: None,
416 thumbnail: None,
417 aliases: Vec::new(),
418 urls: Vec::new(),
419 role: Vec::new(),
420 nationality: None,
421 date_of_birth: None,
422 place_of_birth: None,
423 status: None,
424 org_type: None,
425 jurisdiction: None,
426 headquarters: None,
427 founded_date: None,
428 registration_number: None,
429 event_type: None,
430 occurred_at: None,
431 severity: None,
432 doc_type: None,
433 issued_at: None,
434 issuing_authority: None,
435 case_number: None,
436 case_type: None,
437 amounts: Vec::new(),
438 asset_type: None,
439 value: None,
440 tags: entity.tags.clone(),
441 };
442
443 for (key, value) in &entity.fields {
444 match key.as_str() {
445 "qualifier" => node.qualifier = single(value),
446 "description" => node.description = single(value),
447 "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
448 "aliases" => node.aliases = list(value),
449 "urls" => node.urls = list(value),
450 "role" => node.role = list(value),
451 "nationality" => node.nationality = single(value),
452 "date_of_birth" => node.date_of_birth = single(value),
453 "place_of_birth" => node.place_of_birth = single(value),
454 "status" => node.status = single(value),
455 "org_type" => node.org_type = single(value),
456 "jurisdiction" => {
457 node.jurisdiction = single(value).and_then(|s| parse_jurisdiction(&s));
458 }
459 "headquarters" => node.headquarters = single(value),
460 "founded_date" => node.founded_date = single(value),
461 "registration_number" => node.registration_number = single(value),
462 "event_type" => node.event_type = single(value),
463 "occurred_at" => node.occurred_at = single(value),
464 "severity" => node.severity = single(value),
465 "doc_type" => node.doc_type = single(value),
466 "issued_at" => node.issued_at = single(value),
467 "issuing_authority" => node.issuing_authority = single(value),
468 "case_number" => node.case_number = single(value),
469 "asset_type" => node.asset_type = single(value),
470 "value" => node.value = single(value).and_then(|s| parse_money(&s)),
471 _ => {} }
473 }
474
475 node
476}
477
478fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
479 let mut output = RelOutput {
480 id: id.to_string(),
481 rel_type: rel.rel_type.clone(),
482 source_id: source_id.to_string(),
483 target_id: target_id.to_string(),
484 source_urls: rel.source_urls.clone(),
485 description: None,
486 amounts: Vec::new(),
487 valid_from: None,
488 valid_until: None,
489 };
490
491 for (key, value) in &rel.fields {
492 match key.as_str() {
493 "description" => output.description = Some(value.clone()),
494 "amounts" => {
495 output.amounts = AmountEntry::parse_dsl(value).unwrap_or_default();
496 }
497 "valid_from" => output.valid_from = Some(value.clone()),
498 "valid_until" => output.valid_until = Some(value.clone()),
499 _ => {}
500 }
501 }
502
503 output
504}
505
506fn single(value: &FieldValue) -> Option<String> {
507 match value {
508 FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
509 _ => None,
510 }
511}
512
513fn list(value: &FieldValue) -> Vec<String> {
514 match value {
515 FieldValue::List(items) => items.clone(),
516 FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
517 FieldValue::Single(_) => Vec::new(),
518 }
519}
520
521fn parse_jurisdiction(s: &str) -> Option<Jurisdiction> {
524 if s.is_empty() {
525 return None;
526 }
527 if let Some((country, subdivision)) = s.split_once('/') {
528 Some(Jurisdiction {
529 country: country.to_string(),
530 subdivision: Some(subdivision.to_string()),
531 })
532 } else {
533 Some(Jurisdiction {
534 country: s.to_string(),
535 subdivision: None,
536 })
537 }
538}
539
540fn parse_money(s: &str) -> Option<Money> {
543 let parts: Vec<&str> = s.splitn(3, ' ').collect();
544 if parts.len() < 3 {
545 return None;
546 }
547 let amount = parts[0].parse::<i64>().ok()?;
548 let currency = parts[1].to_string();
549 let display = parts[2]
551 .strip_prefix('"')
552 .and_then(|s| s.strip_suffix('"'))
553 .unwrap_or(parts[2])
554 .to_string();
555 Some(Money {
556 amount,
557 currency,
558 display,
559 })
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::entity::{FieldValue, Label};
566 use std::collections::HashMap;
567
568 fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
569 Entity {
570 name: name.to_string(),
571 label,
572 fields: fields
573 .into_iter()
574 .map(|(k, v)| (k.to_string(), v))
575 .collect(),
576 id: None,
577 line: 1,
578 tags: Vec::new(),
579 slug: None,
580 }
581 }
582
583 #[test]
584 fn build_minimal_output() {
585 let entities = vec![make_entity("Alice", Label::Person, vec![])];
586 let rels = vec![];
587 let result = build_output(
588 "test",
589 "01TEST00000000000000000000",
590 "Title",
591 "Summary",
592 &[],
593 None,
594 None,
595 None,
596 None,
597 &[],
598 &[],
599 &HashMap::new(),
600 &entities,
601 &rels,
602 &[],
603 )
604 .unwrap();
605
606 assert_eq!(result.output.case_id, "test");
607 assert_eq!(result.output.title, "Title");
608 assert_eq!(result.output.summary, "Summary");
609 assert_eq!(result.output.nodes.len(), 2); assert_eq!(result.output.nodes[0].name, "Alice");
611 assert_eq!(result.output.nodes[0].label, "person");
612 assert!(!result.output.nodes[0].id.is_empty());
613 assert_eq!(result.case_pending.len(), 1);
615 }
616
617 #[test]
618 fn node_fields_populated() {
619 let entities = vec![make_entity(
620 "Mark",
621 Label::Person,
622 vec![
623 ("qualifier", FieldValue::Single("Kit Manager".into())),
624 ("nationality", FieldValue::Single("GB".into())),
625 ("role", FieldValue::Single("custom:Kit Manager".into())),
626 (
627 "aliases",
628 FieldValue::List(vec!["Marky".into(), "MB".into()]),
629 ),
630 ],
631 )];
632 let result = build_output(
633 "case",
634 "01TEST00000000000000000000",
635 "T",
636 "",
637 &[],
638 None,
639 None,
640 None,
641 None,
642 &[],
643 &[],
644 &HashMap::new(),
645 &entities,
646 &[],
647 &[],
648 )
649 .unwrap();
650 let node = &result.output.nodes[0];
651
652 assert_eq!(node.qualifier, Some("Kit Manager".into()));
653 assert_eq!(node.nationality, Some("GB".into()));
654 assert_eq!(node.role, vec!["custom:Kit Manager"]);
655 assert_eq!(node.aliases, vec!["Marky", "MB"]);
656 assert!(node.org_type.is_none());
657 }
658
659 #[test]
660 fn relationship_output() {
661 let entities = vec![
662 make_entity("Alice", Label::Person, vec![]),
663 make_entity("Corp", Label::Organization, vec![]),
664 ];
665 let rels = vec![Rel {
666 source_name: "Alice".into(),
667 target_name: "Corp".into(),
668 rel_type: "employed_by".into(),
669 source_urls: vec!["https://example.com".into()],
670 fields: vec![("amounts".into(), "50000 EUR".into())],
671 id: None,
672 line: 10,
673 }];
674
675 let result = build_output(
676 "case",
677 "01TEST00000000000000000000",
678 "T",
679 "",
680 &[],
681 None,
682 None,
683 None,
684 None,
685 &[],
686 &[],
687 &HashMap::new(),
688 &entities,
689 &rels,
690 &[],
691 )
692 .unwrap();
693 assert_eq!(result.output.relationships.len(), 3); let rel = &result.output.relationships[0];
696 assert_eq!(rel.rel_type, "employed_by");
697 assert_eq!(rel.source_urls, vec!["https://example.com"]);
698 assert_eq!(rel.amounts.len(), 1);
699 assert_eq!(rel.amounts[0].value, 50_000);
700 assert_eq!(rel.amounts[0].currency, "EUR");
701 assert_eq!(rel.source_id, result.output.nodes[0].id);
702 assert_eq!(rel.target_id, result.output.nodes[1].id);
703 assert!(
705 result
706 .case_pending
707 .iter()
708 .any(|p| matches!(p.kind, WriteBackKind::Relationship))
709 );
710 }
711
712 #[test]
713 fn empty_optional_fields_omitted_in_json() {
714 let entities = vec![make_entity("Test", Label::Person, vec![])];
715 let result = build_output(
716 "case",
717 "01TEST00000000000000000000",
718 "T",
719 "",
720 &[],
721 None,
722 None,
723 None,
724 None,
725 &[],
726 &[],
727 &HashMap::new(),
728 &entities,
729 &[],
730 &[],
731 )
732 .unwrap();
733 let json = serde_json::to_string(&result.output).unwrap_or_default();
734
735 assert!(!json.contains("qualifier"));
736 assert!(!json.contains("description"));
737 assert!(!json.contains("aliases"));
738 assert!(!json.contains("summary"));
739 }
740
741 #[test]
742 fn json_roundtrip() {
743 let entities = vec![
744 make_entity(
745 "Alice",
746 Label::Person,
747 vec![("nationality", FieldValue::Single("Dutch".into()))],
748 ),
749 make_entity(
750 "Corp",
751 Label::Organization,
752 vec![("org_type", FieldValue::Single("corporation".into()))],
753 ),
754 ];
755 let rels = vec![Rel {
756 source_name: "Alice".into(),
757 target_name: "Corp".into(),
758 rel_type: "employed_by".into(),
759 source_urls: vec!["https://example.com".into()],
760 fields: vec![],
761 id: None,
762 line: 1,
763 }];
764 let sources = vec![crate::parser::SourceEntry::Url(
765 "https://example.com".into(),
766 )];
767
768 let result = build_output(
769 "test-case",
770 "01TEST00000000000000000000",
771 "Test Case",
772 "A summary.",
773 &[],
774 None,
775 None,
776 None,
777 None,
778 &sources,
779 &[],
780 &HashMap::new(),
781 &entities,
782 &rels,
783 &[],
784 )
785 .unwrap();
786 let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
787
788 assert!(json.contains("\"case_id\": \"test-case\""));
789 assert!(json.contains("\"nationality\": \"Dutch\""));
790 assert!(json.contains("\"org_type\": \"corporation\""));
791 assert!(json.contains("\"type\": \"employed_by\""));
792 }
793
794 #[test]
795 fn no_pending_when_ids_present() {
796 let entities = vec![Entity {
797 name: "Alice".to_string(),
798 label: Label::Person,
799 fields: vec![],
800 id: Some("01JABC000000000000000000AA".to_string()),
801 line: 1,
802 tags: Vec::new(),
803 slug: None,
804 }];
805 let result = build_output(
806 "case",
807 "01TEST00000000000000000000",
808 "T",
809 "",
810 &[],
811 None,
812 None,
813 None,
814 None,
815 &[],
816 &[],
817 &HashMap::new(),
818 &entities,
819 &[],
820 &[],
821 )
822 .unwrap();
823 assert!(result.case_pending.is_empty());
824 }
825
826 #[test]
827 fn jurisdiction_structured_output() {
828 let entities = vec![make_entity(
829 "KPK",
830 Label::Organization,
831 vec![
832 ("org_type", FieldValue::Single("government_agency".into())),
833 (
834 "jurisdiction",
835 FieldValue::Single("ID/South Sulawesi".into()),
836 ),
837 ],
838 )];
839 let result = build_output(
840 "case",
841 "01TEST00000000000000000000",
842 "T",
843 "",
844 &[],
845 None,
846 None,
847 None,
848 None,
849 &[],
850 &[],
851 &HashMap::new(),
852 &entities,
853 &[],
854 &[],
855 )
856 .unwrap();
857 let node = &result.output.nodes[0];
858 let j = node
859 .jurisdiction
860 .as_ref()
861 .expect("jurisdiction should be set");
862 assert_eq!(j.country, "ID");
863 assert_eq!(j.subdivision.as_deref(), Some("South Sulawesi"));
864
865 let json = serde_json::to_string(&result.output).unwrap_or_default();
866 assert!(json.contains("\"country\":\"ID\""));
867 assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
868 }
869
870 #[test]
871 fn jurisdiction_country_only() {
872 let entities = vec![make_entity(
873 "KPK",
874 Label::Organization,
875 vec![("jurisdiction", FieldValue::Single("GB".into()))],
876 )];
877 let result = build_output(
878 "case",
879 "01TEST00000000000000000000",
880 "T",
881 "",
882 &[],
883 None,
884 None,
885 None,
886 None,
887 &[],
888 &[],
889 &HashMap::new(),
890 &entities,
891 &[],
892 &[],
893 )
894 .unwrap();
895 let j = result.output.nodes[0].jurisdiction.as_ref().unwrap();
896 assert_eq!(j.country, "GB");
897 assert!(j.subdivision.is_none());
898 }
899
900 #[test]
901 fn money_structured_output() {
902 let entities = vec![make_entity(
903 "Bribe Fund",
904 Label::Asset,
905 vec![(
906 "value",
907 FieldValue::Single("500000000000 IDR \"Rp 500 billion\"".into()),
908 )],
909 )];
910 let result = build_output(
911 "case",
912 "01TEST00000000000000000000",
913 "T",
914 "",
915 &[],
916 None,
917 None,
918 None,
919 None,
920 &[],
921 &[],
922 &HashMap::new(),
923 &entities,
924 &[],
925 &[],
926 )
927 .unwrap();
928 let node = &result.output.nodes[0];
929 let m = node.value.as_ref().expect("value should be set");
930 assert_eq!(m.amount, 500_000_000_000);
931 assert_eq!(m.currency, "IDR");
932 assert_eq!(m.display, "Rp 500 billion");
933
934 let json = serde_json::to_string(&result.output).unwrap_or_default();
935 assert!(json.contains("\"amount\":500000000000"));
936 assert!(json.contains("\"currency\":\"IDR\""));
937 assert!(json.contains("\"display\":\"Rp 500 billion\""));
938 }
939
940 #[test]
941 fn rel_temporal_fields_output() {
942 let entities = vec![
943 make_entity("Alice", Label::Person, vec![]),
944 make_entity("Corp", Label::Organization, vec![]),
945 ];
946 let rels = vec![Rel {
947 source_name: "Alice".into(),
948 target_name: "Corp".into(),
949 rel_type: "employed_by".into(),
950 source_urls: vec![],
951 fields: vec![
952 ("valid_from".into(), "2020-01".into()),
953 ("valid_until".into(), "2024-06".into()),
954 ],
955 id: None,
956 line: 1,
957 }];
958 let result = build_output(
959 "case",
960 "01TEST00000000000000000000",
961 "T",
962 "",
963 &[],
964 None,
965 None,
966 None,
967 None,
968 &[],
969 &[],
970 &HashMap::new(),
971 &entities,
972 &rels,
973 &[],
974 )
975 .unwrap();
976 let rel = &result.output.relationships[0];
977 assert_eq!(rel.valid_from.as_deref(), Some("2020-01"));
978 assert_eq!(rel.valid_until.as_deref(), Some("2024-06"));
979
980 let json = serde_json::to_string(&result.output).unwrap_or_default();
981 assert!(json.contains("\"valid_from\":\"2020-01\""));
982 assert!(json.contains("\"valid_until\":\"2024-06\""));
983 assert!(!json.contains("effective_date"));
984 assert!(!json.contains("expiry_date"));
985 }
986
987 #[test]
988 fn build_output_with_related_cases() {
989 use crate::parser::RelatedCase;
990
991 let related = vec![RelatedCase {
992 case_path: "id/corruption/2002/target-case".into(),
993 description: "Related scandal".into(),
994 }];
995 let mut case_map = HashMap::new();
996 case_map.insert(
997 "id/corruption/2002/target-case".to_string(),
998 (
999 "01TARGET0000000000000000000".to_string(),
1000 "Target Case Title".to_string(),
1001 ),
1002 );
1003
1004 let result = build_output(
1005 "case",
1006 "01TEST00000000000000000000",
1007 "T",
1008 "",
1009 &[],
1010 None,
1011 None,
1012 None,
1013 None,
1014 &[],
1015 &related,
1016 &case_map,
1017 &[],
1018 &[],
1019 &[],
1020 )
1021 .unwrap();
1022
1023 let rel = result
1025 .output
1026 .relationships
1027 .iter()
1028 .find(|r| r.rel_type == "related_to")
1029 .expect("expected a related_to relationship");
1030 assert_eq!(rel.target_id, "01TARGET0000000000000000000");
1031 assert_eq!(rel.description, Some("Related scandal".into()));
1032
1033 let target_node = result
1035 .output
1036 .nodes
1037 .iter()
1038 .find(|n| n.id == "01TARGET0000000000000000000" && n.label == "case")
1039 .expect("expected a target case node");
1040 assert_eq!(target_node.label, "case");
1041 }
1042
1043 #[test]
1044 fn build_output_related_case_not_in_index() {
1045 use crate::parser::RelatedCase;
1046
1047 let related = vec![RelatedCase {
1048 case_path: "id/corruption/2002/nonexistent-case".into(),
1049 description: "Does not exist".into(),
1050 }];
1051
1052 let errs = match build_output(
1053 "case",
1054 "01TEST00000000000000000000",
1055 "T",
1056 "",
1057 &[],
1058 None,
1059 None,
1060 None,
1061 None,
1062 &[],
1063 &related,
1064 &HashMap::new(),
1065 &[],
1066 &[],
1067 &[],
1068 ) {
1069 Err(e) => e,
1070 Ok(_) => panic!("expected error for missing related case"),
1071 };
1072
1073 assert!(
1074 errs.iter()
1075 .any(|e| e.message.contains("not found in index"))
1076 );
1077 }
1078}