1use std::fmt;
7
8use serde::Serialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum EntityLabel {
18 Person,
19 Organization,
20 Event,
21 Document,
22 Asset,
23 Case,
24}
25
26impl fmt::Display for EntityLabel {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Person => write!(f, "person"),
30 Self::Organization => write!(f, "organization"),
31 Self::Event => write!(f, "event"),
32 Self::Document => write!(f, "document"),
33 Self::Asset => write!(f, "asset"),
34 Self::Case => write!(f, "case"),
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Role {
47 Politician,
48 Executive,
49 CivilServant,
50 Military,
51 Judiciary,
52 LawEnforcement,
53 Journalist,
54 Academic,
55 Activist,
56 Athlete,
57 Lawyer,
58 Lobbyist,
59 Banker,
60 Accountant,
61 Consultant,
62 Militant,
63 Criminal,
64 ReligiousLeader,
65 RebelLeader,
66 Custom(String),
68}
69
70const MAX_CUSTOM_LEN: usize = 100;
72
73impl Role {
74 pub const KNOWN: &[&str] = &[
76 "politician",
77 "executive",
78 "civil_servant",
79 "military",
80 "judiciary",
81 "law_enforcement",
82 "journalist",
83 "academic",
84 "activist",
85 "athlete",
86 "lawyer",
87 "lobbyist",
88 "banker",
89 "accountant",
90 "consultant",
91 "militant",
92 "criminal",
93 "religious_leader",
94 "rebel_leader",
95 ];
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum PersonStatus {
102 Active,
103 Deceased,
104 Imprisoned,
105 Fugitive,
106 Acquitted,
107 Pardoned,
108}
109
110impl PersonStatus {
111 pub const KNOWN: &[&str] = &[
112 "active",
113 "deceased",
114 "imprisoned",
115 "fugitive",
116 "acquitted",
117 "pardoned",
118 ];
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
127#[serde(rename_all = "snake_case")]
128pub enum OrgType {
129 GovernmentMinistry,
130 GovernmentAgency,
131 LocalGovernment,
132 Legislature,
133 Court,
134 LawEnforcement,
135 Prosecutor,
136 Regulator,
137 PoliticalParty,
138 StateEnterprise,
139 Corporation,
140 Bank,
141 Ngo,
142 Media,
143 University,
144 SportsClub,
145 SportsBody,
146 TradeUnion,
147 LobbyGroup,
148 Military,
149 ReligiousBody,
150 MilitantGroup,
151 Custom(String),
152}
153
154impl OrgType {
155 pub const KNOWN: &[&str] = &[
156 "government_ministry",
157 "government_agency",
158 "local_government",
159 "legislature",
160 "court",
161 "law_enforcement",
162 "prosecutor",
163 "regulator",
164 "political_party",
165 "state_enterprise",
166 "corporation",
167 "bank",
168 "ngo",
169 "media",
170 "university",
171 "sports_club",
172 "sports_body",
173 "trade_union",
174 "lobby_group",
175 "military",
176 "religious_body",
177 "militant_group",
178 ];
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
183#[serde(rename_all = "snake_case")]
184pub enum OrgStatus {
185 Active,
186 Dissolved,
187 Suspended,
188 Merged,
189}
190
191impl OrgStatus {
192 pub const KNOWN: &[&str] = &["active", "dissolved", "suspended", "merged"];
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
201#[serde(rename_all = "snake_case")]
202pub enum EventType {
203 Arrest,
204 Indictment,
205 Trial,
206 Conviction,
207 Acquittal,
208 Sentencing,
209 Appeal,
210 Pardon,
211 Parole,
212 Bribery,
213 Embezzlement,
214 Fraud,
215 Extortion,
216 MoneyLaundering,
217 Murder,
218 Assault,
219 Dismissal,
220 Resignation,
221 Appointment,
222 Election,
223 InvestigationOpened,
224 InvestigationClosed,
225 Raid,
226 Seizure,
227 Warrant,
228 FugitiveFlight,
229 FugitiveCapture,
230 PolicyChange,
231 ContractAward,
232 FinancialDefault,
233 Bailout,
234 WhistleblowerReport,
235 Bombing,
236 SuicideBombing,
237 Massacre,
238 Execution,
239 Death,
240 Release,
241 Report,
242 Discovery,
243 Disaster,
244 Deportation,
245 Shooting,
246 Kidnapping,
247 PrisonEscape,
248 Petition,
249 Surrender,
250 Confession,
251 Hijacking,
252 Assassination,
253 Protest,
254 Sanctions,
255 Custom(String),
256}
257
258impl EventType {
259 pub const KNOWN: &[&str] = &[
260 "arrest",
261 "indictment",
262 "trial",
263 "conviction",
264 "acquittal",
265 "sentencing",
266 "appeal",
267 "pardon",
268 "parole",
269 "bribery",
270 "embezzlement",
271 "fraud",
272 "extortion",
273 "money_laundering",
274 "murder",
275 "assault",
276 "dismissal",
277 "resignation",
278 "appointment",
279 "election",
280 "investigation_opened",
281 "investigation_closed",
282 "raid",
283 "seizure",
284 "warrant",
285 "fugitive_flight",
286 "fugitive_capture",
287 "policy_change",
288 "contract_award",
289 "financial_default",
290 "bailout",
291 "whistleblower_report",
292 "bombing",
293 "suicide_bombing",
294 "massacre",
295 "execution",
296 "death",
297 "release",
298 "report",
299 "discovery",
300 "disaster",
301 "deportation",
302 "shooting",
303 "kidnapping",
304 "prison_escape",
305 "petition",
306 "surrender",
307 "confession",
308 "hijacking",
309 "assassination",
310 "protest",
311 "sanctions",
312 ];
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
317#[serde(rename_all = "snake_case")]
318pub enum Severity {
319 Minor,
320 Significant,
321 Major,
322 Critical,
323}
324
325impl Severity {
326 pub const KNOWN: &[&str] = &["minor", "significant", "major", "critical"];
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
335#[serde(rename_all = "snake_case")]
336pub enum DocType {
337 CourtRuling,
338 Indictment,
339 ChargeSheet,
340 Warrant,
341 Contract,
342 Permit,
343 AuditReport,
344 FinancialDisclosure,
345 Legislation,
346 Regulation,
347 PressRelease,
348 InvestigationReport,
349 SanctionsNotice,
350 Custom(String),
351}
352
353impl DocType {
354 pub const KNOWN: &[&str] = &[
355 "court_ruling",
356 "indictment",
357 "charge_sheet",
358 "warrant",
359 "contract",
360 "permit",
361 "audit_report",
362 "financial_disclosure",
363 "legislation",
364 "regulation",
365 "press_release",
366 "investigation_report",
367 "sanctions_notice",
368 ];
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
377#[serde(rename_all = "snake_case")]
378pub enum AssetType {
379 Cash,
380 BankAccount,
381 RealEstate,
382 Vehicle,
383 Equity,
384 ContractValue,
385 Grant,
386 BudgetAllocation,
387 SeizedAsset,
388 Custom(String),
389}
390
391impl AssetType {
392 pub const KNOWN: &[&str] = &[
393 "cash",
394 "bank_account",
395 "real_estate",
396 "vehicle",
397 "equity",
398 "contract_value",
399 "grant",
400 "budget_allocation",
401 "seized_asset",
402 ];
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
407#[serde(rename_all = "snake_case")]
408pub enum AssetStatus {
409 Active,
410 Frozen,
411 Seized,
412 Forfeited,
413 Returned,
414}
415
416impl AssetStatus {
417 pub const KNOWN: &[&str] = &["active", "frozen", "seized", "forfeited", "returned"];
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
426#[serde(rename_all = "snake_case")]
427pub enum CaseType {
428 Corruption,
429 Fraud,
430 Bribery,
431 Embezzlement,
432 Murder,
433 Assault,
434 CivilRights,
435 Regulatory,
436 Political,
437 Terrorism,
438 Negligence,
439 DrugTrafficking,
440 SexualAssault,
441 HumanTrafficking,
442 EnvironmentalCrime,
443 HumanRightsViolation,
444 OrganizedCrime,
445 DrugManufacturing,
446 Custom(String),
447}
448
449impl CaseType {
450 pub const KNOWN: &[&str] = &[
451 "corruption",
452 "fraud",
453 "bribery",
454 "embezzlement",
455 "murder",
456 "assault",
457 "civil_rights",
458 "regulatory",
459 "political",
460 "terrorism",
461 "negligence",
462 "drug_trafficking",
463 "sexual_assault",
464 "human_trafficking",
465 "environmental_crime",
466 "human_rights_violation",
467 "organized_crime",
468 "drug_manufacturing",
469 ];
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
474#[serde(rename_all = "snake_case")]
475pub enum CaseStatus {
476 Open,
477 UnderInvestigation,
478 Trial,
479 Convicted,
480 Acquitted,
481 Closed,
482 Appeal,
483 Pardoned,
484}
485
486impl CaseStatus {
487 pub const KNOWN: &[&str] = &[
488 "open",
489 "under_investigation",
490 "trial",
491 "convicted",
492 "acquitted",
493 "closed",
494 "appeal",
495 "pardoned",
496 ];
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
507pub struct Money {
508 pub amount: i64,
509 pub currency: String,
510 pub display: String,
511}
512
513pub const MAX_CURRENCY_LEN: usize = 3;
515
516pub const MAX_MONEY_DISPLAY_LEN: usize = 100;
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
521pub struct Jurisdiction {
522 pub country: String,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub subdivision: Option<String>,
527}
528
529pub const MAX_COUNTRY_LEN: usize = 2;
531
532pub const MAX_SUBDIVISION_LEN: usize = 200;
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
537pub struct Source {
538 pub url: String,
540 pub domain: String,
542 #[serde(skip_serializing_if = "Option::is_none")]
544 pub title: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
547 pub published_at: Option<String>,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 pub archived_url: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
553 pub language: Option<String>,
554}
555
556pub const MAX_SOURCE_URL_LEN: usize = 2048;
558
559pub const MAX_SOURCE_DOMAIN_LEN: usize = 253;
561
562pub const MAX_SOURCE_TITLE_LEN: usize = 300;
564
565pub const MAX_SOURCE_LANGUAGE_LEN: usize = 2;
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
588pub struct AmountEntry {
589 pub value: i64,
590 pub currency: String,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub label: Option<String>,
593 pub approximate: bool,
594}
595
596pub const MAX_AMOUNT_ENTRIES: usize = 10;
598
599const MAX_AMOUNT_LABEL_LEN: usize = 50;
601
602pub const AMOUNT_LABEL_KNOWN: &[&str] = &[
604 "bribe",
605 "fine",
606 "restitution",
607 "state_loss",
608 "kickback",
609 "embezzlement",
610 "fraud",
611 "gratification",
612 "bailout",
613 "procurement",
614 "penalty",
615 "fee",
616 "donation",
617 "loan",
618];
619
620impl AmountEntry {
621 pub fn parse_dsl(input: &str) -> Result<Vec<Self>, String> {
625 let input = input.trim();
626 if input.is_empty() {
627 return Ok(Vec::new());
628 }
629
630 let entries: Vec<&str> = input
631 .split('|')
632 .map(str::trim)
633 .filter(|s| !s.is_empty())
634 .collect();
635
636 if entries.len() > MAX_AMOUNT_ENTRIES {
637 return Err(format!(
638 "too many amount entries ({}, max {MAX_AMOUNT_ENTRIES})",
639 entries.len()
640 ));
641 }
642
643 entries.iter().map(|e| Self::parse_one(e)).collect()
644 }
645
646 fn parse_one(entry: &str) -> Result<Self, String> {
647 let (approximate, rest) = if let Some(r) = entry.strip_prefix('~') {
648 (true, r.trim_start())
649 } else {
650 (false, entry)
651 };
652
653 let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
654 match parts.len() {
655 2 | 3 => {
656 let value = parts[0]
657 .parse::<i64>()
658 .map_err(|_| format!("invalid amount value: {:?}", parts[0]))?;
659 let currency = Self::validate_currency(parts[1])?;
660 let label = if parts.len() == 3 {
661 Some(Self::validate_label(parts[2])?)
662 } else {
663 None
664 };
665 Ok(Self {
666 value,
667 currency,
668 label,
669 approximate,
670 })
671 }
672 _ => Err(format!("invalid amount format: {entry:?}")),
673 }
674 }
675
676 fn validate_currency(s: &str) -> Result<String, String> {
677 let upper = s.to_uppercase();
678 if upper.len() > MAX_CURRENCY_LEN
679 || upper.is_empty()
680 || !upper.chars().all(|c| c.is_ascii_uppercase())
681 {
682 return Err(format!("invalid currency: {s:?}"));
683 }
684 Ok(upper)
685 }
686
687 fn validate_label(s: &str) -> Result<String, String> {
688 if s.len() > MAX_AMOUNT_LABEL_LEN {
689 return Err(format!("amount label too long: {s:?}"));
690 }
691 if AMOUNT_LABEL_KNOWN.contains(&s) || parse_custom(s).is_some() {
692 Ok(s.to_string())
693 } else {
694 Err(format!(
695 "unknown amount label: {s:?} (use custom:Value for custom)"
696 ))
697 }
698 }
699
700 pub fn format_display(&self) -> String {
702 let prefix = if self.approximate { "~" } else { "" };
703 let formatted_value = format_human_number(self.value);
704 let label_suffix = match &self.label {
705 None => String::new(),
706 Some(l) => format!(" ({})", l.replace('_', " ")),
707 };
708 format!("{prefix}{} {formatted_value}{label_suffix}", self.currency)
709 }
710
711 pub fn format_list(entries: &[Self]) -> String {
713 entries
714 .iter()
715 .map(Self::format_display)
716 .collect::<Vec<_>>()
717 .join("; ")
718 }
719}
720
721impl fmt::Display for AmountEntry {
722 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
723 write!(f, "{}", self.format_display())
724 }
725}
726
727fn format_human_number(n: i64) -> String {
739 let abs = n.unsigned_abs();
740 let neg = if n < 0 { "-" } else { "" };
741
742 let (divisor, suffix) = if abs >= 1_000_000_000_000 {
743 (1_000_000_000_000_u64, "trillion")
744 } else if abs >= 1_000_000_000 {
745 (1_000_000_000, "billion")
746 } else if abs >= 1_000_000 {
747 (1_000_000, "million")
748 } else {
749 return format_integer(n);
750 };
751
752 let whole = abs / divisor;
753 let remainder = abs % divisor;
754
755 if remainder == 0 {
756 return format!("{neg}{whole} {suffix}");
757 }
758
759 let frac = (remainder * 100) / divisor;
761 if frac == 0 {
762 format!("{neg}{whole} {suffix}")
763 } else if frac.is_multiple_of(10) {
764 format!("{neg}{whole}.{} {suffix}", frac / 10)
765 } else {
766 format!("{neg}{whole}.{frac:02} {suffix}")
767 }
768}
769
770fn format_integer(n: i64) -> String {
773 let s = n.to_string();
774 let bytes = s.as_bytes();
775 let mut result = String::with_capacity(s.len() + s.len() / 3);
776 let start = usize::from(n < 0);
777 if n < 0 {
778 result.push('-');
779 }
780 let digits = &bytes[start..];
781 for (i, &b) in digits.iter().enumerate() {
782 if i > 0 && (digits.len() - i).is_multiple_of(3) {
783 result.push('.');
784 }
785 result.push(b as char);
786 }
787 result
788}
789
790pub fn parse_custom(value: &str) -> Option<&str> {
797 let custom = value.strip_prefix("custom:")?;
798 if custom.is_empty() || custom.len() > MAX_CUSTOM_LEN {
799 return None;
800 }
801 Some(custom)
802}
803
804#[cfg(test)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn entity_label_display() {
814 assert_eq!(EntityLabel::Person.to_string(), "person");
815 assert_eq!(EntityLabel::Organization.to_string(), "organization");
816 assert_eq!(EntityLabel::Event.to_string(), "event");
817 assert_eq!(EntityLabel::Document.to_string(), "document");
818 assert_eq!(EntityLabel::Asset.to_string(), "asset");
819 assert_eq!(EntityLabel::Case.to_string(), "case");
820 }
821
822 #[test]
823 fn entity_label_serializes_snake_case() {
824 let json = serde_json::to_string(&EntityLabel::Organization).unwrap_or_default();
825 assert_eq!(json, "\"organization\"");
826 }
827
828 #[test]
829 fn money_serialization() {
830 let m = Money {
831 amount: 500_000_000_000,
832 currency: "IDR".into(),
833 display: "Rp 500 billion".into(),
834 };
835 let json = serde_json::to_string(&m).unwrap_or_default();
836 assert!(json.contains("\"amount\":500000000000"));
837 assert!(json.contains("\"currency\":\"IDR\""));
838 assert!(json.contains("\"display\":\"Rp 500 billion\""));
839 }
840
841 #[test]
842 fn jurisdiction_without_subdivision() {
843 let j = Jurisdiction {
844 country: "ID".into(),
845 subdivision: None,
846 };
847 let json = serde_json::to_string(&j).unwrap_or_default();
848 assert!(json.contains("\"country\":\"ID\""));
849 assert!(!json.contains("subdivision"));
850 }
851
852 #[test]
853 fn jurisdiction_with_subdivision() {
854 let j = Jurisdiction {
855 country: "ID".into(),
856 subdivision: Some("South Sulawesi".into()),
857 };
858 let json = serde_json::to_string(&j).unwrap_or_default();
859 assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
860 }
861
862 #[test]
863 fn source_minimal() {
864 let s = Source {
865 url: "https://kompas.com/article".into(),
866 domain: "kompas.com".into(),
867 title: None,
868 published_at: None,
869 archived_url: None,
870 language: None,
871 };
872 let json = serde_json::to_string(&s).unwrap_or_default();
873 assert!(json.contains("\"domain\":\"kompas.com\""));
874 assert!(!json.contains("title"));
875 assert!(!json.contains("language"));
876 }
877
878 #[test]
879 fn source_full() {
880 let s = Source {
881 url: "https://kompas.com/article".into(),
882 domain: "kompas.com".into(),
883 title: Some("Breaking news".into()),
884 published_at: Some("2024-01-15".into()),
885 archived_url: Some(
886 "https://web.archive.org/web/2024/https://kompas.com/article".into(),
887 ),
888 language: Some("id".into()),
889 };
890 let json = serde_json::to_string(&s).unwrap_or_default();
891 assert!(json.contains("\"title\":\"Breaking news\""));
892 assert!(json.contains("\"language\":\"id\""));
893 }
894
895 #[test]
896 fn parse_custom_valid() {
897 assert_eq!(parse_custom("custom:Kit Manager"), Some("Kit Manager"));
898 }
899
900 #[test]
901 fn parse_custom_empty() {
902 assert_eq!(parse_custom("custom:"), None);
903 }
904
905 #[test]
906 fn parse_custom_too_long() {
907 let long = format!("custom:{}", "a".repeat(101));
908 assert_eq!(parse_custom(&long), None);
909 }
910
911 #[test]
912 fn parse_custom_no_prefix() {
913 assert_eq!(parse_custom("politician"), None);
914 }
915
916 #[test]
917 fn amount_entry_parse_simple() {
918 let entries = AmountEntry::parse_dsl("660000 USD bribe").unwrap();
919 assert_eq!(entries.len(), 1);
920 assert_eq!(entries[0].value, 660_000);
921 assert_eq!(entries[0].currency, "USD");
922 assert_eq!(entries[0].label.as_deref(), Some("bribe"));
923 assert!(!entries[0].approximate);
924 }
925
926 #[test]
927 fn amount_entry_parse_approximate() {
928 let entries = AmountEntry::parse_dsl("~16800000000000 IDR state_loss").unwrap();
929 assert_eq!(entries.len(), 1);
930 assert!(entries[0].approximate);
931 assert_eq!(entries[0].value, 16_800_000_000_000);
932 assert_eq!(entries[0].currency, "IDR");
933 assert_eq!(entries[0].label.as_deref(), Some("state_loss"));
934 }
935
936 #[test]
937 fn amount_entry_parse_multiple() {
938 let entries = AmountEntry::parse_dsl("660000 USD bribe | 250000000 IDR fine").unwrap();
939 assert_eq!(entries.len(), 2);
940 assert_eq!(entries[0].currency, "USD");
941 assert_eq!(entries[1].currency, "IDR");
942 }
943
944 #[test]
945 fn amount_entry_parse_no_label() {
946 let entries = AmountEntry::parse_dsl("1000 EUR").unwrap();
947 assert_eq!(entries.len(), 1);
948 assert!(entries[0].label.is_none());
949 }
950
951 #[test]
952 fn amount_entry_parse_empty() {
953 let entries = AmountEntry::parse_dsl("").unwrap();
954 assert!(entries.is_empty());
955 }
956
957 #[test]
958 fn amount_entry_parse_invalid_value() {
959 assert!(AmountEntry::parse_dsl("abc USD").is_err());
960 }
961
962 #[test]
963 fn amount_entry_parse_unknown_label() {
964 assert!(AmountEntry::parse_dsl("1000 USD unknown_label").is_err());
965 }
966
967 #[test]
968 fn amount_entry_parse_custom_label() {
969 let entries = AmountEntry::parse_dsl("1000 USD custom:MyLabel").unwrap();
970 assert_eq!(entries[0].label.as_deref(), Some("custom:MyLabel"));
971 }
972
973 #[test]
974 fn amount_entry_format_display() {
975 let entry = AmountEntry {
976 value: 660_000,
977 currency: "USD".into(),
978 label: Some("bribe".into()),
979 approximate: false,
980 };
981 assert_eq!(entry.format_display(), "USD 660.000 (bribe)");
982 }
983
984 #[test]
985 fn amount_entry_format_approximate() {
986 let entry = AmountEntry {
987 value: 16_800_000_000_000,
988 currency: "IDR".into(),
989 label: Some("state_loss".into()),
990 approximate: true,
991 };
992 assert_eq!(entry.format_display(), "~IDR 16.8 trillion (state loss)");
993 }
994
995 #[test]
996 fn amount_entry_format_no_label() {
997 let entry = AmountEntry {
998 value: 1000,
999 currency: "EUR".into(),
1000 label: None,
1001 approximate: false,
1002 };
1003 assert_eq!(entry.format_display(), "EUR 1.000");
1004 }
1005
1006 #[test]
1007 fn amount_entry_serialization() {
1008 let entry = AmountEntry {
1009 value: 660_000,
1010 currency: "USD".into(),
1011 label: Some("bribe".into()),
1012 approximate: false,
1013 };
1014 let json = serde_json::to_string(&entry).unwrap_or_default();
1015 assert!(json.contains("\"value\":660000"));
1016 assert!(json.contains("\"currency\":\"USD\""));
1017 assert!(json.contains("\"label\":\"bribe\""));
1018 assert!(json.contains("\"approximate\":false"));
1019 }
1020
1021 #[test]
1022 fn amount_entry_serialization_no_label() {
1023 let entry = AmountEntry {
1024 value: 1000,
1025 currency: "EUR".into(),
1026 label: None,
1027 approximate: false,
1028 };
1029 let json = serde_json::to_string(&entry).unwrap_or_default();
1030 assert!(!json.contains("label"));
1031 }
1032
1033 #[test]
1034 fn format_integer_commas() {
1035 assert_eq!(format_integer(0), "0");
1036 assert_eq!(format_integer(999), "999");
1037 assert_eq!(format_integer(1000), "1.000");
1038 assert_eq!(format_integer(1_000_000), "1.000.000");
1039 assert_eq!(format_integer(16_800_000_000_000), "16.800.000.000.000");
1040 }
1041
1042 #[test]
1043 fn format_human_number_below_million() {
1044 assert_eq!(format_human_number(0), "0");
1045 assert_eq!(format_human_number(999), "999");
1046 assert_eq!(format_human_number(1_000), "1.000");
1047 assert_eq!(format_human_number(660_000), "660.000");
1048 assert_eq!(format_human_number(999_999), "999.999");
1049 }
1050
1051 #[test]
1052 fn format_human_number_millions() {
1053 assert_eq!(format_human_number(1_000_000), "1 million");
1054 assert_eq!(format_human_number(1_500_000), "1.5 million");
1055 assert_eq!(format_human_number(250_000_000), "250 million");
1056 assert_eq!(format_human_number(1_230_000), "1.23 million");
1057 }
1058
1059 #[test]
1060 fn format_human_number_billions() {
1061 assert_eq!(format_human_number(1_000_000_000), "1 billion");
1062 assert_eq!(format_human_number(4_580_000_000), "4.58 billion");
1063 assert_eq!(format_human_number(100_000_000_000), "100 billion");
1064 }
1065
1066 #[test]
1067 fn format_human_number_trillions() {
1068 assert_eq!(format_human_number(1_000_000_000_000), "1 trillion");
1069 assert_eq!(format_human_number(4_580_000_000_000), "4.58 trillion");
1070 assert_eq!(format_human_number(144_000_000_000_000), "144 trillion");
1071 assert_eq!(format_human_number(16_800_000_000_000), "16.8 trillion");
1072 }
1073
1074 #[test]
1075 fn amount_entry_too_many() {
1076 let dsl = (0..11)
1077 .map(|i| format!("{i} USD"))
1078 .collect::<Vec<_>>()
1079 .join(" | ");
1080 assert!(AmountEntry::parse_dsl(&dsl).is_err());
1081 }
1082
1083 #[test]
1084 fn role_known_values_count() {
1085 assert_eq!(Role::KNOWN.len(), 19);
1086 }
1087
1088 #[test]
1089 fn event_type_known_values_count() {
1090 assert_eq!(EventType::KNOWN.len(), 52);
1091 }
1092
1093 #[test]
1094 fn org_type_known_values_count() {
1095 assert_eq!(OrgType::KNOWN.len(), 22);
1096 }
1097
1098 #[test]
1099 fn severity_known_values_count() {
1100 assert_eq!(Severity::KNOWN.len(), 4);
1101 }
1102}