Skip to main content

ci_bill/
display.rs

1//! Escaping shared by the text and Markdown reports.
2//!
3//! CSV cells, scanned YAML, and fetched API fields all land in these
4//! reports unvalidated, so a control character or a Unicode bidi override
5//! in one of them must never reach the terminal or a Markdown table raw.
6
7use std::fmt::Write;
8
9/// Replaces every C0 control character, DEL, and Unicode bidi override with
10/// a visible escape (`\n`, `\t`, `\xHH`, `\u{HHHH}`). Everything else,
11/// including other printable Unicode, passes through unchanged. JSON output
12/// does not use this: `serde_json` already escapes control characters.
13pub fn escape_display(text: &str) -> String {
14    let mut out = String::with_capacity(text.len());
15    for ch in text.chars() {
16        match ch {
17            '\n' => out.push_str("\\n"),
18            '\r' => out.push_str("\\r"),
19            '\t' => out.push_str("\\t"),
20            c if (c as u32) < 0x20 || c as u32 == 0x7f => {
21                let _ = write!(out, "\\x{:02x}", c as u32);
22            }
23            c if is_bidi_override(c) => {
24                let _ = write!(out, "\\u{{{:04x}}}", c as u32);
25            }
26            c => out.push(c),
27        }
28    }
29    out
30}
31
32/// A file path as the reports show it: with `/` separators on every OS, so
33/// `--workflows samples/.github/workflows` on Windows prints
34/// `samples/.github/workflows/lint.yml`, not a mix of `/` and `\`.
35pub fn display_path(path: &std::path::Path) -> String {
36    let text = path.to_string_lossy();
37    if std::path::MAIN_SEPARATOR == '\\' {
38        text.replace('\\', "/")
39    } else {
40        text.into_owned()
41    }
42}
43
44/// Explicit directional-formatting characters, the ones that can make text
45/// render in a different order than it reads (e.g. U+202E RIGHT-TO-LEFT
46/// OVERRIDE).
47fn is_bidi_override(c: char) -> bool {
48    matches!(c as u32, 0x202a..=0x202e | 0x2066..=0x2069)
49}
50
51#[cfg(test)]
52mod tests {
53    #[test]
54    fn display_path_uses_forward_slashes() {
55        let path = std::path::Path::new("samples/.github/workflows/lint.yml");
56        assert_eq!(
57            super::display_path(path),
58            "samples/.github/workflows/lint.yml"
59        );
60        #[cfg(windows)]
61        assert_eq!(
62            super::display_path(std::path::Path::new("samples/.github/workflows\\lint.yml")),
63            "samples/.github/workflows/lint.yml"
64        );
65    }
66
67    use super::*;
68
69    #[test]
70    fn control_characters_and_bidi_overrides_become_visible() {
71        assert_eq!(escape_display("plain"), "plain");
72        assert_eq!(escape_display("a\nb"), "a\\nb");
73        assert_eq!(escape_display("a\rb"), "a\\rb");
74        assert_eq!(escape_display("a\tb"), "a\\tb");
75        assert_eq!(escape_display("a\x1bb"), "a\\x1bb");
76        assert_eq!(escape_display("a\x07b"), "a\\x07b");
77        assert_eq!(escape_display("a\x7fb"), "a\\x7fb");
78        assert_eq!(escape_display("a\0b"), "a\\x00b");
79        assert_eq!(escape_display("a\u{202e}b"), "a\\u{202e}b");
80        assert_eq!(escape_display("a\u{2066}b"), "a\\u{2066}b");
81        assert_eq!(escape_display("caf\u{e9}"), "caf\u{e9}");
82    }
83}