Skip to main content

piw/
format.rs

1//! Port of `src/render/format.ts` and `src/workflows/text.ts`, including
2//! JavaScript-compatible rounding so durations format identically.
3
4use chrono::DateTime;
5
6/// `Math.round`: round half away from zero for positive values.
7fn js_round(value: f64) -> i64 {
8    (value + 0.5).floor() as i64
9}
10
11/// `Number.prototype.toFixed(digits)` for the non-negative values we format.
12fn js_to_fixed(value: f64, digits: u32) -> String {
13    let factor = 10f64.powi(digits as i32);
14    let scaled = js_round(value * factor) as f64 / factor;
15    format!("{scaled:.*}", digits as usize)
16}
17
18pub fn format_duration(duration_ms: i64) -> String {
19    if duration_ms < 1_000 {
20        return format!("{}ms", duration_ms.max(0));
21    }
22    let seconds = duration_ms as f64 / 1_000.0;
23    if seconds < 60.0 {
24        let digits = if seconds < 10.0 { 1 } else { 0 };
25        return format!("{}s", js_to_fixed(seconds, digits));
26    }
27    let minutes = (seconds / 60.0).floor() as i64;
28    let rest = js_round(seconds % 60.0);
29    format!("{minutes}m{rest:02}s")
30}
31
32/// `Date.parse` for the ISO-8601 timestamps SQLite workflow state contain. Returns
33/// milliseconds since the epoch, or `None` for unparsable input.
34pub fn parse_timestamp_ms(value: &str) -> Option<i64> {
35    DateTime::parse_from_rfc3339(value)
36        .ok()
37        .map(|value| value.timestamp_millis())
38}
39
40/// Remove ANSI escape sequences (CSI style) from a string.
41pub fn strip_ansi(text: &str) -> String {
42    let chars: Vec<char> = text.chars().collect();
43    let mut result = String::new();
44    let mut index = 0;
45    while index < chars.len() {
46        if chars[index] == '\u{1b}' && chars.get(index + 1) == Some(&'[') {
47            index += 2;
48            while index < chars.len() && !chars[index].is_ascii_alphabetic() {
49                index += 1;
50            }
51            index += 1;
52            continue;
53        }
54        result.push(chars[index]);
55        index += 1;
56    }
57    result
58}
59
60/// Remove ANSI escapes and control characters from untrusted text so
61/// rendering it cannot alter terminal state. Line breaks and tabs collapse
62/// to single spaces.
63pub fn sanitize_text(text: &str) -> String {
64    let stripped = strip_ansi(text);
65    let mut result = String::new();
66    let mut pending_space = false;
67    for char in stripped.chars() {
68        if matches!(char, '\t' | '\n' | '\r') {
69            pending_space = true;
70            continue;
71        }
72        if pending_space {
73            result.push(' ');
74            pending_space = false;
75        }
76        // C0 controls, DEL, and C1 controls (U+0080..U+009F): some terminals
77        // treat 8-bit C1 bytes like 0x9B as CSI, so they must go too.
78        if ('\u{0}'..='\u{1f}').contains(&char) || ('\u{7f}'..='\u{9f}').contains(&char) {
79            continue;
80        }
81        result.push(char);
82    }
83    if pending_space {
84        result.push(' ');
85    }
86    result
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn duration_matches_js() {
95        assert_eq!(format_duration(0), "0ms");
96        assert_eq!(format_duration(-5), "0ms");
97        assert_eq!(format_duration(999), "999ms");
98        assert_eq!(format_duration(1_000), "1.0s");
99        assert_eq!(format_duration(1_050), "1.1s"); // JS (1.05).toFixed(1) === "1.1"
100        assert_eq!(format_duration(5_000), "5.0s");
101        assert_eq!(format_duration(9_940), "9.9s");
102        assert_eq!(format_duration(10_500), "11s"); // JS (10.5).toFixed(0) === "11"
103        assert_eq!(format_duration(59_499), "59s");
104        assert_eq!(format_duration(60_000), "1m00s");
105        assert_eq!(format_duration(90_500), "1m31s"); // Math.round(30.5) === 31
106        assert_eq!(format_duration(3_599_000), "59m59s");
107    }
108
109    #[test]
110    fn sanitize_collapses_control_runs() {
111        assert_eq!(sanitize_text("a\n\nb\tc"), "a b c");
112        assert_eq!(sanitize_text("\u{1b}[31mred\u{1b}[0m"), "red");
113        assert_eq!(sanitize_text("bell\u{7}!"), "bell!");
114        // 8-bit C1 controls (e.g. C1 CSI and OSC) must be removed too.
115        assert_eq!(sanitize_text("a\u{9b}2Jb"), "a2Jb");
116        assert_eq!(sanitize_text("a\u{9d}52;xb"), "a52;xb");
117        assert_eq!(sanitize_text("del\u{7f}!"), "del!");
118    }
119}