Skip to main content

codex_ops/
format.rs

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}