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
14pub(crate) fn expand(config: DTConfig) -> Result<DTConfig> {
29 let mut ret = DTConfig {
30 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 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 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 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
93fn 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 .map(|x| {
113 x.unwrap_or_else(|_| panic!("Failed globbing source path '{}'", path.display(),))
114 })
115 .filter(|x| !x.is_for_other_host(hostname_sep))
117 .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 .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(|x| !x.is_for_other_host(hostname_sep))
160 .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
189fn resolve(config: DTConfig) -> Result<DTConfig> {
194 let mut mapping: HashMap<PathBuf, usize> = HashMap::new();
197
198 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 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 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
258fn 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
278pub 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(®istry)) {
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 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