pub(crate) mod hooks;
pub(crate) mod logging;
pub(crate) mod progress_options;
use std::{
collections::BTreeMap,
fmt::{self, Display, Formatter},
path::PathBuf,
};
use abscissa_core::{FrameworkError, FrameworkErrorKind, config::Config, path::AbsPathBuf};
use anyhow::{Result, anyhow};
use clap::{Parser, ValueHint};
use conflate::Merge;
use directories::ProjectDirs;
use itertools::Itertools;
use jiff::{Timestamp, Zoned, tz::TimeZone};
use log::Level;
use reqwest::Url;
use rustic_core::SnapshotGroupCriterion;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, serde_as};
#[cfg(not(all(feature = "mount", feature = "webdav")))]
use toml::Value;
#[cfg(feature = "mount")]
use crate::commands::mount::MountCmd;
#[cfg(feature = "webdav")]
use crate::commands::webdav::WebDavCmd;
use crate::{
commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
config::{hooks::Hooks, logging::LoggingOptions, progress_options::ProgressOptions},
filtering::SnapshotFilter,
repository::AllRepositoryOptions,
};
#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct RusticConfig {
#[clap(flatten, next_help_heading = "Global options")]
pub global: GlobalOptions,
#[clap(flatten, next_help_heading = "Repository options")]
pub repository: AllRepositoryOptions,
#[clap(flatten, next_help_heading = "Snapshot filter options")]
pub snapshot_filter: SnapshotFilter,
#[clap(skip)]
pub backup: BackupCmd,
#[clap(skip)]
pub copy: CopyCmd,
#[clap(skip)]
pub forget: ForgetOptions,
#[cfg(feature = "mount")]
#[clap(skip)]
pub mount: MountCmd,
#[cfg(not(feature = "mount"))]
#[clap(skip)]
#[merge(skip)]
pub mount: Option<Value>,
#[cfg(feature = "webdav")]
#[clap(skip)]
pub webdav: WebDavCmd,
#[cfg(not(feature = "webdav"))]
#[clap(skip)]
#[merge(skip)]
pub webdav: Option<Value>,
}
impl Display for RusticConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let config = toml::to_string_pretty(self)
.unwrap_or_else(|_| "<Error serializing config>".to_string());
write!(f, "{config}",)
}
}
impl RusticConfig {
pub fn merge_profile(
&mut self,
profile: &str,
merge_logs: &mut Vec<(Level, String)>,
level_missing: Level,
) -> Result<(), FrameworkError> {
let profile_filename = if profile.ends_with(".toml") {
profile.to_string()
} else {
profile.to_string() + ".toml"
};
let paths = get_config_paths(&profile_filename);
if let Some(path) = paths.iter().find(|path| path.exists()) {
merge_logs.push((Level::Info, format!("using config {}", path.display())));
let config_content = std::fs::read_to_string(AbsPathBuf::canonicalize(path)?)?;
let config_content = if self.global.profile_substitute_env {
subst::substitute(&config_content, &subst::Env).map_err(|e| {
abscissa_core::error::context::Context::new(
FrameworkErrorKind::ParseError,
Some(Box::new(e)),
)
})?
} else {
config_content
};
let mut config = Self::load_toml(config_content)?;
for profile in &config.global.use_profiles.clone() {
config.merge_profile(profile, merge_logs, Level::Warn)?;
}
self.merge(config);
} else {
let paths_string = paths.iter().map(|path| path.display()).join(", ");
merge_logs.push((
level_missing,
format!(
"using no config file, none of these exist: {}",
&paths_string
),
));
};
Ok(())
}
}
#[serde_as]
#[derive(Default, Debug, Parser, Clone, Deserialize, Serialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct GlobalOptions {
#[clap(long, global = true, env = "RUSTIC_PROFILE_SUBSTITUTE_ENV")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub profile_substitute_env: bool,
#[clap(
short = 'P',
long = "use-profile",
global = true,
value_name = "PROFILE",
env = "RUSTIC_USE_PROFILE"
)]
#[merge(strategy=conflate::vec::append)]
pub use_profiles: Vec<String>,
#[clap(
long,
short = 'g',
global = true,
value_name = "CRITERION",
env = "RUSTIC_GROUP_BY"
)]
#[serde_as(as = "Option<DisplayFromStr>")]
#[merge(strategy=conflate::option::overwrite_none)]
pub group_by: Option<SnapshotGroupCriterion>,
#[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub dry_run: bool,
#[clap(long, global = true, env = "RUSTIC_DRY_RUN_WARMUP")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub dry_run_warmup: bool,
#[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub check_index: bool,
#[clap(flatten)]
#[serde(flatten)]
pub logging_options: LoggingOptions,
#[clap(flatten)]
#[serde(flatten)]
pub progress_options: ProgressOptions,
#[clap(skip)]
pub hooks: Hooks,
#[clap(skip)]
#[merge(strategy = conflate::btreemap::append_or_ignore)]
pub env: BTreeMap<String, String>,
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, env = "RUSTIC_PROMETHEUS", value_name = "PUSHGATEWAY_URL", value_hint = ValueHint::Url)]
#[merge(strategy=conflate::option::overwrite_none)]
pub prometheus: Option<Url>,
#[clap(long, value_name = "USER", env = "RUSTIC_PROMETHEUS_USER")]
#[merge(strategy=conflate::option::overwrite_none)]
pub prometheus_user: Option<String>,
#[clap(long, value_name = "PASSWORD", env = "RUSTIC_PROMETHEUS_PASS")]
#[merge(strategy=conflate::option::overwrite_none)]
pub prometheus_pass: Option<String>,
#[clap(skip)]
#[merge(strategy=conflate::btreemap::append_or_ignore)]
pub metrics_labels: BTreeMap<String, String>,
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, env = "RUSTIC_OTEL", value_name = "ENDPOINT_URL", value_hint = ValueHint::Url)]
#[merge(strategy=conflate::option::overwrite_none)]
pub opentelemetry: Option<Url>,
#[clap(long, global = true, env = "RUSTIC_SHOW_TIME_OFFSET")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub show_time_offset: bool,
}
pub fn parse_labels(s: &str) -> Result<BTreeMap<String, String>> {
s.split(',')
.filter_map(|s| {
let s = s.trim();
(!s.is_empty()).then_some(s)
})
.map(|s| -> Result<_> {
let pos = s.find('=').ok_or_else(|| {
anyhow!("invalid prometheus label definition: no `=` found in `{s}`")
})?;
Ok((s[..pos].to_owned(), s[pos + 1..].to_owned()))
})
.try_collect()
}
impl GlobalOptions {
pub fn is_metrics_configured(&self) -> bool {
self.prometheus.is_some() || self.opentelemetry.is_some()
}
pub fn format_timestamp(&self, timestamp: Timestamp) -> String {
self.format_time(×tamp.to_zoned(TimeZone::UTC))
.to_string()
}
pub fn format_time(&self, time: &Zoned) -> impl Display {
if self.show_time_offset {
time.strftime("%Y-%m-%d %H:%M:%S%z")
} else {
let tz = TimeZone::system();
if time.offset() == tz.to_offset(time.timestamp()) {
time.strftime("%Y-%m-%d %H:%M:%S")
} else {
time.with_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S*")
}
}
}
}
fn get_config_paths(filename: &str) -> Vec<PathBuf> {
[
ProjectDirs::from("", "", "rustic")
.map(|project_dirs| project_dirs.config_dir().to_path_buf()),
get_global_config_path(),
Some(PathBuf::from(".")),
]
.into_iter()
.filter_map(|path| {
path.map(|mut p| {
p.push(filename);
p
})
})
.collect()
}
#[cfg(target_os = "windows")]
fn get_global_config_path() -> Option<PathBuf> {
std::env::var_os("PROGRAMDATA").map(|program_data| {
let mut path = PathBuf::from(program_data);
path.push(r"rustic\config");
path
})
}
#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
fn get_global_config_path() -> Option<PathBuf> {
None
}
#[cfg(not(any(target_os = "windows", target_os = "ios", target_arch = "wasm32")))]
fn get_global_config_path() -> Option<PathBuf> {
Some(PathBuf::from("/etc/rustic"))
}
#[cfg(test)]
mod tests {
use super::*;
use insta::{assert_debug_snapshot, assert_snapshot};
#[test]
fn test_default_config_passes() {
let config = RusticConfig::default();
assert_debug_snapshot!(config);
}
#[test]
fn test_default_config_display_passes() {
let config = RusticConfig::default();
assert_snapshot!(config);
}
#[test]
fn test_global_env_roundtrip_passes() {
let mut config = RusticConfig::default();
for i in 0..10 {
let _ = config
.global
.env
.insert(format!("KEY{i}"), format!("VALUE{i}"));
}
let serialized = toml::to_string(&config).unwrap();
assert_snapshot!(serialized);
let deserialized: RusticConfig = toml::from_str(&serialized).unwrap();
assert_snapshot!(deserialized);
assert_debug_snapshot!(deserialized);
}
}