Skip to main content

weave_content/
output.rs

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/// 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    #[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/// JSON output for a single node (entity).
36#[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    // Person fields
54    #[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    // Organization fields
65    #[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    // Event fields
76    #[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    // Document fields
83    #[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    // Case fields
92    #[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    // Asset fields
97    #[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    // Tags
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    pub tags: Vec<String>,
104}
105
106/// JSON output for a single relationship.
107#[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
126/// Result of building output: the JSON structure plus any pending write-backs.
127pub struct BuildResult {
128    pub output: CaseOutput,
129    /// IDs generated for inline entities and relationships in case files.
130    pub case_pending: Vec<PendingId>,
131    /// IDs generated for registry entities (keyed by entity name for
132    /// the caller to route to the correct entity file).
133    pub registry_pending: Vec<(String, PendingId)>,
134}
135
136/// Build the JSON output from parsed entities and relationships.
137///
138/// Resolves stored IDs or generates new ones. Returns errors if any ID is invalid.
139/// `registry_entities` are shared entities referenced by relationships -- they are
140/// included in the output nodes alongside inline entities.
141///
142/// The case itself is emitted as a `Case` node, and `INVOLVED_IN` relationships
143/// are auto-generated for every entity in the case.
144#[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    // Build entity name -> ID mapping
172    let mut entity_ids: Vec<(String, String)> = Vec::new();
173    let mut nodes = Vec::new();
174
175    // Process inline entities (events from case file)
176    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    // Process referenced registry entities (shared people/organizations)
197    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    // Parse case amounts from DSL string
259    let case_amounts_parsed = match case_amounts {
260        Some(s) => AmountEntry::parse_dsl(s).unwrap_or_default(),
261        None => Vec::new(),
262    };
263
264    // Build Case node
265    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    // Auto-generate INVOLVED_IN relationships: (entity)-[:INVOLVED_IN]->(case)
305    // For all entities: shared (people/organizations) and inline (events).
306    // Uses IDs from ## Involved section if present; generates and writes back if missing.
307    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        // Find entity name from entity_ids mapping
314        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        // Look up existing ID from ## Involved section
321        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    // Generate related_to relationships from ## Related Cases
355    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                    // Add minimal target case node so HTML renderer can link to it
379                    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            _ => {} // Unknown fields already rejected by validator
513        }
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
562/// Parse a validated jurisdiction string (`"ID"` or `"ID/West Java"`) into a
563/// structured [`Jurisdiction`].
564fn 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
581/// Parse a validated money string (`"500000000000 IDR \"Rp 500 billion\""`)
582/// into a structured [`Money`].
583fn 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    // Strip surrounding quotes from display
591    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); // Alice + Case node
652        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        // Entity had no id, so one should be generated + INVOLVED_IN id
656        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); // 1 explicit + 2 INVOLVED_IN for inline entities
738
739        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        // Relationship had no id, so one should be generated
748        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        // Should have a related_to relationship
1083        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        // Should have a target case node
1093        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}