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}