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 CivilRights,
384 Regulatory,
385 Political,
386 Custom(String),
387}
388
389impl CaseType {
390 pub const KNOWN: &[&str] = &[
391 "corruption",
392 "fraud",
393 "bribery",
394 "embezzlement",
395 "murder",
396 "civil_rights",
397 "regulatory",
398 "political",
399 ];
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
404#[serde(rename_all = "snake_case")]
405pub enum CaseStatus {
406 Open,
407 UnderInvestigation,
408 Trial,
409 Convicted,
410 Acquitted,
411 Closed,
412 Appeal,
413 Pardoned,
414}
415
416impl CaseStatus {
417 pub const KNOWN: &[&str] = &[
418 "open",
419 "under_investigation",
420 "trial",
421 "convicted",
422 "acquitted",
423 "closed",
424 "appeal",
425 "pardoned",
426 ];
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
437pub struct Money {
438 pub amount: i64,
439 pub currency: String,
440 pub display: String,
441}
442
443pub const MAX_CURRENCY_LEN: usize = 3;
445
446pub const MAX_MONEY_DISPLAY_LEN: usize = 100;
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
451pub struct Jurisdiction {
452 pub country: String,
454 #[serde(skip_serializing_if = "Option::is_none")]
456 pub subdivision: Option<String>,
457}
458
459pub const MAX_COUNTRY_LEN: usize = 2;
461
462pub const MAX_SUBDIVISION_LEN: usize = 200;
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
467pub struct Source {
468 pub url: String,
470 pub domain: String,
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub title: Option<String>,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub published_at: Option<String>,
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub archived_url: Option<String>,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub language: Option<String>,
484}
485
486pub const MAX_SOURCE_URL_LEN: usize = 2048;
488
489pub const MAX_SOURCE_DOMAIN_LEN: usize = 253;
491
492pub const MAX_SOURCE_TITLE_LEN: usize = 300;
494
495pub const MAX_SOURCE_LANGUAGE_LEN: usize = 2;
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518pub struct AmountEntry {
519 pub value: i64,
520 pub currency: String,
521 #[serde(skip_serializing_if = "Option::is_none")]
522 pub label: Option<String>,
523 pub approximate: bool,
524}
525
526pub const MAX_AMOUNT_ENTRIES: usize = 10;
528
529const MAX_AMOUNT_LABEL_LEN: usize = 50;
531
532pub const AMOUNT_LABEL_KNOWN: &[&str] = &[
534 "bribe",
535 "fine",
536 "restitution",
537 "state_loss",
538 "kickback",
539 "embezzlement",
540 "fraud",
541 "gratification",
542 "bailout",
543 "procurement",
544 "penalty",
545 "fee",
546 "donation",
547 "loan",
548];
549
550impl AmountEntry {
551 pub fn parse_dsl(input: &str) -> Result<Vec<Self>, String> {
555 let input = input.trim();
556 if input.is_empty() {
557 return Ok(Vec::new());
558 }
559
560 let entries: Vec<&str> = input
561 .split('|')
562 .map(str::trim)
563 .filter(|s| !s.is_empty())
564 .collect();
565
566 if entries.len() > MAX_AMOUNT_ENTRIES {
567 return Err(format!(
568 "too many amount entries ({}, max {MAX_AMOUNT_ENTRIES})",
569 entries.len()
570 ));
571 }
572
573 entries.iter().map(|e| Self::parse_one(e)).collect()
574 }
575
576 fn parse_one(entry: &str) -> Result<Self, String> {
577 let (approximate, rest) = if let Some(r) = entry.strip_prefix('~') {
578 (true, r.trim_start())
579 } else {
580 (false, entry)
581 };
582
583 let parts: Vec<&str> = rest.splitn(3, char::is_whitespace).collect();
584 match parts.len() {
585 2 | 3 => {
586 let value = parts[0]
587 .parse::<i64>()
588 .map_err(|_| format!("invalid amount value: {:?}", parts[0]))?;
589 let currency = Self::validate_currency(parts[1])?;
590 let label = if parts.len() == 3 {
591 Some(Self::validate_label(parts[2])?)
592 } else {
593 None
594 };
595 Ok(Self {
596 value,
597 currency,
598 label,
599 approximate,
600 })
601 }
602 _ => Err(format!("invalid amount format: {entry:?}")),
603 }
604 }
605
606 fn validate_currency(s: &str) -> Result<String, String> {
607 let upper = s.to_uppercase();
608 if upper.len() > MAX_CURRENCY_LEN
609 || upper.is_empty()
610 || !upper.chars().all(|c| c.is_ascii_uppercase())
611 {
612 return Err(format!("invalid currency: {s:?}"));
613 }
614 Ok(upper)
615 }
616
617 fn validate_label(s: &str) -> Result<String, String> {
618 if s.len() > MAX_AMOUNT_LABEL_LEN {
619 return Err(format!("amount label too long: {s:?}"));
620 }
621 if AMOUNT_LABEL_KNOWN.contains(&s) || parse_custom(s).is_some() {
622 Ok(s.to_string())
623 } else {
624 Err(format!(
625 "unknown amount label: {s:?} (use custom:Value for custom)"
626 ))
627 }
628 }
629
630 pub fn format_display(&self) -> String {
632 let prefix = if self.approximate { "~" } else { "" };
633 let formatted_value = format_human_number(self.value);
634 let label_suffix = match &self.label {
635 None => String::new(),
636 Some(l) => format!(" ({})", l.replace('_', " ")),
637 };
638 format!("{prefix}{} {formatted_value}{label_suffix}", self.currency)
639 }
640
641 pub fn format_list(entries: &[Self]) -> String {
643 entries
644 .iter()
645 .map(Self::format_display)
646 .collect::<Vec<_>>()
647 .join("; ")
648 }
649}
650
651impl fmt::Display for AmountEntry {
652 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
653 write!(f, "{}", self.format_display())
654 }
655}
656
657fn format_human_number(n: i64) -> String {
669 let abs = n.unsigned_abs();
670 let neg = if n < 0 { "-" } else { "" };
671
672 let (divisor, suffix) = if abs >= 1_000_000_000_000 {
673 (1_000_000_000_000_u64, "trillion")
674 } else if abs >= 1_000_000_000 {
675 (1_000_000_000, "billion")
676 } else if abs >= 1_000_000 {
677 (1_000_000, "million")
678 } else {
679 return format_integer(n);
680 };
681
682 let whole = abs / divisor;
683 let remainder = abs % divisor;
684
685 if remainder == 0 {
686 return format!("{neg}{whole} {suffix}");
687 }
688
689 let frac = (remainder * 100) / divisor;
691 if frac == 0 {
692 format!("{neg}{whole} {suffix}")
693 } else if frac.is_multiple_of(10) {
694 format!("{neg}{whole}.{} {suffix}", frac / 10)
695 } else {
696 format!("{neg}{whole}.{frac:02} {suffix}")
697 }
698}
699
700fn format_integer(n: i64) -> String {
703 let s = n.to_string();
704 let bytes = s.as_bytes();
705 let mut result = String::with_capacity(s.len() + s.len() / 3);
706 let start = usize::from(n < 0);
707 if n < 0 {
708 result.push('-');
709 }
710 let digits = &bytes[start..];
711 for (i, &b) in digits.iter().enumerate() {
712 if i > 0 && (digits.len() - i).is_multiple_of(3) {
713 result.push('.');
714 }
715 result.push(b as char);
716 }
717 result
718}
719
720pub fn parse_custom(value: &str) -> Option<&str> {
727 let custom = value.strip_prefix("custom:")?;
728 if custom.is_empty() || custom.len() > MAX_CUSTOM_LEN {
729 return None;
730 }
731 Some(custom)
732}
733
734#[cfg(test)]
739mod tests {
740 use super::*;
741
742 #[test]
743 fn entity_label_display() {
744 assert_eq!(EntityLabel::Person.to_string(), "person");
745 assert_eq!(EntityLabel::Organization.to_string(), "organization");
746 assert_eq!(EntityLabel::Event.to_string(), "event");
747 assert_eq!(EntityLabel::Document.to_string(), "document");
748 assert_eq!(EntityLabel::Asset.to_string(), "asset");
749 assert_eq!(EntityLabel::Case.to_string(), "case");
750 }
751
752 #[test]
753 fn entity_label_serializes_snake_case() {
754 let json = serde_json::to_string(&EntityLabel::Organization).unwrap_or_default();
755 assert_eq!(json, "\"organization\"");
756 }
757
758 #[test]
759 fn money_serialization() {
760 let m = Money {
761 amount: 500_000_000_000,
762 currency: "IDR".into(),
763 display: "Rp 500 billion".into(),
764 };
765 let json = serde_json::to_string(&m).unwrap_or_default();
766 assert!(json.contains("\"amount\":500000000000"));
767 assert!(json.contains("\"currency\":\"IDR\""));
768 assert!(json.contains("\"display\":\"Rp 500 billion\""));
769 }
770
771 #[test]
772 fn jurisdiction_without_subdivision() {
773 let j = Jurisdiction {
774 country: "ID".into(),
775 subdivision: None,
776 };
777 let json = serde_json::to_string(&j).unwrap_or_default();
778 assert!(json.contains("\"country\":\"ID\""));
779 assert!(!json.contains("subdivision"));
780 }
781
782 #[test]
783 fn jurisdiction_with_subdivision() {
784 let j = Jurisdiction {
785 country: "ID".into(),
786 subdivision: Some("South Sulawesi".into()),
787 };
788 let json = serde_json::to_string(&j).unwrap_or_default();
789 assert!(json.contains("\"subdivision\":\"South Sulawesi\""));
790 }
791
792 #[test]
793 fn source_minimal() {
794 let s = Source {
795 url: "https://kompas.com/article".into(),
796 domain: "kompas.com".into(),
797 title: None,
798 published_at: None,
799 archived_url: None,
800 language: None,
801 };
802 let json = serde_json::to_string(&s).unwrap_or_default();
803 assert!(json.contains("\"domain\":\"kompas.com\""));
804 assert!(!json.contains("title"));
805 assert!(!json.contains("language"));
806 }
807
808 #[test]
809 fn source_full() {
810 let s = Source {
811 url: "https://kompas.com/article".into(),
812 domain: "kompas.com".into(),
813 title: Some("Breaking news".into()),
814 published_at: Some("2024-01-15".into()),
815 archived_url: Some(
816 "https://web.archive.org/web/2024/https://kompas.com/article".into(),
817 ),
818 language: Some("id".into()),
819 };
820 let json = serde_json::to_string(&s).unwrap_or_default();
821 assert!(json.contains("\"title\":\"Breaking news\""));
822 assert!(json.contains("\"language\":\"id\""));
823 }
824
825 #[test]
826 fn parse_custom_valid() {
827 assert_eq!(parse_custom("custom:Kit Manager"), Some("Kit Manager"));
828 }
829
830 #[test]
831 fn parse_custom_empty() {
832 assert_eq!(parse_custom("custom:"), None);
833 }
834
835 #[test]
836 fn parse_custom_too_long() {
837 let long = format!("custom:{}", "a".repeat(101));
838 assert_eq!(parse_custom(&long), None);
839 }
840
841 #[test]
842 fn parse_custom_no_prefix() {
843 assert_eq!(parse_custom("politician"), None);
844 }
845
846 #[test]
847 fn amount_entry_parse_simple() {
848 let entries = AmountEntry::parse_dsl("660000 USD bribe").unwrap();
849 assert_eq!(entries.len(), 1);
850 assert_eq!(entries[0].value, 660_000);
851 assert_eq!(entries[0].currency, "USD");
852 assert_eq!(entries[0].label.as_deref(), Some("bribe"));
853 assert!(!entries[0].approximate);
854 }
855
856 #[test]
857 fn amount_entry_parse_approximate() {
858 let entries = AmountEntry::parse_dsl("~16800000000000 IDR state_loss").unwrap();
859 assert_eq!(entries.len(), 1);
860 assert!(entries[0].approximate);
861 assert_eq!(entries[0].value, 16_800_000_000_000);
862 assert_eq!(entries[0].currency, "IDR");
863 assert_eq!(entries[0].label.as_deref(), Some("state_loss"));
864 }
865
866 #[test]
867 fn amount_entry_parse_multiple() {
868 let entries = AmountEntry::parse_dsl("660000 USD bribe | 250000000 IDR fine").unwrap();
869 assert_eq!(entries.len(), 2);
870 assert_eq!(entries[0].currency, "USD");
871 assert_eq!(entries[1].currency, "IDR");
872 }
873
874 #[test]
875 fn amount_entry_parse_no_label() {
876 let entries = AmountEntry::parse_dsl("1000 EUR").unwrap();
877 assert_eq!(entries.len(), 1);
878 assert!(entries[0].label.is_none());
879 }
880
881 #[test]
882 fn amount_entry_parse_empty() {
883 let entries = AmountEntry::parse_dsl("").unwrap();
884 assert!(entries.is_empty());
885 }
886
887 #[test]
888 fn amount_entry_parse_invalid_value() {
889 assert!(AmountEntry::parse_dsl("abc USD").is_err());
890 }
891
892 #[test]
893 fn amount_entry_parse_unknown_label() {
894 assert!(AmountEntry::parse_dsl("1000 USD unknown_label").is_err());
895 }
896
897 #[test]
898 fn amount_entry_parse_custom_label() {
899 let entries = AmountEntry::parse_dsl("1000 USD custom:MyLabel").unwrap();
900 assert_eq!(entries[0].label.as_deref(), Some("custom:MyLabel"));
901 }
902
903 #[test]
904 fn amount_entry_format_display() {
905 let entry = AmountEntry {
906 value: 660_000,
907 currency: "USD".into(),
908 label: Some("bribe".into()),
909 approximate: false,
910 };
911 assert_eq!(entry.format_display(), "USD 660.000 (bribe)");
912 }
913
914 #[test]
915 fn amount_entry_format_approximate() {
916 let entry = AmountEntry {
917 value: 16_800_000_000_000,
918 currency: "IDR".into(),
919 label: Some("state_loss".into()),
920 approximate: true,
921 };
922 assert_eq!(entry.format_display(), "~IDR 16.8 trillion (state loss)");
923 }
924
925 #[test]
926 fn amount_entry_format_no_label() {
927 let entry = AmountEntry {
928 value: 1000,
929 currency: "EUR".into(),
930 label: None,
931 approximate: false,
932 };
933 assert_eq!(entry.format_display(), "EUR 1.000");
934 }
935
936 #[test]
937 fn amount_entry_serialization() {
938 let entry = AmountEntry {
939 value: 660_000,
940 currency: "USD".into(),
941 label: Some("bribe".into()),
942 approximate: false,
943 };
944 let json = serde_json::to_string(&entry).unwrap_or_default();
945 assert!(json.contains("\"value\":660000"));
946 assert!(json.contains("\"currency\":\"USD\""));
947 assert!(json.contains("\"label\":\"bribe\""));
948 assert!(json.contains("\"approximate\":false"));
949 }
950
951 #[test]
952 fn amount_entry_serialization_no_label() {
953 let entry = AmountEntry {
954 value: 1000,
955 currency: "EUR".into(),
956 label: None,
957 approximate: false,
958 };
959 let json = serde_json::to_string(&entry).unwrap_or_default();
960 assert!(!json.contains("label"));
961 }
962
963 #[test]
964 fn format_integer_commas() {
965 assert_eq!(format_integer(0), "0");
966 assert_eq!(format_integer(999), "999");
967 assert_eq!(format_integer(1000), "1.000");
968 assert_eq!(format_integer(1_000_000), "1.000.000");
969 assert_eq!(format_integer(16_800_000_000_000), "16.800.000.000.000");
970 }
971
972 #[test]
973 fn format_human_number_below_million() {
974 assert_eq!(format_human_number(0), "0");
975 assert_eq!(format_human_number(999), "999");
976 assert_eq!(format_human_number(1_000), "1.000");
977 assert_eq!(format_human_number(660_000), "660.000");
978 assert_eq!(format_human_number(999_999), "999.999");
979 }
980
981 #[test]
982 fn format_human_number_millions() {
983 assert_eq!(format_human_number(1_000_000), "1 million");
984 assert_eq!(format_human_number(1_500_000), "1.5 million");
985 assert_eq!(format_human_number(250_000_000), "250 million");
986 assert_eq!(format_human_number(1_230_000), "1.23 million");
987 }
988
989 #[test]
990 fn format_human_number_billions() {
991 assert_eq!(format_human_number(1_000_000_000), "1 billion");
992 assert_eq!(format_human_number(4_580_000_000), "4.58 billion");
993 assert_eq!(format_human_number(100_000_000_000), "100 billion");
994 }
995
996 #[test]
997 fn format_human_number_trillions() {
998 assert_eq!(format_human_number(1_000_000_000_000), "1 trillion");
999 assert_eq!(format_human_number(4_580_000_000_000), "4.58 trillion");
1000 assert_eq!(format_human_number(144_000_000_000_000), "144 trillion");
1001 assert_eq!(format_human_number(16_800_000_000_000), "16.8 trillion");
1002 }
1003
1004 #[test]
1005 fn amount_entry_too_many() {
1006 let dsl = (0..11)
1007 .map(|i| format!("{i} USD"))
1008 .collect::<Vec<_>>()
1009 .join(" | ");
1010 assert!(AmountEntry::parse_dsl(&dsl).is_err());
1011 }
1012
1013 #[test]
1014 fn role_known_values_count() {
1015 assert_eq!(Role::KNOWN.len(), 15);
1016 }
1017
1018 #[test]
1019 fn event_type_known_values_count() {
1020 assert_eq!(EventType::KNOWN.len(), 32);
1021 }
1022
1023 #[test]
1024 fn org_type_known_values_count() {
1025 assert_eq!(OrgType::KNOWN.len(), 21);
1026 }
1027
1028 #[test]
1029 fn severity_known_values_count() {
1030 assert_eq!(Severity::KNOWN.len(), 4);
1031 }
1032}