use super::utils::editor; use anyhow::Result; use directories::BaseDirs; use log::{debug, LevelFilter}; use pretty_env_logger::formatted_timed_builder; use serde::Deserialize; use std::collections::BTreeMap; use std::fs::write; use std::path::PathBuf; use std::process::Command; use std::{env, fs}; use structopt::StructOpt; use strum::{EnumIter, EnumString, EnumVariantNames, IntoEnumIterator, VariantNames}; macro_rules! check_deprecated { ($config:expr, $old:ident, $section:ident, $new:ident) => { if $config.$old.is_some() { println!(concat!( "'", stringify!($old), "' configuration option is deprecated. Rename it to '", stringify!($new), "' and put it under the section [", stringify!($section), "]", )); } }; } 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 } } }; } type Commands = BTreeMap; #[derive(EnumString, EnumVariantNames, Debug, Clone, PartialEq, Deserialize, EnumIter)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "snake_case")] pub enum Step { System, PackageManagers, GitRepos, Vim, Emacs, Gem, Node, Composer, Sdkman, Remotes, Rustup, Cargo, Flutter, Go, Shell, Opam, Vcpkg, Pipx, Stack, Tlmgr, Myrepos, Pearl, Jetpack, Atom, Firmware, Restarts, Tldr, Wsl, Tmux, } #[derive(Deserialize, Default, Debug)] pub struct Git { max_concurrency: Option, arguments: Option, repos: Option>, pull_predefined: Option, } #[derive(Deserialize, Default, Debug)] pub struct Windows { accept_all_updates: Option, } #[derive(Deserialize, Default, Debug)] pub struct Brew { greedy_cask: Option, } #[derive(Deserialize, Default, Debug)] pub struct Linux { yay_arguments: Option, trizen_arguments: Option, dnf_arguments: Option, enable_tlmgr: Option, } #[derive(Deserialize, Default, Debug)] pub struct Composer { self_update: Option, } #[derive(Deserialize, Default, Debug)] #[serde(deny_unknown_fields)] /// Configuration file pub struct ConfigFile { pre_commands: Option, commands: Option, git_repos: Option>, predefined_git_repos: Option, disable: Option>, remote_topgrades: Option>, ssh_arguments: Option, git_arguments: Option, tmux_arguments: Option, set_title: Option, assume_yes: Option, yay_arguments: Option, no_retry: Option, run_in_tmux: Option, cleanup: Option, notify_each_step: Option, accept_all_windows_updates: Option, only: Option>, composer: Option, brew: Option, linux: Option, git: Option, windows: Option, } impl ConfigFile { fn ensure(base_dirs: &BaseDirs) -> Result { #[cfg(not(target_os = "macos"))] let config_path = base_dirs.config_dir().join("topgrade.toml"); #[cfg(target_os = "macos")] let config_path = base_dirs.home_dir().join(".config/topgrade.toml"); if !config_path.exists() { debug!("No configuration exists"); write(&config_path, include_str!("../config.example.toml")).map_err(|e| { debug!( "Unable to write the example configuration file to {}: {}. Using blank config.", config_path.display(), e ); e })?; } else { debug!("Configuration at {}", config_path.display()); } Ok(config_path) } /// Read the configuration file. /// /// If the configuration file does not exist the function returns the default ConfigFile. fn read(base_dirs: &BaseDirs, config_path: Option) -> Result { let config_path = if let Some(path) = config_path { path } else { Self::ensure(base_dirs)? }; let contents = fs::read_to_string(&config_path).map_err(|e| { log::error!("Unable to read {}", config_path.display()); e })?; let mut result: Self = toml::from_str(&contents).map_err(|e| { log::error!("Failed to deserialize {}", config_path.display()); e })?; if let Some(ref mut paths) = &mut result.git_repos { for path in paths.iter_mut() { *path = shellexpand::tilde::<&str>(&path.as_ref()).into_owned(); } } debug!("Loaded configuration: {:?}", result); Ok(result) } fn edit(base_dirs: &BaseDirs) -> Result<()> { let config_path = Self::ensure(base_dirs)?; let editor = editor(); debug!("Editing {} with {}", config_path.display(), editor); Command::new(editor) .arg(config_path) .spawn() .and_then(|mut p| p.wait())?; Ok(()) } } #[derive(StructOpt, Debug)] #[structopt(name = "Topgrade", setting = structopt::clap::AppSettings::ColoredHelp)] /// Command line arguments pub struct CommandLineArgs { /// Edit the configuration file #[structopt(long = "edit-config")] edit_config: bool, /// Run inside tmux #[structopt(short = "t", long = "tmux")] run_in_tmux: bool, /// Cleanup temporary or old files #[structopt(short = "c", long = "cleanup")] cleanup: bool, /// Print what would be done #[structopt(short = "n", long = "dry-run")] dry_run: bool, /// Do not ask to retry failed steps #[structopt(long = "no-retry")] no_retry: bool, /// Do not perform upgrades for the given steps #[structopt(long = "disable", possible_values = &Step::VARIANTS)] disable: Vec, /// Perform only the specified steps (experimental) #[structopt(long = "only", possible_values = &Step::VARIANTS)] only: Vec, /// Output logs #[structopt(short = "v", long = "verbose")] verbose: bool, /// Prompt for a key before exiting #[structopt(short = "k", long = "keep")] keep_at_end: bool, /// Say yes to package manager's prompt (experimental) #[structopt(short = "y", long = "yes")] yes: bool, /// Don't pull the predefined git repos #[structopt(long = "disable-predefined-git-repos")] disable_predefined_git_repos: bool, /// Alternative configuration file #[structopt(long = "config")] config: Option, } impl CommandLineArgs { pub fn edit_config(&self) -> bool { self.edit_config } } /// 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 /// command line arguments. pub struct Config { opt: CommandLineArgs, config_file: ConfigFile, allowed_steps: Vec, } impl Config { /// Load the configuration. /// /// The function parses the command line arguments and reading the configuration file. pub fn load(base_dirs: &BaseDirs, opt: CommandLineArgs) -> Result { let mut builder = formatted_timed_builder(); if opt.verbose { builder.filter(Some("topgrade"), LevelFilter::Trace); } builder.init(); let config_file = ConfigFile::read(base_dirs, opt.config.clone()).unwrap_or_else(|e| { // Inform the user about errors when loading the configuration, // but fallback to the default config to at least attempt to do something log::error!("failed to load configuration: {}", e); 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); let allowed_steps = Self::allowed_steps(&opt, &config_file); Ok(Self { opt, config_file, allowed_steps, }) } /// Launch an editor to edit the configuration pub fn edit(base_dirs: &BaseDirs) -> Result<()> { ConfigFile::edit(base_dirs) } /// The list of commands to run before performing any step. pub fn pre_commands(&self) -> &Option { &self.config_file.pre_commands } /// The list of custom steps. pub fn commands(&self) -> &Option { &self.config_file.commands } /// The list of additional git repositories to pull. pub fn git_repos(&self) -> &Option> { get_deprecated!(&self.config_file, git_repos, git, repos) } /// Tell whether the specified step should run. /// /// If the step appears either in the `--disable` command line argument /// or the `disable` option in the configuration, the function returns false. pub fn should_run(&self, step: Step) -> bool { self.allowed_steps.contains(&step) } fn allowed_steps(opt: &CommandLineArgs, config_file: &ConfigFile) -> Vec { let mut enabled_steps: Vec = if !opt.only.is_empty() { opt.only.clone() } else { config_file .only .as_ref() .map_or_else(|| Step::iter().collect(), |v| v.clone()) }; let disabled_steps: Vec = if !opt.disable.is_empty() { opt.disable.clone() } else { config_file.disable.as_ref().map_or_else(Vec::new, |v| v.clone()) }; enabled_steps.retain(|e| !disabled_steps.contains(e)); enabled_steps } /// 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) } /// Tell whether we should perform cleanup steps. pub fn cleanup(&self) -> bool { self.opt.cleanup || self.config_file.cleanup.unwrap_or(false) } /// Tell whether we are dry-running. pub fn dry_run(&self) -> bool { self.opt.dry_run } /// 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) } /// List of remote hosts to run Topgrade in pub fn remote_topgrades(&self) -> &Option> { &self.config_file.remote_topgrades } /// Extra SSH arguments pub fn ssh_arguments(&self) -> &Option { &self.config_file.ssh_arguments } /// Extra Git arguments pub fn git_arguments(&self) -> &Option { get_deprecated!(&self.config_file, git_arguments, git, arguments) } /// Extra Tmux arguments #[allow(dead_code)] pub fn tmux_arguments(&self) -> &Option { &self.config_file.tmux_arguments } /// Prompt for a key before exiting pub fn keep_at_end(&self) -> bool { self.opt.keep_at_end || env::var("TOPGRADE_KEEP_END").is_ok() } /// Whether to set the terminal title pub fn set_title(&self) -> bool { self.config_file.set_title.unwrap_or(true) } /// Whether to say yes to package managers #[allow(dead_code)] pub fn yes(&self) -> bool { self.config_file.assume_yes.unwrap_or(self.opt.yes) } /// Whether to accept all Windows updates #[allow(dead_code)] pub fn accept_all_windows_updates(&self) -> bool { get_deprecated!( self.config_file, accept_all_windows_updates, windows, accept_all_updates ) .unwrap_or(true) } /// Whether Brew cask should be greedy #[allow(dead_code)] pub fn brew_cask_greedy(&self) -> bool { self.config_file .brew .as_ref() .and_then(|c| c.greedy_cask) .unwrap_or(false) } /// Whether Composer should update itself pub fn composer_self_update(&self) -> bool { self.config_file .composer .as_ref() .and_then(|c| c.self_update) .unwrap_or(false) } /// Whether to send a desktop notification at the beginning of every step #[allow(dead_code)] pub fn notify_each_step(&self) -> bool { self.config_file.notify_each_step.unwrap_or(false) } /// Extra trizen arguments #[allow(dead_code)] pub fn trizen_arguments(&self) -> &str { &self .config_file .linux .as_ref() .and_then(|s| s.trizen_arguments.as_deref()) .unwrap_or("") } /// Extra yay arguments #[allow(dead_code)] pub fn yay_arguments(&self) -> &str { get_deprecated!(self.config_file, yay_arguments, linux, yay_arguments) .as_deref() .unwrap_or("--devel") } /// Extra yay arguments #[allow(dead_code)] pub fn dnf_arguments(&self) -> Option<&str> { self.config_file .linux .as_ref() .and_then(|linux| linux.dnf_arguments.as_deref()) } /// Concurrency limit for git pub fn git_concurrency_limit(&self) -> Option { self.config_file.git.as_ref().and_then(|git| git.max_concurrency) } /// Extra yay arguments #[allow(dead_code)] pub fn enable_tlmgr_linux(&self) -> bool { self.config_file .linux .as_ref() .and_then(|linux| linux.enable_tlmgr) .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) } }