diff --git a/Cargo.lock b/Cargo.lock index 31e432a0..cf364747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1088,6 +1088,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merge" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bbef93abb1da61525bbc45eeaff6473a41907d19f8f9aa5168d214e10693e9" +dependencies = [ + "merge_derive", + "num-traits", +] + +[[package]] +name = "merge_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209d075476da2e63b4b29e72a2ef627b840589588e71400a25e3565c4f849d07" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "mime" version = "0.3.17" @@ -1494,9 +1516,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.5.6" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" +checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" dependencies = [ "aho-corasick", "memchr", @@ -1512,6 +1534,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-split" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "544861d1810a3e429bb5e80266e537138b0e69e59bed9334326ae129a4c3e676" +dependencies = [ + "regex", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -2137,11 +2168,13 @@ dependencies = [ "home", "lazy_static", "libc", + "merge", "nix 0.24.3", "notify-rust", "once_cell", "parselnk", "regex", + "regex-split", "rust-ini", "self_update", "semver", diff --git a/Cargo.toml b/Cargo.toml index 256da2cc..c7c7d94c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,12 +41,14 @@ tempfile = "~3.2" cfg-if = "~1.0" tokio = { version = "~1.18", features = ["process", "rt-multi-thread"] } futures = "~0.3" -regex = "~1.5" +regex = "~1.7" semver = "~1.0" shell-words = "~1.1" color-eyre = "~0.6" tracing = { version = "~0.1", features = ["attributes", "log"] } tracing-subscriber = { version = "~0.3", features = ["env-filter", "time"] } +merge = "0.1.0" +regex-split = "0.1.0" [target.'cfg(target_os = "macos")'.dependencies] notify-rust = "~4.5" diff --git a/config.example.toml b/config.example.toml index 2936509f..5f311b68 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,3 +1,10 @@ +# Include any additional configuration file(s) +# [include] sections are processed in the order you write them +# Files in $CONFIG_DIR/topgrade/topgrade.d/ are automatically included at the beginning of this file +[include] +#paths = ["/etc/topgrade.toml"] + +[misc] # Don't ask for confirmations #assume_yes = true diff --git a/src/config.rs b/src/config.rs index 7a54dbae..f37c931c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,20 @@ #![allow(dead_code)] + use std::collections::BTreeMap; -use std::fs::write; -use std::path::PathBuf; +use std::fs::{write, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::Command; use std::{env, fs}; use clap::{ArgEnum, Parser}; use clap_complete::Shell; -use color_eyre::eyre; use color_eyre::eyre::Context; use color_eyre::eyre::Result; use etcetera::base_strategy::BaseStrategy; +use merge::Merge; use regex::Regex; +use regex_split::RegexSplit; use serde::Deserialize; use strum::{EnumIter, EnumString, EnumVariantNames, IntoEnumIterator}; use tracing::debug; @@ -19,6 +22,7 @@ use which_crate::which; use crate::command::CommandExt; use crate::sudo::SudoKind; +use crate::utils::string_prepend_str; use super::utils::{editor, hostname}; @@ -51,21 +55,43 @@ macro_rules! check_deprecated { } }; } -macro_rules! get_deprecated { - ($config:expr, $old:ident, $section:ident, $new:ident) => { - if $config.$old.is_some() { - &$config.$old - } else { - if let Some(section) = &$config.$section { - §ion.$new - } else { - &None + +/// Get a deprecated option moved from a section to another +macro_rules! get_deprecated_moved_opt { + ($old_section:expr, $old:ident, $new_section:expr, $new:ident) => {{ + if let Some(old_section) = &$old_section { + if old_section.$old.is_some() { + return &old_section.$old; } } - }; + + if let Some(new_section) = &$new_section { + return &new_section.$new; + } + + return &None; + }}; +} + +macro_rules! get_deprecated_moved_or_default_to { + ($old_section:expr, $old:ident, $new_section:expr, $new:ident, $default_ret:ident) => {{ + if let Some(old_section) = &$old_section { + if let Some(old) = old_section.$old { + return old; + } + } + + if let Some(new_section) = &$new_section { + if let Some(new) = new_section.$new { + return new; + } + } + + return $default_ret; + }}; } -type Commands = BTreeMap; +pub type Commands = BTreeMap; #[derive(ArgEnum, EnumString, EnumVariantNames, Debug, Clone, PartialEq, Eq, Deserialize, EnumIter, Copy)] #[clap(rename_all = "snake_case")] @@ -169,24 +195,38 @@ pub enum Step { Yarn, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] +#[serde(deny_unknown_fields)] +pub struct Include { + #[merge(strategy = merge::vec::append)] + paths: Vec, +} + +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Git { max_concurrency: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] repos: Option>, + pull_predefined: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Vagrant { + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] directories: Option>, + power_on: Option, always_suspend: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Windows { accept_all_updates: Option, @@ -197,7 +237,7 @@ pub struct Windows { wsl_update_use_web_download: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Python { enable_pip_review: Option, @@ -205,43 +245,45 @@ pub struct Python { enable_pipupgrade: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] #[allow(clippy::upper_case_acronyms)] pub struct Distrobox { use_root: Option, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] containers: Option>, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] #[allow(clippy::upper_case_acronyms)] pub struct Yarn { use_sudo: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] #[allow(clippy::upper_case_acronyms)] pub struct NPM { use_sudo: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] #[allow(clippy::upper_case_acronyms)] pub struct Firmware { upgrade: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] #[allow(clippy::upper_case_acronyms)] pub struct Flatpak { use_sudo: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Brew { greedy_cask: Option, @@ -262,88 +304,191 @@ pub enum ArchPackageManager { Yay, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Linux { + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] yay_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] aura_aur_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] aura_pacman_arguments: Option, arch_package_manager: Option, show_arch_news: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] garuda_update_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] trizen_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] pikaur_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] pamac_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] dnf_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] nix_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] apt_arguments: Option, + enable_tlmgr: Option, redhat_distro_sync: Option, suse_dup: Option, rpm_ostree: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] emerge_sync_flags: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] emerge_update_flags: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Composer { self_update: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] pub struct Vim { force_plug_update: Option, } -#[derive(Deserialize, Default, Debug)] +#[derive(Deserialize, Default, Debug, Merge)] #[serde(deny_unknown_fields)] -/// Configuration file -pub struct ConfigFile { - sudo_command: Option, +pub struct Misc { pre_sudo: Option, - pre_commands: Option, - post_commands: Option, - commands: Option, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] git_repos: Option>, + predefined_git_repos: Option, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] disable: Option>, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] ignore_failures: Option>, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] remote_topgrades: Option>, + remote_topgrade_path: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] ssh_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] git_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] tmux_arguments: Option, + set_title: Option, + display_time: Option, + display_preamble: Option, + assume_yes: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] yay_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] aura_aur_arguments: Option, + + #[merge(strategy = crate::utils::merge_strategies::string_append_opt)] aura_pacman_arguments: Option, - python: Option, + no_retry: Option, + run_in_tmux: Option, + cleanup: Option, + notify_each_step: Option, + accept_all_windows_updates: Option, + skip_notify: Option, + bashit_branch: Option, + + #[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)] only: Option>, + + no_self_update: Option, +} + +#[derive(Deserialize, Default, Debug, Merge)] +#[serde(deny_unknown_fields)] +/// Configuration file +pub struct ConfigFile { + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] + include: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] + misc: Option, + + sudo_command: Option, + + #[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)] + pre_commands: Option, + + #[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)] + post_commands: Option, + + #[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)] + commands: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] + python: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] composer: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] brew: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] linux: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] git: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] windows: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] npm: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] yarn: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] vim: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] firmware: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] vagrant: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] flatpak: Option, + + #[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)] distrobox: Option, - no_self_update: Option, } fn config_directory() -> PathBuf { @@ -354,58 +499,164 @@ fn config_directory() -> PathBuf { return crate::WINDOWS_DIRS.config_dir(); } +/// The only purpose of this struct is to deserialize only the `include` field of the config file. +#[derive(Deserialize, Default, Debug)] +struct ConfigFileIncludeOnly { + include: Option, +} + impl ConfigFile { - fn ensure() -> Result { + /// Returns the main config file and any additional config files + /// 0 = main config file + /// 1 = additional config files coming from topgrade.d + fn ensure() -> Result<(PathBuf, Vec)> { + let mut res = (PathBuf::new(), Vec::new()); + let config_directory = config_directory(); - let config_path = config_directory.join("topgrade.toml"); - let alt_config_path = config_directory.join("topgrade/topgrade.toml"); + let possible_config_paths = vec![ + config_directory.join("topgrade.toml"), + config_directory.join("topgrade/topgrade.toml"), + ]; + + // Search for the main config file + for path in possible_config_paths.iter() { + if path.exists() { + debug!("Configuration at {}", path.display()); + res.0 = path.clone(); + break; + } + } - if config_path.exists() { - debug!("Configuration at {}", config_path.display()); - Ok(config_path) - } else if alt_config_path.exists() { - debug!("Configuration at {}", alt_config_path.display()); - Ok(alt_config_path) - } else { + res.1 = Self::ensure_topgrade_d(&config_directory)?; + + // If no config file exists, create a default one in the config directory + if !res.0.exists() && res.1.is_empty() { debug!("No configuration exists"); - write(&config_path, EXAMPLE_CONFIG).map_err(|e| { + write(&res.0, EXAMPLE_CONFIG).map_err(|e| { debug!( "Unable to write the example configuration file to {}: {}. Using blank config.", - config_path.display(), + &res.0.display(), e ); e })?; - Ok(config_path) } + + Ok(res) + } + + /// Searches topgrade.d for additional config files + fn ensure_topgrade_d(config_directory: &Path) -> Result> { + let mut res = Vec::new(); + let dir_to_search = config_directory.join("topgrade.d"); + + if dir_to_search.exists() { + for entry in fs::read_dir(dir_to_search)? { + let entry = entry?; + if entry.file_type()?.is_file() { + debug!( + "Found additional (directory) configuration file at {}", + entry.path().display() + ); + res.push(entry.path()); + } + } + res.sort(); + } else { + debug!("No additional configuration directory exists, creating one"); + fs::create_dir_all(&dir_to_search)?; + } + + Ok(res) } /// Read the configuration file. /// - /// If the configuration file does not exist the function returns the default ConfigFile. + /// If the configuration file does not exist, the function returns the default ConfigFile. fn read(config_path: Option) -> Result { + let mut result = Self::default(); + let config_path = if let Some(path) = config_path { path } else { - Self::ensure()? + let (path, dir_include) = Self::ensure()?; + + /* + The Function was called without a config_path, we need + to read the include directory before returning the main config path + */ + for include in dir_include { + let include_contents = fs::read_to_string(&include).map_err(|e| { + tracing::error!("Unable to read {}", include.display()); + e + })?; + let include_contents_parsed = toml::from_str(include_contents.as_str()).map_err(|e| { + tracing::error!("Failed to deserialize {}", include.display()); + e + })?; + + result.merge(include_contents_parsed); + } + + path }; - let contents = fs::read_to_string(&config_path).map_err(|e| { + let mut contents_non_split = fs::read_to_string(&config_path).map_err(|e| { tracing::error!("Unable to read {}", config_path.display()); e })?; - let mut result: Self = toml::from_str(&contents).map_err(|e| { - tracing::error!("Failed to deserialize {}", config_path.display()); - e - })?; + Self::ensure_misc_is_present(&mut contents_non_split, &config_path); - if let Some(ref mut paths) = &mut result.git_repos { - for path in paths.iter_mut() { - let expanded = shellexpand::tilde::<&str>(&path.as_ref()).into_owned(); - debug!("Path {} expanded to {}", path, expanded); - *path = expanded; + // To parse [include] sections in the order as they are written, + // we split the file and parse each part as a separate file + let regex_match_include = Regex::new(r"\[include]").expect("Failed to compile regex"); + let contents_split = regex_match_include.split_inclusive_left(contents_non_split.as_str()); + + for contents in contents_split { + let config_file_include_only: ConfigFileIncludeOnly = toml::from_str(contents).map_err(|e| { + tracing::error!("Failed to deserialize an include section of {}", config_path.display()); + e + })?; + + if let Some(includes) = &config_file_include_only.include { + // Parses the [include] section present in the slice + for include in includes.paths.iter().rev() { + let include_path = shellexpand::tilde::<&str>(&include.as_ref()).into_owned(); + let include_path = PathBuf::from(include_path); + let include_contents = match fs::read_to_string(&include_path) { + Ok(c) => c, + Err(e) => { + tracing::error!("Unable to read {}: {}", include_path.display(), e); + continue; + } + }; + match toml::from_str::(&include_contents) { + Ok(include_parsed) => result.merge(include_parsed), + Err(e) => { + tracing::error!("Failed to deserialize {}: {}", include_path.display(), e); + continue; + } + }; + + debug!("Configuration include found: {}", include_path.display()); + } + } + + match toml::from_str::(contents) { + Ok(contents) => result.merge(contents), + Err(e) => tracing::error!("Failed to deserialize {}: {}", config_path.display(), e), + } + } + + if let Some(misc) = &mut result.misc { + if let Some(ref mut paths) = &mut misc.git_repos { + for path in paths.iter_mut() { + let expanded = shellexpand::tilde::<&str>(&path.as_ref()).into_owned(); + debug!("Path {} expanded to {}", path, expanded); + *path = expanded; + } } } @@ -423,7 +674,7 @@ impl ConfigFile { } fn edit() -> Result<()> { - let config_path = Self::ensure()?; + let config_path = Self::ensure()?.0; let editor = editor(); debug!("Editor: {:?}", editor); @@ -436,6 +687,18 @@ impl ConfigFile { .status_checked() .context("Failed to open configuration file editor") } + + /// [Misc] was added later, here we check if it is present in the config file and add it if not + fn ensure_misc_is_present(contents: &mut String, path: &PathBuf) { + if !contents.contains("[misc]") { + debug!("Adding [misc] section to {}", path.display()); + string_prepend_str(contents, "[misc]\n"); + + File::create(path) + .and_then(|mut f| f.write_all(contents.as_bytes())) + .expect("Tried to auto-migrate the config file, unable to write to config file.\nPlease add \"[misc]\" section manually to the first line of the file.\nError"); + } + } } // Command line arguments @@ -565,7 +828,7 @@ impl CommandLineArgs { /// Represents the application configuration /// /// The struct holds the loaded configuration file, as well as the arguments parsed from the command line. -/// Its provided methods decide the appropriate options based on combining the configuraiton file and the +/// Its provided methods decide the appropriate options based on combining the configuration file and the /// command line arguments. pub struct Config { opt: CommandLineArgs, @@ -576,7 +839,7 @@ pub struct Config { impl Config { /// Load the configuration. /// - /// The function parses the command line arguments and reading the configuration file. + /// The function parses the command line arguments and reads the configuration file. pub fn load(opt: CommandLineArgs) -> Result { let config_directory = config_directory(); let config_file = if config_directory.is_dir() { @@ -587,15 +850,17 @@ impl Config { ConfigFile::default() }) } else { - tracing::debug!("Configuration directory {} does not exist", config_directory.display()); + debug!("Configuration directory {} does not exist", config_directory.display()); ConfigFile::default() }; - check_deprecated!(config_file, git_arguments, git, arguments); - check_deprecated!(config_file, git_repos, git, repos); - check_deprecated!(config_file, predefined_git_repos, git, pull_predefined); - check_deprecated!(config_file, yay_arguments, linux, yay_arguments); - check_deprecated!(config_file, accept_all_windows_updates, windows, accept_all_updates); + if let Some(misc) = &config_file.misc { + check_deprecated!(misc, git_arguments, git, arguments); + check_deprecated!(misc, git_repos, git, repos); + check_deprecated!(misc, predefined_git_repos, git, pull_predefined); + check_deprecated!(misc, yay_arguments, linux, yay_arguments); + check_deprecated!(misc, accept_all_windows_updates, windows, accept_all_updates); + } let allowed_steps = Self::allowed_steps(&opt, &config_file); @@ -628,7 +893,7 @@ impl Config { /// The list of additional git repositories to pull. pub fn git_repos(&self) -> &Option> { - get_deprecated!(self.config_file, git_repos, git, repos) + get_deprecated_moved_opt!(&self.config_file.misc, git_repos, &self.config_file.git, repos) } /// Tell whether the specified step should run. @@ -643,8 +908,10 @@ impl Config { let mut enabled_steps: Vec = Vec::new(); enabled_steps.extend(&opt.only); - if let Some(only) = config_file.only.as_ref() { - enabled_steps.extend(only) + if let Some(misc) = config_file.misc.as_ref() { + if let Some(only) = misc.only.as_ref() { + enabled_steps.extend(only); + } } if enabled_steps.is_empty() { @@ -653,27 +920,47 @@ impl Config { let mut disabled_steps: Vec = Vec::new(); disabled_steps.extend(&opt.disable); - if let Some(disabled) = config_file.disable.as_ref() { - disabled_steps.extend(disabled); + if let Some(misc) = config_file.misc.as_ref() { + if let Some(disabled) = misc.disable.as_ref() { + disabled_steps.extend(disabled); + } } enabled_steps.retain(|e| !disabled_steps.contains(e) || opt.only.contains(e)); enabled_steps } - /// Tell whether we should run a self update. + /// Tell whether we should run a self-update. pub fn no_self_update(&self) -> bool { - self.opt.no_self_update || self.config_file.no_self_update.unwrap_or(false) + self.opt.no_self_update + || self + .config_file + .misc + .as_ref() + .and_then(|misc| misc.no_self_update) + .unwrap_or(false) } /// Tell whether we should run in tmux. pub fn run_in_tmux(&self) -> bool { - self.opt.run_in_tmux || self.config_file.run_in_tmux.unwrap_or(false) + self.opt.run_in_tmux + || self + .config_file + .misc + .as_ref() + .and_then(|misc| misc.run_in_tmux) + .unwrap_or(false) } /// Tell whether we should perform cleanup steps. pub fn cleanup(&self) -> bool { - self.opt.cleanup || self.config_file.cleanup.unwrap_or(false) + self.opt.cleanup + || self + .config_file + .misc + .as_ref() + .and_then(|misc| misc.cleanup) + .unwrap_or(false) } /// Tell whether we are dry-running. @@ -683,32 +970,54 @@ impl Config { /// Tell whether we should not attempt to retry anything. pub fn no_retry(&self) -> bool { - self.opt.no_retry || self.config_file.no_retry.unwrap_or(false) + self.opt.no_retry + || self + .config_file + .misc + .as_ref() + .and_then(|misc| misc.no_retry) + .unwrap_or(false) } /// List of remote hosts to run Topgrade in - pub fn remote_topgrades(&self) -> &Option> { - &self.config_file.remote_topgrades + pub fn remote_topgrades(&self) -> Option<&Vec> { + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.remote_topgrades.as_ref()) } /// Path to Topgrade executable used for all remote hosts pub fn remote_topgrade_path(&self) -> &str { - self.config_file.remote_topgrade_path.as_deref().unwrap_or("topgrade") + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.remote_topgrade_path.as_deref()) + .unwrap_or("topgrade") } /// Extra SSH arguments - pub fn ssh_arguments(&self) -> &Option { - &self.config_file.ssh_arguments + pub fn ssh_arguments(&self) -> Option<&String> { + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.ssh_arguments.as_ref()) } /// Extra Git arguments pub fn git_arguments(&self) -> &Option { - get_deprecated!(self.config_file, git_arguments, git, arguments) + get_deprecated_moved_opt!(&self.config_file.misc, git_arguments, &self.config_file.git, arguments) } /// Extra Tmux arguments - pub fn tmux_arguments(&self) -> eyre::Result> { - let args = &self.config_file.tmux_arguments.as_deref().unwrap_or_default(); + pub fn tmux_arguments(&self) -> Result> { + let args = &self + .config_file + .misc + .as_ref() + .and_then(|misc| misc.tmux_arguments.as_ref()) + .map(String::to_owned) + .unwrap_or_default(); shell_words::split(args) // The only time the parse failed is in case of a missing close quote. // The error message looks like this: @@ -726,7 +1035,7 @@ impl Config { /// Skip sending a notification at the end of a run pub fn skip_notify(&self) -> bool { - if let Some(yes) = self.config_file.skip_notify { + if let Some(yes) = self.config_file.misc.as_ref().and_then(|misc| misc.skip_notify) { return yes; } @@ -735,12 +1044,16 @@ impl Config { /// Whether to set the terminal title pub fn set_title(&self) -> bool { - self.config_file.set_title.unwrap_or(true) + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.set_title) + .unwrap_or(true) } /// Whether to say yes to package managers pub fn yes(&self, step: Step) -> bool { - if let Some(yes) = self.config_file.assume_yes { + if let Some(yes) = self.config_file.misc.as_ref().and_then(|misc| misc.assume_yes) { return yes; } @@ -757,18 +1070,22 @@ impl Config { /// Bash-it branch pub fn bashit_branch(&self) -> &str { - self.config_file.bashit_branch.as_deref().unwrap_or("stable") + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.bashit_branch.as_deref()) + .unwrap_or("stable") } /// Whether to accept all Windows updates pub fn accept_all_windows_updates(&self) -> bool { - get_deprecated!( - self.config_file, + get_deprecated_moved_or_default_to!( + &self.config_file.misc, accept_all_windows_updates, - windows, - accept_all_updates + &self.config_file.windows, + accept_all_updates, + true ) - .unwrap_or(true) } /// Whether to self rename the Topgrade executable during the run @@ -836,7 +1153,11 @@ impl Config { /// Whether to send a desktop notification at the beginning of every step pub fn notify_each_step(&self) -> bool { - self.config_file.notify_each_step.unwrap_or(false) + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.notify_each_step) + .unwrap_or(false) } /// Extra garuda-update arguments @@ -962,7 +1283,7 @@ impl Config { self.config_file.git.as_ref().and_then(|git| git.max_concurrency) } - /// Should we power on vagrant boxes if needed + /// Determine whether we should power on vagrant boxes pub fn vagrant_power_on(&self) -> Option { self.config_file.vagrant.as_ref().and_then(|vagrant| vagrant.power_on) } @@ -992,7 +1313,7 @@ impl Config { .unwrap_or(false) } - /// Use distro-sync in Red Hat based distrbutions + /// Use distro-sync in Red Hat based distributions pub fn redhat_distro_sync(&self) -> bool { self.config_file .linux @@ -1019,18 +1340,25 @@ impl Config { .unwrap_or(false) } - /// Should we ignore failures for this step + /// Determine if we should ignore failures for this step pub fn ignore_failure(&self, step: Step) -> bool { self.config_file - .ignore_failures + .misc .as_ref() + .and_then(|misc| misc.ignore_failures.as_ref()) .map(|v| v.contains(&step)) .unwrap_or(false) } pub fn use_predefined_git_repos(&self) -> bool { !self.opt.disable_predefined_git_repos - && get_deprecated!(self.config_file, predefined_git_repos, git, pull_predefined).unwrap_or(true) + && get_deprecated_moved_or_default_to!( + &self.config_file.misc, + predefined_git_repos, + &self.config_file.git, + pull_predefined, + true + ) } pub fn verbose(&self) -> bool { @@ -1056,7 +1384,11 @@ impl Config { /// If `true`, `sudo` should be called after `pre_commands` in order to elevate at the /// start of the session (and not in the middle). pub fn pre_sudo(&self) -> bool { - self.config_file.pre_sudo.unwrap_or(false) + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.pre_sudo) + .unwrap_or(false) } #[cfg(target_os = "linux")] @@ -1150,11 +1482,19 @@ impl Config { } pub fn display_time(&self) -> bool { - self.config_file.display_time.unwrap_or(true) + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.display_time) + .unwrap_or(true) } pub fn display_preamble(&self) -> bool { - self.config_file.display_preamble.unwrap_or(true) + self.config_file + .misc + .as_ref() + .and_then(|misc| misc.display_preamble) + .unwrap_or(true) } pub fn should_run_custom_command(&self, name: &str) -> bool { diff --git a/src/utils.rs b/src/utils.rs index 23ec64ca..472e26a0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,13 @@ -use crate::error::SkipStep; -use color_eyre::eyre::Result; - use std::env; use std::ffi::OsStr; use std::fmt::Debug; use std::path::{Path, PathBuf}; + +use color_eyre::eyre::Result; use tracing::{debug, error}; +use crate::error::SkipStep; + pub trait PathExt where Self: Sized, @@ -101,6 +102,13 @@ pub fn require_option(option: Option, cause: String) -> Result { } } +pub fn string_prepend_str(string: &mut String, s: &str) { + let mut new_string = String::with_capacity(string.len() + s.len()); + new_string.push_str(s); + new_string.push_str(string); + *string = new_string; +} + /* sys-info-rs * * Copyright (c) 2015 Siyu Wang @@ -152,3 +160,56 @@ pub fn hostname() -> Result { .map_err(|err| SkipStep(format!("Failed to get hostname: {err}")).into()) .map(|output| output.stdout.trim().to_owned()) } + +pub mod merge_strategies { + use merge::Merge; + + use crate::config::Commands; + + /// Prepends right to left (both Option>) + pub fn vec_prepend_opt(left: &mut Option>, right: Option>) { + if let Some(left_vec) = left { + if let Some(mut right_vec) = right { + right_vec.append(left_vec); + let _ = std::mem::replace(left, Some(right_vec)); + } + } else { + *left = right; + } + } + + /// Appends an Option to another Option + pub fn string_append_opt(left: &mut Option, right: Option) { + if let Some(left_str) = left { + if let Some(right_str) = right { + left_str.push(' '); + left_str.push_str(&right_str); + } + } else { + *left = right; + } + } + + pub fn inner_merge_opt(left: &mut Option, right: Option) + where + T: Merge, + { + if let Some(ref mut left_inner) = left { + if let Some(right_inner) = right { + left_inner.merge(right_inner); + } + } else { + *left = right; + } + } + + pub fn commands_merge_opt(left: &mut Option, right: Option) { + if let Some(ref mut left_inner) = left { + if let Some(right_inner) = right { + left_inner.extend(right_inner); + } + } else { + *left = right; + } + } +}