1use serde::Serialize;
2
3pub fn to_pretty_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
4 serde_json::to_string_pretty(value)
5}
6
7pub fn format_integer(value: i64) -> String {
8 add_group_separators(&value.to_string())
9}
10
11pub fn round_credits(value: f64) -> f64 {
12 ((value + f64::EPSILON) * 1_000_000.0).round() / 1_000_000.0
13}
14
15pub fn credits_to_usd(credits: f64) -> f64 {
16 (((credits / 25.0) + f64::EPSILON) * 1_000_000.0).round() / 1_000_000.0
17}
18
19pub fn format_credits(value: f64) -> String {
20 format_decimal_2(value)
21}
22
23pub fn format_usd(value: f64) -> String {
24 format!("${}", format_decimal_2(value))
25}
26
27pub fn format_csv(rows: &[Vec<String>]) -> String {
28 rows.iter()
29 .map(|row| {
30 row.iter()
31 .map(|cell| escape_csv_cell(cell))
32 .collect::<Vec<_>>()
33 .join(",")
34 })
35 .collect::<Vec<_>>()
36 .join("\n")
37}
38
39pub fn format_markdown_table(rows: &[Vec<String>]) -> String {
40 let Some((header, body)) = rows.split_first() else {
41 return String::new();
42 };
43
44 let mut lines = Vec::with_capacity(rows.len() + 1);
45 lines.push(markdown_row(header));
46 lines.push(markdown_row(
47 &header.iter().map(|_| "---".to_string()).collect::<Vec<_>>(),
48 ));
49 lines.extend(body.iter().map(|row| markdown_row(row)));
50 lines.join("\n")
51}
52
53pub fn format_plain_table(rows: &[Vec<String>]) -> String {
54 let Some(header) = rows.first() else {
55 return String::new();
56 };
57 let widths = (0..header.len())
58 .map(|column| {
59 rows.iter()
60 .map(|row| row.get(column).map(|cell| cell.len()).unwrap_or(0))
61 .max()
62 .unwrap_or(0)
63 })
64 .collect::<Vec<_>>();
65
66 rows.iter()
67 .map(|row| {
68 row.iter()
69 .enumerate()
70 .map(|(column, cell)| {
71 let width = widths[column];
72 if column == 0 {
73 format!("{cell:<width$}")
74 } else {
75 format!("{cell:>width$}")
76 }
77 })
78 .collect::<Vec<_>>()
79 .join(" ")
80 })
81 .collect::<Vec<_>>()
82 .join("\n")
83}
84
85fn format_decimal_2(value: f64) -> String {
86 let sign = if value.is_sign_negative() { "-" } else { "" };
87 let formatted = format!("{:.2}", value.abs());
88 let (integer, fractional) = formatted
89 .split_once('.')
90 .expect("formatted number has decimal point");
91 format!("{sign}{}.{}", add_group_separators(integer), fractional)
92}
93
94fn add_group_separators(value: &str) -> String {
95 let (sign, digits) = value
96 .strip_prefix('-')
97 .map_or(("", value), |rest| ("-", rest));
98 let mut output = String::new();
99 for (index, char) in digits.chars().rev().enumerate() {
100 if index > 0 && index % 3 == 0 {
101 output.push(',');
102 }
103 output.push(char);
104 }
105 format!("{sign}{}", output.chars().rev().collect::<String>())
106}
107
108fn escape_csv_cell(value: &str) -> String {
109 if value.contains('"') || value.contains(',') || value.contains('\n') || value.contains('\r') {
110 format!("\"{}\"", value.replace('"', "\"\""))
111 } else {
112 value.to_string()
113 }
114}
115
116fn markdown_row(row: &[String]) -> String {
117 format!(
118 "| {} |",
119 row.iter()
120 .map(|cell| cell.replace('|', "\\|"))
121 .collect::<Vec<_>>()
122 .join(" | ")
123 )
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use serde::Serialize;
130
131 #[derive(Serialize)]
132 struct JsonFixture {
133 name: &'static str,
134 calls: u32,
135 }
136
137 #[test]
138 fn formats_numbers_like_the_typescript_cli() {
139 assert_eq!(format_integer(1_234_567), "1,234,567");
140 assert_eq!(format_credits(1234.5), "1,234.50");
141 assert_eq!(format_usd(49.5), "$49.50");
142 assert_eq!(round_credits(0.1234567), 0.123457);
143 assert_eq!(credits_to_usd(25.0), 1.0);
144 }
145
146 #[test]
147 fn formats_csv_and_markdown_with_escaping() {
148 let rows = vec![
149 vec!["Name".to_string(), "Value".to_string()],
150 vec!["alpha,beta".to_string(), "x|y".to_string()],
151 vec!["quote\"cell".to_string(), "line\nbreak".to_string()],
152 ];
153
154 assert_eq!(
155 format_csv(&rows),
156 "Name,Value\n\"alpha,beta\",x|y\n\"quote\"\"cell\",\"line\nbreak\""
157 );
158 assert_eq!(
159 format_markdown_table(&rows),
160 "| Name | Value |\n| --- | --- |\n| alpha,beta | x\\|y |\n| quote\"cell | line\nbreak |"
161 );
162 }
163
164 #[test]
165 fn formats_plain_table_with_right_aligned_numeric_columns() {
166 let rows = vec![
167 vec!["Group".to_string(), "Calls".to_string()],
168 vec!["day".to_string(), "12".to_string()],
169 vec!["month".to_string(), "1,234".to_string()],
170 ];
171
172 assert_eq!(
173 format_plain_table(&rows),
174 "Group Calls\nday 12\nmonth 1,234"
175 );
176 }
177
178 #[test]
179 fn formats_pretty_json() {
180 let json = to_pretty_json(&JsonFixture {
181 name: "fixture",
182 calls: 2,
183 })
184 .expect("json");
185
186 assert_eq!(json, "{\n \"name\": \"fixture\",\n \"calls\": 2\n}");
187 }
188}