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