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