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