bonnie 0.3.2

Simple, cross-platform, and fast command aliases with superpowers.
Documentation
// Bones is Bonnie's command execution runtime, which mainly handles ordered subcommands

use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command as OsCommand;

// This enables recursion of ordered subcommands (which would be the most complex use-case of Bonnie thus far)
// This really represents (from Bonnie's perspective) a future for an exit code
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Bone {
    Simple(BonesCore),
    Complex(BonesCommand),
}
impl Bone {
    // Executes this command, returning its exit code
    // This takes an optional buffer to write data about the command being executed in testing
    pub fn run(
        &self,
        name: &str,
        verbose: bool,
        output: &mut impl std::io::Write,
    ) -> Result<i32, String> {
        match self {
            Bone::Simple(core) => {
                // Execute the command core
                let exit_code = core.execute(name, verbose, output)?;
                // Return the exit code of the command sequence
                Ok(exit_code)
            }
            Bone::Complex(command) => {
                // If it's complex and thus recursive, we depend on the Bones language parser
                command.run(verbose, output)
            }
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BonesCommand {
    // A HashMap of command names to vectors of raw commands to be executed
    // The commands to run are expected to have interpolation and target/shell resolution already done
    cmds: HashMap<String, Bone>,
    // The directive from of how to run the commands (written as per Bones' specification)
    directive: BonesDirective,
}
impl BonesCommand {
    // This creates a full Bones command
    // This is used when actual logic is given by the user (ordered subcommands)
    pub fn new(directive: &BonesDirective, cmds: HashMap<String, Bone>) -> Self {
        Self {
            directive: directive.clone(),
            cmds,
        }
    }
    // Runs a Bones command by evaluating the directive itself and calling commands in sequence recursively
    // Currently, the logic of the Bones language lives here
    fn run(&self, verbose: bool, output: &mut impl std::io::Write) -> Result<i32, String> {
        // This system is highly recursive, so everything is done in this function for progressively less complex directives
        fn run_for_directive(
            directive: &BonesDirective,
            cmds: &HashMap<String, Bone>,
            verbose: bool,
            output: &mut impl std::io::Write,
        ) -> Result<i32, String> {
            // Get the token, which names the command we'll be running
            let command_name = &directive.0;
            // Now get the corresponding Bone if it exists
            let bone = cmds.get(command_name);
            let bone = match bone {
                Some(bone) => bone,
                None => return Err(format!("Error in executing Bones directive: subcommand '{}' not found. This is probably a typo in your Bonnie configuration.", command_name)),
            };
            // Now execute it and get the exit code (this may recursively call this function if ordered subcommands are nested, but that dcoesn't matter)
            // Bonnie treats all command cores as futures for an exit code, we don't care about any side effects (printing, server execution, etc.)
            let exit_code = bone.run(command_name, verbose, output)?;
            // Iterate over the conditions given and check if any of them match that exit code
            // We'll run the first one that does (even if more do after that)
            // TODO document the above behaviour
            let mut final_exit_code = exit_code;
            for (operator, directive) in directive.1.iter() {
                if operator.matches(&exit_code) {
                    // An operator has matched, check if it has an associated directive
                    final_exit_code = match directive {
                        // If it does, run that and get its exit code
                        Some(directive) => run_for_directive(directive, cmds, verbose, output)?,
                        // If not, return the exit code we just got above
                        None => exit_code,
                    };
                }
            }

            // All nestings have resolved to one exit code, we return it
            Ok(final_exit_code)
        }

        // Begin the recursion on this top-level directive
        // This will eventually return the exit code from the lowest level of recursion, which we return
        let exit_code = run_for_directive(&self.directive, &self.cmds, verbose, output)?;
        Ok(exit_code)
    }
}

// A directive telling the Bones engine how to progress between ordered subcommands
// This maps the command to run to a set of conditions as to how to proceed based on its exit code
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BonesDirective(String, HashMap<BonesOperator, Option<BonesDirective>>);
// This is used for direct parsing, before we've had a chance to handle the operators
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct RawBonesDirective(String, HashMap<String, Option<RawBonesDirective>>);
impl RawBonesDirective {
    // This converts to a `BonesDirective` by parsing the operator strings into full operators
    fn convert_to_proper(&self) -> Result<BonesDirective, String> {
        // Parse the conditions `HashMap`
        let mut parsed_conditions: HashMap<BonesOperator, Option<BonesDirective>> = HashMap::new();
        for (raw_operator, raw_directive) in &self.1 {
            let operator = BonesOperator::parse_str(raw_operator)?;
            // Parse the directive recursively
            // We need to use a full `match` statement for `?`
            let directive = match raw_directive {
                Some(raw_directive) => Some(raw_directive.convert_to_proper()?),
                None => None,
            };
            parsed_conditions.insert(operator, directive);
        }

        Ok(
            // We don't need to do any parsing on the command name, just the conditions
            BonesDirective(self.0.to_string(), parsed_conditions),
        )
    }
}
// Bones operators can be more than just exit codes, this defines their possibilities
// For deserialization, this is left tagged (we pre-parse)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, std::hash::Hash)]
pub enum BonesOperator {
    // A simple exit code comparison
    ExitCode(i32),
    // A negative exit code comparison ('anything except ...')
    NotExitCode(i32),
    // An operator that will match no matter what its command returned
    Any,
    // An operator that will never match no matter what its command returned
    None,
    // The requirement for command success (an alias for `ExitCode(0)`)
    Success,
    // The requirement for command failure (an alias for `NotExitCode(0)`)
    Failure,
    // Matches if any contained operators match (or statement)
    Union(Vec<BonesOperator>),
    // Matches if all contained operators match (and statement)
    // No it shouldn't be possible to have multiple exit codes match simultaneously but this is here anyway for potential future additions
    Intersection(Vec<BonesOperator>),
}
impl BonesOperator {
    // Checks if the given exit code matches this operator
    fn matches(&self, exit_code: &i32) -> bool {
        // This can be recursive due to the `Union` an d`Intersection` variants
        fn matches(exit_code: &i32, variant: &BonesOperator) -> bool {
            // Go through each different type of operator possible
            match variant {
                BonesOperator::Success => *exit_code == 0,
                BonesOperator::Failure => *exit_code != 0,
                BonesOperator::ExitCode(comparison) => exit_code == comparison,
                BonesOperator::NotExitCode(comparison) => exit_code != comparison,
                BonesOperator::Any => true,
                BonesOperator::None => false,
                BonesOperator::Union(operators) => {
                    let mut is_match = false;
                    for operator in operators {
                        let op_matches = operator.matches(exit_code);
                        // We only need one of them to be true
                        if op_matches {
                            is_match = true;
                            break;
                        }
                    }
                    is_match
                }
                BonesOperator::Intersection(operators) => {
                    let mut is_match = false;
                    for operator in operators {
                        let op_matches = operator.matches(exit_code);
                        // We only need one of them to be false (aka. all of them have to be true)
                        is_match = op_matches;
                        if !op_matches {
                            break;
                        }
                    }
                    is_match
                }
            }
        }

        matches(exit_code, self)
    }
    // Parses a string operator given in a directive string into a fully-fledged variant
    fn parse_str(raw_operator: &str) -> Result<Self, String> {
        // Attempt to parse it as an exit code integer (we'll use that twice)
        let exit_code = raw_operator.parse::<i32>();
        let operator = match raw_operator {
            _ if exit_code.is_ok() => BonesOperator::ExitCode(exit_code.unwrap()),
            _ if raw_operator.starts_with('!') => {
                let exit_code_str = raw_operator.get(1..);
                let exit_code = match exit_code_str {
                    Some(exit_code) => match exit_code.parse::<i32>() {
                        Ok(exit_code) => exit_code,
                        Err(_) => return Err(format!("Couldn't parse exit code as 32-bit integer from `NotExitCode` operator invocation '{}'.", raw_operator))
                    },
                    None => return Err(format!("Couldn't extract exit code from `NotExitCode` operator invocation '{}'.", raw_operator))
                };
                BonesOperator::NotExitCode(exit_code)
            }
            // The next four are simple because they have no attached data
            "Any" => BonesOperator::Any,
            "None" => BonesOperator::None,
            "Success" => BonesOperator::Success,
            "Failure" => BonesOperator::Failure,
            // These require recursion
            _ if raw_operator.contains('|') => {
                let parts: Vec<&str> = raw_operator.split('|').collect();
                let mut operators: Vec<BonesOperator> = Vec::new();
                // Recursively parse each operator
                for part in parts {
                    operators.push(BonesOperator::parse_str(part)?)
                }
                BonesOperator::Union(operators)
            }
            _ if raw_operator.contains('+') => {
                let parts: Vec<&str> = raw_operator.split('+').collect();
                let mut operators: Vec<BonesOperator> = Vec::new();
                // Recursively parse each operator
                for part in parts {
                    operators.push(BonesOperator::parse_str(part)?)
                }
                BonesOperator::Intersection(operators)
            }
            _ => {
                return Err(format!(
                    "Unrecognized operator '{}' in Bones directive.",
                    raw_operator
                ))
            }
        };

        Ok(operator)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BonesCore {
    pub cmd: String,        // All the stages are joined by the delimiter
    pub shell: Vec<String>, // Vector of executable and arguments thereto
}
impl BonesCore {
    fn execute(
        &self,
        name: &str,
        verbose: bool,
        output: &mut impl std::io::Write,
    ) -> Result<i32, String> {
        // Get the executable from the shell (the first element)
        let executable = self.shell.get(0);
        let executable = match executable {
            // If the shell is not universal to all stages, we return an error
            // We should not have to interpolate anything into the executable
            Some(executable) if executable.contains("{COMMAND}") => return Err(format!("The shell for the command '{}' attempts to interpolate the command in its first element, which was expected to be a string literal with no interpolation, as it is an executable.", name)),
            Some(executable) => executable,
            None => return Err(format!("The shell for the command '{}' is empty. Shells must contain at least one element as an executable to invoke.", name))
        };
        // Get the arguments to that executable
        // We interpolate the command in where necessary
        let args = self.shell.get(1..);
        let args: Vec<String> = match args {
            Some(args) => args
                .iter()
                .map(|part| part.replace("{COMMAND}", &self.cmd))
                .collect(),
            // If there are no arguments, we really don't care, shells can be as weird as they want
            None => Vec::new(),
        };
        // If we're in debug, write details about the command to the given output (technical)
        if cfg!(debug_assertions) {
            writeln!(output, "{}, {:?}", executable, args)
                .expect("Failed to write technical information.");
        }
        // If the user wants it, write the actual command we'll run to the given output
        if verbose {
            writeln!(
                output,
                "Running command '{}' with arguments '{:?}'.",
                executable, args
            )
            .expect("Failed to write verbose information.");
        }
        // Prepare the child process
        let child = OsCommand::new(&executable).args(args).spawn();

        // The child must be mutable so we can wait for it to finish later
        let mut child = match child {
            Ok(child) => child,
            Err(_) => return Err(
                format!(
                    "Command '{}' failed to run. This doesn't mean the command produced an error, but that the process couldn't even be initialised.",
                    &name
                )
            )
        };
        // If we don't wait on the child, any long-running commands will print into the prompt because the parent terminates first (try it yourself with the `long` command)
        let child = child.wait();
        let exit_status = match child {
            Ok(exit_status) => exit_status,
            Err(_) => return Err(
                format!(
                    "Command '{}' didn't run (parent unable to wait on child process). See the Bonnie documentation for more details on this problem.",
                    &name
                )
            )
        };

        // We now need to pass that exit code through so Bonnie can terminate with it (otherwise `&&` chaining doesn't work as expected, etc.)
        // This will work on both Unix and Windows (and so theoretically any other weird OSes that make any sense at all)
        Ok(match exit_status.code() {
            Some(exit_code) => exit_code,       // If we have an exit code, use it
            None if exit_status.success() => 0, // If we don't, but we know the command succeeded, return 0 (success code)
            None => 1, // If we don't know an exit code but we know that the command failed, return 1 (general error code)
        })
    }
}

// This parses a directive string into a `BonesDirective` that can be executed
// The logic of parsing and executing is made separate so we can cache the parsed form for large configuration files
// This function basically interprets a miniature programming language
// Right now, this is quite slow due to its extensive use of RegEx, any ideas to speed it up would be greatly appreciated!
pub fn parse_directive_str(directive_str: &str) -> Result<BonesDirective, String> {
    let directive_json: String;
    // Check if we have the alternative super-simple form (just one command, rare but easy to parse)
    if !directive_str.contains('{') {
        directive_json = "[\"".to_string() + directive_str + "\", {}]"
    } else {
        // We transform the directive string into compliant JSON with a series of substitutions
        // Execute non-regex substitutions
        let stage1 = directive_str.replace("}", "}]");
        // We can unwrap all the RegExps because we know they're valid
        // Please refer to the Bones specification to understand how these work
        let re1 = Regex::new(r"(?m)^(\s*)(.+) => (.+)\b \{").unwrap();
        let sub1 = "$1\"$2\": [\"$3\", {";
        let re2 = Regex::new(r"(?m)^(\s*)(.+) => (.+)\b").unwrap();
        let sub2 = "$1\"$2\": [\"$3\", {}]";
        let re3 = Regex::new(r"^\s*\b(.+) \{").unwrap();
        let sub3 = "[\"$1\", {";
        // Execute each of those substitutions
        let stage2 = re1.replace_all(&stage1, sub1);
        let stage3 = re2.replace_all(&stage2, sub2);
        directive_json = re3.replace_all(&stage3, sub3).to_string();
    }
    // Now we can deserialize that directly using Serde
    let raw_directive = serde_json::from_str::<RawBonesDirective>(&directive_json);
    let raw_directive = match raw_directive {
        Ok(raw_directive) => raw_directive,
        Err(err) => return Err(format!("The following error occurred while parsing a Bones directive: '{}'. Please note that your code is transformed in several ways before this step, so you may need to refer to the documentation on Bones directives.", err))
    };
    // Now we handle the operators
    let directive = raw_directive.convert_to_proper()?;

    Ok(directive)
}