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