Skip to main content

weave_content/
output.rs

1use serde::Serialize;
2
3use crate::domain::{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    pub case_id: String,
14    pub title: String,
15    #[serde(skip_serializing_if = "String::is_empty")]
16    pub summary: String,
17    #[serde(skip_serializing_if = "Vec::is_empty")]
18    pub tags: Vec<String>,
19    pub nodes: Vec<NodeOutput>,
20    pub relationships: Vec<RelOutput>,
21    pub sources: Vec<crate::parser::SourceEntry>,
22}
23
24/// JSON output for a single node (entity).
25#[derive(Debug, Serialize)]
26pub struct NodeOutput {
27    pub id: String,
28    pub label: String,
29    pub name: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub qualifier: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub description: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub thumbnail: Option<String>,
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub aliases: Vec<String>,
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub urls: Vec<String>,
40    // Person fields
41    #[serde(skip_serializing_if = "Vec::is_empty")]
42    pub role: Vec<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub nationality: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub date_of_birth: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub place_of_birth: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub status: Option<String>,
51    // Organization fields
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub org_type: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub jurisdiction: Option<Jurisdiction>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub headquarters: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub founded_date: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub registration_number: Option<String>,
62    // Event fields
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub event_type: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub occurred_at: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub severity: Option<String>,
69    // Document fields
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub doc_type: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub issued_at: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub issuing_authority: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub case_number: Option<String>,
78    // Asset fields
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub asset_type: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub value: Option<Money>,
83    // Tags
84    #[serde(skip_serializing_if = "Vec::is_empty")]
85    pub tags: Vec<String>,
86}
87
88/// JSON output for a single relationship.
89#[derive(Debug, Serialize)]
90pub struct RelOutput {
91    pub id: String,
92    #[serde(rename = "type")]
93    pub rel_type: String,
94    pub source_id: String,
95    pub target_id: String,
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    pub source_urls: Vec<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub description: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub amount: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub currency: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub valid_from: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub valid_until: Option<String>,
108}
109
110/// Result of building output: the JSON structure plus any pending write-backs.
111pub struct BuildResult {
112    pub output: CaseOutput,
113    /// IDs generated for inline entities and relationships in case files.
114    pub case_pending: Vec<PendingId>,
115    /// IDs generated for registry entities (keyed by entity name for
116    /// the caller to route to the correct entity file).
117    pub registry_pending: Vec<(String, PendingId)>,
118}
119
120/// Build the JSON output from parsed entities and relationships.
121///
122/// Resolves stored IDs or generates new ones. Returns errors if any ID is invalid.
123/// `registry_entities` are shared entities referenced by relationships -- they are
124/// included in the output nodes alongside inline entities.
125pub fn build_output(
126    case_id: &str,
127    title: &str,
128    summary: &str,
129    case_tags: &[String],
130    sources: &[crate::parser::SourceEntry],
131    entities: &[Entity],
132    rels: &[Rel],
133    registry_entities: &[Entity],
134) -> Result<BuildResult, Vec<ParseError>> {
135    let mut errors = Vec::new();
136    let mut case_pending = Vec::new();
137    let mut registry_pending = Vec::new();
138
139    // Build entity name -> ID mapping
140    let mut entity_ids: Vec<(String, String)> = Vec::new();
141    let mut nodes = Vec::new();
142
143    // Process inline entities (events from case file)
144    for e in entities {
145        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
146            Ok((id, generated)) => {
147                let id_str = id.to_string();
148                if generated {
149                    case_pending.push(PendingId {
150                        line: e.line,
151                        id: id_str.clone(),
152                        kind: WriteBackKind::InlineEvent,
153                    });
154                }
155                entity_ids.push((e.name.clone(), id_str.clone()));
156                nodes.push(entity_to_node(&id_str, e));
157            }
158            Err(err) => errors.push(err),
159        }
160    }
161
162    // Process referenced registry entities (shared people/organizations)
163    for e in registry_entities {
164        match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
165            Ok((id, generated)) => {
166                let id_str = id.to_string();
167                if generated {
168                    registry_pending.push((
169                        e.name.clone(),
170                        PendingId {
171                            line: e.line,
172                            id: id_str.clone(),
173                            kind: WriteBackKind::EntityFrontMatter,
174                        },
175                    ));
176                }
177                entity_ids.push((e.name.clone(), id_str.clone()));
178                nodes.push(entity_to_node(&id_str, e));
179            }
180            Err(err) => errors.push(err),
181        }
182    }
183
184    let mut relationships = Vec::new();
185    for r in rels {
186        let source_id = entity_ids
187            .iter()
188            .find(|(name, _)| name == &r.source_name)
189            .map(|(_, id)| id.clone())
190            .unwrap_or_default();
191        let target_id = entity_ids
192            .iter()
193            .find(|(name, _)| name == &r.target_name)
194            .map(|(_, id)| id.clone())
195            .unwrap_or_default();
196
197        match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
198            Ok((id, generated)) => {
199                let id_str = id.to_string();
200                // Skip write-back for preceded_by relationships -- they are generated
201                // from timeline chain syntax and have no bullet to attach an ID to.
202                if generated && r.rel_type != "preceded_by" {
203                    case_pending.push(PendingId {
204                        line: r.line,
205                        id: id_str.clone(),
206                        kind: WriteBackKind::Relationship,
207                    });
208                }
209                relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
210            }
211            Err(err) => errors.push(err),
212        }
213    }
214
215    if !errors.is_empty() {
216        return Err(errors);
217    }
218
219    Ok(BuildResult {
220        output: CaseOutput {
221            case_id: case_id.to_string(),
222            title: title.to_string(),
223            summary: summary.to_string(),
224            tags: case_tags.to_vec(),
225            nodes,
226            relationships,
227            sources: sources.to_vec(),
228        },
229        case_pending,
230        registry_pending,
231    })
232}
233
234fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
235    let label = entity.label.to_string();
236
237    let mut node = NodeOutput {
238        id: id.to_string(),
239        label,
240        name: entity.name.clone(),
241        qualifier: None,
242        description: None,
243        thumbnail: None,
244        aliases: Vec::new(),
245        urls: Vec::new(),
246        role: Vec::new(),
247        nationality: None,
248        date_of_birth: None,
249        place_of_birth: None,
250        status: None,
251        org_type: None,
252        jurisdiction: None,
253        headquarters: None,
254        founded_date: None,
255        registration_number: None,
256        event_type: None,
257        occurred_at: None,
258        severity: None,
259        doc_type: None,
260        issued_at: None,
261        issuing_authority: None,
262        case_number: None,
263        asset_type: None,
264        value: None,
265        tags: entity.tags.clone(),
266    };
267
268    for (key, value) in &entity.fields {
269        match key.as_str() {
270            "qualifier" => node.qualifier = single(value),
271            "description" => node.description = single(value),
272            "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
273            "aliases" => node.aliases = list(value),
274            "urls" => node.urls = list(value),
275            "role" => node.role = list(value),
276            "nationality" => node.nationality = single(value),
277            "date_of_birth" => node.date_of_birth = single(value),
278            "place_of_birth" => node.place_of_birth = single(value),
279            "status" => node.status = single(value),
280            "org_type" => node.org_type = single(value),
281            "jurisdiction" => {
282                node.jurisdiction = single(value).and_then(|s| parse_jurisdiction(&s));
283            }
284            "headquarters" => node.headquarters = single(value),
285            "founded_date" => node.founded_date = single(value),
286            "registration_number" => node.registration_number = single(value),
287            "event_type" => node.event_type = single(value),
288            "occurred_at" => node.occurred_at = single(value),
289            "severity" => node.severity = single(value),
290            "doc_type" => node.doc_type = single(value),
291            "issued_at" => node.issued_at = single(value),
292            "issuing_authority" => node.issuing_authority = single(value),
293            "case_number" => node.case_number = single(value),
294            "asset_type" => node.asset_type = single(value),
295            "value" => node.value = single(value).and_then(|s| parse_money(&s)),
296            _ => {} // Unknown fields already rejected by validator
297        }
298    }
299
300    node
301}
302
303fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
304    let mut output = RelOutput {
305        id: id.to_string(),
306        rel_type: rel.rel_type.clone(),
307        source_id: source_id.to_string(),
308        target_id: target_id.to_string(),
309        source_urls: rel.source_urls.clone(),
310        description: None,
311        amount: None,
312        currency: None,
313        valid_from: None,
314        valid_until: None,
315    };
316
317    for (key, value) in &rel.fields {
318        match key.as_str() {
319            "description" => output.description = Some(value.clone()),
320            "amount" => output.amount = Some(value.clone()),
321            "currency" => output.currency = Some(value.clone()),
322            "valid_from" => output.valid_from = Some(value.clone()),
323            "valid_until" => output.valid_until = Some(value.clone()),
324            _ => {}
325        }
326    }
327
328    output
329}
330
331fn single(value: &FieldValue) -> Option<String> {
332    match value {
333        FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
334        _ => None,
335    }
336}
337
338fn list(value: &FieldValue) -> Vec<String> {
339    match value {
340        FieldValue::List(items) => items.clone(),
341        FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
342        FieldValue::Single(_) => Vec::new(),
343    }
344}
345
346/// Parse a validated jurisdiction string (`"ID"` or `"ID/West Java"`) into a
347/// structured [`Jurisdiction`].
348fn parse_jurisdiction(s: &str) -> Option<Jurisdiction> {
349    if s.is_empty() {
350        return None;
351    }
352    if let Some((country, subdivision)) = s.split_once('/') {
353        Some(Jurisdiction {
354            country: country.to_string(),
355            subdivision: Some(subdivision.to_string()),
356        })
357    } else {
358        Some(Jurisdiction {
359            country: s.to_string(),
360            subdivision: None,
361        })
362    }
363}
364
365/// Parse a validated money string (`"500000000000 IDR \"Rp 500 billion\""`)
366/// into a structured [`Money`].
367fn parse_money(s: &str) -> Option<Money> {
368    let parts: Vec<&str> = s.splitn(3, ' ').collect();
369    if parts.len() < 3 {
370        return None;
371    }
372    let amount = parts[0].parse::<i64>().ok()?;
373    let currency = parts[1].to_string();
374    // Strip surrounding quotes from display
375    let display = parts[2]
376        .strip_prefix('"')
377        .and_then(|s| s.strip_suffix('"'))
378        .unwrap_or(parts[2])
379        .to_string();
380    Some(Money {
381        amount,
382        currency,
383        display,
384    })
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::entity::{FieldValue, Label};
391
392    fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
393        Entity {
394            name: name.to_string(),
395            label,
396            fields: fields
397                .into_iter()
398                .map(|(k, v)| (k.to_string(), v))
399                .collect(),
400            id: None,
401            line: 1,
402            tags: Vec::new(),
403        }
404    }
405
406    #[test]
407    fn build_minimal_output() {
408        let entities = vec![make_entity("Alice", Label::Person, vec![])];
409        let rels = vec![];
410        let result =
411            build_output("test", "Title", "Summary", &[], &[], &entities, &rels, &[]).unwrap();
412
413        assert_eq!(result.output.case_id, "test");
414        assert_eq!(result.output.title, "Title");
415        assert_eq!(result.output.summary, "Summary");
416        assert_eq!(result.output.nodes.len(), 1);
417        assert_eq!(result.output.nodes[0].name, "Alice");
418        assert_eq!(result.output.nodes[0].label, "person");
419        assert!(!result.output.nodes[0].id.is_empty());
420        // Entity had no id, so one should be generated
421        assert_eq!(result.case_pending.len(), 1);
422    }
423
424    #[test]
425    fn node_fields_populated() {
426        let entities = vec![make_entity(
427            "Mark",
428            Label::Person,
429            vec![
430                ("qualifier", FieldValue::Single("Kit Manager".into())),
431                ("nationality", FieldValue::Single("GB".into())),
432                ("role", FieldValue::Single("custom:Kit Manager".into())),
433                (
434                    "aliases",
435                    FieldValue::List(vec!["Marky".into(), "MB".into()]),
436                ),
437            ],
438        )];
439        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
440        let node = &result.output.nodes[0];
441
442        assert_eq!(node.qualifier, Some("Kit Manager".into()));
443        assert_eq!(node.nationality, Some("GB".into()));
444        assert_eq!(node.role, vec!["custom:Kit Manager"]);
445        assert_eq!(node.aliases, vec!["Marky", "MB"]);
446        assert!(node.org_type.is_none());
447    }
448
449    #[test]
450    fn relationship_output() {
451        let entities = vec![
452            make_entity("Alice", Label::Person, vec![]),
453            make_entity("Corp", Label::Organization, vec![]),
454        ];
455        let rels = vec![Rel {
456            source_name: "Alice".into(),
457            target_name: "Corp".into(),
458            rel_type: "employed_by".into(),
459            source_urls: vec!["https://example.com".into()],
460            fields: vec![("amount".into(), "EUR 50,000".into())],
461            id: None,
462            line: 10,
463        }];
464
465        let result = build_output("case", "T", "", &[], &[], &entities, &rels, &[]).unwrap();
466        assert_eq!(result.output.relationships.len(), 1);
467
468        let rel = &result.output.relationships[0];
469        assert_eq!(rel.rel_type, "employed_by");
470        assert_eq!(rel.source_urls, vec!["https://example.com"]);
471        assert_eq!(rel.amount, Some("EUR 50,000".into()));
472        assert_eq!(rel.source_id, result.output.nodes[0].id);
473        assert_eq!(rel.target_id, result.output.nodes[1].id);
474        // Relationship had no id, so one should be generated
475        assert!(
476            result
477                .case_pending
478                .iter()
479                .any(|p| matches!(p.kind, WriteBackKind::Relationship))
480        );
481    }
482
483    #[test]
484    fn empty_optional_fields_omitted_in_json() {
485        let entities = vec![make_entity("Test", Label::Person, vec![])];
486        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
487        let json = serde_json::to_string(&result.output).unwrap_or_default();
488
489        assert!(!json.contains("qualifier"));
490        assert!(!json.contains("description"));
491        assert!(!json.contains("aliases"));
492        assert!(!json.contains("summary"));
493    }
494
495    #[test]
496    fn json_roundtrip() {
497        let entities = vec![
498            make_entity(
499                "Alice",
500                Label::Person,
501                vec![("nationality", FieldValue::Single("Dutch".into()))],
502            ),
503            make_entity(
504                "Corp",
505                Label::Organization,
506                vec![("org_type", FieldValue::Single("corporation".into()))],
507            ),
508        ];
509        let rels = vec![Rel {
510            source_name: "Alice".into(),
511            target_name: "Corp".into(),
512            rel_type: "employed_by".into(),
513            source_urls: vec!["https://example.com".into()],
514            fields: vec![],
515            id: None,
516            line: 1,
517        }];
518        let sources = vec![crate::parser::SourceEntry::Url(
519            "https://example.com".into(),
520        )];
521
522        let result = build_output(
523            "test-case",
524            "Test Case",
525            "A summary.",
526            &[],
527            &sources,
528            &entities,
529            &rels,
530            &[],
531        )
532        .unwrap();
533        let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
534
535        assert!(json.contains("\"case_id\": \"test-case\""));
536        assert!(json.contains("\"nationality\": \"Dutch\""));
537        assert!(json.contains("\"org_type\": \"corporation\""));
538        assert!(json.contains("\"type\": \"employed_by\""));
539    }
540
541    #[test]
542    fn no_pending_when_ids_present() {
543        let entities = vec![Entity {
544            name: "Alice".to_string(),
545            label: Label::Person,
546            fields: vec![],
547            id: Some("01JABC000000000000000000AA".to_string()),
548            line: 1,
549            tags: Vec::new(),
550        }];
551        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
552        assert!(result.case_pending.is_empty());
553    }
554
555    #[test]
556    fn jurisdiction_structured_output() {
557        let entities = vec![make_entity(
558            "KPK",
559            Label::Organization,
560            vec![
561                ("org_type", FieldValue::Single("government_agency".into())),
562                (
563                    "jurisdiction",
564                    FieldValue::Single("ID/South Sulawesi".into()),
565                ),
566            ],
567        )];
568        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
569        let node = &result.output.nodes[0];
570        let j = node
571            .jurisdiction
572            .as_ref()
573            .expect("jurisdiction should be set");
574        assert_eq!(j.country, "ID");
575        assert_eq!(j.subdivision.as_deref(), Some("South Sulawesi"));
576
577        let json = serde_json::to_string(&result.output).unwrap_or_default();
578        assert!(json.contains("\"country\":\"ID\""));
579        assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
580    }
581
582    #[test]
583    fn jurisdiction_country_only() {
584        let entities = vec![make_entity(
585            "KPK",
586            Label::Organization,
587            vec![("jurisdiction", FieldValue::Single("GB".into()))],
588        )];
589        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
590        let j = result.output.nodes[0].jurisdiction.as_ref().unwrap();
591        assert_eq!(j.country, "GB");
592        assert!(j.subdivision.is_none());
593    }
594
595    #[test]
596    fn money_structured_output() {
597        let entities = vec![make_entity(
598            "Bribe Fund",
599            Label::Asset,
600            vec![(
601                "value",
602                FieldValue::Single("500000000000 IDR \"Rp 500 billion\"".into()),
603            )],
604        )];
605        let result = build_output("case", "T", "", &[], &[], &entities, &[], &[]).unwrap();
606        let node = &result.output.nodes[0];
607        let m = node.value.as_ref().expect("value should be set");
608        assert_eq!(m.amount, 500_000_000_000);
609        assert_eq!(m.currency, "IDR");
610        assert_eq!(m.display, "Rp 500 billion");
611
612        let json = serde_json::to_string(&result.output).unwrap_or_default();
613        assert!(json.contains("\"amount\":500000000000"));
614        assert!(json.contains("\"currency\":\"IDR\""));
615        assert!(json.contains("\"display\":\"Rp 500 billion\""));
616    }
617
618    #[test]
619    fn rel_temporal_fields_output() {
620        let entities = vec![
621            make_entity("Alice", Label::Person, vec![]),
622            make_entity("Corp", Label::Organization, vec![]),
623        ];
624        let rels = vec![Rel {
625            source_name: "Alice".into(),
626            target_name: "Corp".into(),
627            rel_type: "employed_by".into(),
628            source_urls: vec![],
629            fields: vec![
630                ("valid_from".into(), "2020-01".into()),
631                ("valid_until".into(), "2024-06".into()),
632            ],
633            id: None,
634            line: 1,
635        }];
636        let result = build_output("case", "T", "", &[], &[], &entities, &rels, &[]).unwrap();
637        let rel = &result.output.relationships[0];
638        assert_eq!(rel.valid_from.as_deref(), Some("2020-01"));
639        assert_eq!(rel.valid_until.as_deref(), Some("2024-06"));
640
641        let json = serde_json::to_string(&result.output).unwrap_or_default();
642        assert!(json.contains("\"valid_from\":\"2020-01\""));
643        assert!(json.contains("\"valid_until\":\"2024-06\""));
644        assert!(!json.contains("effective_date"));
645        assert!(!json.contains("expiry_date"));
646    }
647}