use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File = 0,
Dir = 1,
}
impl EntryKind {
pub(crate) fn from_u8(v: u8) -> Option<EntryKind> {
match v {
0 => Some(EntryKind::File),
1 => Some(EntryKind::Dir),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub rel_path: String,
pub kind: EntryKind,
pub size: u64,
pub mtime_secs: u64,
pub mtime_nanos: u32,
pub mode: u32,
}
impl TreeEntry {
pub fn from_metadata(rel_path: String, kind: EntryKind, meta: &std::fs::Metadata) -> Self {
let mtime = meta.modified().ok();
let (mtime_secs, mtime_nanos) = mtime
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| (d.as_secs(), d.subsec_nanos()))
.unwrap_or((0, 0));
Self {
rel_path,
kind,
size: if kind == EntryKind::File {
meta.len()
} else {
0
},
mtime_secs,
mtime_nanos,
mode: metadata_mode(meta),
}
}
}
#[cfg(unix)]
fn metadata_mode(meta: &std::fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o7777
}
#[cfg(not(unix))]
fn metadata_mode(_meta: &std::fs::Metadata) -> u32 {
0
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeManifest {
entries: BTreeMap<String, TreeEntry>,
}
impl TreeManifest {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, entry: TreeEntry) {
self.entries.insert(entry.rel_path.clone(), entry);
}
pub fn get(&self, rel_path: &str) -> Option<&TreeEntry> {
self.entries.get(rel_path)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> impl Iterator<Item = &TreeEntry> {
self.entries.values()
}
pub fn total_file_bytes(&self) -> u64 {
self.entries
.values()
.filter(|e| e.kind == EntryKind::File)
.map(|e| e.size)
.sum()
}
pub fn scan(root: &Path) -> io::Result<TreeManifest> {
Self::scan_with_excludes(root, &ExcludeSet::new())
}
pub fn scan_with_excludes(root: &Path, excludes: &ExcludeSet) -> io::Result<TreeManifest> {
let mut manifest = TreeManifest::new();
let root_meta = std::fs::metadata(root)?;
if !root_meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("tree root is not a directory: {}", root.display()),
));
}
Self::scan_dir(root, "", excludes, &mut manifest)?;
Ok(manifest)
}
fn scan_dir(
dir: &Path,
prefix: &str,
excludes: &ExcludeSet,
out: &mut TreeManifest,
) -> io::Result<()> {
let mut entries: Vec<(String, PathBuf, std::fs::Metadata, bool)> = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let meta = match entry.metadata() {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(e),
};
let is_symlink = std::fs::symlink_metadata(entry.path())
.map(|m| m.is_symlink())
.unwrap_or(false);
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
entries.push((rel, entry.path(), meta, is_symlink));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (rel, path, meta, is_symlink) in entries {
if is_symlink {
let target_meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
eprintln!("warning: skipping dangling symlink: {}", path.display());
continue;
}
Err(e) => return Err(e),
};
if target_meta.is_file() {
if rel.ends_with(".ffsync-index") || rel.ends_with(".ffsync-tmp") {
continue;
}
if excludes.matches(&rel, EntryKind::File) {
continue;
}
out.insert(TreeEntry::from_metadata(rel, EntryKind::File, &target_meta));
} else {
eprintln!(
"warning: skipping directory symlink (not recursed): {}",
path.display()
);
}
} else if meta.is_dir() {
if excludes.matches(&rel, EntryKind::Dir) {
continue;
}
out.insert(TreeEntry::from_metadata(rel.clone(), EntryKind::Dir, &meta));
Self::scan_dir(&path, &rel, excludes, out)?;
} else if meta.is_file() {
if rel.ends_with(".ffsync-index") || rel.ends_with(".ffsync-tmp") {
continue;
}
if excludes.matches(&rel, EntryKind::File) {
continue;
}
out.insert(TreeEntry::from_metadata(rel, EntryKind::File, &meta));
} else {
eprintln!("warning: skipping non-regular file: {}", path.display());
}
}
Ok(())
}
}
pub const TREE_FLAG_CHECKSUM: u16 = 1 << 0; pub const TREE_FLAG_NO_DELETE: u16 = 1 << 1; pub const TREE_FLAG_EAGER: u16 = 1 << 7;
pub const TREE_FLAG_DRY_RUN: u16 = 1 << 6;
#[derive(Debug, Clone, PartialEq, Eq)]
enum PatternSeg {
DoubleStar,
Parts(Vec<Part>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Part {
Lit(char),
Star,
One,
Class {
negated: bool,
items: Vec<ClassItem>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ClassItem {
Char(char),
Range(char, char),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludePattern {
raw: String,
anchored: bool,
dir_only: bool,
negated: bool,
segs: Vec<PatternSeg>,
}
impl ExcludePattern {
pub fn new(raw: &str) -> Result<Self, String> {
if raw.is_empty() {
return Err("empty exclude pattern".to_string());
}
let (negated, rest) = if raw.starts_with("!!") {
(false, &raw[1..])
} else if let Some(r) = raw.strip_prefix('!') {
(true, r)
} else {
(false, raw)
};
if rest.is_empty() {
return Err("exclude pattern is only a negation: {raw:?}".to_string());
}
if rest.contains('\\') {
return Err(format!(
"backslash in exclude pattern (no escapes, paths are /-separated): {raw:?}"
));
}
let dir_only = rest.ends_with('/');
let core = rest.trim_end_matches('/');
if core.is_empty() {
return Err(format!("exclude pattern is only a slash: {raw:?}"));
}
let anchored = core.contains('/');
let core = core.strip_prefix('/').unwrap_or(core);
let mut segs = Vec::new();
for seg in core.split('/') {
if seg == "**" {
segs.push(PatternSeg::DoubleStar);
} else {
segs.push(PatternSeg::Parts(parse_parts(seg, raw)?));
}
}
Ok(Self {
raw: raw.to_string(),
anchored,
dir_only,
negated,
segs,
})
}
pub fn raw(&self) -> &str {
&self.raw
}
pub fn is_negated(&self) -> bool {
self.negated
}
pub fn matches(&self, rel_path: &str, kind: EntryKind) -> bool {
if self.dir_only && kind != EntryKind::Dir {
return false;
}
let path_segs: Vec<&str> = rel_path.split('/').collect();
if self.anchored {
match_segs(&self.segs, &path_segs)
} else {
(0..path_segs.len()).any(|start| match_segs(&self.segs, &path_segs[start..]))
}
}
}
fn parse_parts(seg: &str, whole: &str) -> Result<Vec<Part>, String> {
let chars: Vec<char> = seg.chars().collect();
let mut parts = Vec::new();
let mut i = 0usize;
while i < chars.len() {
match chars[i] {
'*' => {
while i < chars.len() && chars[i] == '*' {
i += 1;
}
parts.push(Part::Star);
}
'?' => {
parts.push(Part::One);
i += 1;
}
'[' => {
match parse_class(&chars, i) {
Some((class, next)) => {
parts.push(class);
i = next;
}
None => {
parts.push(Part::Lit('['));
i += 1;
}
}
}
c => {
parts.push(Part::Lit(c));
i += 1;
}
}
}
if parts.is_empty() {
return Err(format!("empty segment in exclude pattern: {whole:?}"));
}
Ok(parts)
}
fn parse_class(chars: &[char], start: usize) -> Option<(Part, usize)> {
let mut i = start + 1; let negated = i < chars.len() && chars[i] == '!';
if negated {
i += 1;
}
let mut items = Vec::new();
let mut first = true;
while i < chars.len() {
if chars[i] == ']' && !first {
return Some((Part::Class { negated, items }, i + 1));
}
first = false;
let lo = chars[i];
if i + 2 < chars.len() && chars[i + 1] == '-' && chars[i + 2] != ']' {
items.push(ClassItem::Range(lo, chars[i + 2]));
i += 3;
} else {
items.push(ClassItem::Char(lo));
i += 1;
}
}
None }
fn match_segs(segs: &[PatternSeg], path: &[&str]) -> bool {
match segs.split_first() {
None => path.is_empty(),
Some((PatternSeg::DoubleStar, rest)) => {
(0..=path.len()).any(|skip| match_segs(rest, &path[skip..]))
}
Some((PatternSeg::Parts(parts), rest)) => {
let Some((head, tail)) = path.split_first() else {
return false;
};
match_parts(parts, head) && match_segs(rest, tail)
}
}
}
fn match_parts(parts: &[Part], text: &str) -> bool {
let text: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
while ti < text.len() {
let advanced = if pi < parts.len() {
match &parts[pi] {
Part::Star => {
star = Some((pi, ti));
pi += 1;
true
}
Part::One => {
pi += 1;
ti += 1;
true
}
Part::Lit(c) => {
if text[ti] == *c {
pi += 1;
ti += 1;
true
} else {
false
}
}
Part::Class { negated, items } => {
let c = text[ti];
let hit = items.iter().any(|it| match it {
ClassItem::Char(x) => *x == c,
ClassItem::Range(lo, hi) => c >= *lo && c <= *hi,
});
if hit != *negated {
pi += 1;
ti += 1;
true
} else {
false
}
}
}
} else {
false
};
if advanced {
continue;
}
match star {
Some((sp, st)) => {
let st = st + 1;
if st > text.len() {
return false;
}
star = Some((sp, st));
pi = sp + 1;
ti = st;
}
None => return false,
}
}
while pi < parts.len() && parts[pi] == Part::Star {
pi += 1;
}
pi == parts.len()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExcludeSet {
patterns: Vec<ExcludePattern>,
}
impl ExcludeSet {
pub fn new() -> Self {
Self::default()
}
pub fn from_patterns<I, S>(raws: I) -> Result<Self, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut set = Self::new();
for raw in raws {
set.push(raw.as_ref())?;
}
Ok(set)
}
pub fn push(&mut self, raw: &str) -> Result<(), String> {
self.patterns.push(ExcludePattern::new(raw)?);
Ok(())
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
pub fn len(&self) -> usize {
self.patterns.len()
}
pub fn raws(&self) -> Vec<String> {
self.patterns.iter().map(|p| p.raw().to_string()).collect()
}
pub fn has_negations(&self) -> bool {
self.patterns.iter().any(|p| p.is_negated())
}
fn excluded(&self, rel_path: &str, kind: EntryKind) -> bool {
for p in &self.patterns {
if p.matches(rel_path, kind) {
return !p.is_negated();
}
}
false
}
pub fn matches(&self, rel_path: &str, kind: EntryKind) -> bool {
self.excluded(rel_path, kind)
}
pub fn matches_path_or_ancestor(&self, rel_path: &str, kind: EntryKind) -> bool {
if self.excluded(rel_path, kind) {
return true;
}
let mut prefix = String::new();
for seg in rel_path.split('/') {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(seg);
if self.excluded(&prefix, EntryKind::Dir) {
return true;
}
}
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Triage {
Skip,
Sync,
Transfer,
CreateDir,
Delete,
Conflict {
sender_kind: EntryKind,
remove_receiver_entry: bool,
},
}
impl fmt::Display for Triage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Triage::Skip => write!(f, "skip"),
Triage::Sync => write!(f, "sync"),
Triage::Transfer => write!(f, "transfer"),
Triage::CreateDir => write!(f, "create-dir"),
Triage::Delete => write!(f, "delete"),
Triage::Conflict { .. } => write!(f, "conflict"),
}
}
}
pub fn triage(
_rel_path: &str,
sender: Option<&TreeEntry>,
receiver: Option<&TreeEntry>,
checksum: bool,
) -> Triage {
match (sender, receiver) {
(None, None) => Triage::Skip, (Some(s), None) => match s.kind {
EntryKind::File => Triage::Transfer,
EntryKind::Dir => Triage::CreateDir,
},
(None, Some(_r)) => {
Triage::Delete
}
(Some(s), Some(r)) => {
match (s.kind, r.kind) {
(EntryKind::Dir, EntryKind::Dir) => Triage::Skip,
(EntryKind::File, EntryKind::File) => {
if !checksum
&& s.size == r.size
&& s.mtime_secs == r.mtime_secs
&& s.mtime_nanos == r.mtime_nanos
{
Triage::Skip
} else {
Triage::Sync
}
}
_ => Triage::Conflict {
sender_kind: s.kind,
remove_receiver_entry: true,
},
}
}
}
}
pub fn validate_rel_path(p: &str) -> Result<(), String> {
if p.is_empty() {
return Err("empty path".to_string());
}
if p.starts_with('/') || p.ends_with('/') {
return Err(format!("leading or trailing slash: {p:?}"));
}
if p.contains('\\') {
return Err(format!(
"backslash in path (wire form is /-separated): {p:?}"
));
}
if p.split('/').any(|c| c.is_empty() || c == "." || c == "..") {
return Err(format!("empty, '.', or '..' path component: {p:?}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, kind: EntryKind, size: u64, secs: u64, nanos: u32) -> TreeEntry {
TreeEntry {
rel_path: path.to_string(),
kind,
size,
mtime_secs: secs,
mtime_nanos: nanos,
mode: 0,
}
}
#[test]
fn triage_metadata_match_skips() {
let s = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
let r = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r), false), Triage::Skip);
assert_eq!(triage("a.bin", Some(&s), Some(&r), true), Triage::Sync);
}
#[test]
fn triage_size_or_mtime_difference_syncs() {
let s = entry("a.bin", EntryKind::File, 100, 1600000000, 500);
let r1 = entry("a.bin", EntryKind::File, 101, 1600000000, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r1), false), Triage::Sync);
let r2 = entry("a.bin", EntryKind::File, 100, 1600000000, 501);
assert_eq!(triage("a.bin", Some(&s), Some(&r2), false), Triage::Sync);
let r3 = entry("a.bin", EntryKind::File, 100, 1600000001, 500);
assert_eq!(triage("a.bin", Some(&s), Some(&r3), false), Triage::Sync);
}
#[test]
fn triage_one_sided_and_kind_conflicts() {
let f = entry("x", EntryKind::File, 5, 1, 0);
let d = entry("x", EntryKind::Dir, 0, 1, 0);
assert_eq!(triage("x", Some(&f), None, false), Triage::Transfer);
assert_eq!(triage("x", Some(&d), None, false), Triage::CreateDir);
assert_eq!(triage("x", None, Some(&f), false), Triage::Delete);
assert_eq!(triage("x", None, Some(&d), false), Triage::Delete);
assert_eq!(
triage("x", Some(&f), Some(&d), false),
Triage::Conflict {
sender_kind: EntryKind::File,
remove_receiver_entry: true
}
);
assert_eq!(
triage("x", Some(&d), Some(&f), false),
Triage::Conflict {
sender_kind: EntryKind::Dir,
remove_receiver_entry: true
}
);
}
#[test]
fn rel_path_validation() {
assert!(validate_rel_path("a").is_ok());
assert!(validate_rel_path("a/b/c.txt").is_ok());
assert!(validate_rel_path("").is_err());
assert!(validate_rel_path("/abs").is_err());
assert!(validate_rel_path("a/").is_err());
assert!(validate_rel_path("a//b").is_err());
assert!(validate_rel_path("a/../b").is_err());
assert!(validate_rel_path("a/./b").is_err());
assert!(validate_rel_path("..").is_err());
assert!(validate_rel_path("a\\b").is_err());
}
#[test]
fn manifest_scan_and_canonical_order() {
let dir = std::env::temp_dir().join(format!("ffsync_tree_scan_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub/deep")).unwrap();
std::fs::create_dir_all(dir.join("empty_dir")).unwrap();
std::fs::write(dir.join("b.txt"), b"bbb").unwrap();
std::fs::write(dir.join("a.txt"), b"a").unwrap();
std::fs::write(dir.join("sub/one.bin"), b"1111").unwrap();
std::fs::write(dir.join("sub/deep/two.bin"), b"22").unwrap();
let m = TreeManifest::scan(&dir).unwrap();
let paths: Vec<&str> = m.entries().map(|e| e.rel_path.as_str()).collect();
assert_eq!(
paths,
vec![
"a.txt",
"b.txt",
"empty_dir",
"sub",
"sub/deep",
"sub/deep/two.bin",
"sub/one.bin"
]
);
assert_eq!(m.get("a.txt").unwrap().size, 1);
assert_eq!(m.get("sub/one.bin").unwrap().size, 4);
assert_eq!(m.get("empty_dir").unwrap().kind, EntryKind::Dir);
assert_eq!(m.total_file_bytes(), 10);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_rejects_non_directory_root() {
let f = std::env::temp_dir().join(format!("ffsync_tree_notadir_{}", std::process::id()));
std::fs::write(&f, b"x").unwrap();
assert!(TreeManifest::scan(&f).is_err());
let _ = std::fs::remove_file(&f);
}
fn pat(raw: &str) -> ExcludePattern {
ExcludePattern::new(raw).unwrap()
}
fn m(raw: &str, path: &str, kind: EntryKind) -> bool {
pat(raw).matches(path, kind)
}
#[test]
fn exclude_basename_patterns_match_at_any_depth() {
assert!(m("*.tmp", "a.tmp", EntryKind::File));
assert!(m("*.tmp", "deep/dir/a.tmp", EntryKind::File));
assert!(!m("*.tmp", "dir.tmp/x", EntryKind::File)); assert!(m("node_modules", "node_modules", EntryKind::Dir));
assert!(m("node_modules", "a/node_modules", EntryKind::Dir));
assert!(!m("node_modules", "node_modules/x", EntryKind::File)); }
#[test]
fn exclude_anchored_patterns_match_from_root_only() {
assert!(m("/build", "build", EntryKind::Dir));
assert!(!m("/build", "sub/build", EntryKind::Dir));
assert!(m("doc/*.md", "doc/readme.md", EntryKind::File));
assert!(!m("doc/*.md", "doc/sub/readme.md", EntryKind::File));
assert!(m("a/b/c", "a/b/c", EntryKind::File));
}
#[test]
fn exclude_star_never_crosses_slash() {
assert!(m("*.c", "main.c", EntryKind::File));
assert!(m("*.c", "a/main.c", EntryKind::File));
assert!(m("/*.c", "main.c", EntryKind::File));
assert!(!m("/*.c", "a/main.c", EntryKind::File)); assert!(!m("/a/*/f", "a/b/c/f", EntryKind::File)); assert!(m("/a/*/f", "a/b/f", EntryKind::File));
}
#[test]
fn exclude_question_mark_is_exactly_one_char() {
assert!(m("file?.txt", "file1.txt", EntryKind::File));
assert!(!m("file?.txt", "file12.txt", EntryKind::File));
assert!(!m("file?.txt", "file.txt", EntryKind::File));
assert!(m("a?c", "abc", EntryKind::File));
}
#[test]
fn exclude_character_classes() {
assert!(m("[abc].txt", "a.txt", EntryKind::File));
assert!(m("[a-c].txt", "b.txt", EntryKind::File));
assert!(!m("[a-c].txt", "d.txt", EntryKind::File));
assert!(m("[!a-c].txt", "d.txt", EntryKind::File));
assert!(!m("[!a-c].txt", "b.txt", EntryKind::File));
assert!(m("a[.txt", "a[.txt", EntryKind::File));
assert!(!m("a[.txt", "ab.txt", EntryKind::File));
}
#[test]
fn exclude_double_star_spans_directories() {
assert!(m("logs/**/*.log", "logs/a.log", EntryKind::File));
assert!(m("logs/**/*.log", "logs/a/b/a.log", EntryKind::File));
assert!(!m("logs/**/*.log", "other/a.log", EntryKind::File));
assert!(m("a/**/b", "a/b", EntryKind::File));
assert!(m("a/**/b", "a/x/y/b", EntryKind::File));
assert!(m("a**b", "axxb", EntryKind::File));
assert!(!m("a**b", "a/b", EntryKind::File));
}
#[test]
fn exclude_dir_only_trailing_slash() {
assert!(m("build/", "build", EntryKind::Dir));
assert!(!m("build/", "build", EntryKind::File));
assert!(m("cache/", "deep/cache", EntryKind::Dir));
assert!(!m("cache/", "deep/cache", EntryKind::File));
}
#[test]
fn exclude_parse_errors() {
assert!(ExcludePattern::new("").is_err());
assert!(ExcludePattern::new("a\\b").is_err()); assert!(ExcludePattern::new("/").is_err());
assert!(ExcludePattern::new("a//b").is_err()); }
#[test]
fn exclude_set_path_or_ancestor_protection() {
let set = ExcludeSet::from_patterns(["node_modules", "*.lock"]).unwrap();
assert!(set.matches_path_or_ancestor("node_modules", EntryKind::Dir));
assert!(set.matches_path_or_ancestor("node_modules/pkg/index.js", EntryKind::File));
assert!(!set.matches_path_or_ancestor("src/main.rs", EntryKind::File));
let set2 = ExcludeSet::from_patterns(["target"]).unwrap();
assert!(set2.matches_path_or_ancestor("target/debug/deps/a", EntryKind::File));
}
#[test]
fn scan_with_excludes_prunes_dirs_and_files() {
let dir = std::env::temp_dir().join(format!("ffsync_tree_excl_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("node_modules/pkg/index.js"), b"js").unwrap();
std::fs::write(dir.join("src/main.rs"), b"rs").unwrap();
std::fs::write(dir.join("debug.log"), b"log").unwrap();
std::fs::write(dir.join("keep.log"), b"keep").unwrap();
let ex = ExcludeSet::from_patterns(["node_modules", "*.log"]).unwrap();
let manifest = TreeManifest::scan_with_excludes(&dir, &ex).unwrap();
let paths: Vec<&str> = manifest.entries().map(|e| e.rel_path.as_str()).collect();
assert_eq!(paths, vec!["src", "src/main.rs"]);
let full = TreeManifest::scan_with_excludes(&dir, &ExcludeSet::new()).unwrap();
assert_eq!(full.len(), 7);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exclude_round_trip_through_wire_form() {
let raws = ["node_modules", "/build/", "logs/**/*.log", "[abc]?.tmp"];
let set = ExcludeSet::from_patterns(raws).unwrap();
assert_eq!(
set.raws(),
raws.iter().map(|s| s.to_string()).collect::<Vec<_>>()
);
let set2 = ExcludeSet::from_patterns(set.raws()).unwrap();
assert_eq!(set, set2);
}
#[test]
fn negation_reincludes_in_first_match_wins_order() {
let set = ExcludeSet::from_patterns(["!keep.log", "*.log"]).unwrap();
assert!(set.has_negations());
assert!(
!set.matches("keep.log", EntryKind::File),
"the earlier negation re-includes"
);
assert!(
!set.matches("sub/keep.log", EntryKind::File),
"basename patterns match at any depth, so the negation does too"
);
assert!(set.matches("delete.log", EntryKind::File));
let set = ExcludeSet::from_patterns(["*.log", "!keep.log"]).unwrap();
assert!(set.matches("keep.log", EntryKind::File));
let set = ExcludeSet::from_patterns(["!a", "b"]).unwrap();
assert!(!set.matches("c", EntryKind::File));
let set = ExcludeSet::from_patterns(["a", "b"]).unwrap();
assert!(!set.has_negations());
assert!(set.matches("a", EntryKind::File));
assert!(set.matches("b", EntryKind::File));
assert!(!set.matches("c", EntryKind::File));
}
#[test]
fn negation_literal_bang_escape() {
let set = ExcludeSet::from_patterns(["!!weird!name"]).unwrap();
assert!(set.matches("!weird!name", EntryKind::File));
assert!(!set.matches("weird!name", EntryKind::File));
let set = ExcludeSet::from_patterns(["[]!]x"]).unwrap();
assert!(set.matches("!x", EntryKind::File));
assert!(set.matches("]x", EntryKind::File));
assert!(!set.matches("x", EntryKind::File));
assert!(ExcludeSet::from_patterns(["!"]).is_err());
}
#[test]
fn negation_delete_pass_semantics() {
let set = ExcludeSet::from_patterns(["keep", "!keep/temp"]).unwrap();
assert!(set.matches_path_or_ancestor("keep/temp", EntryKind::File));
let set = ExcludeSet::from_patterns(["!keep/temp", "keep/*"]).unwrap();
assert!(!set.matches_path_or_ancestor("keep/temp", EntryKind::File));
assert!(set.matches_path_or_ancestor("keep/other", EntryKind::File));
let set = ExcludeSet::from_patterns(["*.log", "!keep.log"]).unwrap();
assert!(set.matches_path_or_ancestor("keep.log", EntryKind::File));
assert!(set.matches_path_or_ancestor("other.log", EntryKind::File));
let set = ExcludeSet::from_patterns(["!keep.log", "*.log"]).unwrap();
assert!(!set.matches_path_or_ancestor("keep.log", EntryKind::File));
assert!(set.matches_path_or_ancestor("other.log", EntryKind::File));
}
#[test]
fn negation_under_pruned_dir_cannot_reinclude() {
let dir = std::env::temp_dir().join(format!(
"ffsync_neg_scan_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(dir.join("target/keep")).unwrap();
std::fs::write(dir.join("target/keep/x.txt"), b"x").unwrap();
std::fs::write(dir.join("target/y.txt"), b"y").unwrap();
let ex = ExcludeSet::from_patterns(["target/", "!target/keep"]).unwrap();
let manifest = TreeManifest::scan_with_excludes(&dir, &ex).unwrap();
assert!(
manifest.get("target/keep/x.txt").is_none(),
"pruned subtree: negation cannot re-include (documented)"
);
let ex = ExcludeSet::from_patterns(["!target/keep", "target/*"]).unwrap();
let manifest = TreeManifest::scan_with_excludes(&dir, &ex).unwrap();
let paths: Vec<&str> = manifest.entries().map(|e| e.rel_path.as_str()).collect();
assert!(
paths.contains(&"target/keep/x.txt"),
"negation-first re-includes the kept subtree: {paths:?}"
);
assert!(
!paths.contains(&"target/y.txt"),
"the sibling file stays excluded: {paths:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}