dt_core/
syncing.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    rc::Rc,
5};
6
7use crate::{
8    config::*,
9    error::{Error as AppError, Result},
10    item::Operate,
11    registry::{Register, Registry},
12};
13
14/// Expands tildes and globs in [`sources`], returns the updated config
15/// object.
16///
17/// It does the following operations on given config:
18///
19/// 1. Convert all [`base`]s, [`target`]s to absolute paths.
20/// 2. Replace [`base`]s and paths in [`sources`] with their host-specific
21///    counterpart, if there exists any.
22/// 3. Recursively expand globs and directories found in [`sources`].
23///
24/// [`sources`]: crate::config::Group::sources
25/// [`global.staging`]: crate::config::GlobalConfig::staging
26/// [`base`]: crate::config::Group::base
27/// [`target`]: crate::config::Group::target
28pub(crate) fn expand(config: DTConfig) -> Result<DTConfig> {
29    let mut ret = DTConfig {
30        // Remove `global` and `context` in expanded configuration object.
31        // Further references of these two values are referenced via Rc from
32        // within groups.
33        global: config.global,
34        context: config.context,
35        local: Vec::new(),
36        remote: Vec::new(),
37    };
38
39    for original in config.local {
40        let mut next = LocalGroup {
41            global: Rc::clone(&original.global),
42            base: original.base.to_owned().absolute()?,
43            sources: Vec::new(),
44            target: original.target.to_owned().absolute()?,
45            ..original.to_owned()
46        };
47
48        let group_hostname_sep = original.get_hostname_sep();
49
50        // Check for host-specific `base`
51        let host_specific_base = next.base.to_owned().host_specific(&group_hostname_sep);
52        if host_specific_base.exists() {
53            next.base = host_specific_base;
54        }
55
56        // Check for host-specific `sources`
57        let sources: Vec<PathBuf> = original
58            .sources
59            .iter()
60            .map(|s| {
61                let try_s = next
62                    .base
63                    .join(s)
64                    .absolute()
65                    .unwrap_or_else(|e| panic!("{}", e));
66                let try_s = try_s.host_specific(&group_hostname_sep);
67                if try_s.exists() {
68                    try_s
69                } else {
70                    s.to_owned()
71                }
72            })
73            .collect();
74
75        // Recursively expand source paths
76        for s in &sources {
77            let s = next.base.join(s);
78            let mut s = expand_recursive(&s, &next.get_hostname_sep(), true)?;
79            next.sources.append(&mut s);
80        }
81        next.sources.sort();
82        next.sources.dedup();
83        ret.local.push(next);
84    }
85
86    let ret = resolve(ret)?;
87
88    check_readable(&ret)?;
89
90    Ok(ret)
91}
92
93/// Recursively expands glob from a given path.
94///
95/// - If `do_glob` is `true`, tries to expand glob;
96/// - If `do_glob` is `false`, `path` must be a directory, then children of `path` are recursively
97///   expanded.
98///
99/// Returns a [`Vec`] of the expanded paths.
100///
101/// [`Vec`]: Vec
102fn expand_recursive(path: &Path, hostname_sep: &str, do_glob: bool) -> Result<Vec<PathBuf>> {
103    if do_glob {
104        let globbing_options = glob::MatchOptions {
105            case_sensitive: true,
106            require_literal_separator: true,
107            require_literal_leading_dot: true,
108        };
109
110        let initial: Vec<PathBuf> = glob::glob_with(&path.to_string_lossy(), globbing_options)?
111            // Extract value from Result<PathBuf>
112            .map(|x| {
113                x.unwrap_or_else(|_| panic!("Failed globbing source path '{}'", path.display(),))
114            })
115            // Filter out paths that are meant for other hosts
116            .filter(|x| !x.is_for_other_host(hostname_sep))
117            // **After** filtering out paths that are meant for other
118            // hosts, replace current path to its host-specific
119            // counterpart if it exists.
120            .map(|x| {
121                let host_specific_x = x.to_owned().host_specific(hostname_sep);
122                if host_specific_x.exists() {
123                    host_specific_x
124                } else {
125                    x
126                }
127            })
128            // Convert to absolute paths
129            .map(|x| {
130                x.to_owned().absolute().unwrap_or_else(|_| {
131                    panic!("Failed converting to absolute path '{}'", x.display(),)
132                })
133            })
134            .collect();
135        if initial.is_empty() {
136            log::warn!("'{}' did not match anything", path.display());
137        }
138
139        let mut ret: Vec<PathBuf> = Vec::new();
140        for p in initial {
141            if p.is_file() {
142                ret.push(p);
143            } else if p.is_dir() {
144                ret.append(&mut expand_recursive(&p, hostname_sep, false)?);
145            } else {
146                log::warn!("Skipping unimplemented file type at '{}'", p.display(),);
147                log::trace!("{:#?}", p.symlink_metadata()?);
148            }
149        }
150
151        Ok(ret)
152    } else {
153        let initial: Vec<PathBuf> = std::fs::read_dir(path)?
154            .map(|x| {
155                x.unwrap_or_else(|_| panic!("Cannot read dir '{}' properly", path.display()))
156                    .path()
157            })
158            // Filter out paths that are meant for other hosts
159            .filter(|x| !x.is_for_other_host(hostname_sep))
160            // **After** filtering out paths that are meant for other
161            // hosts, replace current path to its host-specific
162            // counterpart if it exists.
163            .map(|x| {
164                let host_specific_x = x.to_owned().host_specific(hostname_sep);
165                if host_specific_x.exists() {
166                    host_specific_x
167                } else {
168                    x
169                }
170            })
171            .collect();
172
173        let mut ret: Vec<PathBuf> = Vec::new();
174        for p in initial {
175            if p.is_file() {
176                ret.push(p);
177            } else if p.is_dir() {
178                ret.append(&mut expand_recursive(&p, hostname_sep, false)?);
179            } else {
180                log::warn!("Skipping unimplemented file type at '{}'", p.display(),);
181                log::trace!("{:#?}", p.symlink_metadata()?);
182            }
183        }
184
185        Ok(ret)
186    }
187}
188
189/// Resolve priorities within expanded [`DTConfig`], this function is called
190/// after [`expand`] so that it can correctly resolve priorities of all
191/// expanded sources, and before [`check_readable`], since it does not have to
192/// query the filesystem.
193fn resolve(config: DTConfig) -> Result<DTConfig> {
194    // Maps an item to the index of the group which holds the highest priority
195    // of it.
196    let mut mapping: HashMap<PathBuf, usize> = HashMap::new();
197
198    // Get each item's highest priority group.
199    for i in 0..config.local.len() {
200        let current_priority = &config.local[i].scope;
201        for s in &config.local[i].sources {
202            let t = s.to_owned().make_target(
203                &config.local[i].get_hostname_sep(),
204                &config.local[i].base,
205                &config.local[i].target,
206                config.local[i].get_renaming_rules(),
207            )?;
208            match mapping.get(&t) {
209                Some(prev_group_idx) => {
210                    let prev_priority = &config.local[*prev_group_idx].scope;
211                    // Only replace group index when current group has
212                    // strictly higher priority than previous group, thus
213                    // achieving "former defined groups of the same scope have
214                    // higher priority" effect.
215                    if current_priority > prev_priority {
216                        mapping.insert(t, i);
217                    }
218                }
219                None => {
220                    mapping.insert(t, i);
221                }
222            }
223        }
224    }
225
226    // Remove redundant groups.
227    Ok(DTConfig {
228        local: config
229            .local
230            .iter()
231            .enumerate()
232            .map(|(cur_id, group)| LocalGroup {
233                sources: group
234                    .sources
235                    .iter()
236                    .filter(|&s| {
237                        let t = s
238                            .to_owned()
239                            .make_target(
240                                &group.get_hostname_sep(),
241                                &group.base,
242                                &group.target,
243                                group.get_renaming_rules(),
244                            )
245                            .unwrap();
246                        let best_id = *mapping.get(&t).unwrap();
247                        best_id == cur_id
248                    })
249                    .map(|s| s.to_owned())
250                    .collect(),
251                ..group.to_owned()
252            })
253            .collect(),
254        ..config
255    })
256}
257
258/// Checks validity of the given [DTConfig].
259fn check_readable(config: &DTConfig) -> Result<()> {
260    for group in &config.local {
261        for s in &group.sources {
262            if std::fs::File::open(s).is_err() {
263                return Err(AppError::IoError(format!(
264                    "'{}' is not readable in group '{}'",
265                    s.display(),
266                    group.name,
267                )));
268            }
269            if !s.is_file() {
270                unreachable!();
271            }
272        }
273    }
274
275    Ok(())
276}
277
278/// Syncs items specified with given [DTConfig].
279pub fn sync(config: DTConfig, dry_run: bool) -> Result<()> {
280    if config.local.is_empty() {
281        log::warn!("Nothing to be synced");
282        return Ok(());
283    }
284    log::trace!("Local groups to process: {:#?}", config.local);
285
286    let config = expand(config)?;
287    let registry = Rc::new(Registry::default().register_helpers()?.load(&config)?);
288
289    for group in &config.local {
290        log::info!("Local group: [{}]", group.name);
291        if group.sources.is_empty() {
292            log::debug!("Group [{}]: skipping due to empty group", group.name,);
293            continue;
294        } else {
295            log::debug!(
296                "Group [{}]: {} {} detected",
297                group.name,
298                group.sources.len(),
299                if group.sources.len() <= 1 {
300                    "item"
301                } else {
302                    "items"
303                },
304            );
305        }
306
307        let group_ref = Rc::new(group.to_owned());
308        for spath in &group.sources {
309            if dry_run {
310                if let Err(e) = spath.populate_dry(Rc::clone(&group_ref)) {
311                    if group.is_failure_ignored() {
312                        log::warn!("Error ignored: {}", e);
313                    } else {
314                        return Err(e);
315                    }
316                }
317            } else {
318                #[allow(clippy::collapsible_else_if)]
319                if let Err(e) = spath.populate(Rc::clone(&group_ref), Rc::clone(&registry)) {
320                    if group.is_failure_ignored() {
321                        log::warn!("Error ignored: {}", e);
322                    } else {
323                        return Err(e);
324                    }
325                }
326            }
327        }
328    }
329    Ok(())
330}
331
332#[cfg(test)]
333mod tests {
334    mod validation {
335        use std::str::FromStr;
336
337        use color_eyre::{eyre::eyre, Report};
338        use pretty_assertions::assert_eq;
339
340        use crate::config::DTConfig;
341        use crate::error::Error as AppError;
342
343        use super::super::expand;
344        use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
345
346        #[test]
347        fn unreadable_source() -> Result<(), Report> {
348            // setup
349            let source_basename = "src-file-but-unreadable";
350            let base = prepare_directory(
351                get_testroot("syncing")
352                    .join("unreadable_source")
353                    .join("base"),
354                0o755,
355            )?;
356            let _source_path = prepare_file(base.join(source_basename), 0o200)?;
357            let target_path = prepare_directory(
358                get_testroot("syncing")
359                    .join("unreadable_source")
360                    .join("target"),
361                0o755,
362            )?;
363
364            if let Err(err) = expand(
365                DTConfig::from_str(&format!(
366                    r#"
367[[local]]
368name = "source is unreadable"
369base = "{}"
370sources = ["{}"]
371target = "{}""#,
372                    base.display(),
373                    source_basename,
374                    target_path.display(),
375                ))
376                .unwrap(),
377            ) {
378                assert_eq!(
379                err,
380                AppError::IoError(
381                    "'/tmp/dt-testing/syncing/unreadable_source/base/src-file-but-unreadable' is not readable in group 'source is unreadable'"
382                        .to_owned(),
383                ),
384                "{}",
385                err,
386            );
387                Ok(())
388            } else {
389                Err(eyre!(
390                    "This config should not be loaded because source item is not readable"
391                ))
392            }
393        }
394    }
395
396    mod expansion {
397        use std::{path::PathBuf, str::FromStr};
398
399        use color_eyre::Report;
400        use pretty_assertions::assert_eq;
401
402        use crate::{config::*, item::Operate};
403
404        use super::super::expand;
405        use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
406
407        #[test]
408        fn glob() -> Result<(), Report> {
409            let target_path =
410                prepare_directory(get_testroot("syncing").join("glob").join("target"), 0o755)?;
411
412            let config = expand(
413                DTConfig::from_str(&format!(
414                    r#"
415[[local]]
416name = "globbing test"
417base = ".."
418sources = ["dt-c*"]
419target = "{}""#,
420                    target_path.display(),
421                ))
422                .unwrap(),
423            )?;
424            for group in &config.local {
425                assert_eq!(
426                    group.sources,
427                    vec![
428                        PathBuf::from_str("../dt-cli/Cargo.toml")
429                            .unwrap()
430                            .absolute()?,
431                        PathBuf::from_str("../dt-cli/README.md")
432                            .unwrap()
433                            .absolute()?,
434                        PathBuf::from_str("../dt-cli/src/main.rs")
435                            .unwrap()
436                            .absolute()?,
437                        PathBuf::from_str("../dt-core/Cargo.toml")
438                            .unwrap()
439                            .absolute()?,
440                        PathBuf::from_str("../dt-core/README.md")
441                            .unwrap()
442                            .absolute()?,
443                        PathBuf::from_str("../dt-core/src/config.rs")
444                            .unwrap()
445                            .absolute()?,
446                        PathBuf::from_str("../dt-core/src/error.rs")
447                            .unwrap()
448                            .absolute()?,
449                        PathBuf::from_str("../dt-core/src/item.rs")
450                            .unwrap()
451                            .absolute()?,
452                        PathBuf::from_str("../dt-core/src/lib.rs")
453                            .unwrap()
454                            .absolute()?,
455                        PathBuf::from_str("../dt-core/src/registry.rs")
456                            .unwrap()
457                            .absolute()?,
458                        PathBuf::from_str("../dt-core/src/syncing.rs")
459                            .unwrap()
460                            .absolute()?,
461                        PathBuf::from_str("../dt-core/src/utils.rs")
462                            .unwrap()
463                            .absolute()?,
464                    ],
465                );
466            }
467            Ok(())
468        }
469
470        #[test]
471        fn sorting_and_deduping() -> Result<(), Report> {
472            println!("Creating base ..");
473            let base_path = prepare_directory(
474                get_testroot("syncing")
475                    .join("sorting_and_deduping")
476                    .join("base"),
477                0o755,
478            )?;
479            println!("Creating target ..");
480            let target_path = prepare_directory(
481                get_testroot("syncing")
482                    .join("sorting_and_deduping")
483                    .join("target"),
484                0o755,
485            )?;
486            for f in ["A-a", "A-b", "A-c", "B-a", "B-b", "B-c"] {
487                println!("Creating source {} ..", f);
488                prepare_file(base_path.join(f), 0o644)?;
489            }
490
491            let config = expand(
492                DTConfig::from_str(&format!(
493                    r#"
494[[local]]
495name = "sorting and deduping"
496base = "{}"
497sources = ["B-*", "*-c", "A-b", "A-a"]
498target = "{}""#,
499                    base_path.display(),
500                    target_path.display(),
501                ))
502                .unwrap(),
503            )?;
504            for group in config.local {
505                assert_eq!(
506                    group.sources,
507                    vec![
508                        base_path.join("A-a"),
509                        base_path.join("A-b"),
510                        base_path.join("A-c"),
511                        base_path.join("B-a"),
512                        base_path.join("B-b"),
513                        base_path.join("B-c"),
514                    ],
515                );
516            }
517            Ok(())
518        }
519    }
520
521    mod priority_resolving {
522        use std::str::FromStr;
523
524        use crate::{config::*, error::*, syncing::expand};
525
526        #[test]
527        fn proper_priority_orders() -> Result<()> {
528            assert!(DTScope::Dropin > DTScope::App);
529            assert!(DTScope::App > DTScope::General);
530            assert!(DTScope::Dropin > DTScope::General);
531
532            assert!(DTScope::App < DTScope::Dropin);
533            assert!(DTScope::General < DTScope::App);
534            assert!(DTScope::General < DTScope::Dropin);
535
536            Ok(())
537        }
538
539        #[test]
540        fn former_group_has_higher_priority_within_same_scope() -> Result<()> {
541            let config = expand(DTConfig::from_str(
542                r#"
543                [[local]]
544                name = "highest"
545                # Scope is omitted to use default scope (i.e. General)
546                base = "../dt-cli"
547                sources = ["Cargo.toml"]
548                target = "."
549                [[local]]
550                name = "low"
551                # Scope is omitted to use default scope (i.e. General)
552                base = "../dt-server"
553                sources = ["Cargo.toml"]
554                target = "."
555        "#,
556            )?)?;
557
558            assert!(!config.local[0].sources.is_empty());
559            assert!(config.local[1].sources.is_empty());
560
561            Ok(())
562        }
563
564        #[test]
565        fn dropin_has_highest_priority() -> Result<()> {
566            let config = expand(DTConfig::from_str(
567                r#"
568                [[local]]
569                name = "lowest"
570                scope = "General"
571                base = "../dt-cli"
572                sources = ["Cargo.toml"]
573                target = "."
574                [[local]]
575                name = "medium"
576                scope = "App"
577                base = "../dt-server"
578                sources = ["Cargo.toml"]
579                target = "."
580                [[local]]
581                name = "highest"
582                scope = "Dropin"
583                base = ".."
584                sources = ["Cargo.toml"]
585                target = "."
586            "#,
587            )?)?;
588
589            assert!(config.local[0].sources.is_empty());
590            assert!(config.local[1].sources.is_empty());
591            assert!(!config.local[2].sources.is_empty());
592
593            Ok(())
594        }
595
596        #[test]
597        fn app_has_medium_priority() -> Result<()> {
598            let config = expand(DTConfig::from_str(
599                r#"
600                [[local]]
601                name = "lowest"
602                scope = "General"
603                base = "../dt-cli"
604                sources = ["Cargo.toml"]
605                target = "."
606                [[local]]
607                name = "medium"
608                scope = "App"
609                base = "../dt-server"
610                sources = ["Cargo.toml"]
611                target = "."
612            "#,
613            )?)?;
614
615            assert!(config.local[0].sources.is_empty());
616            assert!(!config.local[1].sources.is_empty());
617
618            Ok(())
619        }
620
621        #[test]
622        fn default_scope_is_general() -> Result<()> {
623            let config = expand(DTConfig::from_str(
624                r#"
625                [[local]]
626                name = "omitted scope but defined first, has higher priority"
627                # Scope is omitted to use default scope (i.e. General)
628                base = "../dt-cli"
629                sources = ["Cargo.toml"]
630                target = "."
631                [[local]]
632                name = "specified scope but defined last, has lower priority"
633                scope = "General"
634                base = "../dt-server"
635                sources = ["Cargo.toml"]
636                target = "."
637            "#,
638            )?)?;
639
640            assert!(!config.local[0].sources.is_empty());
641            assert!(config.local[1].sources.is_empty());
642
643            let config = expand(DTConfig::from_str(
644                r#"
645                [[local]]
646                name = "omitted scope, uses general"
647                # Scope is omitted to use default scope (i.e. General)
648                base = ".."
649                sources = ["Cargo.toml"]
650                target = "."
651                [[local]]
652                name = "specified scope with higher priority"
653                scope = "App"
654                base = ".."
655                sources = ["Cargo.toml"]
656                target = "."
657            "#,
658            )?)?;
659
660            assert!(config.local[0].sources.is_empty());
661            assert!(!config.local[1].sources.is_empty());
662
663            Ok(())
664        }
665
666        #[test]
667        fn duplicated_item_same_name_same_scope() -> Result<()> {
668            let config = expand(DTConfig::from_str(
669                r#"
670                [[local]]
671                name = "dup"
672                scope = "General"
673                base = "../dt-cli"
674                sources = ["Cargo.toml"]
675                target = "."
676                [[local]]
677                name = "dup"
678                scope = "General"
679                base = "../dt-server"
680                sources = ["Cargo.toml"]
681                target = "."
682            "#,
683            )?)?;
684
685            assert!(!config.local[0].sources.is_empty());
686            assert!(config.local[1].sources.is_empty());
687
688            Ok(())
689        }
690
691        #[test]
692        fn duplicated_item_same_name_different_scope() -> Result<()> {
693            let config = expand(DTConfig::from_str(
694                r#"
695                [[local]]
696                name = "dup"
697                scope = "General"
698                base = "../dt-cli"
699                sources = ["Cargo.toml"]
700                target = "."
701                [[local]]
702                name = "dup"
703                scope = "App"
704                base = "../dt-server"
705                sources = ["Cargo.toml"]
706                target = "."
707            "#,
708            )?)?;
709
710            assert!(config.local[0].sources.is_empty());
711            assert!(!config.local[1].sources.is_empty());
712
713            Ok(())
714        }
715    }
716}
717
718// Author: Blurgy <gy@blurgy.xyz>
719// Date:   Sep 23 2021, 00:05 [CST]