xtask-todo-lib 0.1.21

Todo workspace library and cargo devshell subcommand
Documentation
use std::fmt;

/// Redirect: fd 0=stdin, 1=stdout, 2=stderr
#[derive(Debug)]
pub struct Redirect {
    pub fd: u8,
    pub path: String,
}

#[derive(Debug)]
pub struct SimpleCommand {
    pub argv: Vec<String>,
    pub redirects: Vec<Redirect>,
}

#[derive(Debug)]
pub struct Pipeline {
    pub commands: Vec<SimpleCommand>,
}

#[derive(Debug)]
pub struct ParseError(pub String);

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl std::error::Error for ParseError {}

/// Tokenize line: split on whitespace, treat `>`, `2>`, `<`, `|` as separate tokens.
fn tokenize(line: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut chars = line.chars().peekable();

    while let Some(c) = chars.next() {
        if c.is_whitespace() {
            if !current.is_empty() {
                tokens.push(std::mem::take(&mut current));
            }
            continue;
        }
        if c == '2' && chars.peek() == Some(&'>') {
            chars.next();
            if !current.is_empty() {
                tokens.push(std::mem::take(&mut current));
            }
            tokens.push("2>".to_string());
            continue;
        }
        if c == '>' || c == '<' || c == '|' {
            if !current.is_empty() {
                tokens.push(std::mem::take(&mut current));
            }
            tokens.push(c.to_string());
            continue;
        }
        current.push(c);
    }
    if !current.is_empty() {
        tokens.push(current);
    }
    tokens
}

/// Split token list by `|` into command token lists.
fn split_by_pipe(tokens: Vec<String>) -> Vec<Vec<String>> {
    let mut commands = Vec::new();
    let mut current = Vec::new();
    for t in tokens {
        if t == "|" {
            if !current.is_empty() {
                commands.push(std::mem::take(&mut current));
            }
        } else {
            current.push(t);
        }
    }
    if !current.is_empty() {
        commands.push(current);
    }
    if commands.is_empty() {
        commands.push(Vec::new());
    }
    commands
}

/// Parse one command's tokens into `SimpleCommand` (argv + redirects).
fn parse_simple_command(tokens: &[String]) -> Result<SimpleCommand, ParseError> {
    let mut argv = Vec::new();
    let mut redirects = Vec::new();
    let mut i = 0;
    while i < tokens.len() {
        let t = &tokens[i];
        if t == ">" {
            i += 1;
            let path = tokens
                .get(i)
                .ok_or_else(|| ParseError("redirect '>' missing path".to_string()))?;
            redirects.push(Redirect {
                fd: 1,
                path: path.clone(),
            });
        } else if t == "2>" {
            i += 1;
            let path = tokens
                .get(i)
                .ok_or_else(|| ParseError("redirect '2>' missing path".to_string()))?;
            redirects.push(Redirect {
                fd: 2,
                path: path.clone(),
            });
        } else if t == "<" {
            i += 1;
            let path = tokens
                .get(i)
                .ok_or_else(|| ParseError("redirect '<' missing path".to_string()))?;
            redirects.push(Redirect {
                fd: 0,
                path: path.clone(),
            });
        } else {
            argv.push(t.clone());
        }
        i += 1;
    }
    Ok(SimpleCommand { argv, redirects })
}

/// Parse a single line into a pipeline of commands (split by `|`) with redirects.
///
/// # Errors
/// Returns `ParseError` if a redirect is missing its path.
pub fn parse_line(line: &str) -> Result<Pipeline, ParseError> {
    let tokens = tokenize(line.trim());
    let command_tokens_list = split_by_pipe(tokens);
    let mut commands = Vec::new();
    for ct in command_tokens_list {
        commands.push(parse_simple_command(&ct)?);
    }
    Ok(Pipeline { commands })
}

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

    #[test]
    fn parse_simple_pwd() {
        let p = parse_line("pwd").unwrap();
        assert_eq!(p.commands.len(), 1);
        assert_eq!(p.commands[0].argv, vec!["pwd"]);
    }

    #[test]
    fn parse_exit_quit() {
        parse_line("exit").unwrap();
        parse_line("quit").unwrap();
    }

    #[test]
    fn parse_redirect_stdout() {
        let p = parse_line("echo hello > out").unwrap();
        assert_eq!(p.commands[0].argv, vec!["echo", "hello"]);
        assert_eq!(p.commands[0].redirects.len(), 1);
        assert_eq!(p.commands[0].redirects[0].fd, 1);
        assert_eq!(p.commands[0].redirects[0].path, "out");
    }

    #[test]
    fn parse_redirect_stderr() {
        let p = parse_line("cmd 2> err").unwrap();
        assert_eq!(p.commands[0].redirects[0].fd, 2);
        assert_eq!(p.commands[0].redirects[0].path, "err");
    }

    /// Tokenize "2>" with preceding token (no space) to cover tokenize branch.
    #[test]
    fn parse_redirect_2_after_token() {
        let p = parse_line("a2> out").unwrap();
        assert_eq!(p.commands[0].argv, vec!["a"]);
        assert_eq!(p.commands[0].redirects[0].fd, 2);
        assert_eq!(p.commands[0].redirects[0].path, "out");
    }

    /// Tokenize ">" with preceding token (no space) to cover tokenize single-char branch.
    #[test]
    fn parse_redirect_gt_after_token() {
        let p = parse_line("a> out").unwrap();
        assert_eq!(p.commands[0].argv, vec!["a"]);
        assert_eq!(p.commands[0].redirects[0].fd, 1);
        assert_eq!(p.commands[0].redirects[0].path, "out");
    }

    #[test]
    fn parse_redirect_stdin() {
        let p = parse_line("cat < in").unwrap();
        assert_eq!(p.commands[0].redirects[0].fd, 0);
        assert_eq!(p.commands[0].redirects[0].path, "in");
    }

    #[test]
    fn parse_redirect_missing_path_err() {
        assert!(parse_line("echo >").is_err());
        assert!(parse_line("echo 2>").is_err());
        assert!(parse_line("cat <").is_err());
    }

    #[test]
    fn parse_error_display() {
        let e = parse_line("echo >").unwrap_err();
        assert!(e.to_string().contains("redirect") || e.0.contains("path"));
    }

    #[test]
    fn parse_pipeline() {
        let p = parse_line("a | b").unwrap();
        assert_eq!(p.commands.len(), 2);
        assert_eq!(p.commands[0].argv, vec!["a"]);
        assert_eq!(p.commands[1].argv, vec!["b"]);
    }

    #[test]
    fn parse_pipe_only_empty_command() {
        let p = parse_line("|").unwrap();
        assert_eq!(p.commands.len(), 1);
        assert!(p.commands[0].argv.is_empty());
    }

    #[test]
    fn parse_stdout_redirect_token() {
        let p = parse_line("echo x > out").unwrap();
        assert_eq!(p.commands[0].redirects[0].fd, 1);
        assert_eq!(p.commands[0].redirects[0].path, "out");
    }
}