lib/
raw_schema.rs

1// This file contains the schema that all Bonnie configuration files are deserialised with
2// They will then be parsed into the schema defined in `schema.rs` using the logic in the methods on this schema
3// The use of `#[serde(untagged)]` on all `enum`s simply ensures that Serde doesn't require them to be labelled as to their variant
4// This raw schema will also derive the `Arbitrary` trait for fuzzing when that feature is enabled
5
6use crate::bones::parse_directive_str;
7use crate::default_shells::get_default_shells;
8use crate::schema;
9use crate::version::{get_version_parts, VersionCompatibility, VersionDifference, BONNIE_VERSION};
10use serde::Deserialize;
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Deserialize)]
14pub struct Config {
15    version: String,                // This will be used to confirm compatibility
16    env_files: Option<Vec<String>>, // Files specified here have their environment variables loaded into Bonnie
17    default_shell: Option<DefaultShell>,
18    scripts: Scripts,
19}
20impl Config {
21    pub fn new(cfg_string: &str) -> Result<Self, String> {
22        let cfg: Result<Self, toml::de::Error> = toml::from_str(cfg_string);
23        let cfg = match cfg {
24            Ok(cfg) => cfg,
25            // We explicitly handle the missing version for better backward-compatibility before 0.2.0 and because it's an easy mistake to make
26            Err(err) if err.to_string().starts_with("missing field `version`") => return Err("Your Bonnie configuration file appears to be missing a 'version' key. From Bonnie 0.2.0 onwards, this key is mandatory for compatibility reasons. Please add `version = \"".to_string() + BONNIE_VERSION + "\"` to the top of your Bonnie configuration file."),
27            Err(err) => return Err(format!("Invalid Bonnie configuration file. Error: '{}'", err))
28        };
29
30        Ok(cfg)
31    }
32    // Runs all the necessary methods to fully parse the config, consuming `self`
33    // Takes the current version of Bonnie (extracted for testing purposes)
34    // This accepts an output for warnings (extracted for testing)
35    pub fn to_final(
36        &self,
37        bonnie_version_str: &str,
38        output: &mut impl std::io::Write,
39    ) -> Result<schema::Config, String> {
40        // These two are run for their side-effects (both also used in loading from a cache)
41        Self::parse_version_against_current(&self.version, bonnie_version_str, output)?;
42        Self::load_env_files(self.env_files.clone())?;
43        // And then we get the final config
44        let cfg = self.parse()?;
45
46        Ok(cfg)
47    }
48    // Parses the version of the config to check for compatibility issues, consuming `self`
49    // We extract the version of Bonnie itself for testing purposes
50    // This si generic because it's used in caching logic as well
51    pub fn parse_version_against_current(
52        cfg_version_str: &str,
53        bonnie_version_str: &str,
54        output: &mut impl std::io::Write,
55    ) -> Result<(), String> {
56        // Split the program and config file versions into their components
57        let bonnie_version = get_version_parts(bonnie_version_str)?;
58        let cfg_version = get_version_parts(cfg_version_str)?;
59        // Compare the two and warn/error appropriately
60        let compat = bonnie_version.is_compatible_with(&cfg_version);
61        match compat {
62            VersionCompatibility::DifferentBetaVersion(version_difference) => return Err("The provided configuration file is incompatible with this version of Bonnie. You are running Bonnie v".to_string() + bonnie_version_str + ", but the configuration file expects Bonnie v" + cfg_version_str + ". " + match version_difference {
63                VersionDifference::TooNew => "This issue can be fixed by updating Bonnie to the appropriate version, which can be done at https://github.com/arctic-hen7/bonnie/releases.",
64                VersionDifference::TooOld => "This issue can be fixed by updating the configuration file, which may require changing some of its syntax (see https://github.com/arctic-hen7/bonnie for how to do so). Alternatively, you can download an older version of Bonnie from https://github.com/arctic-hen7/bonnie/releases (not recommended)."
65            }),
66            VersionCompatibility::DifferentMajor(version_difference) => return Err("The provided configuration file is incompatible with this version of Bonnie. You are running Bonnie v".to_string() + bonnie_version_str + ", but the configuration file expects Bonnie v" + cfg_version_str + ". " + match version_difference {
67                VersionDifference::TooNew => "This issue can be fixed by updating Bonnie to the appropriate version, which can be done at https://github.com/arctic-hen7/bonnie/releases.",
68                VersionDifference::TooOld => "This issue can be fixed by updating the configuration file, which may require changing some of its syntax (see https://github.com/arctic-hen7/bonnie for how to do so). Alternatively, you can download an older version of Bonnie from https://github.com/arctic-hen7/bonnie/releases (not recommended)."
69            }),
70            // These next two are just warnings, not errors
71            VersionCompatibility::DifferentMinor(version_difference) => writeln!(output, "{}", "The provided configuration file is compatible with this version of Bonnie, but has a different minor version. You are running Bonnie v".to_string() + bonnie_version_str + ", but the configuration file expects Bonnie v" + cfg_version_str + ". " + match version_difference {
72                VersionDifference::TooNew => "This issue can be fixed by updating Bonnie to the appropriate version, which can be done at https://github.com/arctic-hen7/bonnie/releases.",
73                VersionDifference::TooOld => "This issue can be fixed by updating the configuration file, which may require changing some of its syntax (see https://github.com/arctic-hen7/bonnie for how to do so). Alternatively, you can download an older version of Bonnie from https://github.com/arctic-hen7/bonnie/releases (not recommended)."
74            }).expect("Failed to write warning."),
75            VersionCompatibility::DifferentPatch(version_difference) => writeln!(output, "{}", "The provided configuration file is compatible with this version of Bonnie, but has a different patch version. You are running Bonnie v".to_string() + bonnie_version_str + ", but the configuration file expects Bonnie v" + cfg_version_str + ". " + match version_difference {
76                VersionDifference::TooNew => "You may want to update Bonnie to the appropriate version, which can be done at https://github.com/arctic-hen7/bonnie/releases.",
77                VersionDifference::TooOld => "You may want to update the configuration file (which shouldn't require any syntax changes)."
78            }).expect("Failed to write warning."),
79            _ => ()
80        };
81
82        // If we haven't returned an error yet, the version is valid (and warnings have been emitted as necessary)
83        Ok(())
84    }
85    // Loads the environment variable files requested in the config
86    // This is generic because it's called in caching as well
87    pub fn load_env_files(env_files: Option<Vec<String>>) -> Result<(), String> {
88        let env_files = match env_files {
89            Some(env_files) => env_files,
90            None => Vec::new(),
91        };
92        // Parse each of the requested environment variable files
93        for env_file in env_files.iter() {
94            // Load the file
95            // This will be loaded for the Bonnie program, which allows us to interpolate them into commands
96            let res = dotenv::from_filename(&env_file);
97            if res.is_err() {
98                return Err(format!("Requested environment variable file '{}' could not be loaded. Either the file doesn't exist, Bonnie doesn't have the permissions necessary to access it, or something inside it can't be processed.", &env_file));
99            }
100        }
101
102        Ok(())
103    }
104    // Parses the rest of the config into the final form, consuming `self`
105    // A very large portion of Bonnie's logic lives here or is called here (spec transformation)
106    fn parse(&self) -> Result<schema::Config, String> {
107        // Parse the default shell
108        let default_shell = match &self.default_shell {
109            // If we're just given a shell string, use it as the generic shell
110            Some(DefaultShell::Simple(generic)) => schema::DefaultShell {
111                generic: generic.parse(),
112                targets: HashMap::new(),
113            },
114            // If we have all the information we need, just transform it
115            Some(DefaultShell::Complex { generic, targets }) => schema::DefaultShell {
116                generic: generic.parse(),
117                targets: match targets {
118                    Some(raw_targets) => {
119                        // This is just transformation logic
120                        let mut targets = HashMap::new();
121                        for (target_name, shell) in raw_targets.iter() {
122                            targets.insert(target_name.to_string(), shell.parse());
123                        }
124                        targets
125                    }
126                    None => HashMap::new(), // We'll just use the generic if we don't have anything else
127                },
128            },
129            // If no default shell is provided, we'll use the default paradigm (see `default_shells.rs`)
130            None => get_default_shells(),
131        };
132        // Parse the scripts (brace yourself!)
133        // We do this inside a function because it's recursive
134        // Unfortunately we can't define methods on type aliases, so this goes here
135        // This involves validation logic to ensure invalid property combinations aren't specified, so we need to know whether or not `order` is specified if this is parsing subcommands
136        fn parse_scripts(
137            raw_scripts: &Scripts,
138            is_order_defined: bool,
139        ) -> Result<schema::Scripts, String> {
140            let mut scripts: schema::Scripts = HashMap::new();
141            for (script_name, raw_command) in raw_scripts.iter() {
142                let command = match raw_command {
143                    Command::Simple(raw_command_wrapper) => schema::Command {
144                        args: Vec::new(),
145                        env_vars: Vec::new(),
146                        subcommands: None,
147                        order: None,
148                        cmd: Some(raw_command_wrapper.parse()), // In the simple form, a command must be given (no subcommands can be specified)
149                        description: None
150                    },
151                    Command::Complex {
152                        args,
153                        env_vars,
154                        subcommands,
155                        order,
156                        cmd,
157                        desc
158                    } => schema::Command {
159                        // If `order` is defined at the level above, we can't interpolate environment variables from here (has to be done at the level `order` was specified)
160                        args: match is_order_defined {
161                            // Unordered subcommands can't take arguments in any case of upper-level `order` definition
162                            _ if matches!(subcommands, Some(_)) && matches!(order, None) && matches!(args, Some(_)) => return Err(format!("Error in parsing Bonnie configuration file: if `subcommands` is specified without `order`, `args` cannot be specified. This error occurred in in the '{}' script/subscript.", script_name)),
163                            // If it was and `args` is specified, return an error
164                            true if matches!(args, Some(_)) => return Err(format!("Error in parsing Bonnie configuration file: if `order` is specified, subscripts cannot specify `args`, as no environment variables can be provided to them. Environment variables to be interpolated in ordered subcommands must be set at the top-level. This error occurred in the '{}' script/subscript.", script_name)),
165                            // If it was but args` isn't specified, it doesn't matter and we just give an empty vector instead
166                            true => Vec::new(),
167                            // If it wasn't, no validation needed
168                            false => args.as_ref().unwrap_or(&Vec::new()).to_vec()
169                        },
170                        // This doesn't need any transformation, just a simple alternative if it's `None`
171                        env_vars: env_vars.as_ref().unwrap_or(&Vec::new()).to_vec(),
172                        // The subcommands are parsed recursively as scripts using this very function
173                        // We parse through whether or not `order` is defined (has validation implications)
174                        subcommands: match subcommands {
175                            // We can't use `.map()` for this because we need support for `?`
176                            Some(subcommands) => Some(
177                                parse_scripts(subcommands, matches!(order, Some(_)))?
178                            ),
179                            None => None
180                        },
181                        // If `order` is defined at the level above and `subcommands` is defined here, `order` must be defined here too
182                        order: match is_order_defined {
183                            true if matches!(subcommands, Some(_)) => match order {
184                                // If it was required and was given, no problem
185                                Some(order) => Some(parse_directive_str(order)?),
186                                // If it was required but not given, return an error
187                                None => return Err(format!("Error in parsing Bonnie configuration file: if `order` is specified, all further nested subsubcommands must also specify `order`. This occurred in the '{}' script/subscript.", script_name))
188                            }
189                            // If it wasn't required, no validation needed
190                            true | false => match order {
191                                Some(order) => Some(parse_directive_str(order)?),
192                                None => None
193                            }
194                        },
195                        // If subcommands were specified, this is optional, otherwise we return an error
196                        cmd: match cmd {
197                            // It was given, but there are also ordered subcommands here, so execution will be ambiguous, return an error
198                            Some(_) if matches!(order, Some(_)) => return Err(format!("Error in parsing Bonnie configuration file: both `cmd` and `order` were specified. This would lead to problems of ambiguous execution, so commands can have either the top-level `cmd` property or ordered subcommands, the two are mutually exclusive. This error occurred in in the '{}' script/subscript.", script_name)),
199                            // It's optional
200                            _ if matches!(subcommands, Some(_)) => cmd.as_ref().map(|cmd| cmd.parse()),
201                            // It's mandatory and given
202                            Some(cmd) => Some(cmd.parse()),
203                            // It's mandatory and not given
204                            None => return Err(format!("Error in parsing Bonnie configuration file: if `subcommands` is not specified, `cmd` is mandatory. This error occurred in in the '{}' script/subscript.", script_name))
205                        },
206                        description: desc.clone()
207                    },
208                };
209                scripts.insert(script_name.to_string(), command);
210            }
211
212            Ok(scripts)
213        }
214
215        let scripts = parse_scripts(&self.scripts, false)?;
216
217        Ok(schema::Config {
218            default_shell,
219            scripts,
220            // Copy these last two in case the final config is cached and needs to be revalidated on load
221            env_files: match &self.env_files {
222                Some(env_files) => env_files.to_vec(),
223                None => Vec::new(),
224            },
225            version: self.version.clone(),
226        })
227    }
228}
229#[derive(Debug, Clone, Deserialize)]
230#[serde(untagged)]
231enum DefaultShell {
232    Simple(Shell), // Just a generic shell
233    Complex {
234        generic: Shell, // A generic shell must be given
235        targets: Option<HashMap<String, Shell>>,
236    },
237}
238// A vector of the executable followed by raw arguments thereto, the location for command interpolation is specified with '{COMMAND}'
239// A custom delimiter can also be specified (the default is ` && `), this should include spaces if necessary
240// Note that the default for PowerShell uses `;` instead
241#[derive(Debug, Clone, Deserialize)]
242#[serde(untagged)]
243pub enum Shell {
244    Simple(Vec<String>),
245    WithDelimiter {
246        parts: Vec<String>,
247        delimiter: String,
248    },
249}
250impl Shell {
251    fn parse(&self) -> schema::Shell {
252        match self {
253            Shell::Simple(parts) => schema::Shell {
254                parts: parts.to_vec(),
255                // The default delimiter is ` && ` in all cases (supported everywhere except Windows PowerShell)
256                delimiter: " && ".to_string(),
257            },
258            Shell::WithDelimiter { parts, delimiter } => schema::Shell {
259                parts: parts.to_vec(),
260                // The default delimiter is `&&` in all cases (supported everywhere except Windows PowerShell)
261                delimiter: delimiter.to_string(),
262            },
263        }
264    }
265}
266type TargetString = String; // A target like `linux` or `x86_64-unknown-linux-musl` (see `rustup` targets)
267type Scripts = HashMap<String, Command>;
268
269#[derive(Debug, Clone, Deserialize)]
270#[serde(untagged)]
271enum Command {
272    Simple(CommandWrapper), // Might be just a string command to run on the default generic shell
273    Complex {
274        args: Option<Vec<String>>,
275        env_vars: Option<Vec<String>>,
276        subcommands: Option<Scripts>, // Subcommands are fully-fledged commands (mostly)
277        order: Option<OrderString>, // 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`
278        cmd: Option<CommandWrapper>, // This is optional if subcommands are specified
279        desc: Option<String>, // This will be rendered in the config's help page ('description' is overly verbose)
280    },
281}
282type OrderString = String; // A string of as yet undefined syntax that defines the progression between subcommands
283                           // This wraps the complexities of having different shell logic for each command in a multi-stage context
284                           // subcommands are specified above this level (see `Command::Complex`)
285#[derive(Debug, Clone, Deserialize)]
286#[serde(untagged)]
287enum CommandWrapper {
288    Universal(CommandCore), // Just a given command
289    Specific {
290        generic: CommandCore,
291        targets: Option<HashMap<TargetString, CommandCore>>,
292    },
293}
294impl CommandWrapper {
295    // Parses `self` into its final form (`schema::CommandWrapper`)
296    fn parse(&self) -> schema::CommandWrapper {
297        match self {
298            // If it's universal to all targets, just provide a generic
299            CommandWrapper::Universal(raw_command_core) => schema::CommandWrapper {
300                generic: raw_command_core.parse(),
301                targets: HashMap::new(),
302            },
303            // If no targets were given in specific form, the expansion is basically the same as if it were universal
304            CommandWrapper::Specific {
305                generic,
306                targets: None,
307            } => schema::CommandWrapper {
308                generic: generic.parse(),
309                targets: HashMap::new(),
310            },
311            CommandWrapper::Specific {
312                generic,
313                targets: Some(targets),
314            } => {
315                let parsed_generic = generic.parse();
316                let mut parsed_targets: HashMap<schema::TargetString, schema::CommandCore> =
317                    HashMap::new();
318                for (target_name, raw_command_core) in targets.iter() {
319                    parsed_targets.insert(target_name.to_string(), raw_command_core.parse());
320                }
321                schema::CommandWrapper {
322                    generic: parsed_generic,
323                    targets: parsed_targets,
324                }
325            }
326        }
327    }
328}
329#[derive(Debug, Clone, Deserialize)]
330#[serde(untagged)]
331enum CommandCore {
332    Simple(CommandBox), // No shell configuration
333    WithShell {
334        exec: CommandBox, // We can't call this `cmd` because otherwise we'd have a collision with the higher-level `cmd`, which leads to misinterpretation
335        shell: Option<Shell>,
336    },
337}
338impl CommandCore {
339    // Parses `self` into its final form (`schema::CommandCore`)
340    fn parse(&self) -> schema::CommandCore {
341        match self {
342            CommandCore::Simple(exec) => schema::CommandCore {
343                exec: exec.parse(),
344                shell: None,
345            },
346            CommandCore::WithShell {
347                exec,
348                shell: Some(shell),
349            } => schema::CommandCore {
350                exec: exec.parse(),
351                shell: Some(shell.parse()),
352            },
353            // If no shell was given in the complex form, the expansion is the same as the simple form
354            CommandCore::WithShell { exec, shell: None } => schema::CommandCore {
355                exec: exec.parse(),
356                shell: None,
357            },
358        }
359    }
360}
361// This represents the possibility of a vector or string at the lowest level
362#[derive(Debug, Clone, Deserialize)]
363#[serde(untagged)]
364enum CommandBox {
365    Simple(String),
366    MultiStage(Vec<String>),
367}
368impl CommandBox {
369    // Parses `self` into its final form (`Vec<schema::CommandWrapper>`)
370    fn parse(&self) -> Vec<String> {
371        match self {
372            // In fully parsed form, all command wrappers are inside vectors for simplicity
373            CommandBox::Simple(cmd_str) => vec![cmd_str.to_string()],
374            CommandBox::MultiStage(cmd_strs) => {
375                cmd_strs.iter().map(|cmd_str| cmd_str.to_string()).collect()
376            }
377        }
378    }
379}