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