netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Platform-aware quoting helpers for shell arguments.

use std::fmt;

#[cfg(not(windows))]
use shell_quote::{QuoteRefExt, Sh};

use crate::localization::{self, keys};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum QuoteError {
    ContainsLineBreak,
}

impl fmt::Display for QuoteError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ContainsLineBreak => {
                write!(
                    f,
                    "{}",
                    localization::message(keys::COMMAND_QUOTE_LINE_BREAK)
                )
            }
        }
    }
}

#[cfg(windows)]
pub(super) fn quote(arg: &str) -> Result<String, QuoteError> {
    if arg.chars().any(|ch| matches!(ch, '\n' | '\r')) {
        return Err(QuoteError::ContainsLineBreak);
    }

    if arg.is_empty() {
        return Ok("\"\"".to_owned());
    }

    let needs_quotes = arg.chars().any(|ch| {
        matches!(
            ch,
            ' ' | '\t' | '"' | '^' | '&' | '|' | '<' | '>' | '%' | '!'
        )
    });
    if !needs_quotes {
        return Ok(arg.to_owned());
    }

    let mut buf = String::with_capacity(arg.len() + 2);
    buf.push('"');
    for ch in arg.chars() {
        match ch {
            '"' => {
                buf.push('^');
                buf.push('"');
            }
            '^' | '&' | '|' | '<' | '>' => {
                buf.push('^');
                buf.push(ch);
            }
            '%' => {
                buf.push('%');
                buf.push('%');
            }
            '!' => {
                buf.push('^');
                buf.push('!');
            }
            _ => buf.push(ch),
        }
    }
    buf.push('"');
    Ok(buf)
}

#[cfg(not(windows))]
pub(super) fn quote(arg: &str) -> Result<String, QuoteError> {
    if arg.chars().any(|ch| matches!(ch, '\n' | '\r')) {
        return Err(QuoteError::ContainsLineBreak);
    }

    let bytes = arg.quoted(Sh);
    match String::from_utf8(bytes) {
        Ok(text) => Ok(text),
        Err(err) => {
            debug_assert!(false, "quoted args must be valid UTF-8: {err}");
            Ok(String::from_utf8_lossy(err.as_bytes()).into_owned())
        }
    }
}

#[cfg(all(windows, test))]
mod tests {
    //! Unit tests for the Windows `cmd.exe` quoting rules implemented by
    //! `quote` in the parent module. Gated on `windows` because it exercises
    //! the `cfg(windows)` branch of `quote`, so it does not run on other
    //! platforms; see `non_windows_tests` below for the Unix counterpart.
    use super::*;
    use anyhow::{Result, ensure};

    #[test]
    fn quote_escapes_cmd_metacharacters() -> Result<()> {
        let success_cases = [
            ("simple", "simple"),
            ("", "\"\""),
            ("needs space", "\"needs space\""),
            ("pipe|test", "\"pipe^|test\""),
            ("redir<test", "\"redir^<test\""),
            ("redir>test", "\"redir^>test\""),
            ("caret^test", "\"caret^^test\""),
            ("tab\ttab", "\"tab\ttab\""),
            ("report&del *.txt", "\"report^&del *.txt\""),
            ("%TEMP%", "\"%%TEMP%%\""),
            ("echo!boom", "\"echo^!boom\""),
            ("say \"hi\"", "\"say ^\"hi^\"\""),
            ("\"", "\"^\"\""),
            ("foo\"bar\"baz", "\"foo^\"bar^\"baz\""),
            ("!DELAYED!", "\"^!DELAYED^!\""),
            ("\"!VAR!\"", "\"^\"^!VAR^!^\"\""),
            (r#"C:\\path\\\"ending"#, r#""C:\\path\^"ending""#),
        ];

        for (input, expected) in success_cases {
            let actual = quote(input)?;
            ensure!(
                actual == expected,
                "quote({input:?}) -> {actual:?}, expected {expected:?}"
            );
        }

        let error_cases = [
            ("line\nbreak", QuoteError::ContainsLineBreak),
            ("carriage\rreturn", QuoteError::ContainsLineBreak),
        ];

        for (input, expected) in error_cases {
            let err = quote(input).expect_err(&format!(
                "quote({input:?}) succeeded but expected error {expected:?}"
            ));
            ensure!(
                err == expected,
                "quote({input:?}) returned error {err:?}, expected {expected:?}"
            );
        }
        Ok(())
    }
}

#[cfg(all(test, not(windows)))]
mod non_windows_tests {
    //! Unit tests for Unix-specific shell quoting behaviour.
    use super::*;

    #[test]
    fn quote_rejects_line_breaks_on_unix() {
        let err = quote("line\nbreak").expect_err("line feeds should be rejected");
        assert_eq!(err, QuoteError::ContainsLineBreak);
    }

    #[test]
    fn quote_wraps_arguments_with_spaces() {
        let quoted = quote("needs space").expect("quote should succeed");
        assert_ne!(quoted, "needs space", "quote should escape spaces");
        assert!(
            quoted.contains('\'') || quoted.contains('"'),
            "quote should include quoting characters: {quoted}"
        );
    }
}