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 = env_files.unwrap_or_default();
89		// Parse each of the requested environment variable files
90		for env_file in env_files.iter() {
91			// Load the file
92			// This will be loaded for the Bonnie program, which allows us to interpolate them into commands
93			let res = dotenv::from_filename(env_file);
94			if res.is_err() {
95				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));
96			}
97		}
98
99		Ok(())
100	}
101	// Parses the rest of the config into the final form, consuming `self`
102	// A very large portion of Bonnie's logic lives here or is called here (spec transformation)
103	fn parse(&self) -> Result<schema::Config, String> {
104		// Parse the default shell
105		let default_shell = match &self.default_shell {
106			// If we're just given a shell string, use it as the generic shell
107			Some(DefaultShell::Simple(generic)) => schema::DefaultShell {
108				generic: generic.parse(),
109				targets: HashMap::new(),
110			},
111			// If we have all the information we need, just transform it
112			Some(DefaultShell::Complex { generic, targets }) => schema::DefaultShell {
113				generic: generic.parse(),
114				targets: match targets {
115					Some(raw_targets) => {
116						// This is just transformation logic
117						let mut targets = HashMap::new();
118						for (target_name, shell) in raw_targets.iter() {
119							targets.insert(target_name.to_string(), shell.parse());
120						}
121						targets
122					}
123					None => HashMap::new(), // We'll just use the generic if we don't have anything else
124				},
125			},
126			// If no default shell is provided, we'll use the default paradigm (see `default_shells.rs`)
127			None => get_default_shells(),
128		};
129		// Parse the scripts (brace yourself!)
130		// We do this inside a function because it's recursive
131		// Unfortunately we can't define methods on type aliases, so this goes here
132		// 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
133		fn parse_scripts(
134			raw_scripts: &Scripts,
135			is_order_defined: bool,
136		) -> Result<schema::Scripts, String> {
137			let mut scripts: schema::Scripts = HashMap::new();
138			for (script_name, raw_command) in raw_scripts.iter() {
139				let command = match raw_command {
140                    Command::Simple(raw_command_wrapper) => schema::Command {
141                        args: Vec::new(),
142                        env_vars: Vec::new(),
143                        subcommands: None,
144                        order: None,
145                        cmd: Some(raw_command_wrapper.parse()), // In the simple form, a command must be given (no subcommands can be specified)
146                        description: None
147                    },
148                    Command::Complex {
149                        args,
150                        env_vars,
151                        subcommands,
152                        order,
153                        cmd,
154                        desc
155                    } => schema::Command {
156                        // 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)
157                        args: match is_order_defined {
158                            // Unordered subcommands can't take arguments in any case of upper-level `order` definition
159                            _ if subcommands.is_some() && order.is_none() && args.is_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)),
160                            // If it was and `args` is specified, return an error
161                            true if args.is_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)),
162                            // If it was but args` isn't specified, it doesn't matter and we just give an empty vector instead
163                            true => Vec::new(),
164                            // If it wasn't, no validation needed
165                            false => args.as_ref().unwrap_or(&Vec::new()).to_vec()
166                        },
167                        // This doesn't need any transformation, just a simple alternative if it's `None`
168                        env_vars: env_vars.as_ref().unwrap_or(&Vec::new()).to_vec(),
169                        // The subcommands are parsed recursively as scripts using this very function
170                        // We parse through whether or not `order` is defined (has validation implications)
171                        subcommands: match subcommands {
172                            // We can't use `.map()` for this because we need support for `?`
173                            Some(subcommands) => Some(
174                                parse_scripts(subcommands, order.is_some())?
175                            ),
176                            None => None
177                        },
178                        // If `order` is defined at the level above and `subcommands` is defined here, `order` must be defined here too
179                        order: match is_order_defined {
180                            true if subcommands.is_some() => match order {
181                                // If it was required and was given, no problem
182                                Some(order) => Some(parse_directive_str(order)?),
183                                // If it was required but not given, return an error
184                                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))
185                            }
186                            // If it wasn't required, no validation needed
187                            true | false => match order {
188                                Some(order) => Some(parse_directive_str(order)?),
189                                None => None
190                            }
191                        },
192                        // If subcommands were specified, this is optional, otherwise we return an error
193                        cmd: match cmd {
194                            // It was given, but there are also ordered subcommands here, so execution will be ambiguous, return an error
195                            Some(_) if order.is_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)),
196                            // It's optional
197                            _ if subcommands.is_some() => cmd.as_ref().map(|cmd| cmd.parse()),
198                            // It's mandatory and given
199                            Some(cmd) => Some(cmd.parse()),
200                            // It's mandatory and not given
201                            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))
202                        },
203                        description: desc.clone()
204                    },
205                };
206				scripts.insert(script_name.to_string(), command);
207			}
208
209			Ok(scripts)
210		}
211
212		let scripts = parse_scripts(&self.scripts, false)?;
213
214		Ok(schema::Config {
215			default_shell,
216			scripts,
217			// Copy these last two in case the final config is cached and needs to be revalidated on load
218			env_files: match &self.env_files {
219				Some(env_files) => env_files.to_vec(),
220				None => Vec::new(),
221			},
222			version: self.version.clone(),
223		})
224	}
225}
226#[derive(Debug, Clone, Deserialize)]
227#[serde(untagged)]
228enum DefaultShell {
229	Simple(Shell), // Just a generic shell
230	Complex {
231		generic: Shell, // A generic shell must be given
232		targets: Option<HashMap<String, Shell>>,
233	},
234}
235// A vector of the executable followed by raw arguments thereto, the location for command interpolation is specified with '{COMMAND}'
236// A custom delimiter can also be specified (the default is ` && `), this should include spaces if necessary
237// Note that the default for PowerShell uses `;` instead
238#[derive(Debug, Clone, Deserialize)]
239#[serde(untagged)]
240pub enum Shell {
241	Simple(Vec<String>),
242	WithDelimiter {
243		parts: Vec<String>,
244		delimiter: String,
245	},
246}
247impl Shell {
248	fn parse(&self) -> schema::Shell {
249		match self {
250			Shell::Simple(parts) => schema::Shell {
251				parts: parts.to_vec(),
252				// The default delimiter is ` && ` in all cases (supported everywhere except Windows PowerShell)
253				delimiter: " && ".to_string(),
254			},
255			Shell::WithDelimiter { parts, delimiter } => schema::Shell {
256				parts: parts.to_vec(),
257				// The default delimiter is `&&` in all cases (supported everywhere except Windows PowerShell)
258				delimiter: delimiter.to_string(),
259			},
260		}
261	}
262}
263type TargetString = String; // A target like `linux` or `x86_64-unknown-linux-musl` (see `rustup` targets)
264type Scripts = HashMap<String, Command>;
265
266#[derive(Debug, Clone, Deserialize)]
267#[serde(untagged)]
268enum Command {
269	Simple(CommandWrapper), // Might be just a string command to run on the default generic shell
270	Complex {
271		args: Option<Vec<String>>,
272		env_vars: Option<Vec<String>>,
273		subcommands: Option<Scripts>, // Subcommands are fully-fledged commands (mostly)
274		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`
275		cmd: Option<CommandWrapper>, // This is optional if subcommands are specified
276		desc: Option<String>, // This will be rendered in the config's help page ('description' is overly verbose)
277	},
278}
279type OrderString = String; // A string of as yet undefined syntax that defines the progression between subcommands
280						   // This wraps the complexities of having different shell logic for each command in a multi-stage context
281						   // subcommands are specified above this level (see `Command::Complex`)
282#[derive(Debug, Clone, Deserialize)]
283#[serde(untagged)]
284enum CommandWrapper {
285	Universal(CommandCore), // Just a given command
286	Specific {
287		generic: CommandCore,
288		targets: Option<HashMap<TargetString, CommandCore>>,
289	},
290}
291impl CommandWrapper {
292	// Parses `self` into its final form (`schema::CommandWrapper`)
293	fn parse(&self) -> schema::CommandWrapper {
294		match self {
295			// If it's universal to all targets, just provide a generic
296			CommandWrapper::Universal(raw_command_core) => schema::CommandWrapper {
297				generic: raw_command_core.parse(),
298				targets: HashMap::new(),
299			},
300			// If no targets were given in specific form, the expansion is basically the same as if it were universal
301			CommandWrapper::Specific {
302				generic,
303				targets: None,
304			} => schema::CommandWrapper {
305				generic: generic.parse(),
306				targets: HashMap::new(),
307			},
308			CommandWrapper::Specific {
309				generic,
310				targets: Some(targets),
311			} => {
312				let parsed_generic = generic.parse();
313				let mut parsed_targets: HashMap<schema::TargetString, schema::CommandCore> =
314					HashMap::new();
315				for (target_name, raw_command_core) in targets.iter() {
316					parsed_targets.insert(target_name.to_string(), raw_command_core.parse());
317				}
318				schema::CommandWrapper {
319					generic: parsed_generic,
320					targets: parsed_targets,
321				}
322			}
323		}
324	}
325}
326#[derive(Debug, Clone, Deserialize)]
327#[serde(untagged)]
328enum CommandCore {
329	Simple(CommandBox), // No shell configuration
330	WithShell {
331		exec: CommandBox, // We can't call this `cmd` because otherwise we'd have a collision with the higher-level `cmd`, which leads to misinterpretation
332		shell: Option<Shell>,
333	},
334}
335impl CommandCore {
336	// Parses `self` into its final form (`schema::CommandCore`)
337	fn parse(&self) -> schema::CommandCore {
338		match self {
339			CommandCore::Simple(exec) => schema::CommandCore {
340				exec: exec.parse(),
341				shell: None,
342			},
343			CommandCore::WithShell {
344				exec,
345				shell: Some(shell),
346			} => schema::CommandCore {
347				exec: exec.parse(),
348				shell: Some(shell.parse()),
349			},
350			// If no shell was given in the complex form, the expansion is the same as the simple form
351			CommandCore::WithShell { exec, shell: None } => schema::CommandCore {
352				exec: exec.parse(),
353				shell: None,
354			},
355		}
356	}
357}
358// This represents the possibility of a vector or string at the lowest level
359#[derive(Debug, Clone, Deserialize)]
360#[serde(untagged)]
361enum CommandBox {
362	Simple(String),
363	MultiStage(Vec<String>),
364}
365impl CommandBox {
366	// Parses `self` into its final form (`Vec<schema::CommandWrapper>`)
367	fn parse(&self) -> Vec<String> {
368		match self {
369			// In fully parsed form, all command wrappers are inside vectors for simplicity
370			CommandBox::Simple(cmd_str) => vec![cmd_str.to_string()],
371			CommandBox::MultiStage(cmd_strs) => {
372				cmd_strs.iter().map(|cmd_str| cmd_str.to_string()).collect()
373			}
374		}
375	}
376}