use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::converge::Entry;
pub const REFUSED: [&str; 1] = [".git"];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
#[serde(default)]
pub include_ignored: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
impl Policy {
pub fn agrees_with(&self, other: &Self) -> Result<(), String> {
let mine = self.normalised();
let theirs = other.normalised();
if mine == theirs {
return Ok(());
}
let (a, b) = (mine, theirs);
let mut differences = Vec::new();
for (name, here, there) in [
("include_ignored", &a.include_ignored, &b.include_ignored),
("exclude", &a.exclude, &b.exclude),
] {
if here != there {
differences.push(format!(
"{name}: here [{}], on the peer [{}]",
here.join(" "),
there.join(" ")
));
}
}
Err(differences.join("; "))
}
fn normalised(&self) -> Self {
let clean = |values: &Vec<String>| -> Vec<String> {
let mut out: Vec<String> = values
.iter()
.map(|v| v.trim().trim_matches('/').replace('\\', "/"))
.filter(|v| !v.is_empty())
.collect();
out.sort();
out.dedup();
out
};
Self {
include_ignored: clean(&self.include_ignored),
exclude: clean(&self.exclude),
}
}
fn covers(prefixes: &[String], relative: &str) -> bool {
prefixes.iter().any(|prefix| {
let prefix = prefix.trim_matches('/');
relative == prefix
|| relative.starts_with(&format!("{prefix}/"))
})
}
}
pub fn is_refused(relative: &Path) -> bool {
relative.components().any(|component| match component {
Component::Normal(name) => REFUSED.iter().any(|refused| name == *refused),
Component::CurDir => false,
_ => true,
})
}
pub type Set = BTreeMap<String, Entry>;
pub fn read(workdir: &Path, policy: &Policy) -> Result<Set, String> {
let mut set = Set::new();
for relative in members(workdir, policy)? {
let absolute = workdir.join(&relative);
let Some(entry) = Entry::read(&absolute)? else {
continue;
};
set.insert(relative, entry);
}
Ok(set)
}
fn members(workdir: &Path, policy: &Policy) -> Result<Vec<String>, String> {
let policy = policy.normalised();
let mut out = Vec::new();
for relative in crate::git_backend::tracked_paths(workdir)? {
out.push(relative);
}
let mut walker = ignore::WalkBuilder::new(workdir);
walker
.hidden(false)
.follow_links(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|entry| {
let refused_name = entry
.path()
.file_name()
.map(|name| REFUSED.iter().any(|refused| name == *refused))
.unwrap_or(false);
if refused_name {
return false;
}
if entry.depth() > 0 && entry.file_type().is_some_and(|kind| kind.is_dir()) {
return !entry.path().join(".git").exists();
}
true
});
for entry in walker.build() {
let entry = entry.map_err(|e| format!("cannot walk {}: {e}", workdir.display()))?;
if entry.depth() == 0 || entry.file_type().is_some_and(|t| t.is_dir()) {
continue;
}
let Ok(relative) = entry.path().strip_prefix(workdir) else {
continue;
};
out.push(to_slash(relative));
}
for prefix in &policy.include_ignored {
collect_ignored(workdir, prefix, &mut out)?;
}
out.sort();
out.dedup();
out.retain(|relative| {
!is_refused(Path::new(relative)) && !Policy::covers(&policy.exclude, relative)
});
Ok(out)
}
fn collect_ignored(workdir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<(), String> {
let root = workdir.join(prefix);
if !root.exists() {
return Ok(());
}
if root.is_file() {
out.push(prefix.to_string());
return Ok(());
}
for entry in walkdir::WalkDir::new(&root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
entry
.file_name()
.to_str()
.map(|name| !REFUSED.contains(&name))
.unwrap_or(false)
})
{
let entry = entry.map_err(|e| format!("cannot walk {}: {e}", root.display()))?;
if !entry.file_type().is_file() && !entry.file_type().is_symlink() {
continue;
}
let Ok(relative) = entry.path().strip_prefix(workdir) else {
continue;
};
out.push(to_slash(relative));
}
Ok(())
}
pub fn to_slash(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
pub fn local_path(workdir: &Path, wire: &str) -> Result<PathBuf, String> {
if wire.is_empty() {
return Err("an empty path is not a file".to_string());
}
if wire.contains('\\') {
return Err(format!("'{wire}' is not a portable path"));
}
let relative = Path::new(wire);
if is_refused(relative) || relative.is_absolute() {
return Err(format!("'{wire}' may not be synchronised"));
}
let mut out = workdir.to_path_buf();
for component in relative.components() {
match component {
Component::Normal(name) => out.push(name),
_ => return Err(format!("'{wire}' is not a path inside the repository")),
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyDigest {
pub include_ignored: Vec<String>,
pub exclude: Vec<String>,
}
impl From<&Policy> for PolicyDigest {
fn from(policy: &Policy) -> Self {
let normalised = policy.normalised();
Self {
include_ignored: normalised.include_ignored,
exclude: normalised.exclude,
}
}
}
#[cfg(test)]
#[path = "scan_tests.rs"]
mod tests;