dt_core/
config.rs

1use std::{
2    fmt::Display,
3    path::{Path, PathBuf},
4    rc::Rc,
5    str::FromStr,
6};
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use serde_regex;
11use serde_tuple::Deserialize_tuple;
12use url::Url;
13
14use crate::{
15    error::{Error as AppError, Result},
16    item::Operate,
17};
18
19/// Helper type for a group's [name]
20///
21/// [name]: Group::name
22#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
23pub struct GroupName(pub PathBuf);
24impl Display for GroupName {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.write_str(&self.0.to_string_lossy())
27    }
28}
29impl GroupName {
30    /// Gets the first component of this name, components are separated by
31    /// slashes.
32    pub fn main(&self) -> String {
33        let first_comp: PathBuf = self.0.components().take(1).collect();
34        first_comp.to_string_lossy().to_string()
35    }
36    /// Checks if this name is empty.
37    ///
38    /// # Example
39    ///
40    /// ```rust
41    /// # use dt_core::{config:: GroupName, error::Error as AppError};
42    /// assert!(GroupName("a".into()).validate().is_ok());
43    /// assert!(GroupName("a/b/c".into()).validate().is_ok());
44    /// assert!(GroupName("/starts/with/slash".into()).validate().is_err());
45    /// assert!(GroupName("relative/../path".into()).validate().is_err());
46    /// # Ok::<(), AppError>(())
47    /// ```
48    pub fn validate(&self) -> Result<()> {
49        if self
50            .0
51            .components()
52            .any(|comp| comp.as_os_str().to_string_lossy() == "..")
53        {
54            Err(AppError::ConfigError(
55                "Group name should not contain relative component".to_owned(),
56            ))
57        } else if self.0.starts_with("/") {
58            Err(AppError::ConfigError(
59                "Group name should not start with slash".to_owned(),
60            ))
61        } else if self.0 == PathBuf::from_str("").unwrap() {
62            Err(AppError::ConfigError(
63                "Group name should not be empty".to_owned(),
64            ))
65        } else {
66            Ok(())
67        }
68    }
69    /// Returns a PathBuf, which adds a [`subgroup_prefix`] to each of the
70    /// components other than the main component.
71    ///
72    /// # Example
73    ///
74    /// ```rust
75    /// # use std::{str::FromStr, path::PathBuf};
76    /// # use dt_core::config::GroupName;
77    /// # use pretty_assertions::assert_eq;
78    /// let gn = GroupName("gui/gtk".into());
79    /// assert_eq!(
80    ///     gn.with_subgroup_prefix("#"),
81    ///     PathBuf::from_str("gui/#gtk").unwrap(),
82    /// );
83    /// ```
84    ///
85    /// [`subgroup_prefix`]: SubgroupPrefix
86    pub fn with_subgroup_prefix(&self, subgroup_prefix: &str) -> PathBuf {
87        PathBuf::from(self.main()).join(
88            self.0
89                .iter()
90                .skip(1)
91                .map(|comp| subgroup_prefix.to_owned() + &comp.to_string_lossy())
92                .collect::<PathBuf>(),
93        )
94    }
95}
96/// Helper type for config key [`staging`]
97///
98/// [`staging`]: GlobalConfig::staging
99#[derive(Clone, Debug, Deserialize, PartialEq)]
100pub struct StagingPath(pub PathBuf);
101impl Default for StagingPath {
102    fn default() -> Self {
103        if let Some(cache_dir) = dirs::data_dir() {
104            Self(cache_dir.join("dt").join("staging"))
105        } else {
106            panic!("Cannot infer default staging directory, set either XDG_DATA_HOME or HOME to solve this.");
107        }
108    }
109}
110/// Syncing methods.
111#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
112pub enum SyncMethod {
113    /// Instructs syncing module to directly copy each item from source to
114    /// target.
115    Copy,
116    /// Instructs syncing module to first copy iach item from source to its
117    /// staging directory, then symlink staged items from their staging
118    /// directory to target.
119    Symlink,
120}
121impl Default for SyncMethod {
122    fn default() -> Self {
123        SyncMethod::Symlink
124    }
125}
126/// Helper type for config key [`subgroup_prefix`]
127///
128/// [`subgroup_prefix`]: GlobalConfig::subgroup_prefix
129#[derive(Clone, Debug, Deserialize)]
130pub struct SubgroupPrefix(pub String);
131impl Default for SubgroupPrefix {
132    fn default() -> Self {
133        Self("#".to_owned())
134    }
135}
136/// Helper type for config key [`allow_overwrite`]
137///
138/// [`allow_overwrite`]: GlobalConfig::allow_overwrite
139#[derive(Clone, Copy, Debug, Deserialize)]
140pub struct AllowOverwrite(pub bool);
141#[allow(clippy::derivable_impls)]
142impl Default for AllowOverwrite {
143    fn default() -> Self {
144        Self(false)
145    }
146}
147/// Helper type for config key [`ignore_failure`]
148///
149/// [`ignore_failure`]: GlobalConfig::ignore_failure
150#[derive(Clone, Copy, Debug, Deserialize)]
151pub struct IgnoreFailure(pub bool);
152#[allow(clippy::derivable_impls)]
153impl Default for IgnoreFailure {
154    fn default() -> Self {
155        Self(false)
156    }
157}
158/// Helper type for config key [`renderable`]
159///
160/// [`renderable`]: GlobalConfig::renderable
161#[derive(Clone, Copy, Debug, Deserialize)]
162pub struct Renderable(pub bool);
163#[allow(clippy::derivable_impls)]
164impl Default for Renderable {
165    fn default() -> Self {
166        Self(true)
167    }
168}
169/// Helper type for config key [`hostname_sep`]
170///
171/// [`hostname_sep`]: GlobalConfig::hostname_sep
172#[derive(Clone, Debug, Deserialize)]
173pub struct HostnameSeparator(pub String);
174impl Default for HostnameSeparator {
175    fn default() -> Self {
176        Self("@@".to_owned())
177    }
178}
179/// Helper type for config key [`rename`]
180///
181/// [`rename`]: GlobalConfig::rename
182#[derive(Clone, Debug, Deserialize)]
183pub struct RenamingRules(pub Vec<RenamingRule>);
184#[allow(clippy::derivable_impls)]
185impl Default for RenamingRules {
186    fn default() -> Self {
187        Self(Vec::new())
188    }
189}
190/// Scope of a group, used to resolve _priority_ of possibly duplicated items,
191/// to ensure every target path is pointed from only one source item.
192///
193/// The order of priority is:
194///
195/// [`Dropin`] > [`App`] > [`General`]
196///
197/// Within the same scope, the first defined group in the config file for DT
198/// has the highest priority, later defined groups have lower priorities.
199///
200/// Groups without a given scope are treated as of [`General`] scope.
201///
202/// [`Dropin`]: DTScope::Dropin
203/// [`App`]: DTScope::App
204/// [`General`]: DTScope::General
205///
206/// # Example
207///
208/// When you want to populate all your config files for apps that follows [the
209/// XDG standard], you might write a config file for DT that looks like this:
210///
211/// [the XDG standard]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
212///
213/// ```toml
214/// [[local]]
215/// name = "xdg_config_home"
216/// base = "/path/to/your/xdg/config/directory"
217/// sources = ["*"]
218/// target = "~/.config"
219/// ```
220///
221/// Let's say after some weeks or months, you have decided to also include
222/// `/usr/share/fontconfig/conf.avail/11-lcdfilter-default.conf` to your
223/// fontconfig directory, which is `~/.config/fontconfig/conf.d`, you do so by
224/// adding another `[[local]]` group into your config file for DT:
225///
226/// ```toml
227/// [[local]]
228/// name = "fontconfig-system"
229/// base = "/usr/share/fontconfig/conf.avail"
230/// sources = ["11-lcdfilter-default.conf"]
231/// target = "~/.config/fontconfig/conf.d"
232/// ```
233///
234/// A problem arises when you also maintain a version of
235/// `11-lcdfilter-default.conf` of your own: If DT syncs the
236/// `fontconfig-system` group last, the resulting config file in your
237/// `$XDG_CONFIG_HOME` is the system version;  While if DT syncs the
238/// `xdg_config_home` group last, that file ended up being your previously
239/// maintained version.
240///
241/// Actually, DT is quite predictable: it only performs operations in the
242/// order defined in the config file for your groups.  By defining the
243/// `fontconfig-system` group last, you can completely avoid the ambiguity
244/// above.
245///
246/// However, since the config file was written by you, a human, and humans are
247/// notorious for making mistakes, it would be great if DT could always know
248/// what to do when duplicated items are discovered in the config file.
249/// Instead of putting the groups with higher priority at the end of your
250/// config file, you could simply define `scope`s in their definitions:
251///
252/// ```toml
253/// [[local]]
254/// name = "fontconfig-system"
255/// scope = "Dropin"
256/// ...
257/// [[local]]
258/// name = "xdg_config_home"
259/// scope = "General"
260/// ...
261/// ```
262///
263/// Now, with the `scope` being set, DT will first remove the source item
264/// `11-lcdfilter-default.conf` (if it exists) from group `xdg_config_home`,
265/// then perform its syncing process.
266///
267/// This is also useful with `dt-cli`'s `-l|--local-name` option, which gives
268/// you more granular control over how items are synced.
269#[derive(Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
270pub enum DTScope {
271    /// The scope with lowest priority, this is the default scope,
272    /// recommended for directories that contains config files for many
273    /// un-categorized applications.
274    General,
275    /// The scope for a specific app, it's priority is higher than
276    /// [`General`] while lower than [`Dropin`].
277    ///
278    /// [`General`]: DTScope::General
279    /// [`Dropin`]: DTScope::Dropin
280    App,
281    /// The scope for drop-in replacements, it has the highest priority.
282    Dropin,
283}
284impl Default for DTScope {
285    fn default() -> Self {
286        DTScope::General
287    }
288}
289
290/// The configuration object deserialized from configuration file, every
291/// field of it is optional.
292#[derive(Clone, Debug, Default, Deserialize)]
293#[serde(default)]
294pub struct DTConfig {
295    /// Sets fallback behaviours.
296    pub global: GlobalConfig,
297
298    /// Defines values for templating.
299    pub context: ContextConfig,
300
301    /// Groups containing local files.
302    pub local: Vec<LocalGroup>,
303
304    /// Groups containing remote files.
305    pub remote: Vec<RemoteGroup>,
306}
307
308impl FromStr for DTConfig {
309    type Err = AppError;
310
311    /// Loads configuration from string.
312    fn from_str(s: &str) -> Result<Self> {
313        toml::from_str::<Self>(s)?.expand_tilde().validate()
314    }
315}
316
317impl DTConfig {
318    /// Loads configuration from a file.
319    pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
320        let path = path.as_ref();
321        if let Ok(confstr) = std::fs::read_to_string(path) {
322            Self::from_str(&confstr)
323        } else {
324            Err(AppError::ConfigError(format!(
325                "Could not load config from '{}'",
326                path.display(),
327            )))
328        }
329    }
330
331    /// Construct another [`DTConfig`] object with groups that match given
332    /// filters.  Groups are matched hierarchically, e.g. a filter `a/b` will
333    /// select `a/b/c` and `a/b/d`, but not `a/bcd`.
334    pub fn filter_names(self, group_names: Vec<String>) -> Self {
335        Self {
336            global: self.global,
337            context: self.context,
338            local: self
339                .local
340                .iter()
341                .filter(|l| group_names.iter().any(|n| l.name.0.starts_with(n)))
342                .map(|l| l.to_owned())
343                .collect(),
344            remote: self
345                .remote
346                .iter()
347                .filter(|l| group_names.iter().any(|n| l.name.0.starts_with(n)))
348                .map(|l| l.to_owned())
349                .collect(),
350        }
351    }
352
353    /// Validates config object.  After this, the original `global` and
354    /// `context` sections are referenced by each group via an [Rc] and can be
355    /// safely ignored in further processing.
356    ///
357    /// [Rc]: std::rc::Rc
358    fn validate(self) -> Result<Self> {
359        if !self.context.0.is_table() {
360            return Err(AppError::ConfigError(
361                "`context` is expected to be a table".to_owned(),
362            ));
363        }
364
365        let global_ref = Rc::new(self.global.to_owned());
366        let context_ref = Rc::new(self.context.to_owned());
367
368        let mut ret: Self = self;
369
370        for group in &mut ret.local {
371            group.global = Rc::clone(&global_ref);
372            group.context = Rc::clone(&context_ref);
373            group.validate()?;
374        }
375        for group in &mut ret.remote {
376            group.global = Rc::clone(&global_ref);
377            group.context = Rc::clone(&context_ref);
378            group.validate()?;
379        }
380
381        Ok(ret)
382    }
383
384    fn expand_tilde(self) -> Self {
385        let mut ret = self;
386
387        // Expand tilde in `global.staging`
388        let staging = &mut ret.global.staging;
389        *staging = if *staging == StagingPath("".into()) {
390            log::warn!("Empty staging path is replaced to '.'");
391            StagingPath(".".into())
392        } else {
393            StagingPath(
394                PathBuf::from_str(&shellexpand::tilde(&staging.0.to_string_lossy())).unwrap(),
395            )
396        };
397
398        // Expand tilde in `base` and `target` of `local`
399        for group in &mut ret.local {
400            // `local.base`
401            group.base = if group.base == PathBuf::from_str("").unwrap() {
402                log::warn!("[{}]: Empty base is replaced to '.'", group.name);
403                ".".into()
404            } else {
405                PathBuf::from_str(&shellexpand::tilde(&group.base.to_string_lossy())).unwrap()
406            };
407
408            // `local.target`
409            group.target = if group.target == PathBuf::from_str("").unwrap() {
410                log::warn!("[{}]: Empty target is replaced to '.'", group.name,);
411                ".".into()
412            } else {
413                PathBuf::from_str(&shellexpand::tilde(&group.target.to_string_lossy())).unwrap()
414            };
415        }
416
417        ret
418    }
419}
420
421/// A single renaming rule, used for generating names for target files which
422/// are different from their sources.
423#[derive(Clone, Debug, Deserialize_tuple)]
424pub struct RenamingRule {
425    /// A regular expression, specifies the pattern against which item names
426    /// are matched.  Regular expression's capture groups (indexed or named)
427    /// are supported.  See the [documentation] for more instructions on
428    /// this.
429    ///
430    /// [documentation]: https://dt.cli.rs/features/03-filename-manipulating.html
431    #[serde(deserialize_with = "serde_regex::deserialize")]
432    pub pattern: Regex,
433
434    /// The substitution rule to apply if pattern matches an item,
435    /// indexed/named capture groups are allowed.
436    pub substitution: String,
437}
438
439/// Configures default behaviours.
440#[derive(Clone, Debug, Default, Deserialize)]
441pub struct GlobalConfig {
442    /// The staging root directory.
443    ///
444    /// Only works when [`method`] (see below) is set to [`Symlink`].  When
445    /// syncing with [`Symlink`] method, items will be copied to their
446    /// staging directory (composed by joining staging root
447    /// directory with their group name), then symlinked (as of `ln -sf`)
448    /// from their staging directory to the target directory.
449    ///
450    /// Default to `$XDG_DATA_HOME/dt/staging` if `XDG_DATA_HOME` is set,
451    /// or `$HOME/.cache/dt/staging` if `HOME` is set.  Panics when
452    /// neither `XDG_DATA_HOME` nor `HOME` is set and config file does
453    /// not specify this.
454    ///
455    /// [`method`]: GlobalConfig::method
456    /// [`Symlink`]: SyncMethod::Symlink
457    #[serde(default)]
458    pub staging: StagingPath,
459
460    /// The syncing method.
461    ///
462    /// Available values are:
463    ///
464    /// - [`Copy`]
465    /// - [`Symlink`]
466    ///
467    /// When [`method`] is [`Copy`], the above [`staging`] setting will be
468    /// disabled.
469    ///
470    /// [`method`]: GlobalConfig::method
471    /// [`staging`]: GlobalConfig::staging
472    /// [`Copy`]: SyncMethod::Copy
473    /// [`Symlink`]: SyncMethod::Symlink
474    #[serde(default)]
475    pub method: SyncMethod,
476
477    /// A string to be prepended to a subgroup's name when creating its
478    /// staging directory with the [`Symlink`] syncing method.
479    ///
480    /// [`Symlink`]: SyncMethod::Symlink
481    #[serde(default)]
482    pub subgroup_prefix: SubgroupPrefix,
483
484    /// Whether to allow overwriting existing files.
485    ///
486    /// This alters syncing behaviours when the target file exists.  If set
487    /// to `true`, no errors/warnings will be omitted when the target
488    /// file exists; otherwise reports error and skips the existing item.
489    /// Using dry run to spot the existing files before syncing is
490    /// recommended.
491    #[serde(default)]
492    pub allow_overwrite: AllowOverwrite,
493
494    /// Whether to treat errors omitted during syncing as warnings.  It has a
495    /// [per-group counterpart] to set per-group behaviours.  Note that errors
496    /// occurred before or after syncing are NOT affected.
497    ///
498    /// [per-group counterpart]: Group::ignore_failure
499    #[serde(default)]
500    pub ignore_failure: IgnoreFailure,
501
502    /// Whether to enable templating.  It has a [per-group counterpart] to set
503    /// if a group is to be rendered.
504    ///
505    /// [per-group counterpart]: Group::renderable
506    #[serde(default)]
507    pub renderable: Renderable,
508
509    /// The hostname separator.
510    ///
511    /// Specifies default value when [`Group::hostname_sep`] is not set.
512    ///
513    /// [`Group::hostname_sep`]: Group::hostname_sep
514    #[serde(default)]
515    pub hostname_sep: HostnameSeparator,
516
517    /// Global item renaming rules.
518    ///
519    /// Rules defined here will be prepended to renaming rules of each group.
520    /// See [`Group::rename`].
521    ///
522    /// [`Group::rename`]: Group::rename
523    #[serde(default)]
524    pub rename: RenamingRules,
525}
526
527/// Templating values are defined in this section.
528#[derive(Clone, Debug, Deserialize, Serialize)]
529pub struct ContextConfig(toml::Value);
530impl Default for ContextConfig {
531    fn default() -> Self {
532        Self(toml::map::Map::new().into())
533    }
534}
535
536/// Configures how items are grouped.
537#[derive(Default, Clone, Deserialize, Debug)]
538pub struct Group<T>
539where
540    T: Operate,
541{
542    /// The global config object loaded from DT's config file.  This field
543    /// _does not_ appear in the config file, but is only used by DT
544    /// internally.  Skipping deserializing is achieved via serde's
545    /// [`skip_deserializing`] attribute, which fills a default value when
546    /// deserializing.
547    ///
548    /// [`skip_deserializing`]: https://serde.rs/field-attrs.html#skip_deserializing
549    #[serde(skip_deserializing)]
550    pub global: Rc<GlobalConfig>,
551
552    /// The context config object loaded from config file.  Like
553    /// [`Group::global`], this field _does not_ appear in the config, but is
554    /// only used by DT internally.
555    ///
556    /// [`Group::global`]: Group::global
557    #[serde(skip_deserializing)]
558    pub context: Rc<ContextConfig>,
559
560    /// Name of this group, used as namespace in staging root directory.
561    pub name: GroupName,
562
563    /// The priority of this group, used to resolve possibly duplicated
564    /// items.  See [`DTScope`] for details.
565    ///
566    /// [`DTScope`]: DTScope
567    #[serde(default)]
568    pub scope: DTScope,
569
570    /// The base directory of all source items.  This simplifies
571    /// configuration files with common prefixes in the [`sources`]
572    /// array.
573    ///
574    /// [`sources`]: Group::sources
575    ///
576    /// ## Example
577    ///
578    /// For a directory structure like:
579    ///
580    /// ```plain
581    /// dt/
582    /// ├── dt-core/
583    /// │  └── src/
584    /// │     └── config.rs
585    /// ├── dt-cli/
586    /// │  └── src/
587    /// │     └── main.rs
588    /// └── README.md
589    /// ```
590    ///
591    /// Consider the following config file:
592    ///
593    /// ```toml
594    /// [[local]]
595    /// base = "dt/dt-cli"
596    /// sources = ["*"]
597    /// target = "."
598    /// ```
599    ///
600    /// It will only sync `src/main.rs` to the configured target directory
601    /// (in this case, the directory where [DT] is being executed).
602    ///
603    /// [DT]: https://github.com/blurgyy/dt
604    pub base: T,
605
606    /// Paths (relative to [`base`]) to the items to be synced.
607    ///
608    /// [`base`]: Group::base
609    pub sources: Vec<T>,
610
611    /// The path of the parent dir of the final synced items.
612    ///
613    /// ## Example
614    ///
615    /// ```toml
616    /// source = ["/source/file"]
617    /// target = "/tar/get"
618    /// ```
619    ///
620    /// will sync `/source/file` to `/tar/get/file` (creating non-existing
621    /// directories along the way), while
622    ///
623    /// ```toml
624    /// source = ["/source/dir"]
625    /// target = "/tar/get/dir"
626    /// ```
627    ///
628    /// will sync `source/dir` to `/tar/get/dir/dir` (creating non-existing
629    /// directories along the way).
630    pub target: PathBuf,
631
632    /// (Optional) Ignored names.
633    ///
634    /// ## Example
635    ///
636    /// Consider the following ignored setting:
637    ///
638    /// ```toml
639    /// ignored = [".git"]
640    /// ```
641    ///
642    /// With this setting, all files or directories with their basename as
643    /// ".git" will be skipped.
644    ///
645    /// Cannot contain slash in any of the patterns.
646    pub ignored: Option<RenamingRules>,
647
648    /// (Optional) Separator for per-host settings, default to `@@`.
649    ///
650    /// An additional item with `${hostname_sep}$(hostname)` appended to the
651    /// original item name will be checked first, before looking for the
652    /// original item.  If the appended item is found, use this item
653    /// instead of the configured one.
654    ///
655    /// Also ignores items that are meant for other hosts by checking if the
656    /// string after [`hostname_sep`] matches current machine's hostname.
657    ///
658    /// [`hostname_sep`]: Group::hostname_sep
659    ///
660    /// ## Example
661    ///
662    /// When the following directory structure exists:
663    ///
664    /// ```plain
665    /// ~/.ssh/
666    /// ├── authorized_keys
667    /// ├── authorized_keys@@sherlock
668    /// ├── authorized_keys@@watson
669    /// ├── config
670    /// ├── config@sherlock
671    /// └── config@watson
672    /// ```
673    ///
674    /// On a machine with hostname set to `watson`, the below configuration
675    /// (extraneous keys are omitted here)
676    ///
677    /// ```toml
678    /// [[local]]
679    /// ...
680    /// hostname_sep = "@@"
681    ///
682    /// base = "~/.ssh"
683    /// sources = ["config"]
684    /// target = "/tmp/sshconfig"
685    /// ...
686    /// ```
687    ///
688    /// will result in the below target (`/tmp/sshconfig`):
689    ///
690    /// ```plain
691    /// /tmp/sshconfig/
692    /// └── config
693    /// ```
694    ///
695    /// Where `/tmp/sshconfig/config` mirrors the content of
696    /// `~/.ssh/config@watson`.
697    pub hostname_sep: Option<HostnameSeparator>,
698
699    /// (Optional) Whether to allow overwriting existing files.  Dead
700    /// symlinks are treated as non-existing, and are always overwritten
701    /// (regardless of this option).
702    pub allow_overwrite: Option<AllowOverwrite>,
703
704    /// (Optional) Whether to treat errors omitted during syncing of this
705    /// group as warnings.  Note that errors occurred before or after syncing
706    /// are NOT affected.
707    pub ignore_failure: Option<IgnoreFailure>,
708
709    /// (Optional) Whether to enable templating for source files in this
710    /// group.
711    #[serde(default)]
712    pub renderable: Option<Renderable>,
713
714    /// (Optional) Syncing method, overrides [`global.method`] key.
715    ///
716    /// [`global.method`]: GlobalConfig::method
717    pub method: Option<SyncMethod>,
718
719    /// A string to be prepended to a subgroup's name when creating its
720    /// staging directory with the [`Symlink`] syncing method, overrides
721    /// [`global.subgroup_prefix`] key.
722    ///
723    /// [`Symlink`]: SyncMethod::Symlink
724    /// [`global.subgroup_prefix`]: GlobalConfig::subgroup_prefix
725    pub subgroup_prefix: Option<SubgroupPrefix>,
726
727    /// (Optional) Renaming rules, appends to [`global.rename`].
728    ///
729    /// [`global.rename`]: GlobalConfig::rename
730    #[serde(default)]
731    pub rename: RenamingRules,
732}
733
734impl<T> Group<T>
735where
736    T: Operate,
737{
738    /// Gets the [`allow_overwrite`] key from a `Group` object, falls back to
739    /// the `allow_overwrite` from its parent global config.
740    ///
741    /// [`allow_overwrite`]: Group::allow_overwrite
742    pub fn is_overwrite_allowed(&self) -> bool {
743        match self.allow_overwrite {
744            Some(AllowOverwrite(allow_overwrite)) => allow_overwrite,
745            _ => self.global.allow_overwrite.0,
746        }
747    }
748
749    /// Gets the [`ignore_failure`] key from a `Group` object, falls back to
750    /// the `ignore_failure` from its parent global config.
751    ///
752    /// [`ignore_failure`]: Group::ignore_failure
753    pub fn is_failure_ignored(&self) -> bool {
754        match self.ignore_failure {
755            Some(IgnoreFailure(ignore_failure)) => ignore_failure,
756            _ => self.global.ignore_failure.0,
757        }
758    }
759
760    /// Gets the absolute path to this group's staging directory, with the
761    /// subgroup components padded with configured [`subgroup_prefix`]es.
762    ///
763    /// [`subgroup_prefix`]: Group::subgroup_prefix
764    pub fn get_staging_dir(&self) -> PathBuf {
765        self.global
766            .staging
767            .0
768            .join(self.name.with_subgroup_prefix(&self.get_subgroup_prefix()))
769    }
770
771    /// Gets the [`method`] key from a `Group` object, falls back to the
772    /// `method` from its parent global config.
773    ///
774    /// [`method`]: Group::method
775    pub fn get_method(&self) -> SyncMethod {
776        match self.method {
777            Some(method) => method,
778            _ => self.global.method,
779        }
780    }
781
782    /// Gets the [`subgroup_prefix`] key from a `Group` object, falls back to
783    /// the `subgroup_prefix` from its parent global config.
784    ///
785    /// [`subgroup_prefix`]: Group::subgroup_prefix
786    pub fn get_subgroup_prefix(&self) -> String {
787        match &self.subgroup_prefix {
788            Some(prefix) => prefix.0.to_owned(),
789            _ => self.global.subgroup_prefix.0.to_owned(),
790        }
791    }
792
793    /// Gets the [`hostname_sep`] key from a `Group` object, falls back to the
794    /// [`hostname_sep`] from its parent global config.
795    ///
796    /// [`hostname_sep`]: Group::hostname_sep
797    pub fn get_hostname_sep(&self) -> String {
798        match &self.hostname_sep {
799            Some(hostname_sep) => hostname_sep.0.to_owned(),
800            _ => self.global.hostname_sep.0.to_owned(),
801        }
802    }
803
804    /// Gets the list of [renaming rules] of this group, which is an array
805    /// of (REGEX, SUBSTITUTION) tuples composed of [`global.rename`] and
806    /// [`group.rename`], used in [`Operate::make_target`] to rename the item.
807    /// The returned list is a combination of the rules from global config and
808    /// the group's own rules.
809    ///
810    /// [renaming rules]: Group::rename
811    /// [`global.rename`]: GlobalConfig::rename
812    /// [`group.rename`]: Group::rename
813    /// [`Operate::make_target`]: crate::item::Operate::make_target
814    pub fn get_renaming_rules(&self) -> Vec<RenamingRule> {
815        let mut ret: Vec<RenamingRule> = Vec::new();
816        for r in &self.global.rename.0 {
817            ret.push(r.to_owned());
818        }
819        for r in &self.rename.0 {
820            ret.push(r.to_owned());
821        }
822        ret
823    }
824
825    /// Check if this group is renderable according to the cascaded config
826    /// options.
827    pub fn is_renderable(&self) -> bool {
828        match self.renderable {
829            Some(Renderable(renderable)) => renderable,
830            _ => self.global.renderable.0,
831        }
832    }
833
834    /// Validates this group with readonly access to the filesystem.  The
835    /// following cases are denied:
836    ///
837    ///   1. Invalid group name
838    ///   2. Source item referencing parent (because items are first populated
839    ///      to the [`staging`] directory, and the structure under the
840    ///      [`staging`] directory depends on their original relative path to
841    ///      their [`base`])
842    ///   3. TODO: Current group contains unimplemented [`ignored`] field
843    ///
844    /// NOTE: When [`base`] is empty, sources will be looked up in the cwd of
845    /// the process.
846    ///
847    /// [`ignored`]: Group::ignored
848    /// [`base`]: Group::base
849    fn _validate_no_fs_query(&self) -> Result<()> {
850        // 1. Invalid group name
851        self.name.validate()?;
852        // 2. Source item referencing parent
853        if self.sources.iter().any(|s| s.is_twisted()) {
854            return Err(AppError::ConfigError(format!(
855                "source item references parent directory in group '{}'",
856                self.name,
857            )));
858        }
859        // 3. Current group contains unimplemented `ignored` field
860        if self.ignored.is_some() {
861            todo!("`ignored` array works poorly and I decided to implement it in the future");
862        }
863
864        Ok(())
865    }
866
867    /// Validates this group via querying the filesystem.  The following cases
868    /// are denied:
869    ///
870    ///   1. Wrong type of existing [`staging`] path (if using the
871    ///      [`Symlink`] method)
872    ///   2. Path to staging root contains readonly parent directory (if
873    ///      using the [`Symlink`] method)
874    ///   3. Wrong type of existing [`target`] path
875    ///   4. Path to [`target`] contains readonly parent directory
876    ///
877    /// [`staging`]: GlobalConfig::staging
878    /// [`Symlink`]: SyncMethod::Symlink
879    /// [`target`]: LocalGroup::target
880    fn _validate_with_fs_query(&self) -> Result<()> {
881        if self.get_method() == SyncMethod::Symlink {
882            let staging_path: PathBuf = self.global.staging.0.to_owned();
883
884            // 1. Wrong type of existing staging path
885            if staging_path.exists() && !staging_path.is_dir() {
886                return Err(AppError::ConfigError(
887                    "staging root path exists but is not a valid directory".to_owned(),
888                ));
889            }
890
891            // 2. Path to staging root contains readonly parent directory
892            // NOTE: Must convert to an absolute path before checking readonly
893            if !staging_path.exists() && staging_path.absolute()?.is_parent_readonly() {
894                return Err(AppError::ConfigError(
895                    "staging root path cannot be created due to insufficient permissions"
896                        .to_owned(),
897                ));
898            }
899        }
900
901        // 3. Wrong type of existing target path
902        if self.target.exists() && !self.target.is_dir() {
903            return Err(AppError::ConfigError(format!(
904                "target path exists but is not a valid directory in group '{}'",
905                self.name,
906            )));
907        }
908
909        // 4. Path to target contains readonly parent directory
910        // NOTE: Must convert to an absolute path before checking readonly
911        if !self.target.exists() && self.target.to_owned().absolute()?.is_parent_readonly() {
912            return Err(AppError::ConfigError(format!(
913                "target path cannot be created due to insufficient permissions in group '{}'",
914                self.name,
915            )));
916        }
917
918        Ok(())
919    }
920}
921
922/// Configures how local items are grouped.
923pub type LocalGroup = Group<PathBuf>;
924
925impl LocalGroup {
926    /// Validates this local group, the following cases are denied:
927    ///
928    /// - Checks without querying the filesystem
929    ///
930    ///   1. Empty [group name]
931    ///   2. Source item referencing parent (because items are first populated
932    ///      to the [`staging`] directory, and the structure under the
933    ///      [`staging`] directory depends on their original relative path to
934    ///      their [`base`])
935    ///   3. Current group contains unimplemented [`ignored`] field
936    ///
937    ///   4. Target and base are the same
938    ///   5. Base contains [`hostname_sep`]
939    ///   6. Source item is absolute (same reason as above)
940    ///   7. Source item contains bad globbing pattern
941    ///   8. Source item contains [`hostname_sep`]
942    ///
943    /// - Checks that need to query the filesystem
944    ///
945    ///   1. Wrong type of existing [`staging`] path (if using the
946    ///      [`Symlink`] method)
947    ///   2. Path to staging root contains readonly parent directory (if
948    ///      using the [`Symlink`] method)
949    ///   3. Wrong type of existing [`target`] path
950    ///   4. Path to [`target`] contains readonly parent directory
951    ///
952    ///   5. Base is unreadable
953    ///
954    /// [group name]: LocalGroup::name
955    /// [`base`]: LocalGroup::base
956    /// [`target`]: LocalGroup::target
957    /// [`staging`]: GlobalConfig::staging
958    /// [`ignored`]: Group::ignored
959    /// [`hostname_sep`]: LocalGroup::hostname_sep
960    /// [`Symlink`]: SyncMethod::Symlink
961    pub fn validate(&self) -> Result<()> {
962        // - Checks without querying the filesystem --------------------------
963        // 1-4
964        self._validate_no_fs_query()?;
965
966        // 5. Target and base are the same
967        if self.base == self.target {
968            return Err(AppError::ConfigError(format!(
969                "base directory and its target are the same in group '{}'",
970                self.name,
971            )));
972        }
973
974        // 6. Base contains hostname_sep
975        let hostname_sep = self.get_hostname_sep();
976        if self.base.to_string_lossy().contains(&hostname_sep) {
977            return Err(AppError::ConfigError(format!(
978                "base directory contains hostname_sep ({}) in group '{}'",
979                hostname_sep, self.name,
980            )));
981        }
982
983        // 7. Source item is absolute
984        if self
985            .sources
986            .iter()
987            .any(|s| s.starts_with("/") || s.starts_with("~"))
988        {
989            return Err(AppError::ConfigError(format!(
990                "source array contains absolute path in group '{}'",
991                self.name,
992            )));
993        }
994
995        // 8. Source item contains bad globbing pattern
996        if self.sources.iter().any(|s| {
997            s.to_str()
998                .unwrap()
999                .split('/')
1000                .any(|component| component == ".*")
1001        }) {
1002            log::error!(
1003                    "'.*' is prohibited for globbing sources because it also matches the parent directory.",
1004                );
1005            log::error!(
1006                    "If you want to match all items that starts with a dot, use ['.[!.]*', '..?*'] as sources.",
1007                );
1008            return Err(AppError::ConfigError("bad globbing pattern".to_owned()));
1009        }
1010
1011        // 9. Source item contains hostname_sep
1012        if self.sources.iter().any(|s| {
1013            let s = s.to_string_lossy();
1014            s.contains(&hostname_sep)
1015        }) {
1016            return Err(AppError::ConfigError(format!(
1017                "a source item contains hostname_sep ({}) in group '{}'",
1018                hostname_sep, self.name,
1019            )));
1020        }
1021
1022        // - Checks that need to query the filesystem ------------------------
1023        // 1-4
1024        self._validate_with_fs_query()?;
1025
1026        // 5. Base is unreadable
1027        if self.base.exists() {
1028            // Check read permission of `base`
1029            if let Err(e) = std::fs::read_dir(&self.base) {
1030                log::error!("Could not read base '{}'", self.base.display());
1031                return Err(e.into());
1032            }
1033        }
1034
1035        Ok(())
1036    }
1037}
1038
1039/// Configures how remote items are grouped.
1040pub type RemoteGroup = Group<Url>;
1041
1042impl RemoteGroup {
1043    /// Validates this remote group, the following cases are denied:
1044    ///
1045    /// - Checks without querying the filesystem
1046    ///
1047    ///   1. Empty [group name]
1048    ///   2. Empty [`target`]
1049    ///   3. Source item referencing parent (because items are first populated
1050    ///      to the [`staging`] directory, and the structure under the
1051    ///      [`staging`] directory depends on their original relative path to
1052    ///      their [`base`])
1053    ///   4. Current group contains unimplemented [`ignored`] field
1054    ///
1055    /// - Checks that need to query the filesystem
1056    ///
1057    ///   1. Wrong type of existing [`staging`] path (if using the
1058    ///      [`Symlink`] method)
1059    ///   2. Path to staging root contains readonly parent directory (if
1060    ///      using the [`Symlink`] method)
1061    ///   3. Wrong type of existing [`target`] path
1062    ///   4. Path to [`target`] contains readonly parent directory
1063    ///
1064    /// [group name]: LocalGroup::name
1065    /// [`base`]: LocalGroup::base
1066    /// [`target`]: LocalGroup::target
1067    /// [`staging`]: GlobalConfig::staging
1068    /// [`ignored`]: Group::ignored
1069    /// [`Symlink`]: SyncMethod::Symlink
1070    fn validate(&self) -> Result<()> {
1071        // - Checks without querying the filesystem --------------------------
1072        // 1-5
1073        self._validate_no_fs_query()?;
1074
1075        // - Checks that need to query the filesystem ------------------------
1076        // 1-4
1077        self._validate_with_fs_query()?;
1078
1079        Ok(())
1080    }
1081}
1082
1083#[cfg(test)]
1084mod overriding_global {
1085    use std::str::FromStr;
1086
1087    use super::{DTConfig, SyncMethod};
1088    use color_eyre::Report;
1089    use pretty_assertions::assert_eq;
1090
1091    #[test]
1092    fn allow_overwrite_no_global() -> Result<(), Report> {
1093        let config = DTConfig::from_str(
1094            r#"
1095[[local]]
1096name = "placeholder"
1097base = "~"
1098sources = ["*"]
1099target = "."
1100allow_overwrite = true"#,
1101        )?;
1102        for group in config.local {
1103            assert_eq!(group.is_overwrite_allowed(), true);
1104        }
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn allow_overwrite_with_global() -> Result<(), Report> {
1110        let config = DTConfig::from_str(
1111            r#"
1112[global]
1113method = "Copy"  # Default value because not testing this key
1114allow_overwrite = true
1115
1116[[local]]
1117name = "placeholder"
1118base = "~"
1119sources = ["*"]
1120target = "."
1121allow_overwrite = false"#,
1122        )?;
1123        for group in config.local {
1124            assert_eq!(group.is_overwrite_allowed(), false);
1125        }
1126        Ok(())
1127    }
1128
1129    #[test]
1130    fn both_allow_overwrite_and_method_no_global() -> Result<(), Report> {
1131        let config = DTConfig::from_str(
1132            r#"
1133[[local]]
1134name = "placeholder"
1135base = "~"
1136sources = ["*"]
1137target = "."
1138method = "Copy"
1139allow_overwrite = true"#,
1140        )?;
1141        for group in config.local {
1142            assert_eq!(group.get_method(), SyncMethod::Copy);
1143            assert_eq!(group.is_overwrite_allowed(), true);
1144        }
1145        Ok(())
1146    }
1147
1148    #[test]
1149    fn both_allow_overwrite_and_method_with_global() -> Result<(), Report> {
1150        let config = DTConfig::from_str(
1151            r#"
1152[global]
1153method = "Copy"
1154allow_overwrite = true
1155
1156[[local]]
1157name = "placeholder"
1158base = "~"
1159sources = ["*"]
1160target = "."
1161method = "Symlink"
1162allow_overwrite = false"#,
1163        )?;
1164        for group in config.local {
1165            assert_eq!(group.get_method(), SyncMethod::Symlink);
1166            assert_eq!(group.is_overwrite_allowed(), false);
1167        }
1168        Ok(())
1169    }
1170
1171    #[test]
1172    fn hostname_sep_no_global() -> Result<(), Report> {
1173        let config = DTConfig::from_str(
1174            r#"
1175[[local]]
1176name = "hostname_sep no global test"
1177hostname_sep = "@-@"
1178base = "~"
1179sources = []
1180target = ".""#,
1181        )?;
1182        for group in config.local {
1183            assert_eq!(group.get_hostname_sep(), "@-@");
1184        }
1185        Ok(())
1186    }
1187
1188    #[test]
1189    fn hostname_sep_with_global() -> Result<(), Report> {
1190        let config = DTConfig::from_str(
1191            r#"
1192[global]
1193hostname_sep = "@-@"
1194
1195[[local]]
1196name = "hostname_sep fall back to global"
1197base = "~"
1198sources = []
1199target = ".""#,
1200        )?;
1201        for group in config.local {
1202            assert_eq!(group.get_hostname_sep(), "@-@");
1203        }
1204        Ok(())
1205    }
1206
1207    #[test]
1208    fn method_no_global() -> Result<(), Report> {
1209        let config = DTConfig::from_str(
1210            r#"
1211[[local]]
1212name = "placeholder"
1213base = "~"
1214sources = ["*"]
1215target = "."
1216method = "Copy""#,
1217        )?;
1218        for group in config.local {
1219            assert_eq!(group.get_method(), SyncMethod::Copy)
1220        }
1221        Ok(())
1222    }
1223
1224    #[test]
1225    fn method_with_global() -> Result<(), Report> {
1226        let config = DTConfig::from_str(
1227            r#"
1228[global]
1229method = "Copy"
1230allow_overwrite = false # Default value because not testing this key
1231
1232[[local]]
1233name = "placeholder"
1234base = "~"
1235sources = ["*"]
1236target = "."
1237method = "Symlink""#,
1238        )?;
1239        for group in config.local {
1240            assert_eq!(group.get_method(), SyncMethod::Symlink)
1241        }
1242        Ok(())
1243    }
1244}
1245
1246#[cfg(test)]
1247mod tilde_expansion {
1248    use std::str::FromStr;
1249
1250    use color_eyre::Report;
1251    use pretty_assertions::assert_eq;
1252
1253    use super::DTConfig;
1254
1255    #[test]
1256    fn all() -> Result<(), Report> {
1257        let config = DTConfig::from_str(
1258            r#"
1259[global]
1260staging = "~"
1261method = "Symlink"
1262allow_overwrite = false
1263
1264
1265[[local]]
1266name = "expand tilde in base and target"
1267base = "~"
1268sources = []
1269target = "~/dt/target""#,
1270        )?;
1271        dbg!(&config.global.staging.0);
1272        assert_eq!(Some(config.global.staging.0), dirs::home_dir());
1273        config.local.iter().all(|group| {
1274            dbg!(&group.base);
1275            dbg!(&group.target);
1276            assert_eq!(Some(group.to_owned().base), dirs::home_dir());
1277            assert_eq!(
1278                Some(group.to_owned().target),
1279                dirs::home_dir()
1280                    .map(|p| p.join("dt"))
1281                    .map(|p| p.join("target")),
1282            );
1283            true
1284        });
1285        Ok(())
1286    }
1287}
1288
1289#[cfg(test)]
1290mod validation {
1291    use std::str::FromStr;
1292
1293    use color_eyre::{eyre::eyre, Report};
1294    use pretty_assertions::assert_eq;
1295
1296    use super::DTConfig;
1297    use crate::error::Error as AppError;
1298
1299    #[test]
1300    fn relative_component_in_group_name() -> Result<(), Report> {
1301        if let Err(err) = DTConfig::from_str(
1302            r#"
1303[[local]]
1304name = "a/../b"
1305base = "~"
1306sources = []
1307target = ".""#,
1308        ) {
1309            assert_eq!(
1310                err,
1311                AppError::ConfigError(
1312                    "Group name should not contain relative component".to_owned(),
1313                ),
1314                "{}",
1315                err,
1316            );
1317            Ok(())
1318        } else {
1319            Err(eyre!("This config should not be loaded because a group's name contains relative component"))
1320        }
1321    }
1322
1323    #[test]
1324    fn prefix_slash_in_group_name() -> Result<(), Report> {
1325        if let Err(err) = DTConfig::from_str(
1326            r#"
1327[[local]]
1328name = "/a/b/c/d"
1329base = "~"
1330sources = []
1331target = ".""#,
1332        ) {
1333            assert_eq!(
1334                err,
1335                AppError::ConfigError("Group name should not start with slash".to_owned(),),
1336                "{}",
1337                err,
1338            );
1339            Ok(())
1340        } else {
1341            Err(eyre!(
1342                "This config should not be loaded because a group's name starts with a slash"
1343            ))
1344        }
1345    }
1346
1347    #[test]
1348    fn empty_group_name() -> Result<(), Report> {
1349        if let Err(err) = DTConfig::from_str(
1350            r#"
1351[[local]]
1352name = ""
1353base = "~"
1354sources = []
1355target = ".""#,
1356        ) {
1357            assert_eq!(
1358                err,
1359                AppError::ConfigError("Group name should not be empty".to_owned(),),
1360                "{}",
1361                err,
1362            );
1363            Ok(())
1364        } else {
1365            Err(eyre!(
1366                "This config should not be loaded because a group's name is empty"
1367            ))
1368        }
1369    }
1370
1371    #[test]
1372    fn base_is_target() -> Result<(), Report> {
1373        if let Err(err) = DTConfig::from_str(
1374            r#"
1375[[local]]
1376name = "base is target"
1377base = "~"
1378sources = []
1379target = "~""#,
1380        ) {
1381            assert_eq!(
1382                err,
1383                AppError::ConfigError(
1384                    "base directory and its target are the same in group 'base is target'"
1385                        .to_owned(),
1386                ),
1387                "{}",
1388                err,
1389            );
1390            Ok(())
1391        } else {
1392            Err(eyre!(
1393                "This config should not be loaded because base and target are the same"
1394            ))
1395        }
1396    }
1397
1398    #[test]
1399    fn base_contains_hostname_sep() -> Result<(), Report> {
1400        if let Err(err) = DTConfig::from_str(
1401            r#"
1402[[local]]
1403name = "base contains hostname_sep"
1404hostname_sep = "@@"
1405base = "~/.config/sytemd/user@@elbert"
1406sources = []
1407target = ".""#,
1408        ) {
1409            assert_eq!(
1410                err,
1411                AppError::ConfigError(
1412                    "base directory contains hostname_sep (@@) in group 'base contains hostname_sep'"
1413                        .to_owned(),
1414                ),
1415                "{}",
1416                err,
1417            );
1418            Ok(())
1419        } else {
1420            Err(eyre!(
1421                "This config should not be loaded because a base contains hostname_sep"
1422            ))
1423        }
1424    }
1425
1426    #[test]
1427    fn source_item_referencing_parent() -> Result<(), Report> {
1428        if let Err(err) = DTConfig::from_str(
1429            r#"
1430[[local]]
1431name = "source item references parent dir"
1432base = "."
1433sources = ["../Cargo.toml"]
1434target = "target""#,
1435        ) {
1436            assert_eq!(
1437                err,
1438                AppError::ConfigError(
1439                    "source item references parent directory in group 'source item references parent dir'"
1440                        .to_owned(),
1441                ),
1442                "{}",
1443                err,
1444            );
1445            Ok(())
1446        } else {
1447            Err(eyre!("This config should not be loaded because a source item references parent directory"))
1448        }
1449    }
1450
1451    #[test]
1452    fn source_item_is_absolute() -> Result<(), Report> {
1453        if let Err(err) = DTConfig::from_str(
1454            r#"
1455[[local]]
1456name = "source item is absolute"
1457base = "~"
1458sources = ["/usr/share/gdb-dashboard/.gdbinit"]
1459target = "/tmp""#,
1460        ) {
1461            assert_eq!(
1462                err,
1463                AppError::ConfigError(
1464                    "source array contains absolute path in group 'source item is absolute'"
1465                        .to_owned(),
1466                ),
1467                "{}",
1468                err,
1469            );
1470            Ok(())
1471        } else {
1472            Err(eyre!(
1473                "This config should not be loaded because a source item is an absolute path"
1474            ))
1475        }
1476    }
1477
1478    #[test]
1479    fn except_dot_asterisk_glob() -> Result<(), Report> {
1480        if let Err(err) = DTConfig::from_str(
1481            r#"
1482[[local]]
1483name = "placeholder"
1484base = "~"
1485sources = [".*"]
1486target = ".""#,
1487        ) {
1488            assert_eq!(
1489                err,
1490                AppError::ConfigError("bad globbing pattern".to_owned()),
1491                "{}",
1492                err,
1493            );
1494            Ok(())
1495        } else {
1496            Err(eyre!(
1497                "This config should not be loaded because it contains bad globs (.* and /.*)"
1498            ))
1499        }
1500    }
1501
1502    #[test]
1503    fn source_item_contains_hostname_sep() -> Result<(), Report> {
1504        if let Err(err) = DTConfig::from_str(
1505            r#"
1506[[local]]
1507name = "@@ in source item"
1508base = "~/.config/nvim"
1509sources = ["init.vim@@elbert"]
1510target = ".""#,
1511        ) {
1512            assert_eq!(
1513                err,
1514                AppError::ConfigError(
1515                    "a source item contains hostname_sep (@@) in group '@@ in source item'"
1516                        .to_owned()
1517                ),
1518                "{}",
1519                err,
1520            );
1521            Ok(())
1522        } else {
1523            Err(eyre!(
1524                "This config should not be loaded because a source item contains hostname_sep"
1525            ))
1526        }
1527    }
1528}
1529
1530#[cfg(test)]
1531mod validation_physical {
1532    use std::str::FromStr;
1533
1534    use color_eyre::{eyre::eyre, Report};
1535
1536    use super::DTConfig;
1537    use crate::error::Error as AppError;
1538    use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
1539
1540    #[test]
1541    fn non_existent_relative_staging_and_target() -> Result<(), Report> {
1542        if let Err(err) = DTConfig::from_str(
1543            r#"
1544[global]
1545staging = "staging-882b842397c5b44929b9c5f4e83130c9-dir"
1546
1547[[local]]
1548name = "readable relative non-existent target"
1549base = "base-7f2f7ff8407a330751f13dc5ec86db1b-dir"
1550sources = ["b1db25c31c23950132a44f6faec2005c"]
1551target = "target-ce59cb1aea35e22e43195d4a444ff2e7-dir""#,
1552        ) {
1553            Err(eyre!("Non-existent, relative but readable staging/target path should be loaded without error (got error :'{}')", err))
1554        } else {
1555            Ok(())
1556        }
1557    }
1558
1559    #[test]
1560    fn staging_is_file() -> Result<(), Report> {
1561        let staging_path = prepare_file(
1562            get_testroot("validation_physical")
1563                .join("staging_is_file")
1564                .join("staging-but-file"),
1565            0o644,
1566        )?;
1567        let base = prepare_directory(
1568            get_testroot("validation_physical")
1569                .join("staging_is_file")
1570                .join("base"),
1571            0o755,
1572        )?;
1573        let target = prepare_directory(
1574            get_testroot("validation_physical")
1575                .join("staging_is_file")
1576                .join("target"),
1577            0o755,
1578        )?;
1579
1580        if let Err(err) = DTConfig::from_str(&format!(
1581            r#"
1582[global]
1583staging = "{}"
1584
1585[[local]]
1586name = "staging is file"
1587base = "{}"
1588sources = []
1589target = "{}""#,
1590            staging_path.display(),
1591            base.display(),
1592            target.display(),
1593        )) {
1594            assert_eq!(
1595                err,
1596                AppError::ConfigError(
1597                    "staging root path exists but is not a valid directory".to_owned(),
1598                ),
1599                "{}",
1600                err,
1601            );
1602            Ok(())
1603        } else {
1604            Err(eyre!(
1605                "This config should not be validated because staging is not a directory",
1606            ))
1607        }
1608    }
1609
1610    #[test]
1611    fn staging_readonly() -> Result<(), Report> {
1612        let staging_path = prepare_directory(
1613            get_testroot("validation_physical")
1614                .join("staging_readonly")
1615                .join("staging-but-readonly"),
1616            0o555,
1617        )?;
1618        let base = prepare_directory(
1619            get_testroot("validation_physical")
1620                .join("staging_readonly")
1621                .join("base"),
1622            0o755,
1623        )?;
1624        let target_path = prepare_directory(
1625            get_testroot("validation_physical")
1626                .join("staging_readonly")
1627                .join("target"),
1628            0o755,
1629        )?;
1630
1631        if let Err(err) = DTConfig::from_str(&format!(
1632            r#"
1633[global]
1634staging = "{}/actual_staging_dir"
1635
1636[[local]]
1637name = "staging is readonly"
1638base = "{}"
1639sources = []
1640target = "{}""#,
1641            staging_path.display(),
1642            base.display(),
1643            target_path.display(),
1644        )) {
1645            assert_eq!(
1646                err,
1647                AppError::ConfigError(
1648                    "staging root path cannot be created due to insufficient permissions"
1649                        .to_owned(),
1650                ),
1651                "{}",
1652                err,
1653            );
1654            Ok(())
1655        } else {
1656            Err(eyre!(
1657                "This config should not be validated because staging path is readonly",
1658            ))
1659        }
1660    }
1661    #[test]
1662    fn target_is_file() -> Result<(), Report> {
1663        let target_path = prepare_file(
1664            get_testroot("validation_physical")
1665                .join("target_is_file")
1666                .join("target-but-file"),
1667            0o755,
1668        )?;
1669        if let Err(err) = DTConfig::from_str(&format!(
1670            r#"
1671[[local]]
1672name = "target path is absolute"
1673base = "."
1674sources = []
1675target = "{}""#,
1676            target_path.display(),
1677        )) {
1678            assert_eq!(
1679                err,
1680                AppError::ConfigError(
1681                    "target path exists but is not a valid directory in group 'target path is absolute'"
1682                        .to_owned(),
1683                ),
1684                "{}",
1685                err,
1686            );
1687            Ok(())
1688        } else {
1689            Err(eyre!(
1690                "This config should not be validated because target is not a directory",
1691            ))
1692        }
1693    }
1694
1695    #[test]
1696    fn target_readonly() -> Result<(), Report> {
1697        // setup
1698        let base = prepare_directory(
1699            get_testroot("validation_physical")
1700                .join("target_readonly")
1701                .join("base"),
1702            0o755,
1703        )?;
1704        let target_path = prepare_directory(
1705            get_testroot("validation_physical")
1706                .join("target_readonly")
1707                .join("target-but-readonly"),
1708            0o555,
1709        )?;
1710
1711        if let Err(err) = DTConfig::from_str(&format!(
1712            r#"
1713[[local]]
1714name = "target is readonly"
1715base = "{}"
1716sources = []
1717target = "{}/actual_target_dir""#,
1718            base.display(),
1719            target_path.display(),
1720        )) {
1721            assert_eq!(
1722                err,
1723                AppError::ConfigError(
1724                    "target path cannot be created due to insufficient permissions in group 'target is readonly'"
1725                        .to_owned(),
1726                ),
1727                "{}",
1728                err,
1729            );
1730            Ok(())
1731        } else {
1732            Err(eyre!(
1733                "This config should not be validated because target path is readonly",
1734            ))
1735        }
1736    }
1737
1738    #[test]
1739    fn identical_configured_base_and_target_in_local() -> Result<(), Report> {
1740        // setup
1741        let base = prepare_directory(
1742            get_testroot("validation_physical")
1743                .join("local_group_has_same_base_and_target")
1744                .join("base-and-target"),
1745            0o755,
1746        )?;
1747        let target_path = base.clone();
1748
1749        if let Err(err) = DTConfig::from_str(&format!(
1750            r#"
1751[[local]]
1752name = "same base and target"
1753base = "{}"
1754sources = []
1755target = "{}"
1756"#,
1757            base.display(),
1758            target_path.display(),
1759        )) {
1760            assert_eq!(
1761                err,
1762                AppError::ConfigError(
1763                    "base directory and its target are the same in group 'same base and target'"
1764                        .to_owned(),
1765                ),
1766                "{}",
1767                err,
1768            );
1769            Ok(())
1770        } else {
1771            Err(eyre!(
1772                "This config should not be validated because a local group's base and target are identical"
1773            ))
1774        }
1775    }
1776
1777    #[test]
1778    fn base_unreadable() -> Result<(), Report> {
1779        let base = prepare_file(
1780            get_testroot("validation_physical")
1781                .join("base_unreadable")
1782                .join("base-but-file"),
1783            0o311,
1784        )?;
1785        if let Err(err) = DTConfig::from_str(&format!(
1786            r#"
1787[[local]]
1788name = "base unreadable (not a directory)"
1789base = "{}"
1790sources = []
1791target = ".""#,
1792            base.display(),
1793        )) {
1794            assert_eq!(
1795                err,
1796                AppError::IoError("Not a directory (os error 20)".to_owned(),),
1797                "{}",
1798                err,
1799            );
1800        } else {
1801            return Err(eyre!(
1802                "This config should not be loaded because base is not a directory",
1803            ));
1804        }
1805
1806        let base = prepare_directory(
1807            get_testroot("validation_physical")
1808                .join("base_unreadable")
1809                .join("base-unreadable"),
1810            0o311,
1811        )?;
1812        if let Err(err) = DTConfig::from_str(&format!(
1813            r#"
1814[[local]]
1815name = "base unreadable (permission denied)"
1816base = "{}"
1817sources = []
1818target = ".""#,
1819            base.display(),
1820        )) {
1821            assert_eq!(
1822                err,
1823                AppError::IoError("Permission denied (os error 13)".to_owned(),),
1824                "{}",
1825                err,
1826            );
1827        } else {
1828            return Err(eyre!(
1829                "This config should not be loaded because insufficient permissions to base",
1830            ));
1831        }
1832
1833        Ok(())
1834    }
1835}
1836
1837// Author: Blurgy <gy@blurgy.xyz>
1838// Date:   Sep 21 2021, 01:14 [CST]