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