1pub(crate) mod hooks;
8pub(crate) mod progress_options;
9
10use std::{
11 collections::BTreeMap,
12 fmt::{self, Display, Formatter},
13 path::PathBuf,
14};
15
16use abscissa_core::{FrameworkError, FrameworkErrorKind, config::Config, path::AbsPathBuf};
17use anyhow::{Result, anyhow};
18use clap::{Parser, ValueHint};
19use conflate::Merge;
20use directories::ProjectDirs;
21use itertools::Itertools;
22use log::Level;
23use reqwest::Url;
24use rustic_core::SnapshotGroupCriterion;
25use serde::{Deserialize, Serialize};
26use serde_with::{DisplayFromStr, serde_as};
27#[cfg(not(all(feature = "mount", feature = "webdav")))]
28use toml::Value;
29
30#[cfg(feature = "mount")]
31use crate::commands::mount::MountCmd;
32#[cfg(feature = "webdav")]
33use crate::commands::webdav::WebDavCmd;
34
35use crate::{
36 commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
37 config::{hooks::Hooks, progress_options::ProgressOptions},
38 filtering::SnapshotFilter,
39 repository::AllRepositoryOptions,
40};
41
42#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
49#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
50pub struct RusticConfig {
51 #[clap(flatten, next_help_heading = "Global options")]
53 pub global: GlobalOptions,
54
55 #[clap(flatten, next_help_heading = "Repository options")]
57 pub repository: AllRepositoryOptions,
58
59 #[clap(flatten, next_help_heading = "Snapshot filter options")]
61 pub snapshot_filter: SnapshotFilter,
62
63 #[clap(skip)]
65 pub backup: BackupCmd,
66
67 #[clap(skip)]
69 pub copy: CopyCmd,
70
71 #[clap(skip)]
73 pub forget: ForgetOptions,
74
75 #[cfg(feature = "mount")]
77 #[clap(skip)]
78 pub mount: MountCmd,
79 #[cfg(not(feature = "mount"))]
80 #[clap(skip)]
81 #[merge(skip)]
82 pub mount: Option<Value>,
83
84 #[cfg(feature = "webdav")]
86 #[clap(skip)]
87 pub webdav: WebDavCmd,
88 #[cfg(not(feature = "webdav"))]
89 #[clap(skip)]
90 #[merge(skip)]
91 pub webdav: Option<Value>,
92}
93
94impl Display for RusticConfig {
95 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
96 let config = toml::to_string_pretty(self)
97 .unwrap_or_else(|_| "<Error serializing config>".to_string());
98
99 write!(f, "{config}",)
100 }
101}
102
103impl RusticConfig {
104 pub fn merge_profile(
113 &mut self,
114 profile: &str,
115 merge_logs: &mut Vec<(Level, String)>,
116 level_missing: Level,
117 ) -> Result<(), FrameworkError> {
118 let profile_filename = profile.to_string() + ".toml";
119 let paths = get_config_paths(&profile_filename);
120
121 if let Some(path) = paths.iter().find(|path| path.exists()) {
122 merge_logs.push((Level::Info, format!("using config {}", path.display())));
123 let config_content = std::fs::read_to_string(AbsPathBuf::canonicalize(path)?)?;
124 let config_content = if self.global.profile_substitute_env {
125 subst::substitute(&config_content, &subst::Env).map_err(|e| {
126 abscissa_core::error::context::Context::new(
127 FrameworkErrorKind::ParseError,
128 Some(Box::new(e)),
129 )
130 })?
131 } else {
132 config_content
133 };
134 let mut config = Self::load_toml(config_content)?;
135 for profile in &config.global.use_profiles.clone() {
137 config.merge_profile(profile, merge_logs, Level::Warn)?;
138 }
139 self.merge(config);
140 } else {
141 let paths_string = paths.iter().map(|path| path.display()).join(", ");
142 merge_logs.push((
143 level_missing,
144 format!(
145 "using no config file, none of these exist: {}",
146 &paths_string
147 ),
148 ));
149 };
150 Ok(())
151 }
152}
153
154#[serde_as]
158#[derive(Default, Debug, Parser, Clone, Deserialize, Serialize, Merge)]
159#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
160pub struct GlobalOptions {
161 #[clap(long, global = true, env = "RUSTIC_PROFILE_SUBSTITUTE_ENV")]
163 #[merge(strategy=conflate::bool::overwrite_false)]
164 pub profile_substitute_env: bool,
165
166 #[clap(
169 short = 'P',
170 long = "use-profile",
171 global = true,
172 value_name = "PROFILE",
173 env = "RUSTIC_USE_PROFILE"
174 )]
175 #[merge(strategy=conflate::vec::append)]
176 pub use_profiles: Vec<String>,
177
178 #[clap(
180 long,
181 short = 'g',
182 global = true,
183 value_name = "CRITERION",
184 env = "RUSTIC_GROUP_BY"
185 )]
186 #[serde_as(as = "Option<DisplayFromStr>")]
187 #[merge(strategy=conflate::option::overwrite_none)]
188 pub group_by: Option<SnapshotGroupCriterion>,
189
190 #[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
192 #[merge(strategy=conflate::bool::overwrite_false)]
193 pub dry_run: bool,
194
195 #[clap(long, global = true, env = "RUSTIC_DRY_RUN_WARMUP")]
197 #[merge(strategy=conflate::bool::overwrite_false)]
198 pub dry_run_warmup: bool,
199
200 #[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
202 #[merge(strategy=conflate::bool::overwrite_false)]
203 pub check_index: bool,
204
205 #[clap(long, global = true, env = "RUSTIC_LOG_LEVEL")]
207 #[merge(strategy=conflate::option::overwrite_none)]
208 pub log_level: Option<String>,
209
210 #[clap(long, global = true, env = "RUSTIC_LOG_FILE", value_name = "LOGFILE", value_hint = ValueHint::FilePath)]
216 #[merge(strategy=conflate::option::overwrite_none)]
217 pub log_file: Option<PathBuf>,
218
219 #[clap(flatten)]
221 #[serde(flatten)]
222 pub progress_options: ProgressOptions,
223
224 #[clap(skip)]
226 pub hooks: Hooks,
227
228 #[clap(skip)]
230 #[merge(strategy = conflate::btreemap::append_or_ignore)]
231 pub env: BTreeMap<String, String>,
232
233 #[serde_as(as = "Option<DisplayFromStr>")]
235 #[clap(long, global = true, env = "RUSTIC_PROMETHEUS", value_name = "PUSHGATEWAY_URL", value_hint = ValueHint::Url)]
236 #[merge(strategy=conflate::option::overwrite_none)]
237 pub prometheus: Option<Url>,
238
239 #[clap(long, value_name = "USER", env = "RUSTIC_PROMETHEUS_USER")]
241 #[merge(strategy=conflate::option::overwrite_none)]
242 pub prometheus_user: Option<String>,
243
244 #[clap(long, value_name = "PASSWORD", env = "RUSTIC_PROMETHEUS_PASS")]
246 #[merge(strategy=conflate::option::overwrite_none)]
247 pub prometheus_pass: Option<String>,
248
249 #[clap(skip)]
251 #[merge(strategy=conflate::btreemap::append_or_ignore)]
252 pub metrics_labels: BTreeMap<String, String>,
253
254 #[serde_as(as = "Option<DisplayFromStr>")]
256 #[clap(long, global = true, env = "RUSTIC_OTEL", value_name = "ENDPOINT_URL", value_hint = ValueHint::Url)]
257 #[merge(strategy=conflate::option::overwrite_none)]
258 pub opentelemetry: Option<Url>,
259}
260
261pub fn parse_labels(s: &str) -> Result<BTreeMap<String, String>> {
262 s.split(',')
263 .filter_map(|s| {
264 let s = s.trim();
265 (!s.is_empty()).then_some(s)
266 })
267 .map(|s| -> Result<_> {
268 let pos = s.find('=').ok_or_else(|| {
269 anyhow!("invalid prometheus label definition: no `=` found in `{s}`")
270 })?;
271 Ok((s[..pos].to_owned(), s[pos + 1..].to_owned()))
272 })
273 .try_collect()
274}
275
276impl GlobalOptions {
277 pub fn is_metrics_configured(&self) -> bool {
278 self.prometheus.is_some() || self.opentelemetry.is_some()
279 }
280}
281
282fn get_config_paths(filename: &str) -> Vec<PathBuf> {
292 [
293 ProjectDirs::from("", "", "rustic")
294 .map(|project_dirs| project_dirs.config_dir().to_path_buf()),
295 get_global_config_path(),
296 Some(PathBuf::from(".")),
297 ]
298 .into_iter()
299 .filter_map(|path| {
300 path.map(|mut p| {
301 p.push(filename);
302 p
303 })
304 })
305 .collect()
306}
307
308#[cfg(target_os = "windows")]
315fn get_global_config_path() -> Option<PathBuf> {
316 std::env::var_os("PROGRAMDATA").map(|program_data| {
317 let mut path = PathBuf::from(program_data);
318 path.push(r"rustic\config");
319 path
320 })
321}
322
323#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
329fn get_global_config_path() -> Option<PathBuf> {
330 None
331}
332
333#[cfg(not(any(target_os = "windows", target_os = "ios", target_arch = "wasm32")))]
340fn get_global_config_path() -> Option<PathBuf> {
341 Some(PathBuf::from("/etc/rustic"))
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use insta::{assert_debug_snapshot, assert_snapshot};
348
349 #[test]
350 fn test_default_config_passes() {
351 let config = RusticConfig::default();
352
353 assert_debug_snapshot!(config);
354 }
355
356 #[test]
357 fn test_default_config_display_passes() {
358 let config = RusticConfig::default();
359
360 assert_snapshot!(config);
361 }
362
363 #[test]
364 fn test_global_env_roundtrip_passes() {
365 let mut config = RusticConfig::default();
366
367 for i in 0..10 {
368 let _ = config
369 .global
370 .env
371 .insert(format!("KEY{i}"), format!("VALUE{i}"));
372 }
373
374 let serialized = toml::to_string(&config).unwrap();
375
376 assert_snapshot!(serialized);
378
379 let deserialized: RusticConfig = toml::from_str(&serialized).unwrap();
380 assert_snapshot!(deserialized);
382
383 assert_debug_snapshot!(deserialized);
385 }
386}