use std::{
collections::HashMap,
path::{Path, PathBuf},
rc::Rc,
};
use crate::{
config::*,
error::{Error as AppError, Result},
item::Operate,
registry::{Register, Registry},
};
pub(crate) fn expand(config: DTConfig) -> Result<DTConfig> {
let mut ret = DTConfig {
global: config.global,
context: config.context,
local: Vec::new(),
remote: Vec::new(),
};
for original in config.local {
let mut next = LocalGroup {
global: Rc::clone(&original.global),
base: original.base.to_owned().absolute()?,
sources: Vec::new(),
target: original.target.to_owned().absolute()?,
..original.to_owned()
};
let group_hostname_sep = original.get_hostname_sep();
let host_specific_base = next.base.to_owned().host_specific(&group_hostname_sep);
if host_specific_base.exists() {
next.base = host_specific_base;
}
let sources: Vec<PathBuf> = original
.sources
.iter()
.map(|s| {
let try_s = next
.base
.join(s)
.absolute()
.unwrap_or_else(|e| panic!("{}", e));
let try_s = try_s.host_specific(&group_hostname_sep);
if try_s.exists() {
try_s
} else {
s.to_owned()
}
})
.collect();
for s in &sources {
let s = next.base.join(s);
let mut s = expand_recursive(&s, &next.get_hostname_sep(), true)?;
next.sources.append(&mut s);
}
next.sources.sort();
next.sources.dedup();
ret.local.push(next);
}
let ret = resolve(ret)?;
check_readable(&ret)?;
Ok(ret)
}
fn expand_recursive(path: &Path, hostname_sep: &str, do_glob: bool) -> Result<Vec<PathBuf>> {
if do_glob {
let globbing_options = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
let initial: Vec<PathBuf> = glob::glob_with(&path.to_string_lossy(), globbing_options)?
.map(|x| {
x.unwrap_or_else(|_| panic!("Failed globbing source path '{}'", path.display(),))
})
.filter(|x| !x.is_for_other_host(hostname_sep))
.map(|x| {
let host_specific_x = x.to_owned().host_specific(hostname_sep);
if host_specific_x.exists() {
host_specific_x
} else {
x
}
})
.map(|x| {
x.to_owned().absolute().unwrap_or_else(|_| {
panic!("Failed converting to absolute path '{}'", x.display(),)
})
})
.collect();
if initial.is_empty() {
log::warn!("'{}' did not match anything", path.display());
}
let mut ret: Vec<PathBuf> = Vec::new();
for p in initial {
if p.is_file() {
ret.push(p);
} else if p.is_dir() {
ret.append(&mut expand_recursive(&p, hostname_sep, false)?);
} else {
log::warn!("Skipping unimplemented file type at '{}'", p.display(),);
log::trace!("{:#?}", p.symlink_metadata()?);
}
}
Ok(ret)
} else {
let initial: Vec<PathBuf> = std::fs::read_dir(path)?
.map(|x| {
x.unwrap_or_else(|_| panic!("Cannot read dir '{}' properly", path.display()))
.path()
})
.filter(|x| !x.is_for_other_host(hostname_sep))
.map(|x| {
let host_specific_x = x.to_owned().host_specific(hostname_sep);
if host_specific_x.exists() {
host_specific_x
} else {
x
}
})
.collect();
let mut ret: Vec<PathBuf> = Vec::new();
for p in initial {
if p.is_file() {
ret.push(p);
} else if p.is_dir() {
ret.append(&mut expand_recursive(&p, hostname_sep, false)?);
} else {
log::warn!("Skipping unimplemented file type at '{}'", p.display(),);
log::trace!("{:#?}", p.symlink_metadata()?);
}
}
Ok(ret)
}
}
fn resolve(config: DTConfig) -> Result<DTConfig> {
let mut mapping: HashMap<PathBuf, usize> = HashMap::new();
for i in 0..config.local.len() {
let current_priority = &config.local[i].scope;
for s in &config.local[i].sources {
let t = s.to_owned().make_target(
&config.local[i].get_hostname_sep(),
&config.local[i].base,
&config.local[i].target,
config.local[i].get_renaming_rules(),
)?;
match mapping.get(&t) {
Some(prev_group_idx) => {
let prev_priority = &config.local[*prev_group_idx].scope;
if current_priority > prev_priority {
mapping.insert(t, i);
}
}
None => {
mapping.insert(t, i);
}
}
}
}
Ok(DTConfig {
local: config
.local
.iter()
.enumerate()
.map(|(cur_id, group)| LocalGroup {
sources: group
.sources
.iter()
.filter(|&s| {
let t = s
.to_owned()
.make_target(
&group.get_hostname_sep(),
&group.base,
&group.target,
group.get_renaming_rules(),
)
.unwrap();
let best_id = *mapping.get(&t).unwrap();
best_id == cur_id
})
.map(|s| s.to_owned())
.collect(),
..group.to_owned()
})
.collect(),
..config
})
}
fn check_readable(config: &DTConfig) -> Result<()> {
for group in &config.local {
for s in &group.sources {
if std::fs::File::open(s).is_err() {
return Err(AppError::IoError(format!(
"'{}' is not readable in group '{}'",
s.display(),
group.name,
)));
}
if !s.is_file() {
unreachable!();
}
}
}
Ok(())
}
pub fn sync(config: DTConfig, dry_run: bool) -> Result<()> {
if config.local.is_empty() {
log::warn!("Nothing to be synced");
return Ok(());
}
log::trace!("Local groups to process: {:#?}", config.local);
let config = expand(config)?;
let registry = Rc::new(Registry::default().register_helpers()?.load(&config)?);
for group in &config.local {
log::info!("Local group: [{}]", group.name);
if group.sources.is_empty() {
log::debug!("Group [{}]: skipping due to empty group", group.name,);
continue;
} else {
log::debug!(
"Group [{}]: {} {} detected",
group.name,
group.sources.len(),
if group.sources.len() <= 1 {
"item"
} else {
"items"
},
);
}
let group_ref = Rc::new(group.to_owned());
for spath in &group.sources {
if dry_run {
if let Err(e) = spath.populate_dry(Rc::clone(&group_ref)) {
if group.is_failure_ignored() {
log::warn!("Error ignored: {}", e);
} else {
return Err(e);
}
}
} else {
#[allow(clippy::collapsible_else_if)]
if let Err(e) = spath.populate(Rc::clone(&group_ref), Rc::clone(®istry)) {
if group.is_failure_ignored() {
log::warn!("Error ignored: {}", e);
} else {
return Err(e);
}
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
mod validation {
use std::str::FromStr;
use color_eyre::{eyre::eyre, Report};
use pretty_assertions::assert_eq;
use crate::config::DTConfig;
use crate::error::Error as AppError;
use super::super::expand;
use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
#[test]
fn unreadable_source() -> Result<(), Report> {
let source_basename = "src-file-but-unreadable";
let base = prepare_directory(
get_testroot("syncing")
.join("unreadable_source")
.join("base"),
0o755,
)?;
let _source_path = prepare_file(base.join(source_basename), 0o200)?;
let target_path = prepare_directory(
get_testroot("syncing")
.join("unreadable_source")
.join("target"),
0o755,
)?;
if let Err(err) = expand(
DTConfig::from_str(&format!(
r#"
[[local]]
name = "source is unreadable"
base = "{}"
sources = ["{}"]
target = "{}""#,
base.display(),
source_basename,
target_path.display(),
))
.unwrap(),
) {
assert_eq!(
err,
AppError::IoError(
"'/tmp/dt-testing/syncing/unreadable_source/base/src-file-but-unreadable' is not readable in group 'source is unreadable'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because source item is not readable"
))
}
}
}
mod expansion {
use std::{path::PathBuf, str::FromStr};
use color_eyre::Report;
use pretty_assertions::assert_eq;
use crate::{config::*, item::Operate};
use super::super::expand;
use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
#[test]
fn glob() -> Result<(), Report> {
let target_path =
prepare_directory(get_testroot("syncing").join("glob").join("target"), 0o755)?;
let config = expand(
DTConfig::from_str(&format!(
r#"
[[local]]
name = "globbing test"
base = ".."
sources = ["dt-c*"]
target = "{}""#,
target_path.display(),
))
.unwrap(),
)?;
for group in &config.local {
assert_eq!(
group.sources,
vec![
PathBuf::from_str("../dt-cli/Cargo.toml")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-cli/README.md")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-cli/src/main.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/Cargo.toml")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/README.md")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/config.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/error.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/item.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/lib.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/registry.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/syncing.rs")
.unwrap()
.absolute()?,
PathBuf::from_str("../dt-core/src/utils.rs")
.unwrap()
.absolute()?,
],
);
}
Ok(())
}
#[test]
fn sorting_and_deduping() -> Result<(), Report> {
println!("Creating base ..");
let base_path = prepare_directory(
get_testroot("syncing")
.join("sorting_and_deduping")
.join("base"),
0o755,
)?;
println!("Creating target ..");
let target_path = prepare_directory(
get_testroot("syncing")
.join("sorting_and_deduping")
.join("target"),
0o755,
)?;
for f in ["A-a", "A-b", "A-c", "B-a", "B-b", "B-c"] {
println!("Creating source {} ..", f);
prepare_file(base_path.join(f), 0o644)?;
}
let config = expand(
DTConfig::from_str(&format!(
r#"
[[local]]
name = "sorting and deduping"
base = "{}"
sources = ["B-*", "*-c", "A-b", "A-a"]
target = "{}""#,
base_path.display(),
target_path.display(),
))
.unwrap(),
)?;
for group in config.local {
assert_eq!(
group.sources,
vec![
base_path.join("A-a"),
base_path.join("A-b"),
base_path.join("A-c"),
base_path.join("B-a"),
base_path.join("B-b"),
base_path.join("B-c"),
],
);
}
Ok(())
}
}
mod priority_resolving {
use std::str::FromStr;
use crate::{config::*, error::*, syncing::expand};
#[test]
fn proper_priority_orders() -> Result<()> {
assert!(DTScope::Dropin > DTScope::App);
assert!(DTScope::App > DTScope::General);
assert!(DTScope::Dropin > DTScope::General);
assert!(DTScope::App < DTScope::Dropin);
assert!(DTScope::General < DTScope::App);
assert!(DTScope::General < DTScope::Dropin);
Ok(())
}
#[test]
fn former_group_has_higher_priority_within_same_scope() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "highest"
# Scope is omitted to use default scope (i.e. General)
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "low"
# Scope is omitted to use default scope (i.e. General)
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(!config.local[0].sources.is_empty());
assert!(config.local[1].sources.is_empty());
Ok(())
}
#[test]
fn dropin_has_highest_priority() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "lowest"
scope = "General"
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "medium"
scope = "App"
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "highest"
scope = "Dropin"
base = ".."
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(config.local[0].sources.is_empty());
assert!(config.local[1].sources.is_empty());
assert!(!config.local[2].sources.is_empty());
Ok(())
}
#[test]
fn app_has_medium_priority() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "lowest"
scope = "General"
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "medium"
scope = "App"
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(config.local[0].sources.is_empty());
assert!(!config.local[1].sources.is_empty());
Ok(())
}
#[test]
fn default_scope_is_general() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "omitted scope but defined first, has higher priority"
# Scope is omitted to use default scope (i.e. General)
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "specified scope but defined last, has lower priority"
scope = "General"
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(!config.local[0].sources.is_empty());
assert!(config.local[1].sources.is_empty());
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "omitted scope, uses general"
# Scope is omitted to use default scope (i.e. General)
base = ".."
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "specified scope with higher priority"
scope = "App"
base = ".."
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(config.local[0].sources.is_empty());
assert!(!config.local[1].sources.is_empty());
Ok(())
}
#[test]
fn duplicated_item_same_name_same_scope() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "dup"
scope = "General"
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "dup"
scope = "General"
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(!config.local[0].sources.is_empty());
assert!(config.local[1].sources.is_empty());
Ok(())
}
#[test]
fn duplicated_item_same_name_different_scope() -> Result<()> {
let config = expand(DTConfig::from_str(
r#"
[[local]]
name = "dup"
scope = "General"
base = "../dt-cli"
sources = ["Cargo.toml"]
target = "."
[[local]]
name = "dup"
scope = "App"
base = "../dt-server"
sources = ["Cargo.toml"]
target = "."
"#,
)?)?;
assert!(config.local[0].sources.is_empty());
assert!(!config.local[1].sources.is_empty());
Ok(())
}
}
}