metacmd 0.0.1

Abstracting commands over multiple environments
Documentation
use std::{
    fmt::{self, Display, Formatter},
    io::{self, Read},
    process as std_process,
};

fn process_cmd_str(s: &str) -> Result<(String, Vec<String>), Error> {
    let mut items = vec![];
    let mut current_item = items.last_mut();

    let mut quote_type: Option<char> = None;
    let mut is_escaping = false;
    for c in s.chars() {
        let is_escape = c == '\\';
        let is_double_quote = c == '"';
        let is_space = c.is_ascii_whitespace();

        let some_current_item = if let Some(some_current_item) = current_item.as_mut() {
            some_current_item
        } else {
            if is_space {
                continue;
            }

            items.push(String::new());
            current_item = items.last_mut();

            current_item.as_mut().unwrap()
        };

        if is_escaping {
            if !is_double_quote && !is_escape {
                return Err(Error::InvalidEscapeSequence(s.to_string(), c));
            }

            some_current_item.push(c);
            is_escaping = false;
            continue;
        }

        if is_escape && quote_type.map_or(false, |q| q == '"') {
            is_escaping = true;
            continue;
        }

        if quote_type.is_none() && (c == '\'' || is_double_quote) {
            quote_type = Some(c);
            continue;
        }

        if quote_type.map_or(false, |q| c == q) {
            quote_type.take();
            continue;
        }

        if is_space && quote_type.is_none() {
            current_item.take();
            continue;
        }

        some_current_item.push(c);
    }

    if is_escaping {
        return Err(Error::UnterminatedEscapeSequence(s.to_string()));
    }

    if let Some(some_quote_type) = quote_type {
        return Err(Error::MissingQuote(s.to_string(), some_quote_type));
    }

    if items.is_empty() {
        return Err(Error::EmptyProgram);
    }

    Ok((items.remove(0), items))
}

#[derive(Debug)]
pub enum Error {
    EmptyProgram,
    MissingQuote(String, char),
    InvalidEscapeSequence(String, char),
    UnterminatedEscapeSequence(String),
    Terminated,
    Local(io::Error),
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Local(err) => Some(err),
            _ => None,
        }
    }
}

impl Display for Error {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        match self {
            Self::EmptyProgram => write!(f, "empty program"),
            Self::MissingQuote(source, quote_type) => {
                write!(f, "missing quote {quote_type}: {source}")
            }
            Self::InvalidEscapeSequence(source, character) => {
                write!(f, r"invalid escape sequence '\{character}': {source}")
            }
            Self::UnterminatedEscapeSequence(source) => {
                write!(f, "unterminated escape sequence: {source}")
            }
            Self::Terminated => write!(f, "process was terminated by a signal"),
            Self::Local(_) => write!(f, "local command failure"),
        }
    }
}

#[derive(Debug)]
pub struct Command {
    program: String,
    args: Vec<String>,
}

impl Command {
    pub fn new<T: AsRef<str>>(command: T) -> Result<Self, Error> {
        let (program, args) = process_cmd_str(command.as_ref())?;
        Ok(Self { program, args })
    }

    pub fn spawn(self) -> Result<Process, Error> {
        let mut child = std_process::Command::new(self.program)
            .args(self.args)
            .stdin(std_process::Stdio::piped())
            .stdout(std_process::Stdio::piped())
            .stderr(std_process::Stdio::piped())
            .spawn()
            .map_err(Error::Local)?;

        Ok(Process {
            stdin: child.stdin.take().expect("stdin should be available"),
            stdout: child.stdout.take().expect("stdout should be available"),
            stderr: child.stderr.take().expect("stderr should be available"),
            child,
        })
    }

    pub fn run(self) -> Result<Output, Error> {
        self.spawn()?.wait()
    }
}

pub struct Process {
    child: std_process::Child,
    stdin: std_process::ChildStdin,
    stdout: std_process::ChildStdout,
    stderr: std_process::ChildStderr,
}

impl Process {
    pub fn wait(mut self) -> Result<Output, Error> {
        drop(self.stdin);
        let status = self.child.wait().map_err(Error::Local)?;

        let mut stdout = String::new();
        let mut stderr = String::new();
        self.stdout
            .read_to_string(&mut stdout)
            .map_err(Error::Local)?;
        self.stderr
            .read_to_string(&mut stderr)
            .map_err(Error::Local)?;

        Ok(Output {
            code: status.code().ok_or(Error::Terminated)?,
            stdout,
            stderr,
        })
    }
}

#[allow(unused)]
pub struct Output {
    code: i32,
    stdout: String,
    stderr: String,
}

#[cfg(test)]
mod tests {
    use std::borrow::Cow;

    use super::*;

    #[test]
    fn command_quoting() -> Result<(), Error> {
        for q in quotes().map(String::from).chain([String::new()]) {
            let command = Command::new(format!("{q}echo{q} arg"))?;
            assert_eq!(command.program, "echo");
            assert_eq!(command.args, ["arg"]);
        }
        Ok(())
    }

    #[test]
    fn command_spacing() -> Result<(), Error> {
        for spacing in spacing_combinations() {
            let command =
                Command::new(format!("{spacing}echo{spacing}arg1{spacing}arg2{spacing}"))?;
            assert_eq!(command.program, "echo");
            assert_eq!(command.args, ["arg1", "arg2"]);
        }
        Ok(())
    }

    #[test]
    fn command_quoted_arguments() -> Result<(), Error> {
        for q in quotes() {
            let cases: &[(String, Cow<str>)] = &[
                (format!("{q}quoted  quoted{q}"), "quoted  quoted".into()),
                (
                    format!("leading{q}quoted  quoted{q}"),
                    "leadingquoted  quoted".into(),
                ),
                (
                    format!("{q}quoted  quoted{q}trailing"),
                    "quoted  quotedtrailing".into(),
                ),
                (
                    format!("leading{q}quoted  quoted{q}trailing"),
                    "leadingquoted  quotedtrailing".into(),
                ),
                (
                    format!("{q}quoted  quoted{q}unquoted{q}quoted  quoted{q}"),
                    "quoted  quotedunquotedquoted  quoted".into(),
                ),
                (
                    format!("leading{q}quoted  quoted{q}unquoted{q}quoted  quoted{q}"),
                    "leadingquoted  quotedunquotedquoted  quoted".into(),
                ),
                (
                    format!("{q}quoted  quoted{q}unquoted{q}quoted  quoted{q}trailing"),
                    "quoted  quotedunquotedquoted  quotedtrailing".into(),
                ),
                (
                    format!("leading{q}quoted  quoted{q}unquoted{q}quoted  quoted{q}trailing"),
                    "leadingquoted  quotedunquotedquoted  quotedtrailing".into(),
                ),
            ];

            for (case, expected) in cases {
                let command = Command::new(format!("echo {case}"))?;
                assert_eq!(command.program, "echo");
                assert_eq!(command.args, [&**expected]);
            }
        }

        Ok(())
    }

    #[test]
    fn command_escaped_arguments() -> Result<(), Error> {
        let cases = &[
            (r#""aaa\"bbb""#, r#"aaa"bbb"#),
            (r#"aaa\"bbb"\"#, r#"aaa\bbb\"#),
            (r#"aaa\\"bbb\\"ccc"#, r"aaa\\bbb\ccc"),
            (r#"'aaa\''bbb'"#, r#"aaa\bbb"#),
            (r#"aaa\'bbb'\"#, r#"aaa\bbb\"#),
            (r#"aaa\\'bbb\\'ccc"#, r"aaa\\bbb\\ccc"),
        ];

        for (case, expected) in cases {
            let command = Command::new(format!("echo {case}"))?;
            assert_eq!(command.program, "echo");
            assert_eq!(command.args, [*expected]);
        }

        Ok(())
    }

    #[test]
    fn empty_program() {
        for spacing in spacing_combinations().chain([String::new()]) {
            assert!(matches!(Command::new(spacing), Err(Error::EmptyProgram)));
        }
    }

    #[test]
    fn non_matching_quotes() {
        for q in quotes() {
            let cases = [
                format!("{q}non closed"),
                format!("{q}closed{q}unquoted{q}non closed"),
            ];

            for case in cases {
                let source = format!("echo {case}");
                let res = Command::new(&source);
                if let Err(Error::MissingQuote(err_source, quote_type)) = res {
                    assert_eq!(err_source, source);
                    assert_eq!(quote_type, q);
                } else {
                    panic!("should fail with missing quote: {source:?}\nresult: {res:#?}")
                };
            }
        }
    }

    #[test]
    fn invalid_escape_sequences() {
        let cases = [' ', 'n', 't', '\''];
        for case in cases {
            let source = format!(r#"echo "\{case}""#);
            let res = Command::new(&source);
            if let Err(Error::InvalidEscapeSequence(err_source, character)) = res {
                assert_eq!(err_source, source);
                assert_eq!(character, case);
            } else {
                panic!("should fail with invalid escape sequence: {source:?}\nresult: {res:#?}")
            };
        }
    }

    #[test]
    fn unterminated_escape_sequence() {
        let source = r#"echo "\"#;
        let res = Command::new(source);
        if let Err(Error::UnterminatedEscapeSequence(err_source)) = res {
            assert_eq!(err_source, source);
        } else {
            panic!("should fail with unterminated escape sequence: {source:?}\nresult: {res:#?}")
        };
    }

    fn quotes() -> impl Iterator<Item = char> {
        ['"', '\''].into_iter()
    }

    fn spacing_combinations() -> impl Iterator<Item = String> {
        const SPACING_CHARS: [char; 3] = [' ', '\t', '\n'];

        SPACING_CHARS.iter().map(|c| c.to_string()).chain(
            SPACING_CHARS
                .iter()
                .flat_map(|s1| SPACING_CHARS.iter().map(move |s2| format!("{s1}{s2}")))
                .flat_map(|s| SPACING_CHARS.iter().map(move |s3| format!("{s}{s3}"))),
        )
    }
}