diff --git a/src/.bonnie.cache.json b/src/.bonnie.cache.json new file mode 100644 index 0000000..8c07c5c --- /dev/null +++ b/src/.bonnie.cache.json @@ -0,0 +1,260 @@ +{ + "default_shell": { + "generic": ["sh", "-c", "{COMMAND}"], + "targets": { "windows": ["cmd", "/C", "{COMMAND}"] } + }, + "scripts": { + "append_with_args": { + "args": ["name"], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo \"Hi %name! Message is: '%%'.\""], + "shell": null + }, + "targets": {} + } + }, + "basic": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { "exec": ["echo Test"], "shell": null }, + "targets": {} + } + }, + "adaptable": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { "exec": ["echo Test"], "shell": null }, + "targets": { + "magic_os": { + "exec": ["please print string \"Test\" > console"], + "shell": null + } + } + } + }, + "subcommands": { + "args": [], + "env_vars": [], + "subcommands": { + "nested": { + "args": [], + "env_vars": [], + "subcommands": { + "basic": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo Nested"], + "shell": null + }, + "targets": {} + } + } + }, + "order": ["basic", {}], + "cmd": null + }, + "multistage_with_interpolation": { + "args": ["name"], + "env_vars": ["GREETING"], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": [ + "echo \"Greeting: %GREETING\"", + "echo \"Name: %name\"" + ], + "shell": null + }, + "targets": {} + } + }, + "basic": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { "exec": ["echo Test"], "shell": null }, + "targets": {} + } + } + }, + "order": null, + "cmd": { + "generic": { "exec": ["echo Parent"], "shell": null }, + "targets": {} + } + }, + "interpolation": { + "args": ["name"], + "env_vars": ["GREETING"], + "subcommands": null, + "order": null, + "cmd": { + "generic": { "exec": ["echo %GREETING %name"], "shell": null }, + "targets": {} + } + }, + "multistage": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo \"Part 1\"", "echo \"Part 2\""], + "shell": null + }, + "targets": {} + } + }, + "super_versatile": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo Test"], + "shell": ["sh", "-c", "{COMMAND}"] + }, + "targets": { + "magic_os": { + "exec": ["please print string \"Test\" > console"], + "shell": [ + "please", + "run", + "command", + "{COMMAND}", + "against", + "sys" + ] + } + } + } + }, + "multistage_with_interpolation": { + "args": ["name"], + "env_vars": ["GREETING"], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": [ + "echo \"Greeting: %GREETING\"", + "echo \"Name: %name\"" + ], + "shell": null + }, + "targets": {} + } + }, + "echo": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo \"Message is: '%%'.\""], + "shell": null + }, + "targets": {} + } + }, + "power": { + "args": ["name"], + "env_vars": [], + "subcommands": { + "error": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo \"An error has occurred.\""], + "shell": null + }, + "targets": {} + } + }, + "basic": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { "exec": ["echo Test"], "shell": null }, + "targets": {} + } + }, + "multistage_with_interpolation": { + "args": [], + "env_vars": ["GREETING"], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": [ + "echo \"Greeting: %GREETING\"", + "echo \"Name: %name\"" + ], + "shell": null + }, + "targets": {} + } + }, + "nested": { + "args": [], + "env_vars": [], + "subcommands": { + "basic": { + "args": [], + "env_vars": [], + "subcommands": null, + "order": null, + "cmd": { + "generic": { + "exec": ["echo Nested"], + "shell": null + }, + "targets": {} + } + } + }, + "order": ["basic", {}], + "cmd": null + } + }, + "order": [ + "basic", + { + "Success": [ + "multistage_with_interpolation", + { "Failure": ["error", {}] } + ], + "Failure": ["error", {}] + } + ], + "cmd": null + } + }, + "env_files": [".env"], + "version": "0.3.0" +} diff --git a/src/bin/main.rs b/src/bin/main.rs index da9bdbd..5ca7fd7 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -1,4 +1,4 @@ -use lib::{get_cfg, Config, BONNIE_VERSION, init, help}; +use lib::{cache, cache_exists, get_cfg, help, init, load_from_cache, Config, BONNIE_VERSION}; use std::env; use std::io::Write; @@ -35,6 +35,7 @@ fn core() -> Result { // TODO add a checker for the executable that offers to install Bonnie if it isn't already? let _executable_name = prog_args.remove(0); // Check for special arguments + let mut should_cache = false; if matches!(prog_args.get(0), Some(_)) { if prog_args[0] == "-v" || prog_args[0] == "--version" { writeln!(stdout, "You are currently running Bonnie v{}! You can see the latest release at https://github.com/arctic-hen7/bonnie/releases.", BONNIE_VERSION).expect("Failed to write version."); @@ -43,22 +44,43 @@ fn core() -> Result { init( // See if a template was provided with the `--template`/`-t` flag match prog_args.get(1).as_ref() { - Some(arg) if &**arg == "-t" || &**arg == "--template" => prog_args.get(2).map(|x| x.to_string()), - _ => None - } + Some(arg) if &**arg == "-t" || &**arg == "--template" => { + prog_args.get(2).map(|x| x.to_string()) + } + _ => None, + }, )?; return Ok(0); } else if prog_args[0] == "-h" || prog_args[0] == "--help" { help(stdout); return Ok(0); + } else if prog_args[0] == "-c" || prog_args[0] == "--cache" { + should_cache = true; } } - // Get the config as a string - let cfg_str = get_cfg()?; - // Create a raw config object and parse it fully - // We use `stdout` for printing warnings - // TODO this takes meaningful millseconds for complex configs, so we should be able to cache its results in `.bonnie.cache.json` for speed in extreme cases - let cfg = Config::new(&cfg_str)?.to_final(BONNIE_VERSION, stdout)?; + // Check if there's a cache we should read from + // If there is but we're explicitly recaching, we should of course read directly from the source file + let cfg; + if cache_exists() && !should_cache { + cfg = load_from_cache(stdout)?; + } else { + // Get the config as a string + let cfg_str = get_cfg()?; + // Create a raw config object and parse it fully + // We use `stdout` for printing warnings + cfg = Config::new(&cfg_str)?.to_final(BONNIE_VERSION, stdout)?; + } + + // Check if we're caching + if should_cache { + cache(&cfg)?; + writeln!( + stdout, + "Your Bonnie configuration has been successfully cached to './.bonnie.cache.json'! This will be used to speed up future execution. Please note that this cache will NOT be updated until you explicitly run `bonnie -c` again." + ).expect("Failed to write caching message."); + return Ok(0); + } + // Determine which command we're actually running let (command_to_run, command_name, relevant_args) = cfg.get_command_for_args(&prog_args)?; // Get the Bone (item in Bones execution runtime) diff --git a/src/bones.rs b/src/bones.rs index 65544ab..ae74f97 100644 --- a/src/bones.rs +++ b/src/bones.rs @@ -1,13 +1,13 @@ // Bones is Bonnie's command execution runtime, which mainly handles ordered subcommands use regex::Regex; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::process::Command as OsCommand; // This enables recursion of ordered subcommands (which would be the most complex use-case of Bonnie thus far) // This really represents (from Bonnie's perspective) a future for an exit code -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum Bone { Simple(Vec), Complex(BonesCommand), @@ -46,7 +46,7 @@ impl Bone { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BonesCommand { // A HashMap of command names to vectors of raw commands to be executed // The commands to run are expected to have interpolation and target/shell resolution already done @@ -112,10 +112,10 @@ impl BonesCommand { // A directive telling the Bones engine how to progress between ordered subcommands // This maps the command to run to a set of conditions as to how to proceed based on its exit code -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BonesDirective(String, HashMap>); // This is used for direct parsing, before we've had a chance to handle the operators -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct RawBonesDirective(String, HashMap>); impl RawBonesDirective { // This converts to a `BonesDirective` by parsing the operator strings into full operators @@ -141,7 +141,7 @@ impl RawBonesDirective { } // Bones operators can be more than just exit codes, this defines their possibilities // For deserialization, this is left tagged (we pre-parse) -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, std::hash::Hash)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, std::hash::Hash)] pub enum BonesOperator { // A simple exit code comparison ExitCode(i32), @@ -256,7 +256,7 @@ impl BonesOperator { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BonesCore { pub cmd: String, pub shell: Vec, // Vector of executable and arguments thereto diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..827f2e6 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,46 @@ +use crate::version::BONNIE_VERSION; +use crate::{raw_schema, schema}; +use std::fs; + +// TODO add support for custom cache file paths with `BONNIE_CACHE` + +// Serializes the given parsed configuration into a JSON string and write it to disk to speed up future execution +// This takes around 100ms on an old i7 for the testing file +pub fn cache(cfg: &schema::Config) -> Result<(), String> { + let cache_str = serde_json::to_string(cfg); + let cache_str = match cache_str { + Ok(cache_str) => cache_str, + Err(err) => return Err(format!("The following error occurred while attempting to cache your parsed Bonnie configuration: '{}'.", err)) + }; + let output = fs::write("./.bonnie.cache.json", cache_str); + match output { + Ok(_) => Ok(()), + Err(err) => return Err(format!("The following error occurred while attempting to write your cached Bonnie configuration to './.bonnie.cache.json': '{}'.", err)) + } +} + +pub fn cache_exists() -> bool { + fs::metadata("./.bonnie.cache.json").is_ok() +} + +// This does NOT attempt to check if the cache is out of date for performance +// The user must manually recache +pub fn load_from_cache(output: &mut impl std::io::Write) -> Result { + let cfg_str = fs::read_to_string("./.bonnie.cache.json"); + let cfg_str = match cfg_str { + Ok(cfg_str) => cfg_str, + Err(err) => return Err(format!("The following error occurred while attempting to read your cached Bonnie configuration: '{}'.", err)) + }; + + let cfg = serde_json::from_str::(&cfg_str); + let cfg = match cfg { + Ok(cfg) => cfg, + Err(err) => return Err(format!("The following error occurred while attempting to parse your cached Bonnie configuration: '{}'. If this persists, you can recache with `bonnie -c`.", err)) + }; + // Check the version + raw_schema::Config::parse_version_against_current(&cfg.version, BONNIE_VERSION, output)?; + // Load the environment variable files + raw_schema::Config::load_env_files(Some(cfg.env_files.clone()))?; + + Ok(cfg) +} diff --git a/src/help.rs b/src/help.rs index bdf6cf3..f928611 100644 --- a/src/help.rs +++ b/src/help.rs @@ -8,6 +8,7 @@ pub fn help(output: &mut impl std::io::Write) { Hello World! ", - version=BONNIE_VERSION - ).expect("Failed to write help page.") + version = BONNIE_VERSION + ) + .expect("Failed to write help page.") } diff --git a/src/init.rs b/src/init.rs index c673cd2..36d45c8 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,5 +1,5 @@ -use std::fs; use crate::version::BONNIE_VERSION; +use std::fs; // Creates a new Bonnie configuration file using a template, or from the default pub fn init(template: Option) -> Result<(), String> { @@ -20,20 +20,20 @@ pub fn init(template: Option) -> Result<(), String> { output = fs::write("./bonnie.toml", contents); } else if matches!(template, Some(_)) && fs::metadata(template.as_ref().unwrap()).is_err() { // We have a template file that doesn't exist - return Err(format!("The given template file at '{}' does not exist or can't be read. Please make sure the file exists and you have the permissions necessary to read from it.", template.as_ref().unwrap())) + return Err(format!("The given template file at '{}' does not exist or can't be read. Please make sure the file exists and you have the permissions necessary to read from it.", template.as_ref().unwrap())); } else { // Create a new `bonnie.toml` file using the default // TODO read the default from `~/.bonnie/template.toml` if it exists output = fs::write( "./bonnie.toml", format!( - "version=\"{version}\" + "version=\"{version}\" [scripts] start = \"echo \\\"No start script yet!\\\"\" ", - version=BONNIE_VERSION - ) + version = BONNIE_VERSION + ), ); } diff --git a/src/lib.rs b/src/lib.rs index 47202ca..6330c22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ mod bones; +mod cache; mod default_shells; mod get_cfg; mod help; @@ -7,8 +8,9 @@ mod raw_schema; mod schema; mod version; +pub use crate::cache::{cache, cache_exists, load_from_cache}; pub use crate::get_cfg::get_cfg; +pub use crate::help::help; +pub use crate::init::init; pub use crate::raw_schema::Config; pub use crate::version::BONNIE_VERSION; -pub use crate::init::init; -pub use crate::help::help; diff --git a/src/raw_schema.rs b/src/raw_schema.rs index 05ec503..1d3fb1a 100644 --- a/src/raw_schema.rs +++ b/src/raw_schema.rs @@ -37,9 +37,9 @@ impl Config { bonnie_version_str: &str, output: &mut impl std::io::Write, ) -> Result { - // These two are run for their side-effects - self.parse_version_against_current(bonnie_version_str, output)?; - self.load_env_files()?; + // These two are run for their side-effects (both also used in loading from a cache) + Self::parse_version_against_current(&self.version, bonnie_version_str, output)?; + Self::load_env_files(self.env_files.clone())?; // And then we get the final config let cfg = self.parse()?; @@ -47,31 +47,32 @@ impl Config { } // Parses the version of the config to check for compatibility issues, consuming `self` // We extract the version of Bonnie itself for testing purposes - fn parse_version_against_current( - &self, + // This si generic because it's used in caching logic as well + pub fn parse_version_against_current( + cfg_version_str: &str, bonnie_version_str: &str, output: &mut impl std::io::Write, ) -> Result<(), String> { // Split the program and config file versions into their components let bonnie_version = get_version_parts(bonnie_version_str)?; - let cfg_version = get_version_parts(&self.version)?; + let cfg_version = get_version_parts(cfg_version_str)?; // Compare the two and warn/error appropriately let compat = bonnie_version.is_compatible_with(&cfg_version); match compat { - 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" + &self.version + ". " + match version_difference { + 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 { 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.", 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)." }), - 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" + &self.version + ". " + match version_difference { + 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 { 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.", 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)." }), // These next two are just warnings, not errors - 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" + &self.version + ". " + match version_difference { + 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 { 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.", 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)." }).expect("Failed to write warning."), - 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" + &self.version + ". " + match version_difference { + 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 { VersionDifference::TooNew => "You may want to update Bonnie to the appropriate version, which can be done at https://github.com/arctic-hen7/bonnie/releases.", VersionDifference::TooOld => "You may want to update the configuration file (which shouldn't require any syntax changes)." }).expect("Failed to write warning."), @@ -82,8 +83,9 @@ impl Config { Ok(()) } // Loads the environment variable files requested in the config - fn load_env_files(&self) -> Result<(), String> { - let env_files = match self.env_files.clone() { + // This is generic because it's called in caching as well + pub fn load_env_files(env_files: Option>) -> Result<(), String> { + let env_files = match env_files { Some(env_files) => env_files, None => Vec::new(), }; @@ -212,6 +214,12 @@ impl Config { Ok(schema::Config { default_shell, scripts, + // Copy these last two in case the final config is cached and needs to be revalidated on load + env_files: match &self.env_files { + Some(env_files) => env_files.to_vec(), + None => Vec::new(), + }, + version: self.version.clone(), }) } } diff --git a/src/schema.rs b/src/schema.rs index a6cc699..0547d63 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -2,14 +2,17 @@ // This does not reflect the actual syntax used in the configuration files themselves (see `raw_schema.rs`) use crate::bones::{Bone, BonesCommand, BonesCore, BonesDirective}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::env; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub default_shell: DefaultShell, pub scripts: Scripts, + // These last two properties are required for loading the config if it's cached + pub env_files: Vec, + pub version: String, } impl Config { // Gets the command requested by the given vector of arguments @@ -92,7 +95,7 @@ impl Config { Ok(data) } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DefaultShell { pub generic: Shell, pub targets: HashMap, // If the required target is not found, `generic` will be tried @@ -103,7 +106,7 @@ pub type Shell = Vec; pub type TargetString = String; // A target like `linux` or `x86_64-unknown-linux-musl` (see `rustup` targets) pub type Scripts = HashMap; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Command { pub args: Vec, pub env_vars: Vec, @@ -297,7 +300,7 @@ impl Command { } // This defines how the command runs on different targets -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommandWrapper { pub generic: CommandCore, pub targets: HashMap, // If empty or target not found, `generic` will be used @@ -349,7 +352,7 @@ impl CommandWrapper { // This is the lowest level of command specification, there is no more recursion allowed here (thus avoiding circularity) // Actual command must be specified here are strings (with potential interpolation of arguments and environment variables) // This can also define which shell the command will use -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommandCore { pub exec: Vec, // These are the actual commands that will be run (named differently to avoid collisions) pub shell: Option, // If given, this is the shell it will be run in, or the `default_shell` config for this target will be used