rustic_rs/
config.rs

1//! Rustic Config
2//!
3//! See instructions in `commands.rs` to specify the path to your
4//! application's configuration file and/or command-line options
5//! for specifying it.
6
7pub(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/// Rustic Configuration
43///
44/// Further documentation can be found [here](https://github.com/rustic-rs/rustic/blob/main/config/README.md).
45///
46/// # Example
47// TODO: add example
48#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
49#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
50pub struct RusticConfig {
51    /// Global options
52    #[clap(flatten, next_help_heading = "Global options")]
53    pub global: GlobalOptions,
54
55    /// Repository options
56    #[clap(flatten, next_help_heading = "Repository options")]
57    pub repository: AllRepositoryOptions,
58
59    /// Snapshot filter options
60    #[clap(flatten, next_help_heading = "Snapshot filter options")]
61    pub snapshot_filter: SnapshotFilter,
62
63    /// Backup options
64    #[clap(skip)]
65    pub backup: BackupCmd,
66
67    /// Copy options
68    #[clap(skip)]
69    pub copy: CopyCmd,
70
71    /// Forget options
72    #[clap(skip)]
73    pub forget: ForgetOptions,
74
75    /// mount options
76    #[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    /// webdav options
85    #[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    /// Merge a profile into the current config by reading the corresponding config file.
105    /// Also recursively merge all profiles given within this config file.
106    ///
107    /// # Arguments
108    ///
109    /// * `profile` - name of the profile to merge
110    /// * `merge_logs` - Vector to collect logs during merging
111    /// * `level_missing` - The log level to use if this profile is missing. Recursive calls will produce a Warning.
112    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            // if "use_profile" is defined in config file, merge the referenced profiles first
136            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/// Global options
155///
156/// These options are available for all commands.
157#[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    /// Substitute environment variables in profiles
162    #[clap(long, global = true, env = "RUSTIC_PROFILE_SUBSTITUTE_ENV")]
163    #[merge(strategy=conflate::bool::overwrite_false)]
164    pub profile_substitute_env: bool,
165
166    /// Config profile to use. This parses the file `<PROFILE>.toml` in the config directory.
167    /// [default: "rustic"]
168    #[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    /// Group snapshots by any combination of host,label,paths,tags, e.g. to find the latest snapshot [default: "host,label,paths"]
179    #[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    /// Only show what would be done without modifying anything. Does not affect read-only commands.
191    #[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
192    #[merge(strategy=conflate::bool::overwrite_false)]
193    pub dry_run: bool,
194
195    /// Additional to dry run, but still issue warm-up command if configured
196    #[clap(long, global = true, env = "RUSTIC_DRY_RUN_WARMUP")]
197    #[merge(strategy=conflate::bool::overwrite_false)]
198    pub dry_run_warmup: bool,
199
200    /// Check if index matches pack files and read pack headers if necessary
201    #[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
202    #[merge(strategy=conflate::bool::overwrite_false)]
203    pub check_index: bool,
204
205    /// Use this log level [default: info]
206    #[clap(long, global = true, env = "RUSTIC_LOG_LEVEL")]
207    #[merge(strategy=conflate::option::overwrite_none)]
208    pub log_level: Option<String>,
209
210    /// Write log messages to the given file instead of printing them.
211    ///
212    /// # Note
213    ///
214    /// Warnings and errors are still additionally printed unless they are ignored by `--log-level`
215    #[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    /// Settings to customize progress bars
220    #[clap(flatten)]
221    #[serde(flatten)]
222    pub progress_options: ProgressOptions,
223
224    /// Hooks
225    #[clap(skip)]
226    pub hooks: Hooks,
227
228    /// List of environment variables to set (only in config file)
229    #[clap(skip)]
230    #[merge(strategy = conflate::btreemap::append_or_ignore)]
231    pub env: BTreeMap<String, String>,
232
233    /// Push metrics to a Prometheus Pushgateway
234    #[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    /// Authenticate to Prometheus Pushgateway using this user
240    #[clap(long, value_name = "USER", env = "RUSTIC_PROMETHEUS_USER")]
241    #[merge(strategy=conflate::option::overwrite_none)]
242    pub prometheus_user: Option<String>,
243
244    /// Authenticate to Prometheus Pushgateway using this password
245    #[clap(long, value_name = "PASSWORD", env = "RUSTIC_PROMETHEUS_PASS")]
246    #[merge(strategy=conflate::option::overwrite_none)]
247    pub prometheus_pass: Option<String>,
248
249    /// Additional labels to set to generated metrics
250    #[clap(skip)]
251    #[merge(strategy=conflate::btreemap::append_or_ignore)]
252    pub metrics_labels: BTreeMap<String, String>,
253
254    /// OpenTelemetry metrics endpoint (HTTP Protobuf)
255    #[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
282/// Get the paths to the config file
283///
284/// # Arguments
285///
286/// * `filename` - name of the config file
287///
288/// # Returns
289///
290/// A vector of [`PathBuf`]s to the config files
291fn 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/// Get the path to the global config directory on Windows.
309///
310/// # Returns
311///
312/// The path to the global config directory on Windows.
313/// If the environment variable `PROGRAMDATA` is not set, `None` is returned.
314#[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/// Get the path to the global config directory on ios and wasm targets.
324///
325/// # Returns
326///
327/// `None` is returned.
328#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
329fn get_global_config_path() -> Option<PathBuf> {
330    None
331}
332
333/// Get the path to the global config directory on non-Windows,
334/// non-iOS, non-wasm targets.
335///
336/// # Returns
337///
338/// "/etc/rustic" is returned.
339#[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        // Check Serialization
377        assert_snapshot!(serialized);
378
379        let deserialized: RusticConfig = toml::from_str(&serialized).unwrap();
380        // Check Deserialization and Display
381        assert_snapshot!(deserialized);
382
383        // Check Debug
384        assert_debug_snapshot!(deserialized);
385    }
386}