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#[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#[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 #[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 #[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 #[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 #[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 #[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 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub tags: Vec<String>,
86}
87
88#[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
110pub struct BuildResult {
112 pub output: CaseOutput,
113 pub case_pending: Vec<PendingId>,
115 pub registry_pending: Vec<(String, PendingId)>,
118}
119
120pub 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 let mut entity_ids: Vec<(String, String)> = Vec::new();
141 let mut nodes = Vec::new();
142
143 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 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 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 _ => {} }
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
346fn 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
365fn 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 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 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 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}