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