Skip to main content

weave_content/
output.rs

1use serde::Serialize;
2
3use crate::domain::{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/// JSON output for a complete case file.
11#[derive(Debug, Serialize)]
12pub struct CaseOutput {
13    /// NULID for the case node.
14    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    /// File-path slug for the case (e.g. `cases/id/corruption/2024/hambalang-case`).
22    #[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    pub nodes: Vec<NodeOutput>,
29    pub relationships: Vec<RelOutput>,
30    pub sources: Vec<crate::parser::SourceEntry>,
31}
32
33/// JSON output for a single node (entity).
34#[derive(Debug, Serialize)]
35pub struct NodeOutput {
36    pub id: String,
37    pub label: String,
38    pub name: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub slug: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub qualifier: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub description: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub thumbnail: Option<String>,
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    pub aliases: Vec<String>,
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    pub urls: Vec<String>,
51    // Person fields
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub role: Vec<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub nationality: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub date_of_birth: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub place_of_birth: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub status: Option<String>,
62    // Organization fields
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub org_type: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub jurisdiction: Option<Jurisdiction>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub headquarters: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub founded_date: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub registration_number: Option<String>,
73    // Event fields
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub event_type: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub occurred_at: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub severity: Option<String>,
80    // Document fields
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub doc_type: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub issued_at: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub issuing_authority: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub case_number: Option<String>,
89    // Case fields
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub case_type: Option<String>,
92    // Asset fields
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub asset_type: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub value: Option<Money>,
97    // Tags
98    #[serde(skip_serializing_if = "Vec::is_empty")]
99    pub tags: Vec<String>,
100}
101
102/// JSON output for a single relationship.
103#[derive(Debug, Serialize)]
104pub struct RelOutput {
105    pub id: String,
106    #[serde(rename = "type")]
107    pub rel_type: String,
108    pub source_id: String,
109    pub target_id: String,
110    #[serde(skip_serializing_if = "Vec::is_empty")]
111    pub source_urls: Vec<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub description: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub amount: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub currency: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub valid_from: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub valid_until: Option<String>,
122}
123
124/// Result of building output: the JSON structure plus any pending write-backs.
125pub struct BuildResult {
126    pub output: CaseOutput,
127    /// IDs generated for inline entities and relationships in case files.
128    pub case_pending: Vec<PendingId>,
129    /// IDs generated for registry entities (keyed by entity name for
130    /// the caller to route to the correct entity file).
131    pub registry_pending: Vec<(String, PendingId)>,
132}
133
134/// Build the JSON output from parsed entities and relationships.
135///
136/// Resolves stored IDs or generates new ones. Returns errors if any ID is invalid.
137/// `registry_entities` are shared entities referenced by relationships -- they are
138/// included in the output nodes alongside inline entities.
139///
140/// The case itself is emitted as a `Case` node, and `INVOLVED_IN` relationships
141/// are auto-generated for every entity in the case.
142#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
143pub fn build_output(
144    case_id: &str,
145    case_nulid: &str,
146    title: &str,
147    summary: &str,
148    case_tags: &[String],
149    case_slug: Option<&str>,
150    case_type: Option<&str>,
151    case_status: Option<&str>,
152    sources: &[crate::parser::SourceEntry],
153    entities: &[Entity],
154    rels: &[Rel],
155    registry_entities: &[Entity],
156) -> Result<BuildResult, Vec<ParseError>> {
157    let mut errors = Vec::new();
158    let mut case_pending = Vec::new();
159    let mut registry_pending = Vec::new();
160
161    // Build entity name -> ID mapping
162    let mut entity_ids: Vec<(String, String)> = Vec::new();
163    let mut nodes = Vec::new();
164
165    // Process inline entities (events from case file)
166    for e in entities {
167        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
168            Ok((id, generated)) => {
169                let id_str = id.to_string();
170                if generated {
171                    case_pending.push(PendingId {
172                        line: e.line,
173                        id: id_str.clone(),
174                        kind: WriteBackKind::InlineEvent,
175                    });
176                }
177                entity_ids.push((e.name.clone(), id_str.clone()));
178                nodes.push(entity_to_node(&id_str, e));
179            }
180            Err(err) => errors.push(err),
181        }
182    }
183
184    // Process referenced registry entities (shared people/organizations)
185    for e in registry_entities {
186        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
187            Ok((id, generated)) => {
188                let id_str = id.to_string();
189                if generated {
190                    registry_pending.push((
191                        e.name.clone(),
192                        PendingId {
193                            line: e.line,
194                            id: id_str.clone(),
195                            kind: WriteBackKind::EntityFrontMatter,
196                        },
197                    ));
198                }
199                entity_ids.push((e.name.clone(), id_str.clone()));
200                nodes.push(entity_to_node(&id_str, e));
201            }
202            Err(err) => errors.push(err),
203        }
204    }
205
206    let mut relationships = Vec::new();
207    for r in rels {
208        let source_id = entity_ids
209            .iter()
210            .find(|(name, _)| name == &r.source_name)
211            .map(|(_, id)| id.clone())
212            .unwrap_or_default();
213        let target_id = entity_ids
214            .iter()
215            .find(|(name, _)| name == &r.target_name)
216            .map(|(_, id)| id.clone())
217            .unwrap_or_default();
218
219        match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
220            Ok((id, generated)) => {
221                let id_str = id.to_string();
222                // Skip write-back for preceded_by relationships -- they are generated
223                // from timeline chain syntax and have no bullet to attach an ID to.
224                if generated && r.rel_type != "preceded_by" {
225                    case_pending.push(PendingId {
226                        line: r.line,
227                        id: id_str.clone(),
228                        kind: WriteBackKind::Relationship,
229                    });
230                }
231                relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
232            }
233            Err(err) => errors.push(err),
234        }
235    }
236
237    if !errors.is_empty() {
238        return Err(errors);
239    }
240
241    // Build Case node
242    let case_node = NodeOutput {
243        id: case_nulid.to_string(),
244        label: "case".to_string(),
245        name: title.to_string(),
246        slug: case_slug.map(String::from),
247        qualifier: None,
248        description: if summary.is_empty() {
249            None
250        } else {
251            Some(summary.to_string())
252        },
253        thumbnail: None,
254        aliases: Vec::new(),
255        urls: Vec::new(),
256        role: Vec::new(),
257        nationality: None,
258        date_of_birth: None,
259        place_of_birth: None,
260        status: case_status.map(String::from),
261        org_type: None,
262        jurisdiction: None,
263        headquarters: None,
264        founded_date: None,
265        registration_number: None,
266        event_type: None,
267        occurred_at: None,
268        severity: None,
269        doc_type: None,
270        issued_at: None,
271        issuing_authority: None,
272        case_number: None,
273        case_type: case_type.map(String::from),
274        asset_type: None,
275        value: None,
276        tags: case_tags.to_vec(),
277    };
278    nodes.push(case_node);
279
280    // Auto-generate INVOLVED_IN relationships: (entity)-[:INVOLVED_IN]->(case)
281    for (_, entity_id) in &entity_ids {
282        let involved_id = nulid_gen::generate().map_err(|e| {
283            vec![ParseError {
284                line: 0,
285                message: e,
286            }]
287        })?;
288        relationships.push(RelOutput {
289            id: involved_id,
290            rel_type: "involved_in".to_string(),
291            source_id: entity_id.clone(),
292            target_id: case_nulid.to_string(),
293            source_urls: Vec::new(),
294            description: None,
295            amount: None,
296            currency: None,
297            valid_from: None,
298            valid_until: None,
299        });
300    }
301
302    Ok(BuildResult {
303        output: CaseOutput {
304            id: case_nulid.to_string(),
305            case_id: case_id.to_string(),
306            title: title.to_string(),
307            summary: summary.to_string(),
308            tags: case_tags.to_vec(),
309            slug: case_slug.map(String::from),
310            case_type: case_type.map(String::from),
311            status: case_status.map(String::from),
312            nodes,
313            relationships,
314            sources: sources.to_vec(),
315        },
316        case_pending,
317        registry_pending,
318    })
319}
320
321fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
322    let label = entity.label.to_string();
323
324    let mut node = NodeOutput {
325        id: id.to_string(),
326        label,
327        name: entity.name.clone(),
328        slug: entity.slug.clone(),
329        qualifier: None,
330        description: None,
331        thumbnail: None,
332        aliases: Vec::new(),
333        urls: Vec::new(),
334        role: Vec::new(),
335        nationality: None,
336        date_of_birth: None,
337        place_of_birth: None,
338        status: None,
339        org_type: None,
340        jurisdiction: None,
341        headquarters: None,
342        founded_date: None,
343        registration_number: None,
344        event_type: None,
345        occurred_at: None,
346        severity: None,
347        doc_type: None,
348        issued_at: None,
349        issuing_authority: None,
350        case_number: None,
351        case_type: None,
352        asset_type: None,
353        value: None,
354        tags: entity.tags.clone(),
355    };
356
357    for (key, value) in &entity.fields {
358        match key.as_str() {
359            "qualifier" => node.qualifier = single(value),
360            "description" => node.description = single(value),
361            "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
362            "aliases" => node.aliases = list(value),
363            "urls" => node.urls = list(value),
364            "role" => node.role = list(value),
365            "nationality" => node.nationality = single(value),
366            "date_of_birth" => node.date_of_birth = single(value),
367            "place_of_birth" => node.place_of_birth = single(value),
368            "status" => node.status = single(value),
369            "org_type" => node.org_type = single(value),
370            "jurisdiction" => {
371                node.jurisdiction = single(value).and_then(|s| parse_jurisdiction(&s));
372            }
373            "headquarters" => node.headquarters = single(value),
374            "founded_date" => node.founded_date = single(value),
375            "registration_number" => node.registration_number = single(value),
376            "event_type" => node.event_type = single(value),
377            "occurred_at" => node.occurred_at = single(value),
378            "severity" => node.severity = single(value),
379            "doc_type" => node.doc_type = single(value),
380            "issued_at" => node.issued_at = single(value),
381            "issuing_authority" => node.issuing_authority = single(value),
382            "case_number" => node.case_number = single(value),
383            "asset_type" => node.asset_type = single(value),
384            "value" => node.value = single(value).and_then(|s| parse_money(&s)),
385            _ => {} // Unknown fields already rejected by validator
386        }
387    }
388
389    node
390}
391
392fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
393    let mut output = RelOutput {
394        id: id.to_string(),
395        rel_type: rel.rel_type.clone(),
396        source_id: source_id.to_string(),
397        target_id: target_id.to_string(),
398        source_urls: rel.source_urls.clone(),
399        description: None,
400        amount: None,
401        currency: None,
402        valid_from: None,
403        valid_until: None,
404    };
405
406    for (key, value) in &rel.fields {
407        match key.as_str() {
408            "description" => output.description = Some(value.clone()),
409            "amount" => output.amount = Some(value.clone()),
410            "currency" => output.currency = Some(value.clone()),
411            "valid_from" => output.valid_from = Some(value.clone()),
412            "valid_until" => output.valid_until = Some(value.clone()),
413            _ => {}
414        }
415    }
416
417    output
418}
419
420fn single(value: &FieldValue) -> Option<String> {
421    match value {
422        FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
423        _ => None,
424    }
425}
426
427fn list(value: &FieldValue) -> Vec<String> {
428    match value {
429        FieldValue::List(items) => items.clone(),
430        FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
431        FieldValue::Single(_) => Vec::new(),
432    }
433}
434
435/// Parse a validated jurisdiction string (`"ID"` or `"ID/West Java"`) into a
436/// structured [`Jurisdiction`].
437fn parse_jurisdiction(s: &str) -> Option<Jurisdiction> {
438    if s.is_empty() {
439        return None;
440    }
441    if let Some((country, subdivision)) = s.split_once('/') {
442        Some(Jurisdiction {
443            country: country.to_string(),
444            subdivision: Some(subdivision.to_string()),
445        })
446    } else {
447        Some(Jurisdiction {
448            country: s.to_string(),
449            subdivision: None,
450        })
451    }
452}
453
454/// Parse a validated money string (`"500000000000 IDR \"Rp 500 billion\""`)
455/// into a structured [`Money`].
456fn parse_money(s: &str) -> Option<Money> {
457    let parts: Vec<&str> = s.splitn(3, ' ').collect();
458    if parts.len() < 3 {
459        return None;
460    }
461    let amount = parts[0].parse::<i64>().ok()?;
462    let currency = parts[1].to_string();
463    // Strip surrounding quotes from display
464    let display = parts[2]
465        .strip_prefix('"')
466        .and_then(|s| s.strip_suffix('"'))
467        .unwrap_or(parts[2])
468        .to_string();
469    Some(Money {
470        amount,
471        currency,
472        display,
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::entity::{FieldValue, Label};
480
481    fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
482        Entity {
483            name: name.to_string(),
484            label,
485            fields: fields
486                .into_iter()
487                .map(|(k, v)| (k.to_string(), v))
488                .collect(),
489            id: None,
490            line: 1,
491            tags: Vec::new(),
492            slug: None,
493        }
494    }
495
496    #[test]
497    fn build_minimal_output() {
498        let entities = vec![make_entity("Alice", Label::Person, vec![])];
499        let rels = vec![];
500        let result = build_output(
501            "test",
502            "01TEST00000000000000000000",
503            "Title",
504            "Summary",
505            &[],
506            None,
507            None,
508            None,
509            &[],
510            &entities,
511            &rels,
512            &[],
513        )
514        .unwrap();
515
516        assert_eq!(result.output.case_id, "test");
517        assert_eq!(result.output.title, "Title");
518        assert_eq!(result.output.summary, "Summary");
519        assert_eq!(result.output.nodes.len(), 2); // Alice + Case node
520        assert_eq!(result.output.nodes[0].name, "Alice");
521        assert_eq!(result.output.nodes[0].label, "person");
522        assert!(!result.output.nodes[0].id.is_empty());
523        // Entity had no id, so one should be generated
524        assert_eq!(result.case_pending.len(), 1);
525    }
526
527    #[test]
528    fn node_fields_populated() {
529        let entities = vec![make_entity(
530            "Mark",
531            Label::Person,
532            vec![
533                ("qualifier", FieldValue::Single("Kit Manager".into())),
534                ("nationality", FieldValue::Single("GB".into())),
535                ("role", FieldValue::Single("custom:Kit Manager".into())),
536                (
537                    "aliases",
538                    FieldValue::List(vec!["Marky".into(), "MB".into()]),
539                ),
540            ],
541        )];
542        let result = build_output(
543            "case",
544            "01TEST00000000000000000000",
545            "T",
546            "",
547            &[],
548            None,
549            None,
550            None,
551            &[],
552            &entities,
553            &[],
554            &[],
555        )
556        .unwrap();
557        let node = &result.output.nodes[0];
558
559        assert_eq!(node.qualifier, Some("Kit Manager".into()));
560        assert_eq!(node.nationality, Some("GB".into()));
561        assert_eq!(node.role, vec!["custom:Kit Manager"]);
562        assert_eq!(node.aliases, vec!["Marky", "MB"]);
563        assert!(node.org_type.is_none());
564    }
565
566    #[test]
567    fn relationship_output() {
568        let entities = vec![
569            make_entity("Alice", Label::Person, vec![]),
570            make_entity("Corp", Label::Organization, vec![]),
571        ];
572        let rels = vec![Rel {
573            source_name: "Alice".into(),
574            target_name: "Corp".into(),
575            rel_type: "employed_by".into(),
576            source_urls: vec!["https://example.com".into()],
577            fields: vec![("amount".into(), "EUR 50,000".into())],
578            id: None,
579            line: 10,
580        }];
581
582        let result = build_output(
583            "case",
584            "01TEST00000000000000000000",
585            "T",
586            "",
587            &[],
588            None,
589            None,
590            None,
591            &[],
592            &entities,
593            &rels,
594            &[],
595        )
596        .unwrap();
597        assert_eq!(result.output.relationships.len(), 3); // 1 explicit + 2 INVOLVED_IN
598
599        let rel = &result.output.relationships[0];
600        assert_eq!(rel.rel_type, "employed_by");
601        assert_eq!(rel.source_urls, vec!["https://example.com"]);
602        assert_eq!(rel.amount, Some("EUR 50,000".into()));
603        assert_eq!(rel.source_id, result.output.nodes[0].id);
604        assert_eq!(rel.target_id, result.output.nodes[1].id);
605        // Relationship had no id, so one should be generated
606        assert!(
607            result
608                .case_pending
609                .iter()
610                .any(|p| matches!(p.kind, WriteBackKind::Relationship))
611        );
612    }
613
614    #[test]
615    fn empty_optional_fields_omitted_in_json() {
616        let entities = vec![make_entity("Test", Label::Person, vec![])];
617        let result = build_output(
618            "case",
619            "01TEST00000000000000000000",
620            "T",
621            "",
622            &[],
623            None,
624            None,
625            None,
626            &[],
627            &entities,
628            &[],
629            &[],
630        )
631        .unwrap();
632        let json = serde_json::to_string(&result.output).unwrap_or_default();
633
634        assert!(!json.contains("qualifier"));
635        assert!(!json.contains("description"));
636        assert!(!json.contains("aliases"));
637        assert!(!json.contains("summary"));
638    }
639
640    #[test]
641    fn json_roundtrip() {
642        let entities = vec![
643            make_entity(
644                "Alice",
645                Label::Person,
646                vec![("nationality", FieldValue::Single("Dutch".into()))],
647            ),
648            make_entity(
649                "Corp",
650                Label::Organization,
651                vec![("org_type", FieldValue::Single("corporation".into()))],
652            ),
653        ];
654        let rels = vec![Rel {
655            source_name: "Alice".into(),
656            target_name: "Corp".into(),
657            rel_type: "employed_by".into(),
658            source_urls: vec!["https://example.com".into()],
659            fields: vec![],
660            id: None,
661            line: 1,
662        }];
663        let sources = vec![crate::parser::SourceEntry::Url(
664            "https://example.com".into(),
665        )];
666
667        let result = build_output(
668            "test-case",
669            "01TEST00000000000000000000",
670            "Test Case",
671            "A summary.",
672            &[],
673            None,
674            None,
675            None,
676            &sources,
677            &entities,
678            &rels,
679            &[],
680        )
681        .unwrap();
682        let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
683
684        assert!(json.contains("\"case_id\": \"test-case\""));
685        assert!(json.contains("\"nationality\": \"Dutch\""));
686        assert!(json.contains("\"org_type\": \"corporation\""));
687        assert!(json.contains("\"type\": \"employed_by\""));
688    }
689
690    #[test]
691    fn no_pending_when_ids_present() {
692        let entities = vec![Entity {
693            name: "Alice".to_string(),
694            label: Label::Person,
695            fields: vec![],
696            id: Some("01JABC000000000000000000AA".to_string()),
697            line: 1,
698            tags: Vec::new(),
699            slug: None,
700        }];
701        let result = build_output(
702            "case",
703            "01TEST00000000000000000000",
704            "T",
705            "",
706            &[],
707            None,
708            None,
709            None,
710            &[],
711            &entities,
712            &[],
713            &[],
714        )
715        .unwrap();
716        assert!(result.case_pending.is_empty());
717    }
718
719    #[test]
720    fn jurisdiction_structured_output() {
721        let entities = vec![make_entity(
722            "KPK",
723            Label::Organization,
724            vec![
725                ("org_type", FieldValue::Single("government_agency".into())),
726                (
727                    "jurisdiction",
728                    FieldValue::Single("ID/South Sulawesi".into()),
729                ),
730            ],
731        )];
732        let result = build_output(
733            "case",
734            "01TEST00000000000000000000",
735            "T",
736            "",
737            &[],
738            None,
739            None,
740            None,
741            &[],
742            &entities,
743            &[],
744            &[],
745        )
746        .unwrap();
747        let node = &result.output.nodes[0];
748        let j = node
749            .jurisdiction
750            .as_ref()
751            .expect("jurisdiction should be set");
752        assert_eq!(j.country, "ID");
753        assert_eq!(j.subdivision.as_deref(), Some("South Sulawesi"));
754
755        let json = serde_json::to_string(&result.output).unwrap_or_default();
756        assert!(json.contains("\"country\":\"ID\""));
757        assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
758    }
759
760    #[test]
761    fn jurisdiction_country_only() {
762        let entities = vec![make_entity(
763            "KPK",
764            Label::Organization,
765            vec![("jurisdiction", FieldValue::Single("GB".into()))],
766        )];
767        let result = build_output(
768            "case",
769            "01TEST00000000000000000000",
770            "T",
771            "",
772            &[],
773            None,
774            None,
775            None,
776            &[],
777            &entities,
778            &[],
779            &[],
780        )
781        .unwrap();
782        let j = result.output.nodes[0].jurisdiction.as_ref().unwrap();
783        assert_eq!(j.country, "GB");
784        assert!(j.subdivision.is_none());
785    }
786
787    #[test]
788    fn money_structured_output() {
789        let entities = vec![make_entity(
790            "Bribe Fund",
791            Label::Asset,
792            vec![(
793                "value",
794                FieldValue::Single("500000000000 IDR \"Rp 500 billion\"".into()),
795            )],
796        )];
797        let result = build_output(
798            "case",
799            "01TEST00000000000000000000",
800            "T",
801            "",
802            &[],
803            None,
804            None,
805            None,
806            &[],
807            &entities,
808            &[],
809            &[],
810        )
811        .unwrap();
812        let node = &result.output.nodes[0];
813        let m = node.value.as_ref().expect("value should be set");
814        assert_eq!(m.amount, 500_000_000_000);
815        assert_eq!(m.currency, "IDR");
816        assert_eq!(m.display, "Rp 500 billion");
817
818        let json = serde_json::to_string(&result.output).unwrap_or_default();
819        assert!(json.contains("\"amount\":500000000000"));
820        assert!(json.contains("\"currency\":\"IDR\""));
821        assert!(json.contains("\"display\":\"Rp 500 billion\""));
822    }
823
824    #[test]
825    fn rel_temporal_fields_output() {
826        let entities = vec![
827            make_entity("Alice", Label::Person, vec![]),
828            make_entity("Corp", Label::Organization, vec![]),
829        ];
830        let rels = vec![Rel {
831            source_name: "Alice".into(),
832            target_name: "Corp".into(),
833            rel_type: "employed_by".into(),
834            source_urls: vec![],
835            fields: vec![
836                ("valid_from".into(), "2020-01".into()),
837                ("valid_until".into(), "2024-06".into()),
838            ],
839            id: None,
840            line: 1,
841        }];
842        let result = build_output(
843            "case",
844            "01TEST00000000000000000000",
845            "T",
846            "",
847            &[],
848            None,
849            None,
850            None,
851            &[],
852            &entities,
853            &rels,
854            &[],
855        )
856        .unwrap();
857        let rel = &result.output.relationships[0];
858        assert_eq!(rel.valid_from.as_deref(), Some("2020-01"));
859        assert_eq!(rel.valid_until.as_deref(), Some("2024-06"));
860
861        let json = serde_json::to_string(&result.output).unwrap_or_default();
862        assert!(json.contains("\"valid_from\":\"2020-01\""));
863        assert!(json.contains("\"valid_until\":\"2024-06\""));
864        assert!(!json.contains("effective_date"));
865        assert!(!json.contains("expiry_date"));
866    }
867}