Added ability to include directories as an extension of the config file (#421)
This commit is contained in:
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
576
src/config.rs
576
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type Commands = BTreeMap<String, String>;
|
||||
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;
|
||||
}};
|
||||
}
|
||||
|
||||
pub type Commands = BTreeMap<String, String>;
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Git {
|
||||
max_concurrency: Option<usize>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
repos: Option<Vec<String>>,
|
||||
|
||||
pull_predefined: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<Vec<String>>,
|
||||
|
||||
power_on: Option<bool>,
|
||||
always_suspend: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Windows {
|
||||
accept_all_updates: Option<bool>,
|
||||
@@ -197,7 +237,7 @@ pub struct Windows {
|
||||
wsl_update_use_web_download: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Python {
|
||||
enable_pip_review: Option<bool>,
|
||||
@@ -205,43 +245,45 @@ pub struct Python {
|
||||
enable_pipupgrade: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct Distrobox {
|
||||
use_root: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
containers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct Yarn {
|
||||
use_sudo: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct NPM {
|
||||
use_sudo: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct Firmware {
|
||||
upgrade: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct Flatpak {
|
||||
use_sudo: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Brew {
|
||||
greedy_cask: Option<bool>,
|
||||
@@ -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<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
aura_aur_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
aura_pacman_arguments: Option<String>,
|
||||
arch_package_manager: Option<ArchPackageManager>,
|
||||
show_arch_news: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
garuda_update_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
trizen_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
pikaur_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
pamac_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
dnf_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
nix_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
apt_arguments: Option<String>,
|
||||
|
||||
enable_tlmgr: Option<bool>,
|
||||
redhat_distro_sync: Option<bool>,
|
||||
suse_dup: Option<bool>,
|
||||
rpm_ostree: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
emerge_sync_flags: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
emerge_update_flags: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Composer {
|
||||
self_update: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Vim {
|
||||
force_plug_update: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Debug)]
|
||||
#[derive(Deserialize, Default, Debug, Merge)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Misc {
|
||||
pre_sudo: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
git_repos: Option<Vec<String>>,
|
||||
|
||||
predefined_git_repos: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
disable: Option<Vec<Step>>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
ignore_failures: Option<Vec<Step>>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
remote_topgrades: Option<Vec<String>>,
|
||||
|
||||
remote_topgrade_path: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
ssh_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
git_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
tmux_arguments: Option<String>,
|
||||
|
||||
set_title: Option<bool>,
|
||||
|
||||
display_time: Option<bool>,
|
||||
|
||||
display_preamble: Option<bool>,
|
||||
|
||||
assume_yes: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
yay_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
aura_aur_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::string_append_opt)]
|
||||
aura_pacman_arguments: Option<String>,
|
||||
|
||||
no_retry: Option<bool>,
|
||||
|
||||
run_in_tmux: Option<bool>,
|
||||
|
||||
cleanup: Option<bool>,
|
||||
|
||||
notify_each_step: Option<bool>,
|
||||
|
||||
accept_all_windows_updates: Option<bool>,
|
||||
|
||||
skip_notify: Option<bool>,
|
||||
|
||||
bashit_branch: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::vec_prepend_opt)]
|
||||
only: Option<Vec<Step>>,
|
||||
|
||||
no_self_update: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<Include>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
misc: Option<Misc>,
|
||||
|
||||
sudo_command: Option<SudoKind>,
|
||||
pre_sudo: Option<bool>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)]
|
||||
pre_commands: Option<Commands>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)]
|
||||
post_commands: Option<Commands>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::commands_merge_opt)]
|
||||
commands: Option<Commands>,
|
||||
git_repos: Option<Vec<String>>,
|
||||
predefined_git_repos: Option<bool>,
|
||||
disable: Option<Vec<Step>>,
|
||||
ignore_failures: Option<Vec<Step>>,
|
||||
remote_topgrades: Option<Vec<String>>,
|
||||
remote_topgrade_path: Option<String>,
|
||||
ssh_arguments: Option<String>,
|
||||
git_arguments: Option<String>,
|
||||
tmux_arguments: Option<String>,
|
||||
set_title: Option<bool>,
|
||||
display_time: Option<bool>,
|
||||
display_preamble: Option<bool>,
|
||||
assume_yes: Option<bool>,
|
||||
yay_arguments: Option<String>,
|
||||
aura_aur_arguments: Option<String>,
|
||||
aura_pacman_arguments: Option<String>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
python: Option<Python>,
|
||||
no_retry: Option<bool>,
|
||||
run_in_tmux: Option<bool>,
|
||||
cleanup: Option<bool>,
|
||||
notify_each_step: Option<bool>,
|
||||
accept_all_windows_updates: Option<bool>,
|
||||
skip_notify: Option<bool>,
|
||||
bashit_branch: Option<String>,
|
||||
only: Option<Vec<Step>>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
composer: Option<Composer>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
brew: Option<Brew>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
linux: Option<Linux>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
git: Option<Git>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
windows: Option<Windows>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
npm: Option<NPM>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
yarn: Option<Yarn>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
vim: Option<Vim>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
firmware: Option<Firmware>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
vagrant: Option<Vagrant>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
flatpak: Option<Flatpak>,
|
||||
|
||||
#[merge(strategy = crate::utils::merge_strategies::inner_merge_opt)]
|
||||
distrobox: Option<Distrobox>,
|
||||
no_self_update: Option<bool>,
|
||||
}
|
||||
|
||||
fn config_directory() -> PathBuf {
|
||||
@@ -354,60 +499,166 @@ 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<Include>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
fn ensure() -> Result<PathBuf> {
|
||||
/// 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<PathBuf>)> {
|
||||
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"),
|
||||
];
|
||||
|
||||
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 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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<Vec<PathBuf>> {
|
||||
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<PathBuf>) -> Result<ConfigFile> {
|
||||
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());
|
||||
Self::ensure_misc_is_present(&mut contents_non_split, &config_path);
|
||||
|
||||
// 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(ref mut paths) = &mut result.git_repos {
|
||||
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::<Self>(&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::<Self>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(paths) = result.git.as_mut().and_then(|git| git.repos.as_mut()) {
|
||||
for path in paths.iter_mut() {
|
||||
@@ -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<Self> {
|
||||
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<Vec<String>> {
|
||||
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<Step> = 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<Step> = Vec::new();
|
||||
disabled_steps.extend(&opt.disable);
|
||||
if let Some(disabled) = config_file.disable.as_ref() {
|
||||
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<Vec<String>> {
|
||||
&self.config_file.remote_topgrades
|
||||
pub fn remote_topgrades(&self) -> Option<&Vec<String>> {
|
||||
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<String> {
|
||||
&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<String> {
|
||||
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<Vec<String>> {
|
||||
let args = &self.config_file.tmux_arguments.as_deref().unwrap_or_default();
|
||||
pub fn tmux_arguments(&self) -> Result<Vec<String>> {
|
||||
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<bool> {
|
||||
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 {
|
||||
|
||||
67
src/utils.rs
67
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<T>(option: Option<T>, cause: String) -> Result<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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<String> {
|
||||
.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<Vec<T>>)
|
||||
pub fn vec_prepend_opt<T>(left: &mut Option<Vec<T>>, right: Option<Vec<T>>) {
|
||||
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<String> to another Option<String>
|
||||
pub fn string_append_opt(left: &mut Option<String>, right: Option<String>) {
|
||||
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<T>(left: &mut Option<T>, right: Option<T>)
|
||||
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<Commands>, right: Option<Commands>) {
|
||||
if let Some(ref mut left_inner) = left {
|
||||
if let Some(right_inner) = right {
|
||||
left_inner.extend(right_inner);
|
||||
}
|
||||
} else {
|
||||
*left = right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user