fundaia 0.26.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Text the server relayed, made safe to print on a terminal.
//!
//! A log line is whatever the container wrote, and a container echoes whatever
//! a visitor sent it — so a request carrying `ESC ] 52 ;` reaches this terminal
//! through `fundaia logs` and writes the clipboard, retitles the window, or
//! walks the cursor back over the previous line to disguise what it printed.
//! Names, commit subjects and failure messages are typed by people with access
//! to the platform, which is a smaller crowd, but the same characters do the
//! same thing.
//!
//! The rule is narrow on purpose: colour is kept, because a build's output is
//! coloured and `logs` promises to print it as the container did; everything
//! else that a terminal would *interpret* rather than *show* is dropped. `--json`
//! is untouched — serde escapes every byte — so a program reading the stream
//! still gets the line whole.

/// Keeps the SGR sequences that colour a line and nothing else the terminal
/// would act on: every other escape, C0 and C1 control is removed. Tabs and
/// newlines stay, because they lay text out rather than command the terminal.
pub fn with_colour(text: &str) -> String {
    strip(text, true)
}

/// A name, a message, a subject: never coloured by whoever typed it, so every
/// escape and control character goes.
pub fn plain(text: &str) -> String {
    strip(text, false)
}

fn strip(text: &str, keep_colour: bool) -> String {
    let mut out = String::with_capacity(text.len());
    let mut chars = text.chars().peekable();

    while let Some(current) = chars.next() {
        match current {
            '\u{1b}' => match chars.next() {
                Some('[') => {
                    let sequence = read_csi(&mut chars);
                    if keep_colour && sequence.ends_with('m') && is_sgr(&sequence) {
                        out.push('\u{1b}');
                        out.push('[');
                        out.push_str(&sequence);
                    }
                }
                // OSC, DCS, SOS, PM, APC: a string that runs until ST or BEL.
                Some(']' | 'P' | 'X' | '^' | '_') => skip_string(&mut chars),
                // A two-character escape (`ESC 7`, `ESC c`…) or a dangling ESC.
                Some(_) | None => {}
            },
            // The eight-bit CSI is the same sequence spelled in one byte.
            '\u{9b}' => {
                let sequence = read_csi(&mut chars);
                if keep_colour && sequence.ends_with('m') && is_sgr(&sequence) {
                    out.push('\u{1b}');
                    out.push('[');
                    out.push_str(&sequence);
                }
            }
            '\u{9d}' | '\u{90}' | '\u{98}' | '\u{9e}' | '\u{9f}' => skip_string(&mut chars),
            '\t' | '\n' => out.push(current),
            c if c.is_control() => {}
            c => out.push(c),
        }
    }

    out
}

/// Parameter bytes, then intermediates, then one final byte: the shape every
/// CSI sequence has. Returns what was consumed, final byte included.
fn read_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
    let mut sequence = String::new();

    while let Some(&next) = chars.peek() {
        chars.next();
        sequence.push(next);
        if ('\u{40}'..='\u{7e}').contains(&next) {
            break;
        }
        // Anything outside the parameter and intermediate ranges ends the
        // sequence malformed, and a malformed one is not kept either way.
        if !('\u{20}'..='\u{3f}').contains(&next) {
            sequence.clear();
            break;
        }
    }

    sequence
}

/// `ESC [ 1;32 m` and nothing wider: digits and semicolons before the `m`.
fn is_sgr(sequence: &str) -> bool {
    sequence
        .strip_suffix('m')
        .is_some_and(|parameters| parameters.chars().all(|c| c.is_ascii_digit() || c == ';'))
}

/// Consumes up to and including the string terminator (`ESC \`, BEL, or the
/// eight-bit ST), or to the end of the text when there is none.
fn skip_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
    while let Some(current) = chars.next() {
        match current {
            '\u{7}' | '\u{9c}' => return,
            '\u{1b}' => {
                if chars.peek() == Some(&'\\') {
                    chars.next();
                }
                return;
            }
            _ => {}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_should_keep_the_colour_of_a_log_line() {
        assert_eq!(
            with_colour("\u{1b}[1;32mok\u{1b}[0m"),
            "\u{1b}[1;32mok\u{1b}[0m"
        );
    }

    #[test]
    fn it_should_drop_an_osc_sequence_that_writes_the_clipboard() {
        assert_eq!(with_colour("a\u{1b}]52;c;Zm9v\u{7}b"), "ab");
    }

    #[test]
    fn it_should_drop_an_osc_sequence_ended_by_st() {
        assert_eq!(with_colour("a\u{1b}]0;title\u{1b}\\b"), "ab");
    }

    #[test]
    fn it_should_drop_cursor_movement_while_keeping_colour() {
        assert_eq!(
            with_colour("\u{1b}[2J\u{1b}[1A\u{1b}[31mred"),
            "\u{1b}[31mred"
        );
    }

    #[test]
    fn it_should_drop_the_eight_bit_csi() {
        assert_eq!(with_colour("x\u{9b}2Jy"), "xy");
    }

    #[test]
    fn it_should_drop_a_carriage_return_and_a_backspace() {
        assert_eq!(with_colour("ok\r\u{8}fail"), "okfail");
    }

    #[test]
    fn it_should_keep_tabs_and_newlines() {
        assert_eq!(with_colour("a\tb\nc"), "a\tb\nc");
    }

    #[test]
    fn it_should_strip_colour_from_a_name() {
        assert_eq!(plain("\u{1b}[31mapi\u{1b}[0m"), "api");
    }

    #[test]
    fn it_should_drop_an_unterminated_osc_to_the_end() {
        assert_eq!(plain("a\u{1b}]8;;http://x"), "a");
    }

    #[test]
    fn it_should_leave_ordinary_unicode_alone() {
        assert_eq!(plain("señal → ✔"), "señal → ✔");
    }
}