lib/
schema.rs

1// This file contains the final schema into which all Bonnie configurations are parsed
2// This does not reflect the actual syntax used in the configuration files themselves (see `raw_schema.rs`)
3
4use crate::bones::{Bone, BonesCommand, BonesCore, BonesDirective};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::env;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct Config {
11    pub default_shell: DefaultShell,
12    pub scripts: Scripts,
13    // These last two properties are required for loading the config if it's cached
14    pub env_files: Vec<String>,
15    pub version: String,
16}
17impl Config {
18    // Gets the command requested by the given vector of arguments
19    // The given arguments are expected not to include the first program argument (`bonnie` or the like)
20    // Returns the command itself, its name, and the arguments relevant thereto
21    pub fn get_command_for_args(
22        &self,
23        args: &[String],
24    ) -> Result<(&Command, String, Vec<String>), String> {
25        // We do everything in here for recursion
26        // We need to know if this is the first time so we know to say 'command' or 'subcommand' in error messages
27        fn get_command_for_scripts_and_args<'a>(
28            scripts: &'a Scripts,
29            args: &[String],
30            first_time: bool,
31        ) -> Result<(&'a Command, String, Vec<String>), String> {
32            // Get the name of the command
33            let command_name = args.get(0);
34            let command_name = match command_name {
35                Some(command_name) => command_name,
36                None => {
37                    return Err(match first_time {
38                        true => String::from("Please provide a command to run. You can use `bonnie help` to see the available commands in this directory."),
39                        false => String::from("Please provide a subcommand to run. You can use `bonnie help` to see the available commands in this directory."),
40                    })
41                }
42            };
43            // Try to find it among those we know
44            let command = scripts.get(command_name);
45            let command = match command {
46                Some(command) => command,
47                None => {
48                    return Err(match first_time {
49                        true => format!("Unknown command '{}'.", command_name),
50                        false => format!("Unknown subcommand '{}'.", command_name),
51                    })
52                }
53            };
54            // We found it, check if it has any unordered subcommands or a root-level command
55            let final_command_and_relevant_args = match &command.subcommands {
56                // It has a root-level command (which can't take arguments) and no more arguments are present, this is the command we want
57                Some(_) if matches!(command.cmd, Some(_)) && args.len() == 1 => {
58                    (command, command_name.to_string(), {
59                        // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
60                        let mut args_for_interpolation = args.to_vec();
61                        args_for_interpolation.remove(0);
62                        args_for_interpolation
63                    })
64                }
65                // It does, recurse on them
66                Some(subcommands) if matches!(command.order, None) => {
67                    // We remove the first argument, which is the name of this, the parent command
68                    let mut args_without_this = args.to_vec();
69                    args_without_this.remove(0);
70                    get_command_for_scripts_and_args(subcommands, &args_without_this, false)?
71                    // It's no longer the first time obviously
72                }
73                // They're ordered and so individually uninvocable, this is the command we want
74                Some(_) => (command, command_name.to_string(), {
75                    // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
76                    let mut args_for_interpolation = args.to_vec();
77                    args_for_interpolation.remove(0);
78                    args_for_interpolation
79                }),
80                // It doesn't, this is the command we want
81                None => (command, command_name.to_string(), {
82                    // We get the arguments to the program, excluding the name of this command, these are the arguments to be inteprolated
83                    let mut args_for_interpolation = args.to_vec();
84                    args_for_interpolation.remove(0);
85                    args_for_interpolation
86                }),
87            };
88
89            Ok(final_command_and_relevant_args)
90        }
91
92        // Begin the recursion on the global scripts with the given arguments
93        let data = get_command_for_scripts_and_args(&self.scripts, args, true)?;
94
95        Ok(data)
96    }
97    // Provides a documentation message for this configuration
98    // If a single command name is given, only it will be documented
99    pub fn document(&self, cmd_to_doc: Option<String>) -> Result<String, String> {
100        // Handle metadata about the whole file first with a preamble
101        let mut meta = format!(
102            "This is the help page for a configuration file. If you'd like help about Bonnie generally, run `bonnie -h` instead.
103Version: {}",
104            self.version,
105        );
106        // Environment variable files
107        let mut env_files = Vec::new();
108        for env_file in &self.env_files {
109            env_files.push(format!("    {}", env_file));
110        }
111        if !env_files.is_empty() {
112            meta += &format!("\nEnvironment variable files:\n{}", env_files.join("\n"));
113        }
114
115        let msg;
116        if let Some(cmd_name) = cmd_to_doc {
117            let cmd = self.scripts.get(&cmd_name);
118            let cmd = match cmd {
119                Some(cmd) => cmd,
120                None => return Err(format!("Command '{}' not found. You can see all supported commands by running `bonnie help`.", cmd_name))
121            };
122            msg = cmd.document(&cmd_name);
123        } else {
124            // Loop through every command and document it
125            let mut msgs = Vec::new();
126            // Sort the subcommands alphabetically
127            let mut cmds: Vec<(&String, &Command)> = self.scripts.iter().collect();
128            cmds.sort_by(|(name, _), (name2, _)| name.cmp(name2));
129            for (cmd_name, cmd) in cmds {
130                msgs.push(cmd.document(cmd_name));
131            }
132
133            msg = msgs.join("\n");
134        }
135        // Space everything out evenly based on the longest command name (thing on the left)
136        // First, we get the longest command name (thing on the left of where tabs will end up)
137        // We loop through each line because otherwise subcommands stuff things up
138        let mut longest_left: usize = 0;
139        for line in msg.lines() {
140            // Get the length of the stuff to the left of the tabs placeholder
141            let left_len = line.split("{TABS}").collect::<Vec<&str>>()[0].len();
142            if left_len > longest_left {
143                longest_left = left_len;
144            }
145        }
146        // Now we loop back through each line and add the appropriate amount of space
147        let mut spaced_msg_lines = Vec::new();
148        for line in msg.lines() {
149            let left_len = line.split("{TABS}").collect::<Vec<&str>>()[0].len();
150            // We want the longest line to have 4 spaces, then the rest should have (longest - length + 4) spaces
151            let spaces = " ".repeat(longest_left - left_len + 4);
152            spaced_msg_lines.push(line.replace("{TABS}", &spaces));
153        }
154        let spaced_msg = spaced_msg_lines.join("\n");
155
156        Ok(format!("{}\n\n{}", meta, spaced_msg))
157    }
158}
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160pub struct DefaultShell {
161    pub generic: Shell,
162    pub targets: HashMap<String, Shell>, // If the required target is not found, `generic` will be tried
163}
164// Shells are a series of values, the first being the executable and the rest being raw arguments
165// One of those arguments must contain '{COMMAND}', where the command will be interpolated
166// They also specify a delimiter to use to separate multistage commands
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168pub struct Shell {
169    pub parts: Vec<String>,
170    pub delimiter: String,
171}
172pub type TargetString = String; // A target like `linux` or `x86_64-unknown-linux-musl` (see `rustup` targets)
173pub type Scripts = HashMap<String, Command>;
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct Command {
177    pub args: Vec<String>,
178    pub env_vars: Vec<String>,
179    pub subcommands: Option<Scripts>, // Subcommands are fully-fledged commands (mostly)
180    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`
181    pub cmd: Option<CommandWrapper>,   // If subcommands are provided, a root command is optional
182    pub description: Option<String>,   // This will be rendered in the config's help page
183}
184impl Command {
185    // Prepares a command by interpolating everything and resolving shell/tagret logic
186    // This requires the name of the command and the file's `DefaultShell` configuration
187    // This interpolates arguments and environment variables
188    // This returns a `BonesCommand` to be executed
189    // This accepts an output for warnings (extracted for testing)
190    pub fn prepare(
191        &self,
192        name: &str,
193        prog_args: &[String],
194        default_shell: &DefaultShell,
195    ) -> Result<Bone, String> {
196        let bone = self.prepare_internal(name, prog_args, default_shell, None)?;
197
198        Ok(bone)
199    }
200    // This is the internal command preparation logic, which is called recursively.
201    // This also takes top-level arguments for recursing on ordered subcommands
202    fn prepare_internal(
203        &self,
204        name: &str,
205        prog_args: &[String],
206        default_shell: &DefaultShell,
207        top_level_args: Option<&[String]>,
208    ) -> Result<Bone, String> {
209        let args = match top_level_args {
210            Some(args) => args,
211            None => &self.args,
212        };
213        let at_top_level = top_level_args.is_none();
214        if matches!(self.subcommands, None)
215            || (matches!(self.subcommands, Some(_)) && matches!(self.cmd, Some(_)))
216        {
217            // We have either a direct command or a parent command that has irrelevant subcommands, either way we're interpolating into `cmd`
218            // Get the vector of command wrappers
219            // Assuming the transformation logic works, an error can't occur here
220            let command_wrapper = self.cmd.as_ref().unwrap();
221            // Interpolate for each individual command
222            // We have to do this in a for loop for `?`
223            let mut cmd_strs: Vec<String> = Vec::new();
224            let (cmds, shell) = command_wrapper.get_commands_and_shell(default_shell);
225            for cmd_str in cmds {
226                let with_env_vars = Command::interpolate_env_vars(&cmd_str, &self.env_vars)?;
227                let (with_args, remaining_args) =
228                    Command::interpolate_specific_args(&with_env_vars, name, args, prog_args)?;
229                let ready_cmd =
230                    Command::interpolate_remaining_arguments(&with_args, &remaining_args);
231                cmd_strs.push(ready_cmd);
232            }
233
234            Ok(
235                // This does not contain recursive `BonesCommands`, so it's `Bone::Simple`
236                Bone::Simple(BonesCore {
237                    // We join every stage of the command into one, separated by the given delimiters
238                    cmd: cmd_strs.join(&shell.delimiter),
239                    // The shell is then just the vector of executable and arguments
240                    shell: shell.parts.to_vec(),
241                }),
242            )
243        } else if matches!(self.subcommands, Some(_)) && matches!(self.order, Some(_)) {
244            // First, we resolve all the subcommands to vectors of strings to actually run
245            let mut cmds: HashMap<String, Bone> = HashMap::new();
246            // Now we run checks on whether the correct number of arguments have been provided if we're at the very top level
247            // Otherwise error messages will relate to irrelevant subcommands
248            // We don't check the case where too few arguments were provided because that's irrelevant (think about it)
249            if at_top_level && args.len() > prog_args.len() {
250                return Err(
251                    format!(
252                        "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.",
253                        command=name,
254                        num_required_args=args.len(),
255                        num_given_args=&prog_args.len()
256                    )
257                );
258            }
259            // We `.unwrap()` here because we know more than the compiler
260            for (subcommand_name, subcommand) in self.subcommands.as_ref().unwrap().iter() {
261                // Parse the subcommand
262                // 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.)
263                let cmd = subcommand.prepare_internal(
264                    subcommand_name,
265                    prog_args,
266                    default_shell,
267                    Some(args),
268                )?;
269                cmds.insert(subcommand_name.to_string(), cmd);
270            }
271
272            // Now we return a complex `Bone` (because it contains a `BonesCommand` with a directive)
273            Ok(Bone::Complex(
274                BonesCommand::new(self.order.as_ref().unwrap(), cmds), // We know more than the compiler by the check above
275            ))
276        } else {
277            // This should not be possible!
278            panic!("Critical logic failure in preparing command. You should report this as a bug.");
279        }
280    }
281    // Interpolates specific arguments (doesn't handle `%%`)
282    // This takes a string to interpolate into and doesn't take `self` so the order is open
283    // This returns the readied command string and the remaining arguments or an error if an argument couldn't be substituted in
284    // Errors for when the argument can't be interpolated can be silenced for ordered subcommands (which have a universal argument list for many subcommands)
285    fn interpolate_specific_args(
286        cmd_str: &str,
287        name: &str,
288        args: &[String],
289        prog_args: &[String],
290    ) -> Result<(String, Vec<String>), String> {
291        // Check if the correct number of arguments was provided
292        // Even if we're inserting the rest later, we still need the mandatory ones
293        if args.len() > prog_args.len() {
294            return Err(
295                format!(
296                    "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.",
297                    command=name,
298                    num_required_args=args.len(),
299                    num_given_args=&prog_args.len()
300                )
301            );
302        }
303        // 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
304        let mut with_args = cmd_str.to_string();
305        // We need to know the index so we can correlate to the index of the argument in `args`
306        for (idx, arg) in args.iter().enumerate() {
307            // The arrays are the same length, see above check
308            // All arguments are shown in the command string as `%name` or the like, so we get that whole string
309            let given_value = &prog_args[idx];
310            let arg_with_sign = "%".to_string() + arg;
311            let new_command = with_args.replace(&arg_with_sign, given_value);
312            // We don't check if we changed something because that doesn't work for multistage or ordered subcommands
313            with_args = new_command;
314        }
315        // Get the program args after a certain point so they can be inserted with `%%` if necessary
316        // We do this by getting the part of slice after the specific arguments
317        let (_, remaining_args) = prog_args.split_at(args.len());
318
319        Ok((with_args, remaining_args.to_vec())) // FIXME
320    }
321    // Interpolates environment variables
322    // This takes a string to interpolate into, the environment variables to interpolate, and the name of the command
323    // This doesn't take `self` so the order is open
324    // This returns the readied command string only, or an error relating to environment variable loading
325    fn interpolate_env_vars(cmd_str: &str, env_vars: &[String]) -> Result<String, String> {
326        let mut with_env_vars = cmd_str.to_string();
327        for env_var_name in env_vars.iter() {
328            // Load the environment variable
329            let env_var = env::var(env_var_name);
330            let env_var = match env_var {
331                Ok(env_var) => env_var,
332                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))
333            };
334            // Interpolate it into the command itself
335            let to_replace = "%".to_string() + env_var_name;
336            let new_command = with_env_vars.replace(&to_replace, &env_var);
337            // We don't check if we changed something because that doesn't work for multistage or ordered subcommands
338            with_env_vars = new_command;
339        }
340
341        Ok(with_env_vars)
342    }
343    // Interpolates all the given arguments at `%%` if it exists
344    // This takes a string to interpolate into and doesn't take `self` so the order is open
345    // This returns the readied command string only
346    fn interpolate_remaining_arguments(cmd_str: &str, prog_args: &[String]) -> String {
347        // This is just a simple `replace` operation for the operator `%%`
348        // Split the command by the block insertion operator `%%`
349        let mut interpolated = String::new();
350        let split_on_operator: Vec<&str> = cmd_str.split("%%").collect();
351        for (idx, part) in split_on_operator.iter().enumerate() {
352            if idx == split_on_operator.len() - 1 {
353                // This is the last element, there's no operator after this
354                interpolated.push_str(part);
355            } else if part.ends_with('\\') {
356                // This part ends with `\`, meaning the operator was escaped
357                // We just give the `%%` back
358                // We only give back the part up until the escape character
359                interpolated.push_str(&part[0..part.len() - 1]);
360                interpolated.push_str("%%");
361            } else {
362                // There's a legitimate operator that should be at the end of this part
363                // We push the program's arguments
364                interpolated.push_str(part);
365                interpolated.push_str(&prog_args.join(" "));
366            }
367        }
368
369        interpolated
370    }
371    // Gets a documentation message for this command based on its metadata and the `desc` properties
372    fn document(&self, name: &str) -> String {
373        let mut msgs = Vec::new();
374        // Get the user-given docs (if they exist)
375        let doc = match &self.description {
376            Some(desc) => desc.to_string(),
377            None => String::from("no 'desc' property set"),
378        };
379
380        // Set up the left side (command name and some arguments info)
381        let mut left = String::new();
382        // Environment variables (before the command name)
383        for env_var in &self.env_vars {
384            left += &format!("<{}> ", env_var);
385        }
386        // Command name
387        left += name;
388        // Arguments (after the command name)
389        for arg in &self.args {
390            left += &format!(" <{}>", arg);
391        }
392        // Ordered or not
393        if self.order.is_some() {
394            left += " (ordered)";
395        }
396        // TODO handle '%%' as `[...]`
397        // That's a placeholder for a number of tabs that spaces everything evenly
398        msgs.push(format!("{}{{TABS}}{}", left, doc));
399
400        // Loop through every subcommand and document it
401        if let Some(subcommands_map) = &self.subcommands {
402            // Sort the subcommands alphabetically
403            let mut subcommands_iter: Vec<(&String, &Command)> = subcommands_map.iter().collect();
404            subcommands_iter.sort_by(|(name, _), (name2, _)| name.cmp(name2));
405            for (cmd_name, cmd) in subcommands_iter {
406                let subcmd_doc = cmd.document(cmd_name);
407                msgs.push(
408                    // We add four spaces in front of every line (that way it works recursively for nested subcommands)
409                    format!("    {}", subcmd_doc.replace("\n", "\n    ")),
410                );
411            }
412        }
413
414        msgs.join("\n")
415    }
416}
417// This defines how the command runs on different targets
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
419pub struct CommandWrapper {
420    pub generic: CommandCore,
421    pub targets: HashMap<TargetString, CommandCore>, // If empty or target not found, `generic` will be used
422}
423impl CommandWrapper {
424    // Gets the command to run, interpolated into a shell from the ambient OS information
425    // This critically resolves which target we're running on
426    fn get_commands_and_shell(&self, default_shell: &DefaultShell) -> (Vec<String>, Shell) {
427        // Get the current target (unfortuantely we can't actually get the value out of `cfg!` yet...)
428        // If the user needs to set custom commands based on target arch etc., they can write a script for it, this is exhaustive enough!
429        let running_on = match true {
430            _ if cfg!(target_os = "windows") => "windows",
431            _ if cfg!(target_os = "macos") => "macos",
432            _ if cfg!(target_os = "ios") => "ios",
433            _ if cfg!(target_os = "linux") => "linux",
434            _ if cfg!(target_os = "android") => "android",
435            _ if cfg!(target_os = "freebsd") => "freebsd",
436            _ if cfg!(target_os = "dragonfly") => "dragonfly",
437            _ if cfg!(target_os = "openbsd") => "openbsd",
438            _ if cfg!(target_os = "netbsd") => "netbsd",
439            _ => "unknown", // If they want to, the user could actually specify something for this (like begging to be run somewhere that makes sense)
440        };
441        // See if that target is specified explicitly
442        let target_specific_command_core = self.targets.get(running_on);
443        let command_core = match target_specific_command_core {
444            Some(command_core) => command_core,
445            None => &self.generic,
446        };
447        // Get the commands as a vector ready for interpolation
448        let cmd = &command_core.exec;
449        // Get the shell, using the configured per-file default if it was undefined
450        let shell = match &command_core.shell {
451            Some(shell) => shell,
452            None => {
453                // If a particular shell has been configured for the current target, use that
454                // Otherwise, use the generic
455                // Remember that the schema transformation inserts program-level defaults if they aren't configured for the file by the user
456                let target_specific_shell = default_shell.targets.get(running_on);
457                match target_specific_shell {
458                    Some(default_shell) => default_shell,
459                    None => &default_shell.generic,
460                }
461            }
462        };
463
464        (cmd.to_vec(), shell.clone())
465    }
466}
467// This is the lowest level of command specification, there is no more recursion allowed here (thus avoiding circularity)
468// Actual command must be specified here are strings (with potential interpolation of arguments and environment variables)
469// This can also define which shell the command will use
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
471pub struct CommandCore {
472    pub exec: Vec<String>, // These are the actual commands that will be run (named differently to avoid collisions)
473    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
474}