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