with-watch 0.1.0

Watch command inputs and rerun commands when they change
Documentation
use crate::error::{Result, WithWatchError};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedShellExpression {
    pub expression: String,
    pub input_candidates: Vec<String>,
}

pub fn parse_shell_expression(expression: &str) -> Result<ParsedShellExpression> {
    let parsed = starbase_args::parse(expression).map_err(|error| WithWatchError::ShellParse {
        message: error.to_string(),
    })?;

    let mut input_candidates = Vec::new();
    for pipeline in parsed.0 {
        collect_pipeline_inputs(pipeline, &mut input_candidates)?;
    }

    Ok(ParsedShellExpression {
        expression: expression.to_string(),
        input_candidates,
    })
}

fn collect_pipeline_inputs(
    pipeline: starbase_args::Pipeline,
    input_candidates: &mut Vec<String>,
) -> Result<()> {
    match pipeline {
        starbase_args::Pipeline::Start(command_list)
        | starbase_args::Pipeline::StartNegated(command_list)
        | starbase_args::Pipeline::Pipe(command_list)
        | starbase_args::Pipeline::PipeAll(command_list)
        | starbase_args::Pipeline::PipeWith(command_list, _) => {
            collect_command_list_inputs(command_list, input_candidates)
        }
    }
}

fn collect_command_list_inputs(
    command_list: starbase_args::CommandList,
    input_candidates: &mut Vec<String>,
) -> Result<()> {
    for sequence in command_list.0 {
        match sequence {
            starbase_args::Sequence::Start(command)
            | starbase_args::Sequence::Then(command)
            | starbase_args::Sequence::AndThen(command)
            | starbase_args::Sequence::OrElse(command)
            | starbase_args::Sequence::Passthrough(command)
            | starbase_args::Sequence::Redirect(command, _) => {
                collect_command_inputs(command, input_candidates)?;
            }
            starbase_args::Sequence::Stop(_) => {}
        }
    }

    Ok(())
}

fn collect_command_inputs(
    command: starbase_args::Command,
    input_candidates: &mut Vec<String>,
) -> Result<()> {
    let mut command_name_seen = false;

    for argument in command.0 {
        match argument {
            starbase_args::Argument::EnvVar(_, value, _) => {
                input_candidates.push(value.as_str().to_string());
            }
            starbase_args::Argument::Flag(_) | starbase_args::Argument::FlagGroup(_) => {}
            starbase_args::Argument::Option(_, Some(value)) => {
                input_candidates.push(value.as_str().to_string());
            }
            starbase_args::Argument::Option(_, None) => {}
            starbase_args::Argument::Value(value) => {
                if !command_name_seen {
                    validate_command_name(value.as_str())?;
                    command_name_seen = true;
                    continue;
                }

                input_candidates.push(value.as_str().to_string());
            }
        }
    }

    Ok(())
}

fn validate_command_name(command_name: &str) -> Result<()> {
    let lowered = command_name.trim().to_ascii_lowercase();
    let unsupported = matches!(
        lowered.as_str(),
        "if" | "then"
            | "else"
            | "elif"
            | "fi"
            | "for"
            | "while"
            | "until"
            | "do"
            | "done"
            | "case"
            | "esac"
            | "function"
            | "{"
            | "}"
    );

    if unsupported {
        return Err(WithWatchError::UnsupportedShellConstruct {
            construct: command_name.to_string(),
        });
    }

    Ok(())
}

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

    #[test]
    fn parses_command_lines_with_and_or_and_pipeline_operators() {
        let parsed = parse_shell_expression("cp src.txt dest.txt && cat dest.txt | grep hello")
            .expect("parse shell");

        assert!(parsed.input_candidates.contains(&"src.txt".to_string()));
        assert!(parsed.input_candidates.contains(&"dest.txt".to_string()));
        assert!(parsed.input_candidates.contains(&"hello".to_string()));
    }

    #[test]
    fn rejects_shell_control_flow_keywords() {
        let error =
            parse_shell_expression("if true; then echo hi; fi").expect_err("expected error");
        assert!(error
            .to_string()
            .contains("Shell control-flow is out of scope"));
    }
}