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