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