1pub(crate) mod hooks;
8pub(crate) mod logging;
9pub(crate) mod progress_options;
10
11use std::{
12 collections::BTreeMap,
13 fmt::{self, Display, Formatter},
14 path::PathBuf,
15};
16
17use abscissa_core::{FrameworkError, FrameworkErrorKind, config::Config, path::AbsPathBuf};
18use anyhow::{Result, anyhow};
19use clap::{Parser, ValueHint};
20use conflate::Merge;
21use directories::ProjectDirs;
22use itertools::Itertools;
23use jiff::{Timestamp, Zoned, tz::TimeZone};
24use log::Level;
25use reqwest::Url;
26use rustic_core::SnapshotGroupCriterion;
27use serde::{Deserialize, Serialize};
28use serde_with::{DisplayFromStr, serde_as};
29#[cfg(not(all(feature = "mount", feature = "webdav")))]
30use toml::Value;
31
32#[cfg(feature = "mount")]
33use crate::commands::mount::MountCmd;
34#[cfg(feature = "webdav")]
35use crate::commands::webdav::WebDavCmd;
36
37use crate::{
38 commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
39 config::{hooks::Hooks, logging::LoggingOptions, progress_options::ProgressOptions},
40 filtering::SnapshotFilter,
41 repository::AllRepositoryOptions,
42};
43
44#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
51#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
52pub struct RusticConfig {
53 #[clap(flatten, next_help_heading = "Global options")]
55 pub global: GlobalOptions,
56
57 #[clap(flatten, next_help_heading = "Repository options")]
59 pub repository: AllRepositoryOptions,
60
61 #[clap(flatten, next_help_heading = "Snapshot filter options")]
63 pub snapshot_filter: SnapshotFilter,
64
65 #[clap(skip)]
67 pub backup: BackupCmd,
68
69 #[clap(skip)]
71 pub copy: CopyCmd,
72
73 #[clap(skip)]
75 pub forget: ForgetOptions,
76
77 #[cfg(feature = "mount")]
79 #[clap(skip)]
80 pub mount: MountCmd,
81 #[cfg(not(feature = "mount"))]
82 #[clap(skip)]
83 #[merge(skip)]
84 pub mount: Option<Value>,
85
86 #[cfg(feature = "webdav")]
88 #[clap(skip)]
89 pub webdav: WebDavCmd,
90 #[cfg(not(feature = "webdav"))]
91 #[clap(skip)]
92 #[merge(skip)]
93 pub webdav: Option<Value>,
94}
95
96impl Display for RusticConfig {
97 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98 let config = toml::to_string_pretty(self)
99 .unwrap_or_else(|_| "<Error serializing config>".to_string());
100
101 write!(f, "{config}",)
102 }
103}
104
105impl RusticConfig {
106 pub fn merge_profile(
115 &mut self,
116 profile: &str,
117 merge_logs: &mut Vec<(Level, String)>,
118 level_missing: Level,
119 ) -> Result<(), FrameworkError> {
120 let profile_filename = if profile.ends_with(".toml") {
121 profile.to_string()
122 } else {
123 profile.to_string() + ".toml"
124 };
125 let paths = get_config_paths(&profile_filename);
126
127 if let Some(path) = paths.iter().find(|path| path.exists()) {
128 merge_logs.push((Level::Info, format!("using config {}", path.display())));
129 let config_content = std::fs::read_to_string(AbsPathBuf::canonicalize(path)?)?;
130 let config_content = if self.global.profile_substitute_env {
131 subst::substitute(&config_content, &subst::Env).map_err(|e| {
132 abscissa_core::error::context::Context::new(
133 FrameworkErrorKind::ParseError,
134 Some(Box::new(e)),
135 )
136 })?
137 } else {
138 config_content
139 };
140 let mut config = Self::load_toml(config_content)?;
141 if config.global.profile_substitute_env && config.global.use_profiles.is_empty() {
143 merge_logs.push((Level::Warn, "Option `profile-substitute-env` is given without any profiles to load! Note that this option does NOT apply to the file where it is specified!".to_string()));
144 }
145 for profile in &config.global.use_profiles.clone() {
147 config.merge_profile(profile, merge_logs, Level::Warn)?;
148 }
149 self.merge(config);
150 } else {
151 let paths_string = paths.iter().map(|path| path.display()).join(", ");
152 merge_logs.push((
153 level_missing,
154 format!(
155 "using no config file, none of these exist: {}",
156 &paths_string
157 ),
158 ));
159 };
160 Ok(())
161 }
162}
163
164#[serde_as]
168#[derive(Default, Debug, Parser, Clone, Deserialize, Serialize, Merge)]
169#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
170pub struct GlobalOptions {
171 #[clap(long, global = true, env = "RUSTIC_PROFILE_SUBSTITUTE_ENV")]
173 #[merge(strategy=conflate::bool::overwrite_false)]
174 pub profile_substitute_env: bool,
175
176 #[clap(
179 short = 'P',
180 long = "use-profile",
181 global = true,
182 value_name = "PROFILE",
183 env = "RUSTIC_USE_PROFILE"
184 )]
185 #[merge(strategy=conflate::vec::append)]
186 pub use_profiles: Vec<String>,
187
188 #[clap(
190 long,
191 short = 'g',
192 global = true,
193 value_name = "CRITERION",
194 env = "RUSTIC_GROUP_BY"
195 )]
196 #[serde_as(as = "Option<DisplayFromStr>")]
197 #[merge(strategy=conflate::option::overwrite_none)]
198 pub group_by: Option<SnapshotGroupCriterion>,
199
200 #[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
202 #[merge(strategy=conflate::bool::overwrite_false)]
203 pub dry_run: bool,
204
205 #[clap(long, global = true, env = "RUSTIC_DRY_RUN_WARMUP")]
207 #[merge(strategy=conflate::bool::overwrite_false)]
208 pub dry_run_warmup: bool,
209
210 #[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
212 #[merge(strategy=conflate::bool::overwrite_false)]
213 pub check_index: bool,
214
215 #[clap(flatten)]
217 #[serde(flatten)]
218 pub logging_options: LoggingOptions,
219
220 #[clap(flatten)]
222 #[serde(flatten)]
223 pub progress_options: ProgressOptions,
224
225 #[clap(skip)]
227 pub hooks: Hooks,
228
229 #[clap(skip)]
231 #[merge(strategy = conflate::btreemap::append_or_ignore)]
232 pub env: BTreeMap<String, String>,
233
234 #[serde_as(as = "Option<DisplayFromStr>")]
236 #[clap(long, global = true, env = "RUSTIC_PROMETHEUS", value_name = "PUSHGATEWAY_URL", value_hint = ValueHint::Url)]
237 #[merge(strategy=conflate::option::overwrite_none)]
238 pub prometheus: Option<Url>,
239
240 #[clap(long, value_name = "USER", env = "RUSTIC_PROMETHEUS_USER")]
242 #[merge(strategy=conflate::option::overwrite_none)]
243 pub prometheus_user: Option<String>,
244
245 #[clap(long, value_name = "PASSWORD", env = "RUSTIC_PROMETHEUS_PASS")]
247 #[merge(strategy=conflate::option::overwrite_none)]
248 pub prometheus_pass: Option<String>,
249
250 #[clap(skip)]
252 #[merge(strategy=conflate::btreemap::append_or_ignore)]
253 pub metrics_labels: BTreeMap<String, String>,
254
255 #[serde_as(as = "Option<DisplayFromStr>")]
257 #[clap(long, global = true, env = "RUSTIC_OTEL", value_name = "ENDPOINT_URL", value_hint = ValueHint::Url)]
258 #[merge(strategy=conflate::option::overwrite_none)]
259 pub opentelemetry: Option<Url>,
260
261 #[clap(long, global = true, env = "RUSTIC_SHOW_TIME_OFFSET")]
263 #[merge(strategy=conflate::bool::overwrite_false)]
264 pub show_time_offset: bool,
265}
266
267pub fn parse_labels(s: &str) -> Result<BTreeMap<String, String>> {
268 s.split(',')
269 .filter_map(|s| {
270 let s = s.trim();
271 (!s.is_empty()).then_some(s)
272 })
273 .map(|s| -> Result<_> {
274 let pos = s.find('=').ok_or_else(|| {
275 anyhow!("invalid prometheus label definition: no `=` found in `{s}`")
276 })?;
277 Ok((s[..pos].to_owned(), s[pos + 1..].to_owned()))
278 })
279 .try_collect()
280}
281
282impl GlobalOptions {
283 pub fn is_metrics_configured(&self) -> bool {
284 self.prometheus.is_some() || self.opentelemetry.is_some()
285 }
286
287 pub fn format_timestamp(&self, timestamp: Timestamp) -> String {
288 self.format_time(×tamp.to_zoned(TimeZone::UTC))
289 .to_string()
290 }
291
292 pub fn format_time(&self, time: &Zoned) -> impl Display {
293 if self.show_time_offset {
294 time.strftime("%Y-%m-%d %H:%M:%S%z")
295 } else {
296 let tz = TimeZone::system();
297 if time.offset() == tz.to_offset(time.timestamp()) {
298 time.strftime("%Y-%m-%d %H:%M:%S")
299 } else {
300 time.with_time_zone(tz).strftime("%Y-%m-%d %H:%M:%S*")
301 }
302 }
303 }
304}
305
306fn get_config_paths(filename: &str) -> Vec<PathBuf> {
316 [
317 ProjectDirs::from("", "", "rustic")
318 .map(|project_dirs| project_dirs.config_dir().to_path_buf()),
319 get_global_config_path(),
320 Some(PathBuf::from(".")),
321 ]
322 .into_iter()
323 .filter_map(|path| {
324 path.map(|mut p| {
325 p.push(filename);
326 p
327 })
328 })
329 .collect()
330}
331
332#[cfg(target_os = "windows")]
339fn get_global_config_path() -> Option<PathBuf> {
340 std::env::var_os("PROGRAMDATA").map(|program_data| {
341 let mut path = PathBuf::from(program_data);
342 path.push(r"rustic\config");
343 path
344 })
345}
346
347#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
353fn get_global_config_path() -> Option<PathBuf> {
354 None
355}
356
357#[cfg(not(any(target_os = "windows", target_os = "ios", target_arch = "wasm32")))]
364fn get_global_config_path() -> Option<PathBuf> {
365 Some(PathBuf::from("/etc/rustic"))
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use insta::{assert_debug_snapshot, assert_snapshot};
372
373 #[test]
374 fn test_default_config_passes() {
375 let config = RusticConfig::default();
376
377 assert_debug_snapshot!(config);
378 }
379
380 #[test]
381 fn test_default_config_display_passes() {
382 let config = RusticConfig::default();
383
384 assert_snapshot!(config);
385 }
386
387 #[test]
388 fn test_global_env_roundtrip_passes() {
389 let mut config = RusticConfig::default();
390
391 for i in 0..10 {
392 let _ = config
393 .global
394 .env
395 .insert(format!("KEY{i}"), format!("VALUE{i}"));
396 }
397
398 let serialized = toml::to_string(&config).unwrap();
399
400 assert_snapshot!(serialized);
402
403 let deserialized: RusticConfig = toml::from_str(&serialized).unwrap();
404 assert_snapshot!(deserialized);
406
407 assert_debug_snapshot!(deserialized);
409 }
410}