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]