1use serde::Serialize;
2
3use crate::entity::{Entity, FieldValue};
4use crate::nulid_gen;
5use crate::parser::ParseError;
6use crate::relationship::Rel;
7use crate::writeback::{PendingId, WriteBackKind};
8
9#[derive(Debug, Serialize)]
11pub struct CaseOutput {
12 pub case_id: String,
13 pub title: String,
14 #[serde(skip_serializing_if = "String::is_empty")]
15 pub summary: String,
16 pub nodes: Vec<NodeOutput>,
17 pub relationships: Vec<RelOutput>,
18 pub sources: Vec<String>,
19}
20
21#[derive(Debug, Serialize)]
23pub struct NodeOutput {
24 pub id: String,
25 pub label: String,
26 pub name: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub qualifier: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub description: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub occurred_at: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub thumbnail: Option<String>,
35 #[serde(skip_serializing_if = "Vec::is_empty")]
36 pub aliases: Vec<String>,
37 #[serde(skip_serializing_if = "Vec::is_empty")]
38 pub urls: Vec<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub date_of_birth: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub place_of_birth: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub nationality: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub occupation: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub institution_type: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub jurisdiction: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub headquarters: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub founded_date: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub registration_number: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub document_type: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub case_number: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub filing_date: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub issuing_authority: Option<String>,
68}
69
70#[derive(Debug, Serialize)]
72pub struct RelOutput {
73 pub id: String,
74 #[serde(rename = "type")]
75 pub rel_type: String,
76 pub source_id: String,
77 pub target_id: String,
78 #[serde(skip_serializing_if = "Vec::is_empty")]
79 pub source_urls: Vec<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub amount: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub currency: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub effective_date: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub expiry_date: Option<String>,
90}
91
92pub struct BuildResult {
94 pub output: CaseOutput,
95 pub case_pending: Vec<PendingId>,
97 pub registry_pending: Vec<(String, PendingId)>,
100}
101
102pub fn build_output(
108 case_id: &str,
109 title: &str,
110 summary: &str,
111 sources: &[String],
112 entities: &[Entity],
113 rels: &[Rel],
114 registry_entities: &[Entity],
115) -> Result<BuildResult, Vec<ParseError>> {
116 let mut errors = Vec::new();
117 let mut case_pending = Vec::new();
118 let mut registry_pending = Vec::new();
119
120 let mut entity_ids: Vec<(String, String)> = Vec::new();
122 let mut nodes = Vec::new();
123
124 for e in entities {
126 match nulid_gen::resolve_id(e.id.as_deref(), e.line) {
127 Ok((id, generated)) => {
128 let id_str = id.to_string();
129 if generated {
130 case_pending.push(PendingId {
131 line: e.line,
132 id: id_str.clone(),
133 kind: WriteBackKind::InlineEvent,
134 });
135 }
136 entity_ids.push((e.name.clone(), id_str.clone()));
137 nodes.push(entity_to_node(&id_str, e));
138 }
139 Err(err) => errors.push(err),
140 }
141 }
142
143 for e in registry_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 registry_pending.push((
150 e.name.clone(),
151 PendingId {
152 line: e.line,
153 id: id_str.clone(),
154 kind: WriteBackKind::EntityFrontMatter,
155 },
156 ));
157 }
158 entity_ids.push((e.name.clone(), id_str.clone()));
159 nodes.push(entity_to_node(&id_str, e));
160 }
161 Err(err) => errors.push(err),
162 }
163 }
164
165 let mut relationships = Vec::new();
166 for r in rels {
167 let source_id = entity_ids
168 .iter()
169 .find(|(name, _)| name == &r.source_name)
170 .map(|(_, id)| id.clone())
171 .unwrap_or_default();
172 let target_id = entity_ids
173 .iter()
174 .find(|(name, _)| name == &r.target_name)
175 .map(|(_, id)| id.clone())
176 .unwrap_or_default();
177
178 match nulid_gen::resolve_id(r.id.as_deref(), r.line) {
179 Ok((id, generated)) => {
180 let id_str = id.to_string();
181 if generated && r.rel_type != "next" {
184 case_pending.push(PendingId {
185 line: r.line,
186 id: id_str.clone(),
187 kind: WriteBackKind::Relationship,
188 });
189 }
190 relationships.push(rel_to_output(&id_str, &source_id, &target_id, r));
191 }
192 Err(err) => errors.push(err),
193 }
194 }
195
196 if !errors.is_empty() {
197 return Err(errors);
198 }
199
200 Ok(BuildResult {
201 output: CaseOutput {
202 case_id: case_id.to_string(),
203 title: title.to_string(),
204 summary: summary.to_string(),
205 nodes,
206 relationships,
207 sources: sources.to_vec(),
208 },
209 case_pending,
210 registry_pending,
211 })
212}
213
214fn entity_to_node(id: &str, entity: &Entity) -> NodeOutput {
215 let label = entity.label.to_string();
216
217 let mut node = NodeOutput {
218 id: id.to_string(),
219 label,
220 name: entity.name.clone(),
221 qualifier: None,
222 description: None,
223 occurred_at: None,
224 thumbnail: None,
225 aliases: Vec::new(),
226 urls: Vec::new(),
227 date_of_birth: None,
228 place_of_birth: None,
229 nationality: None,
230 occupation: None,
231 institution_type: None,
232 jurisdiction: None,
233 headquarters: None,
234 founded_date: None,
235 registration_number: None,
236 document_type: None,
237 case_number: None,
238 filing_date: None,
239 issuing_authority: None,
240 };
241
242 for (key, value) in &entity.fields {
243 match key.as_str() {
244 "qualifier" => node.qualifier = single(value),
245 "description" => node.description = single(value),
246 "occurred_at" => node.occurred_at = single(value),
247 "thumbnail" | "thumbnail_source" => node.thumbnail = single(value),
248 "aliases" => node.aliases = list(value),
249 "urls" => node.urls = list(value),
250 "date_of_birth" => node.date_of_birth = single(value),
251 "place_of_birth" => node.place_of_birth = single(value),
252 "nationality" => node.nationality = single(value),
253 "occupation" => node.occupation = single(value),
254 "institution_type" => node.institution_type = single(value),
255 "jurisdiction" => node.jurisdiction = single(value),
256 "headquarters" => node.headquarters = single(value),
257 "founded_date" => node.founded_date = single(value),
258 "registration_number" => node.registration_number = single(value),
259 "document_type" => node.document_type = single(value),
260 "case_number" => node.case_number = single(value),
261 "filing_date" => node.filing_date = single(value),
262 "issuing_authority" => node.issuing_authority = single(value),
263 _ => {} }
265 }
266
267 node
268}
269
270fn rel_to_output(id: &str, source_id: &str, target_id: &str, rel: &Rel) -> RelOutput {
271 let mut output = RelOutput {
272 id: id.to_string(),
273 rel_type: rel.rel_type.clone(),
274 source_id: source_id.to_string(),
275 target_id: target_id.to_string(),
276 source_urls: rel.source_urls.clone(),
277 description: None,
278 amount: None,
279 currency: None,
280 effective_date: None,
281 expiry_date: None,
282 };
283
284 for (key, value) in &rel.fields {
285 match key.as_str() {
286 "description" => output.description = Some(value.clone()),
287 "amount" => output.amount = Some(value.clone()),
288 "currency" => output.currency = Some(value.clone()),
289 "effective_date" => output.effective_date = Some(value.clone()),
290 "expiry_date" => output.expiry_date = Some(value.clone()),
291 _ => {}
292 }
293 }
294
295 output
296}
297
298fn single(value: &FieldValue) -> Option<String> {
299 match value {
300 FieldValue::Single(s) if !s.is_empty() => Some(s.clone()),
301 _ => None,
302 }
303}
304
305fn list(value: &FieldValue) -> Vec<String> {
306 match value {
307 FieldValue::List(items) => items.clone(),
308 FieldValue::Single(s) if !s.is_empty() => vec![s.clone()],
309 FieldValue::Single(_) => Vec::new(),
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::entity::{FieldValue, Label};
317
318 fn make_entity(name: &str, label: Label, fields: Vec<(&str, FieldValue)>) -> Entity {
319 Entity {
320 name: name.to_string(),
321 label,
322 fields: fields
323 .into_iter()
324 .map(|(k, v)| (k.to_string(), v))
325 .collect(),
326 id: None,
327 line: 1,
328 }
329 }
330
331 #[test]
332 fn build_minimal_output() {
333 let entities = vec![make_entity("Alice", Label::Actor, vec![])];
334 let rels = vec![];
335 let result = build_output("test", "Title", "Summary", &[], &entities, &rels, &[]).unwrap();
336
337 assert_eq!(result.output.case_id, "test");
338 assert_eq!(result.output.title, "Title");
339 assert_eq!(result.output.summary, "Summary");
340 assert_eq!(result.output.nodes.len(), 1);
341 assert_eq!(result.output.nodes[0].name, "Alice");
342 assert_eq!(result.output.nodes[0].label, "actor");
343 assert!(!result.output.nodes[0].id.is_empty());
344 assert_eq!(result.case_pending.len(), 1);
346 }
347
348 #[test]
349 fn node_fields_populated() {
350 let entities = vec![make_entity(
351 "Mark",
352 Label::Actor,
353 vec![
354 ("qualifier", FieldValue::Single("Kit Manager".into())),
355 ("nationality", FieldValue::Single("British".into())),
356 (
357 "occupation",
358 FieldValue::Single("custom:Kit Manager".into()),
359 ),
360 (
361 "aliases",
362 FieldValue::List(vec!["Marky".into(), "MB".into()]),
363 ),
364 ],
365 )];
366 let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
367 let node = &result.output.nodes[0];
368
369 assert_eq!(node.qualifier, Some("Kit Manager".into()));
370 assert_eq!(node.nationality, Some("British".into()));
371 assert_eq!(node.occupation, Some("custom:Kit Manager".into()));
372 assert_eq!(node.aliases, vec!["Marky", "MB"]);
373 assert!(node.institution_type.is_none());
374 }
375
376 #[test]
377 fn relationship_output() {
378 let entities = vec![
379 make_entity("Alice", Label::Actor, vec![]),
380 make_entity("Corp", Label::Institution, vec![]),
381 ];
382 let rels = vec![Rel {
383 source_name: "Alice".into(),
384 target_name: "Corp".into(),
385 rel_type: "employed_by".into(),
386 source_urls: vec!["https://example.com".into()],
387 fields: vec![("amount".into(), "EUR 50,000".into())],
388 id: None,
389 line: 10,
390 }];
391
392 let result = build_output("case", "T", "", &[], &entities, &rels, &[]).unwrap();
393 assert_eq!(result.output.relationships.len(), 1);
394
395 let rel = &result.output.relationships[0];
396 assert_eq!(rel.rel_type, "employed_by");
397 assert_eq!(rel.source_urls, vec!["https://example.com"]);
398 assert_eq!(rel.amount, Some("EUR 50,000".into()));
399 assert_eq!(rel.source_id, result.output.nodes[0].id);
400 assert_eq!(rel.target_id, result.output.nodes[1].id);
401 assert!(
403 result
404 .case_pending
405 .iter()
406 .any(|p| matches!(p.kind, WriteBackKind::Relationship))
407 );
408 }
409
410 #[test]
411 fn empty_optional_fields_omitted_in_json() {
412 let entities = vec![make_entity("Test", Label::Actor, vec![])];
413 let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
414 let json = serde_json::to_string(&result.output).unwrap_or_default();
415
416 assert!(!json.contains("qualifier"));
417 assert!(!json.contains("description"));
418 assert!(!json.contains("aliases"));
419 assert!(!json.contains("summary"));
420 }
421
422 #[test]
423 fn json_roundtrip() {
424 let entities = vec![
425 make_entity(
426 "Alice",
427 Label::Actor,
428 vec![("nationality", FieldValue::Single("Dutch".into()))],
429 ),
430 make_entity(
431 "Corp",
432 Label::Institution,
433 vec![("institution_type", FieldValue::Single("corporation".into()))],
434 ),
435 ];
436 let rels = vec![Rel {
437 source_name: "Alice".into(),
438 target_name: "Corp".into(),
439 rel_type: "employed_by".into(),
440 source_urls: vec!["https://example.com".into()],
441 fields: vec![],
442 id: None,
443 line: 1,
444 }];
445 let sources = vec!["https://example.com".into()];
446
447 let result = build_output(
448 "test-case",
449 "Test Case",
450 "A summary.",
451 &sources,
452 &entities,
453 &rels,
454 &[],
455 )
456 .unwrap();
457 let json = serde_json::to_string_pretty(&result.output).unwrap_or_default();
458
459 assert!(json.contains("\"case_id\": \"test-case\""));
460 assert!(json.contains("\"nationality\": \"Dutch\""));
461 assert!(json.contains("\"institution_type\": \"corporation\""));
462 assert!(json.contains("\"type\": \"employed_by\""));
463 }
464
465 #[test]
466 fn no_pending_when_ids_present() {
467 let entities = vec![Entity {
468 name: "Alice".to_string(),
469 label: Label::Actor,
470 fields: vec![],
471 id: Some("01JABC000000000000000000AA".to_string()),
472 line: 1,
473 }];
474 let result = build_output("case", "T", "", &[], &entities, &[], &[]).unwrap();
475 assert!(result.case_pending.is_empty());
476 }
477}