bonnie 0.3.2

Simple, cross-platform, and fast command aliases with superpowers.
Documentation
// This file contains the final schema into which all Bonnie configurations are parsed
// This does not reflect the actual syntax used in the configuration files themselves (see `raw_schema.rs`)

use crate::bones::{Bone, BonesCommand, BonesCore, BonesDirective};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
    pub default_shell: DefaultShell,
    pub scripts: Scripts,
    // These last two properties are required for loading the config if it's cached
    pub env_files: Vec<String>,
    pub version: String,
}
impl Config {
    // Gets the command requested by the given vector of arguments
    // The given arguments are expected not to include the first program argument (`bonnie` or the like)
    // Returns the command itself, its name, and the arguments relevant thereto
    pub fn get_command_for_args(
        &self,
        args: &[String],
    ) -> Result<(&Command, String, Vec<String>), String> {
        // We do everything in here for recursion
        // We need to know if this is the first time so we know to say 'command' or 'subcommand' in error messages
        fn get_command_for_scripts_and_args<'a>(
            scripts: &'a Scripts,
            args: &[String],
            first_time: bool,
        ) -> Result<(&'a Command, String, Vec<String>), String> {
            // Get the name of the command
            let command_name = args.get(0);
            let command_name = match command_name {
                Some(command_name) => command_name,
                None => {
                    return Err(match first_time {
                        true => String::from("Please provide a command to run."),
                        false => String::from("Please provide a subcommand to run."),
                    })
                }
            };
            // Try to find it among those we know
            let command = scripts.get(command_name);
            let command = match command {
                Some(command) => command,
                None => {
                    return Err(match first_time {
                        true => format!("Unknown command '{}'.", command_name),
                        false => format!("Unknown subcommand '{}'.", command_name),
                    })
                }
            };
            // We found it, check if it has any unordered subcommands or a root-level command
            let final_command_and_relevant_args = match &command.subcommands {
                // It has a root-level command (which can't take arguments) and no more arguments are present, this is the command we want
                Some(_) if matches!(command.cmd, Some(_)) && args.len() == 1 => {
                    (command, command_name.to_string(), {
                        // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
                        let mut args_for_interpolation = args.to_vec();
                        args_for_interpolation.remove(0);
                        args_for_interpolation
                    })
                }
                // It does, recurse on them
                Some(subcommands) if matches!(command.order, None) => {
                    // We remove the first argument, which is the name of this, the parent command
                    let mut args_without_this = args.to_vec();
                    args_without_this.remove(0);
                    get_command_for_scripts_and_args(subcommands, &args_without_this, false)?
                    // It's no longer the first time obviously
                }
                // They're ordered and so individually uninvocable, this is the command we want
                Some(_) => (command, command_name.to_string(), {
                    // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
                    let mut args_for_interpolation = args.to_vec();
                    args_for_interpolation.remove(0);
                    args_for_interpolation
                }),
                // It doesn't, this is the command we want
                None => (command, command_name.to_string(), {
                    // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
                    let mut args_for_interpolation = args.to_vec();
                    args_for_interpolation.remove(0);
                    args_for_interpolation
                }),
            };

            Ok(final_command_and_relevant_args)
        }

        // Begin the recursion on the global scripts with the given arguments
        let data = get_command_for_scripts_and_args(&self.scripts, args, true)?;

        Ok(data)
    }
    // Provides a documentation message for this configuration
    // If a single command name is given, only it will be documented
    pub fn document(&self, cmd_to_doc: Option<String>) -> Result<String, String> {
        // Handle metadata about the whole file first with a preamble
        let mut meta = format!(
            "This is the help page for a configuration file. If you'd like help about Bonnie generally, run `bonnie -h` instead.
Version: {}",
            self.version,
        );
        // Environment variable files
        let mut env_files = Vec::new();
        for env_file in &self.env_files {
            env_files.push(format!("    {}", env_file));
        }
        if !env_files.is_empty() {
            meta += &format!("\nEnvironment variable files:\n{}", env_files.join("\n"));
        }

        let msg;
        if let Some(cmd_name) = cmd_to_doc {
            let cmd = self.scripts.get(&cmd_name);
            let cmd = match cmd {
                Some(cmd) => cmd,
                None => return Err(format!("Command '{}' not found. You can see all supported commands by running `bonnie help`.", cmd_name))
            };
            msg = cmd.document(&cmd_name);
        } else {
            // Loop through every command and document it
            let mut msgs = Vec::new();
            // Sort the subcommands alphabetically
            let mut cmds: Vec<(&String, &Command)> = self.scripts.iter().collect();
            cmds.sort_by(|(name, _), (name2, _)| name.cmp(name2));
            for (cmd_name, cmd) in cmds {
                msgs.push(cmd.document(cmd_name));
            }

            msg = msgs.join("\n");
        }
        // Space everything out evenly based on the longest command name (thing on the left)
        // First, we get the longest command name (thing on the left of where tabs will end up)
        // We loop through each line because otherwise subcommands stuff things up
        let mut longest_left: usize = 0;
        for line in msg.lines() {
            // Get the length of the stuff to the left of the tabs placeholder
            let left_len = line.split("{TABS}").collect::<Vec<&str>>()[0].len();
            if left_len > longest_left {
                longest_left = left_len;
            }
        }
        // Now we loop back through each line and add the appropriate amount of space
        let mut spaced_msg_lines = Vec::new();
        for line in msg.lines() {
            let left_len = line.split("{TABS}").collect::<Vec<&str>>()[0].len();
            // We want the longest line to have 4 spaces, then the rest should have (longest - length + 4) spaces
            let spaces = " ".repeat(longest_left - left_len + 4);
            spaced_msg_lines.push(line.replace("{TABS}", &spaces));
        }
        let spaced_msg = spaced_msg_lines.join("\n");

        Ok(format!("{}\n\n{}", meta, spaced_msg))
    }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DefaultShell {
    pub generic: Shell,
    pub targets: HashMap<String, Shell>, // If the required target is not found, `generic` will be tried
}
// Shells are a series of values, the first being the executable and the rest being raw arguments
// One of those arguments must contain '{COMMAND}', where the command will be interpolated
// They also specify a delimiter to use to separate multistage commands
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Shell {
    pub parts: Vec<String>,
    pub delimiter: String,
}
pub type TargetString = String; // A target like `linux` or `x86_64-unknown-linux-musl` (see `rustup` targets)
pub type Scripts = HashMap<String, Command>;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Command {
    pub args: Vec<String>,
    pub env_vars: Vec<String>,
    pub subcommands: Option<Scripts>, // Subcommands are fully-fledged commands (mostly)
    pub order: Option<BonesDirective>, // If this is specified, subcomands must not specify the `args` property, it may be specified at the top-level of this script as a sibling of `order`
    pub cmd: Option<CommandWrapper>,   // If subcommands are provided, a root command is optional
    pub description: Option<String>,   // This will be rendered in the config's help page
}
impl Command {
    // Prepares a command by interpolating everything and resolving shell/tagret logic
    // This requires the name of the command and the file's `DefaultShell` configuration
    // This interpolates arguments and environment variables
    // This returns a `BonesCommand` to be executed
    // This accepts an output for warnings (extracted for testing)
    pub fn prepare(
        &self,
        name: &str,
        prog_args: &[String],
        default_shell: &DefaultShell,
    ) -> Result<Bone, String> {
        let bone = self.prepare_internal(name, prog_args, default_shell, None)?;

        Ok(bone)
    }
    // This is the internal command preparation logic, which is called recursively.
    // This also takes top-level arguments for recursing on ordered subcommands
    fn prepare_internal(
        &self,
        name: &str,
        prog_args: &[String],
        default_shell: &DefaultShell,
        top_level_args: Option<&[String]>,
    ) -> Result<Bone, String> {
        let args = match top_level_args {
            Some(args) => args,
            None => &self.args,
        };
        let at_top_level = top_level_args.is_none();
        if matches!(self.subcommands, None)
            || (matches!(self.subcommands, Some(_)) && matches!(self.cmd, Some(_)))
        {
            // We have either a direct command or a parent command that has irrelevant subcommands, either way we're interpolating into `cmd`
            // Get the vector of command wrappers
            // Assuming the transformation logic works, an error can't occur here
            let command_wrapper = self.cmd.as_ref().unwrap();
            // Interpolate for each individual command
            // We have to do this in a for loop for `?`
            let mut cmd_strs: Vec<String> = Vec::new();
            let (cmds, shell) = command_wrapper.get_commands_and_shell(default_shell);
            for cmd_str in cmds {
                let with_env_vars = Command::interpolate_env_vars(&cmd_str, &self.env_vars)?;
                let (with_args, remaining_args) =
                    Command::interpolate_specific_args(&with_env_vars, name, args, prog_args)?;
                let ready_cmd =
                    Command::interpolate_remaining_arguments(&with_args, &remaining_args);
                cmd_strs.push(ready_cmd);
            }

            Ok(
                // This does not contain recursive `BonesCommands`, so it's `Bone::Simple`
                Bone::Simple(BonesCore {
                    // We join every stage of the command into one, separated by the given delimiters
                    cmd: cmd_strs.join(&shell.delimiter),
                    // The shell is then just the vector of executable and arguments
                    shell: shell.parts.to_vec(),
                }),
            )
        } else if matches!(self.subcommands, Some(_)) && matches!(self.order, Some(_)) {
            // First, we resolve all the subcommands to vectors of strings to actually run
            let mut cmds: HashMap<String, Bone> = HashMap::new();
            // Now we run checks on whether the correct number of arguments have been provided if we're at the very top level
            // Otherwise error messages will relate to irrelevant subcommands
            // We don't check the case where too few arguments were provided because that's irrelevant (think about it)
            if at_top_level && args.len() > prog_args.len() {
                return Err(
                    format!(
                        "The command '{command}' requires {num_required_args} argument(s), but {num_given_args} argument(s) were provided (too few). Please provide all the required arguments.",
                        command=name,
                        num_required_args=args.len(),
                        num_given_args=&prog_args.len()
                    )
                );
            }
            // We `.unwrap()` here because we know more than the compiler
            for (subcommand_name, subcommand) in self.subcommands.as_ref().unwrap().iter() {
                // Parse the subcommand
                // We parse in the top-level arguments because ordered subcommands can't take their own, they inherit from this level (or the level this level inherits from, etc.)
                let cmd = subcommand.prepare_internal(
                    subcommand_name,
                    prog_args,
                    default_shell,
                    Some(args),
                )?;
                cmds.insert(subcommand_name.to_string(), cmd);
            }

            // Now we return a complex `Bone` (because it contains a `BonesCommand` with a directive)
            Ok(Bone::Complex(
                BonesCommand::new(self.order.as_ref().unwrap(), cmds), // We know more than the compiler by the check above
            ))
        } else {
            // This should not be possible!
            panic!("Critical logic failure in preparing command. You should report this as a bug.");
        }
    }
    // Interpolates specific arguments (doesn't handle `%%`)
    // This takes a string to interpolate into and doesn't take `self` so the order is open
    // This returns the readied command string and the remaining arguments or an error if an argument couldn't be substituted in
    // Errors for when the argument can't be interpolated can be silenced for ordered subcommands (which have a universal argument list for many subcommands)
    fn interpolate_specific_args(
        cmd_str: &str,
        name: &str,
        args: &[String],
        prog_args: &[String],
    ) -> Result<(String, Vec<String>), String> {
        // Check if the correct number of arguments was provided
        // Even if we're inserting the rest later, we still need the mandatory ones
        if args.len() > prog_args.len() {
            return Err(
                format!(
                    "The command '{command}' requires {num_required_args} argument(s), but {num_given_args} argument(s) were provided (too few). Please provide all the required arguments.",
                    command=name,
                    num_required_args=args.len(),
                    num_given_args=&prog_args.len()
                )
            );
        }
        // We don't warn if there are too many and we're not inserting the rest with `%%` later because that would mean checking every potential subcommand for `%%` as well if they exist
        let mut with_args = cmd_str.to_string();
        // We need to know the index so we can correlate to the index of the argument in `args`
        for (idx, arg) in args.iter().enumerate() {
            // The arrays are the same length, see above check
            // All arguments are shown in the command string as `%name` or the like, so we get that whole string
            let given_value = &prog_args[idx];
            let arg_with_sign = "%".to_string() + arg;
            let new_command = with_args.replace(&arg_with_sign, given_value);
            // We don't check if we changed something because that doesn't work for multistage or ordered subcommands
            with_args = new_command;
        }
        // Get the program args after a certain point so they can be inserted with `%%` if necessary
        // We do this by getting the part of slice after the specific arguments
        let (_, remaining_args) = prog_args.split_at(args.len());

        Ok((with_args, remaining_args.to_vec())) // FIXME
    }
    // Interpolates environment variables
    // This takes a string to interpolate into, the environment variables to interpolate, and the name of the command
    // This doesn't take `self` so the order is open
    // This returns the readied command string only, or an error relating to environment variable loading
    fn interpolate_env_vars(cmd_str: &str, env_vars: &[String]) -> Result<String, String> {
        let mut with_env_vars = cmd_str.to_string();
        for env_var_name in env_vars.iter() {
            // Load the environment variable
            let env_var = env::var(env_var_name);
            let env_var = match env_var {
                Ok(env_var) => env_var,
                Err(_) => return Err(format!("The environment variable '{}' couldn't be loaded. This means it either hasn't been defined (you may need to load another environment variable file) or contains invalid characters.", env_var_name))
            };
            // Interpolate it into the command itself
            let to_replace = "%".to_string() + env_var_name;
            let new_command = with_env_vars.replace(&to_replace, &env_var);
            // We don't check if we changed something because that doesn't work for multistage or ordered subcommands
            with_env_vars = new_command;
        }

        Ok(with_env_vars)
    }
    // Interpolates all the given arguments at `%%` if it exists
    // This takes a string to interpolate into and doesn't take `self` so the order is open
    // This returns the readied command string only
    fn interpolate_remaining_arguments(cmd_str: &str, prog_args: &[String]) -> String {
        // This is just a simple `replace` operation for the operator `%%`
        // Split the command by the block insertion operator `%%`
        let mut interpolated = String::new();
        let split_on_operator: Vec<&str> = cmd_str.split("%%").collect();
        for (idx, part) in split_on_operator.iter().enumerate() {
            if idx == split_on_operator.len() - 1 {
                // This is the last element, there's no operator after this
                interpolated.push_str(part);
            } else if part.ends_with('\\') {
                // This part ends with `\`, meaning the operator was escaped
                // We just give the `%%` back
                // We only give back the part up until the escape character
                interpolated.push_str(&part[0..part.len() - 1]);
                interpolated.push_str("%%");
            } else {
                // There's a legitimate operator that should be at the end of this part
                // We push the program's arguments
                interpolated.push_str(part);
                interpolated.push_str(&prog_args.join(" "));
            }
        }

        interpolated
    }
    // Gets a documentation message for this command based on its metadata and the `desc` properties
    fn document(&self, name: &str) -> String {
        let mut msgs = Vec::new();
        // Get the user-given docs (if they exist)
        let doc = match &self.description {
            Some(desc) => desc.to_string(),
            None => String::from("no 'desc' property set"),
        };

        // Set up the left side (command name and some arguments info)
        let mut left = String::new();
        // Environment variables (before the command name)
        for env_var in &self.env_vars {
            left += &format!("<{}> ", env_var);
        }
        // Command name
        left += name;
        // Arguments (after the command name)
        for arg in &self.args {
            left += &format!(" <{}>", arg);
        }
        // Ordered or not
        if self.order.is_some() {
            left += " (ordered)";
        }
        // TODO handle '%%' as `[...]`
        // That's a placeholder for a number of tabs that spaces everything evenly
        msgs.push(format!("{}{{TABS}}{}", left, doc));

        // Loop through every subcommand and document it
        if let Some(subcommands_map) = &self.subcommands {
            // Sort the subcommands alphabetically
            let mut subcommands_iter: Vec<(&String, &Command)> = subcommands_map.iter().collect();
            subcommands_iter.sort_by(|(name, _), (name2, _)| name.cmp(name2));
            for (cmd_name, cmd) in subcommands_iter {
                let subcmd_doc = cmd.document(cmd_name);
                msgs.push(
                    // We add four spaces in front of every line (that way it works recursively for nested subcommands)
                    format!("    {}", subcmd_doc.replace("\n", "\n    ")),
                );
            }
        }

        msgs.join("\n")
    }
}
// This defines how the command runs on different targets
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandWrapper {
    pub generic: CommandCore,
    pub targets: HashMap<TargetString, CommandCore>, // If empty or target not found, `generic` will be used
}
impl CommandWrapper {
    // Gets the command to run, interpolated into a shell from the ambient OS information
    // This critically resolves which target we're running on
    fn get_commands_and_shell(&self, default_shell: &DefaultShell) -> (Vec<String>, Shell) {
        // Get the current target (unfortuantely we can't actually get the value out of `cfg!` yet...)
        // If the user needs to set custom commands based on target arch etc., they can write a script for it, this is exhaustive enough!
        let running_on = match true {
            _ if cfg!(target_os = "windows") => "windows",
            _ if cfg!(target_os = "macos") => "macos",
            _ if cfg!(target_os = "ios") => "ios",
            _ if cfg!(target_os = "linux") => "linux",
            _ if cfg!(target_os = "android") => "android",
            _ if cfg!(target_os = "freebsd") => "freebsd",
            _ if cfg!(target_os = "dragonfly") => "dragonfly",
            _ if cfg!(target_os = "openbsd") => "openbsd",
            _ if cfg!(target_os = "netbsd") => "netbsd",
            _ => "unknown", // If they want to, the user could actually specify something for this (like begging to be run somewhere that makes sense)
        };
        // See if that target is specified explicitly
        let target_specific_command_core = self.targets.get(running_on);
        let command_core = match target_specific_command_core {
            Some(command_core) => command_core,
            None => &self.generic,
        };
        // Get the commands as a vector ready for interpolation
        let cmd = &command_core.exec;
        // Get the shell, using the configured per-file default if it was undefined
        let shell = match &command_core.shell {
            Some(shell) => shell,
            None => {
                // If a particular shell has been configured for the current target, use that
                // Otherwise, use the generic
                // Remember that the schema transformation inserts program-level defaults if they aren't configured for the file by the user
                let target_specific_shell = default_shell.targets.get(running_on);
                match target_specific_shell {
                    Some(default_shell) => default_shell,
                    None => &default_shell.generic,
                }
            }
        };

        (cmd.to_vec(), shell.clone())
    }
}
// This is the lowest level of command specification, there is no more recursion allowed here (thus avoiding circularity)
// Actual command must be specified here are strings (with potential interpolation of arguments and environment variables)
// This can also define which shell the command will use
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandCore {
    pub exec: Vec<String>, // These are the actual commands that will be run (named differently to avoid collisions)
    pub shell: Option<Shell>, // If given, this is the shell it will be run in, or the `default_shell` config for this target will be used
}