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 Custom(String),
64}
65
66const MAX_CUSTOM_LEN: usize = 100;
68
69impl Role {
70 pub const KNOWN: &[&str] = &[
72 "politician",
73 "executive",
74 "civil_servant",
75 "military",
76 "judiciary",
77 "law_enforcement",
78 "journalist",
79 "academic",
80 "activist",
81 "athlete",
82 "lawyer",
83 "lobbyist",
84 "banker",
85 "accountant",
86 "consultant",
87 ];
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
92#[serde(rename_all = "snake_case")]
93pub enum PersonStatus {
94 Active,
95 Deceased,
96 Imprisoned,
97 Fugitive,
98 Acquitted,
99 Pardoned,
100}
101
102impl PersonStatus {
103 pub const KNOWN: &[&str] = &[
104 "active",
105 "deceased",
106 "imprisoned",
107 "fugitive",
108 "acquitted",
109 "pardoned",
110 ];
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum OrgType {
121 GovernmentMinistry,
122 GovernmentAgency,
123 LocalGovernment,
124 Legislature,
125 Court,
126 LawEnforcement,
127 Prosecutor,
128 Regulator,
129 PoliticalParty,
130 StateEnterprise,
131 Corporation,
132 Bank,
133 Ngo,
134 Media,
135 University,
136 SportsClub,
137 SportsBody,
138 TradeUnion,
139 LobbyGroup,
140 Military,
141 ReligiousBody,
142 Custom(String),
143}
144
145impl OrgType {
146 pub const KNOWN: &[&str] = &[
147 "government_ministry",
148 "government_agency",
149 "local_government",
150 "legislature",
151 "court",
152 "law_enforcement",
153 "prosecutor",
154 "regulator",
155 "political_party",
156 "state_enterprise",
157 "corporation",
158 "bank",
159 "ngo",
160 "media",
161 "university",
162 "sports_club",
163 "sports_body",
164 "trade_union",
165 "lobby_group",
166 "military",
167 "religious_body",
168 ];
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum OrgStatus {
175 Active,
176 Dissolved,
177 Suspended,
178 Merged,
179}
180
181impl OrgStatus {
182 pub const KNOWN: &[&str] = &["active", "dissolved", "suspended", "merged"];
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
191#[serde(rename_all = "snake_case")]
192pub enum EventType {
193 Arrest,
194 Indictment,
195 Trial,
196 Conviction,
197 Acquittal,
198 Sentencing,
199 Appeal,
200 Pardon,
201 Parole,
202 Bribery,
203 Embezzlement,
204 Fraud,
205 Extortion,
206 MoneyLaundering,
207 Murder,
208 Assault,
209 Dismissal,
210 Resignation,
211 Appointment,
212 Election,
213 InvestigationOpened,
214 InvestigationClosed,
215 Raid,
216 Seizure,
217 Warrant,
218 FugitiveFlight,
219 FugitiveCapture,
220 PolicyChange,
221 ContractAward,
222 FinancialDefault,
223 Bailout,
224 WhistleblowerReport,
225 Custom(String),
226}
227
228impl EventType {
229 pub const KNOWN: &[&str] = &[
230 "arrest",
231 "indictment",
232 "trial",
233 "conviction",
234 "acquittal",
235 "sentencing",
236 "appeal",
237 "pardon",
238 "parole",
239 "bribery",
240 "embezzlement",
241 "fraud",
242 "extortion",
243 "money_laundering",
244 "murder",
245 "assault",
246 "dismissal",
247 "resignation",
248 "appointment",
249 "election",
250 "investigation_opened",
251 "investigation_closed",
252 "raid",
253 "seizure",
254 "warrant",
255 "fugitive_flight",
256 "fugitive_capture",
257 "policy_change",
258 "contract_award",
259 "financial_default",
260 "bailout",
261 "whistleblower_report",
262 ];
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
267#[serde(rename_all = "snake_case")]
268pub enum Severity {
269 Minor,
270 Significant,
271 Major,
272 Critical,
273}
274
275impl Severity {
276 pub const KNOWN: &[&str] = &["minor", "significant", "major", "critical"];
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
285#[serde(rename_all = "snake_case")]
286pub enum DocType {
287 CourtRuling,
288 Indictment,
289 ChargeSheet,
290 Warrant,
291 Contract,
292 Permit,
293 AuditReport,
294 FinancialDisclosure,
295 Legislation,
296 Regulation,
297 PressRelease,
298 InvestigationReport,
299 SanctionsNotice,
300 Custom(String),
301}
302
303impl DocType {
304 pub const KNOWN: &[&str] = &[
305 "court_ruling",
306 "indictment",
307 "charge_sheet",
308 "warrant",
309 "contract",
310 "permit",
311 "audit_report",
312 "financial_disclosure",
313 "legislation",
314 "regulation",
315 "press_release",
316 "investigation_report",
317 "sanctions_notice",
318 ];
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
327#[serde(rename_all = "snake_case")]
328pub enum AssetType {
329 Cash,
330 BankAccount,
331 RealEstate,
332 Vehicle,
333 Equity,
334 ContractValue,
335 Grant,
336 BudgetAllocation,
337 SeizedAsset,
338 Custom(String),
339}
340
341impl AssetType {
342 pub const KNOWN: &[&str] = &[
343 "cash",
344 "bank_account",
345 "real_estate",
346 "vehicle",
347 "equity",
348 "contract_value",
349 "grant",
350 "budget_allocation",
351 "seized_asset",
352 ];
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
357#[serde(rename_all = "snake_case")]
358pub enum AssetStatus {
359 Active,
360 Frozen,
361 Seized,
362 Forfeited,
363 Returned,
364}
365
366impl AssetStatus {
367 pub const KNOWN: &[&str] = &["active", "frozen", "seized", "forfeited", "returned"];
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
376#[serde(rename_all = "snake_case")]
377pub enum CaseType {
378 Corruption,
379 Fraud,
380 Bribery,
381 Embezzlement,
382 Murder,
383 Assault,
384 CivilRights,
385 Regulatory,
386 Political,
387 Custom(String),
388}
389
390impl CaseType {
391 pub const KNOWN: &[&str] = &[
392 "corruption",
393 "fraud",
394 "bribery",
395 "embezzlement",
396 "murder",
397 "assault",
398 "civil_rights",
399 "regulatory",
400 "political",
401 ];
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
406#[serde(rename_all = "snake_case")]
407pub enum CaseStatus {
408 Open,
409 UnderInvestigation,
410 Trial,
411 Convicted,
412 Acquitted,
413 Closed,
414 Appeal,
415 Pardoned,
416}
417
418impl CaseStatus {
419 pub const KNOWN: &[&str] = &[
420 "open",
421 "under_investigation",
422 "trial",
423 "convicted",
424 "acquitted",
425 "closed",
426 "appeal",
427 "pardoned",
428 ];
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
439pub struct Money {
440 pub amount: i64,
441 pub currency: String,
442 pub display: String,
443}
444
445pub const MAX_CURRENCY_LEN: usize = 3;
447
448pub const MAX_MONEY_DISPLAY_LEN: usize = 100;
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
453pub struct Jurisdiction {
454 pub country: String,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub subdivision: Option<String>,
459}
460
461pub const MAX_COUNTRY_LEN: usize = 2;
463
464pub const MAX_SUBDIVISION_LEN: usize = 200;
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
469pub struct Source {
470 pub url: String,
472 pub domain: String,
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub title: Option<String>,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub published_at: Option<String>,
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub archived_url: Option<String>,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub language: Option<String>,
486}
487
488pub const MAX_SOURCE_URL_LEN: usize = 2048;
490
491pub const MAX_SOURCE_DOMAIN_LEN: usize = 253;
493
494pub const MAX_SOURCE_TITLE_LEN: usize = 300;
496
497pub const MAX_SOURCE_LANGUAGE_LEN: usize = 2;
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
520pub struct AmountEntry {
521 pub value: i64,
522 pub currency: String,
523 #[serde(skip_serializing_if = "Option::is_none")]
524 pub label: Option<String>,
525 pub approximate: bool,
526}
527
528pub const MAX_AMOUNT_ENTRIES: usize = 10;
530
531const MAX_AMOUNT_LABEL_LEN: usize = 50;
533
534pub const AMOUNT_LABEL_KNOWN: &[&str] = &[
536 "bribe",
537 "fine",
538 "restitution",
539 "state_loss",
540 "kickback",
541 "embezzlement",
542 "fraud",
543 "gratification",
544 "bailout",
545 "procurement",
546 "penalty",
547 "fee",
548 "donation",
549 "loan",
550];
551
552impl AmountEntry {
553 pub fn parse_dsl(input: &str) -> Result<Vec<Self>, String> {
557 let input = input.trim();
558 if input.is_empty() {
559 return Ok(Vec::new());
560 }
561
562 let entries: Vec<&str> = input
563 .split('|')
564 .map(str::trim)
565 .filter(|s| !s.is_empty())
566 .collect();
567
568 if entries.len() > MAX_AMOUNT_ENTRIES {
569 return Err(format!(
570 "too many amount entries ({}, max {MAX_AMOUNT_ENTRIES})",
571 entries.len()
572 ));
573 }
574
575 entries.iter().map(|e| Self::parse_one(e)).collect()
576 }
577
578 fn parse_one(entry: &str) -> Result<Self, String> {
579 let (approximate, rest) = if let Some(r) = entry.strip_prefix('~') {
580 (true, r.trim_start())
581 } else {
582 (false, entry)
583 };
584
585 let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
586 match parts.len() {
587 2 | 3 => {
588 let value = parts[0]
589 .parse::<i64>()
590 .map_err(|_| format!("invalid amount value: {:?}", parts[0]))?;
591 let currency = Self::validate_currency(parts[1])?;
592 let label = if parts.len() == 3 {
593 Some(Self::validate_label(parts[2])?)
594 } else {
595 None
596 };
597 Ok(Self {
598 value,
599 currency,
600 label,
601 approximate,
602 })
603 }
604 _ => Err(format!("invalid amount format: {entry:?}")),
605 }
606 }
607
608 fn validate_currency(s: &str) -> Result<String, String> {
609 let upper = s.to_uppercase();
610 if upper.len() > MAX_CURRENCY_LEN
611 || upper.is_empty()
612 || !upper.chars().all(|c| c.is_ascii_uppercase())
613 {
614 return Err(format!("invalid currency: {s:?}"));
615 }
616 Ok(upper)
617 }
618
619 fn validate_label(s: &str) -> Result<String, String> {
620 if s.len() > MAX_AMOUNT_LABEL_LEN {
621 return Err(format!("amount label too long: {s:?}"));
622 }
623 if AMOUNT_LABEL_KNOWN.contains(&s) || parse_custom(s).is_some() {
624 Ok(s.to_string())
625 } else {
626 Err(format!(
627 "unknown amount label: {s:?} (use custom:Value for custom)"
628 ))
629 }
630 }
631
632 pub fn format_display(&self) -> String {
634 let prefix = if self.approximate { "~" } else { "" };
635 let formatted_value = format_human_number(self.value);
636 let label_suffix = match &self.label {
637 None => String::new(),
638 Some(l) => format!(" ({})", l.replace('_', " ")),
639 };
640 format!("{prefix}{} {formatted_value}{label_suffix}", self.currency)
641 }
642
643 pub fn format_list(entries: &[Self]) -> String {
645 entries
646 .iter()
647 .map(Self::format_display)
648 .collect::<Vec<_>>()
649 .join("; ")
650 }
651}
652
653impl fmt::Display for AmountEntry {
654 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655 write!(f, "{}", self.format_display())
656 }
657}
658
659fn format_human_number(n: i64) -> String {
671 let abs = n.unsigned_abs();
672 let neg = if n < 0 { "-" } else { "" };
673
674 let (divisor, suffix) = if abs >= 1_000_000_000_000 {
675 (1_000_000_000_000_u64, "trillion")
676 } else if abs >= 1_000_000_000 {
677 (1_000_000_000, "billion")
678 } else if abs >= 1_000_000 {
679 (1_000_000, "million")
680 } else {
681 return format_integer(n);
682 };
683
684 let whole = abs / divisor;
685 let remainder = abs % divisor;
686
687 if remainder == 0 {
688 return format!("{neg}{whole} {suffix}");
689 }
690
691 let frac = (remainder * 100) / divisor;
693 if frac == 0 {
694 format!("{neg}{whole} {suffix}")
695 } else if frac.is_multiple_of(10) {
696 format!("{neg}{whole}.{} {suffix}", frac / 10)
697 } else {
698 format!("{neg}{whole}.{frac:02} {suffix}")
699 }
700}
701
702fn format_integer(n: i64) -> String {
705 let s = n.to_string();
706 let bytes = s.as_bytes();
707 let mut result = String::with_capacity(s.len() + s.len() / 3);
708 let start = usize::from(n < 0);
709 if n < 0 {
710 result.push('-');
711 }
712 let digits = &bytes[start..];
713 for (i, &b) in digits.iter().enumerate() {
714 if i > 0 && (digits.len() - i).is_multiple_of(3) {
715 result.push('.');
716 }
717 result.push(b as char);
718 }
719 result
720}
721
722pub fn parse_custom(value: &str) -> Option<&str> {
729 let custom = value.strip_prefix("custom:")?;
730 if custom.is_empty() || custom.len() > MAX_CUSTOM_LEN {
731 return None;
732 }
733 Some(custom)
734}
735
736#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn entity_label_display() {
746 assert_eq!(EntityLabel::Person.to_string(), "person");
747 assert_eq!(EntityLabel::Organization.to_string(), "organization");
748 assert_eq!(EntityLabel::Event.to_string(), "event");
749 assert_eq!(EntityLabel::Document.to_string(), "document");
750 assert_eq!(EntityLabel::Asset.to_string(), "asset");
751 assert_eq!(EntityLabel::Case.to_string(), "case");
752 }
753
754 #[test]
755 fn entity_label_serializes_snake_case() {
756 let json = serde_json::to_string(&EntityLabel::Organization).unwrap_or_default();
757 assert_eq!(json, "\"organization\"");
758 }
759
760 #[test]
761 fn money_serialization() {
762 let m = Money {
763 amount: 500_000_000_000,
764 currency: "IDR".into(),
765 display: "Rp 500 billion".into(),
766 };
767 let json = serde_json::to_string(&m).unwrap_or_default();
768 assert!(json.contains("\"amount\":500000000000"));
769 assert!(json.contains("\"currency\":\"IDR\""));
770 assert!(json.contains("\"display\":\"Rp 500 billion\""));
771 }
772
773 #[test]
774 fn jurisdiction_without_subdivision() {
775 let j = Jurisdiction {
776 country: "ID".into(),
777 subdivision: None,
778 };
779 let json = serde_json::to_string(&j).unwrap_or_default();
780 assert!(json.contains("\"country\":\"ID\""));
781 assert!(!json.contains("subdivision"));
782 }
783
784 #[test]
785 fn jurisdiction_with_subdivision() {
786 let j = Jurisdiction {
787 country: "ID".into(),
788 subdivision: Some("South Sulawesi".into()),
789 };
790 let json = serde_json::to_string(&j).unwrap_or_default();
791 assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
792 }
793
794 #[test]
795 fn source_minimal() {
796 let s = Source {
797 url: "https://kompas.com/article".into(),
798 domain: "kompas.com".into(),
799 title: None,
800 published_at: None,
801 archived_url: None,
802 language: None,
803 };
804 let json = serde_json::to_string(&s).unwrap_or_default();
805 assert!(json.contains("\"domain\":\"kompas.com\""));
806 assert!(!json.contains("title"));
807 assert!(!json.contains("language"));
808 }
809
810 #[test]
811 fn source_full() {
812 let s = Source {
813 url: "https://kompas.com/article".into(),
814 domain: "kompas.com".into(),
815 title: Some("Breaking news".into()),
816 published_at: Some("2024-01-15".into()),
817 archived_url: Some(
818 "https://web.archive.org/web/2024/https://kompas.com/article".into(),
819 ),
820 language: Some("id".into()),
821 };
822 let json = serde_json::to_string(&s).unwrap_or_default();
823 assert!(json.contains("\"title\":\"Breaking news\""));
824 assert!(json.contains("\"language\":\"id\""));
825 }
826
827 #[test]
828 fn parse_custom_valid() {
829 assert_eq!(parse_custom("custom:Kit Manager"), Some("Kit Manager"));
830 }
831
832 #[test]
833 fn parse_custom_empty() {
834 assert_eq!(parse_custom("custom:"), None);
835 }
836
837 #[test]
838 fn parse_custom_too_long() {
839 let long = format!("custom:{}", "a".repeat(101));
840 assert_eq!(parse_custom(&long), None);
841 }
842
843 #[test]
844 fn parse_custom_no_prefix() {
845 assert_eq!(parse_custom("politician"), None);
846 }
847
848 #[test]
849 fn amount_entry_parse_simple() {
850 let entries = AmountEntry::parse_dsl("660000 USD bribe").unwrap();
851 assert_eq!(entries.len(), 1);
852 assert_eq!(entries[0].value, 660_000);
853 assert_eq!(entries[0].currency, "USD");
854 assert_eq!(entries[0].label.as_deref(), Some("bribe"));
855 assert!(!entries[0].approximate);
856 }
857
858 #[test]
859 fn amount_entry_parse_approximate() {
860 let entries = AmountEntry::parse_dsl("~16800000000000 IDR state_loss").unwrap();
861 assert_eq!(entries.len(), 1);
862 assert!(entries[0].approximate);
863 assert_eq!(entries[0].value, 16_800_000_000_000);
864 assert_eq!(entries[0].currency, "IDR");
865 assert_eq!(entries[0].label.as_deref(), Some("state_loss"));
866 }
867
868 #[test]
869 fn amount_entry_parse_multiple() {
870 let entries = AmountEntry::parse_dsl("660000 USD bribe | 250000000 IDR fine").unwrap();
871 assert_eq!(entries.len(), 2);
872 assert_eq!(entries[0].currency, "USD");
873 assert_eq!(entries[1].currency, "IDR");
874 }
875
876 #[test]
877 fn amount_entry_parse_no_label() {
878 let entries = AmountEntry::parse_dsl("1000 EUR").unwrap();
879 assert_eq!(entries.len(), 1);
880 assert!(entries[0].label.is_none());
881 }
882
883 #[test]
884 fn amount_entry_parse_empty() {
885 let entries = AmountEntry::parse_dsl("").unwrap();
886 assert!(entries.is_empty());
887 }
888
889 #[test]
890 fn amount_entry_parse_invalid_value() {
891 assert!(AmountEntry::parse_dsl("abc USD").is_err());
892 }
893
894 #[test]
895 fn amount_entry_parse_unknown_label() {
896 assert!(AmountEntry::parse_dsl("1000 USD unknown_label").is_err());
897 }
898
899 #[test]
900 fn amount_entry_parse_custom_label() {
901 let entries = AmountEntry::parse_dsl("1000 USD custom:MyLabel").unwrap();
902 assert_eq!(entries[0].label.as_deref(), Some("custom:MyLabel"));
903 }
904
905 #[test]
906 fn amount_entry_format_display() {
907 let entry = AmountEntry {
908 value: 660_000,
909 currency: "USD".into(),
910 label: Some("bribe".into()),
911 approximate: false,
912 };
913 assert_eq!(entry.format_display(), "USD 660.000 (bribe)");
914 }
915
916 #[test]
917 fn amount_entry_format_approximate() {
918 let entry = AmountEntry {
919 value: 16_800_000_000_000,
920 currency: "IDR".into(),
921 label: Some("state_loss".into()),
922 approximate: true,
923 };
924 assert_eq!(entry.format_display(), "~IDR 16.8 trillion (state loss)");
925 }
926
927 #[test]
928 fn amount_entry_format_no_label() {
929 let entry = AmountEntry {
930 value: 1000,
931 currency: "EUR".into(),
932 label: None,
933 approximate: false,
934 };
935 assert_eq!(entry.format_display(), "EUR 1.000");
936 }
937
938 #[test]
939 fn amount_entry_serialization() {
940 let entry = AmountEntry {
941 value: 660_000,
942 currency: "USD".into(),
943 label: Some("bribe".into()),
944 approximate: false,
945 };
946 let json = serde_json::to_string(&entry).unwrap_or_default();
947 assert!(json.contains("\"value\":660000"));
948 assert!(json.contains("\"currency\":\"USD\""));
949 assert!(json.contains("\"label\":\"bribe\""));
950 assert!(json.contains("\"approximate\":false"));
951 }
952
953 #[test]
954 fn amount_entry_serialization_no_label() {
955 let entry = AmountEntry {
956 value: 1000,
957 currency: "EUR".into(),
958 label: None,
959 approximate: false,
960 };
961 let json = serde_json::to_string(&entry).unwrap_or_default();
962 assert!(!json.contains("label"));
963 }
964
965 #[test]
966 fn format_integer_commas() {
967 assert_eq!(format_integer(0), "0");
968 assert_eq!(format_integer(999), "999");
969 assert_eq!(format_integer(1000), "1.000");
970 assert_eq!(format_integer(1_000_000), "1.000.000");
971 assert_eq!(format_integer(16_800_000_000_000), "16.800.000.000.000");
972 }
973
974 #[test]
975 fn format_human_number_below_million() {
976 assert_eq!(format_human_number(0), "0");
977 assert_eq!(format_human_number(999), "999");
978 assert_eq!(format_human_number(1_000), "1.000");
979 assert_eq!(format_human_number(660_000), "660.000");
980 assert_eq!(format_human_number(999_999), "999.999");
981 }
982
983 #[test]
984 fn format_human_number_millions() {
985 assert_eq!(format_human_number(1_000_000), "1 million");
986 assert_eq!(format_human_number(1_500_000), "1.5 million");
987 assert_eq!(format_human_number(250_000_000), "250 million");
988 assert_eq!(format_human_number(1_230_000), "1.23 million");
989 }
990
991 #[test]
992 fn format_human_number_billions() {
993 assert_eq!(format_human_number(1_000_000_000), "1 billion");
994 assert_eq!(format_human_number(4_580_000_000), "4.58 billion");
995 assert_eq!(format_human_number(100_000_000_000), "100 billion");
996 }
997
998 #[test]
999 fn format_human_number_trillions() {
1000 assert_eq!(format_human_number(1_000_000_000_000), "1 trillion");
1001 assert_eq!(format_human_number(4_580_000_000_000), "4.58 trillion");
1002 assert_eq!(format_human_number(144_000_000_000_000), "144 trillion");
1003 assert_eq!(format_human_number(16_800_000_000_000), "16.8 trillion");
1004 }
1005
1006 #[test]
1007 fn amount_entry_too_many() {
1008 let dsl = (0..11)
1009 .map(|i| format!("{i} USD"))
1010 .collect::<Vec<_>>()
1011 .join(" | ");
1012 assert!(AmountEntry::parse_dsl(&dsl).is_err());
1013 }
1014
1015 #[test]
1016 fn role_known_values_count() {
1017 assert_eq!(Role::KNOWN.len(), 15);
1018 }
1019
1020 #[test]
1021 fn event_type_known_values_count() {
1022 assert_eq!(EventType::KNOWN.len(), 32);
1023 }
1024
1025 #[test]
1026 fn org_type_known_values_count() {
1027 assert_eq!(OrgType::KNOWN.len(), 21);
1028 }
1029
1030 #[test]
1031 fn severity_known_values_count() {
1032 assert_eq!(Severity::KNOWN.len(), 4);
1033 }
1034}