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