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.first();
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 command.cmd.is_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 command.order.is_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 self.subcommands.is_none() || (self.subcommands.is_some() && self.cmd.is_some()) {
215			// We have either a direct command or a parent command that has irrelevant subcommands, either way we're interpolating into `cmd`
216			// Get the vector of command wrappers
217			// Assuming the transformation logic works, an error can't occur here
218			let command_wrapper = self.cmd.as_ref().unwrap();
219			// Interpolate for each individual command
220			// We have to do this in a for loop for `?`
221			let mut cmd_strs: Vec<String> = Vec::new();
222			let (cmds, shell) = command_wrapper.get_commands_and_shell(default_shell);
223			for cmd_str in cmds {
224				let with_env_vars = Command::interpolate_env_vars(&cmd_str, &self.env_vars)?;
225				let (with_args, remaining_args) =
226					Command::interpolate_specific_args(&with_env_vars, name, args, prog_args)?;
227				let ready_cmd =
228					Command::interpolate_remaining_arguments(&with_args, &remaining_args);
229				cmd_strs.push(ready_cmd);
230			}
231
232			Ok(
233				// This does not contain recursive `BonesCommands`, so it's `Bone::Simple`
234				Bone::Simple(BonesCore {
235					// We join every stage of the command into one, separated by the given delimiters
236					cmd: cmd_strs.join(&shell.delimiter),
237					// The shell is then just the vector of executable and arguments
238					shell: shell.parts.to_vec(),
239				}),
240			)
241		} else if self.subcommands.is_some() && self.order.is_some() {
242			// First, we resolve all the subcommands to vectors of strings to actually run
243			let mut cmds: HashMap<String, Bone> = HashMap::new();
244			// Now we run checks on whether the correct number of arguments have been provided if we're at the very top level
245			// Otherwise error messages will relate to irrelevant subcommands
246			// We don't check the case where too few arguments were provided because that's irrelevant (think about it)
247			if at_top_level && args.len() > prog_args.len() {
248				return Err(
249                    format!(
250                        "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.",
251                        command=name,
252                        num_required_args=args.len(),
253                        num_given_args=&prog_args.len()
254                    )
255                );
256			}
257			// We `.unwrap()` here because we know more than the compiler
258			for (subcommand_name, subcommand) in self.subcommands.as_ref().unwrap().iter() {
259				// Parse the subcommand
260				// 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.)
261				let cmd = subcommand.prepare_internal(
262					subcommand_name,
263					prog_args,
264					default_shell,
265					Some(args),
266				)?;
267				cmds.insert(subcommand_name.to_string(), cmd);
268			}
269
270			// Now we return a complex `Bone` (because it contains a `BonesCommand` with a directive)
271			Ok(Bone::Complex(
272				BonesCommand::new(self.order.as_ref().unwrap(), cmds), // We know more than the compiler by the check above
273			))
274		} else {
275			// This should not be possible!
276			panic!("Critical logic failure in preparing command. You should report this as a bug.");
277		}
278	}
279	// Interpolates specific arguments (doesn't handle `%%`)
280	// This takes a string to interpolate into and doesn't take `self` so the order is open
281	// This returns the readied command string and the remaining arguments or an error if an argument couldn't be substituted in
282	// Errors for when the argument can't be interpolated can be silenced for ordered subcommands (which have a universal argument list for many subcommands)
283	fn interpolate_specific_args(
284		cmd_str: &str,
285		name: &str,
286		args: &[String],
287		prog_args: &[String],
288	) -> Result<(String, Vec<String>), String> {
289		// Check if the correct number of arguments was provided
290		// Even if we're inserting the rest later, we still need the mandatory ones
291		if args.len() > prog_args.len() {
292			return Err(
293                format!(
294                    "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.",
295                    command=name,
296                    num_required_args=args.len(),
297                    num_given_args=&prog_args.len()
298                )
299            );
300		}
301		// 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
302		let mut with_args = cmd_str.to_string();
303		// We need to know the index so we can correlate to the index of the argument in `args`
304		for (idx, arg) in args.iter().enumerate() {
305			// The arrays are the same length, see above check
306			// All arguments are shown in the command string as `%name` or the like, so we get that whole string
307			let given_value = &prog_args[idx];
308			let arg_with_sign = "%".to_string() + arg;
309			let new_command = with_args.replace(&arg_with_sign, given_value);
310			// We don't check if we changed something because that doesn't work for multistage or ordered subcommands
311			with_args = new_command;
312		}
313		// Get the program args after a certain point so they can be inserted with `%%` if necessary
314		// We do this by getting the part of slice after the specific arguments
315		let (_, remaining_args) = prog_args.split_at(args.len());
316
317		Ok((with_args, remaining_args.to_vec())) // FIXME
318	}
319	// Interpolates environment variables
320	// This takes a string to interpolate into, the environment variables to interpolate, and the name of the command
321	// This doesn't take `self` so the order is open
322	// This returns the readied command string only, or an error relating to environment variable loading
323	fn interpolate_env_vars(cmd_str: &str, env_vars: &[String]) -> Result<String, String> {
324		let mut with_env_vars = cmd_str.to_string();
325		for env_var_name in env_vars.iter() {
326			// Load the environment variable
327			let env_var = env::var(env_var_name);
328			let env_var = match env_var {
329                Ok(env_var) => env_var,
330                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))
331            };
332			// Interpolate it into the command itself
333			let to_replace = "%".to_string() + env_var_name;
334			let new_command = with_env_vars.replace(&to_replace, &env_var);
335			// We don't check if we changed something because that doesn't work for multistage or ordered subcommands
336			with_env_vars = new_command;
337		}
338
339		Ok(with_env_vars)
340	}
341	// Interpolates all the given arguments at `%%` if it exists
342	// This takes a string to interpolate into and doesn't take `self` so the order is open
343	// This returns the readied command string only
344	fn interpolate_remaining_arguments(cmd_str: &str, prog_args: &[String]) -> String {
345		// This is just a simple `replace` operation for the operator `%%`
346		// Split the command by the block insertion operator `%%`
347		let mut interpolated = String::new();
348		let split_on_operator: Vec<&str> = cmd_str.split("%%").collect();
349		for (idx, part) in split_on_operator.iter().enumerate() {
350			if idx == split_on_operator.len() - 1 {
351				// This is the last element, there's no operator after this
352				interpolated.push_str(part);
353			} else if part.ends_with('\\') {
354				// This part ends with `\`, meaning the operator was escaped
355				// We just give the `%%` back
356				// We only give back the part up until the escape character
357				interpolated.push_str(&part[0..part.len() - 1]);
358				interpolated.push_str("%%");
359			} else {
360				// There's a legitimate operator that should be at the end of this part
361				// We push the program's arguments
362				interpolated.push_str(part);
363				interpolated.push_str(&prog_args.join(" "));
364			}
365		}
366
367		interpolated
368	}
369	// Gets a documentation message for this command based on its metadata and the `desc` properties
370	fn document(&self, name: &str) -> String {
371		let mut msgs = Vec::new();
372		// Get the user-given docs (if they exist)
373		let doc = match &self.description {
374			Some(desc) => desc.to_string(),
375			None => String::from("no 'desc' property set"),
376		};
377
378		// Set up the left side (command name and some arguments info)
379		let mut left = String::new();
380		// Environment variables (before the command name)
381		for env_var in &self.env_vars {
382			left += &format!("<{}> ", env_var);
383		}
384		// Command name
385		left += name;
386		// Arguments (after the command name)
387		for arg in &self.args {
388			left += &format!(" <{}>", arg);
389		}
390		// Ordered or not
391		if self.order.is_some() {
392			left += " (ordered)";
393		}
394		// TODO handle '%%' as `[...]`
395		// That's a placeholder for a number of tabs that spaces everything evenly
396		msgs.push(format!("{}{{TABS}}{}", left, doc));
397
398		// Loop through every subcommand and document it
399		if let Some(subcommands_map) = &self.subcommands {
400			// Sort the subcommands alphabetically
401			let mut subcommands_iter: Vec<(&String, &Command)> = subcommands_map.iter().collect();
402			subcommands_iter.sort_by(|(name, _), (name2, _)| name.cmp(name2));
403			for (cmd_name, cmd) in subcommands_iter {
404				let subcmd_doc = cmd.document(cmd_name);
405				msgs.push(
406					// We add four spaces in front of every line (that way it works recursively for nested subcommands)
407					format!("    {}", subcmd_doc.replace("\n", "\n    ")),
408				);
409			}
410		}
411
412		msgs.join("\n")
413	}
414}
415// This defines how the command runs on different targets
416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
417pub struct CommandWrapper {
418	pub generic: CommandCore,
419	pub targets: HashMap<TargetString, CommandCore>, // If empty or target not found, `generic` will be used
420}
421impl CommandWrapper {
422	// Gets the command to run, interpolated into a shell from the ambient OS information
423	// This critically resolves which target we're running on
424	fn get_commands_and_shell(&self, default_shell: &DefaultShell) -> (Vec<String>, Shell) {
425		// Get the current target (unfortuantely we can't actually get the value out of `cfg!` yet...)
426		// If the user needs to set custom commands based on target arch etc., they can write a script for it, this is exhaustive enough!
427		let running_on = match true {
428			_ if cfg!(target_os = "windows") => "windows",
429			_ if cfg!(target_os = "macos") => "macos",
430			_ if cfg!(target_os = "ios") => "ios",
431			_ if cfg!(target_os = "linux") => "linux",
432			_ if cfg!(target_os = "android") => "android",
433			_ if cfg!(target_os = "freebsd") => "freebsd",
434			_ if cfg!(target_os = "dragonfly") => "dragonfly",
435			_ if cfg!(target_os = "openbsd") => "openbsd",
436			_ if cfg!(target_os = "netbsd") => "netbsd",
437			_ => "unknown", // If they want to, the user could actually specify something for this (like begging to be run somewhere that makes sense)
438		};
439		// See if that target is specified explicitly
440		let target_specific_command_core = self.targets.get(running_on);
441		let command_core = match target_specific_command_core {
442			Some(command_core) => command_core,
443			None => &self.generic,
444		};
445		// Get the commands as a vector ready for interpolation
446		let cmd = &command_core.exec;
447		// Get the shell, using the configured per-file default if it was undefined
448		let shell = match &command_core.shell {
449			Some(shell) => shell,
450			None => {
451				// If a particular shell has been configured for the current target, use that
452				// Otherwise, use the generic
453				// Remember that the schema transformation inserts program-level defaults if they aren't configured for the file by the user
454				let target_specific_shell = default_shell.targets.get(running_on);
455				match target_specific_shell {
456					Some(default_shell) => default_shell,
457					None => &default_shell.generic,
458				}
459			}
460		};
461
462		(cmd.to_vec(), shell.clone())
463	}
464}
465// This is the lowest level of command specification, there is no more recursion allowed here (thus avoiding circularity)
466// Actual command must be specified here are strings (with potential interpolation of arguments and environment variables)
467// This can also define which shell the command will use
468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
469pub struct CommandCore {
470	pub exec: Vec<String>, // These are the actual commands that will be run (named differently to avoid collisions)
471	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
472}