use anyhow::{Context, Result};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub enum FilterAction {
Include,
Exclude,
}
#[derive(Debug, Clone)]
pub struct FilterRule {
pub action: FilterAction,
pub pattern: glob::Pattern,
#[allow(dead_code)] pub pattern_str: String,
pub has_slash: bool,
pub is_dir_only: bool,
}
impl FilterRule {
pub fn new(action: FilterAction, pattern: &str) -> Result<Self> {
let pattern_str = pattern.to_string();
let is_dir_only = pattern.ends_with('/');
let pattern_for_glob = if is_dir_only { pattern.trim_end_matches('/') } else { pattern };
let has_slash = pattern_for_glob.contains('/');
let pattern = glob::Pattern::new(pattern_for_glob).with_context(|| format!("Invalid filter pattern: {}", pattern))?;
Ok(Self { action, pattern, pattern_str, has_slash, is_dir_only })
}
pub fn matches(&self, path: &Path, is_dir: bool) -> bool {
if self.is_dir_only {
if self.has_slash {
if let Some(path_str) = path.to_str() {
if is_dir && self.pattern.matches(path_str) {
return true;
}
for ancestor in path.ancestors().skip(1) {
if let Some(ancestor_str) = ancestor.to_str()
&& !ancestor_str.is_empty()
&& self.pattern.matches(ancestor_str)
{
return true;
}
}
}
false
} else {
let pattern_str = self.pattern.as_str();
let is_wildcard_only = pattern_str == "*";
if is_wildcard_only {
if !is_dir {
return false;
}
if let Some(basename) = path.file_name().and_then(|n| n.to_str()) {
return self.pattern.matches(basename);
}
false
} else {
if let Some(basename) = path.file_name().and_then(|n| n.to_str()) {
if is_dir && self.pattern.matches(basename) {
return true;
}
}
for ancestor in path.ancestors().skip(1) {
if let Some(ancestor_basename) = ancestor.file_name().and_then(|n| n.to_str())
&& self.pattern.matches(ancestor_basename)
{
return true;
}
}
false
}
}
} else if self.has_slash {
if let Some(path_str) = path.to_str() { self.pattern.matches(path_str) } else { false }
} else {
if let Some(basename) = path.file_name().and_then(|n| n.to_str()) { self.pattern.matches(basename) } else { false }
}
}
}
#[derive(Debug, Clone)]
pub struct FilterEngine {
rules: Vec<FilterRule>,
}
impl FilterEngine {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: &str) -> Result<()> {
let rule = rule.trim();
if rule.is_empty() || rule.starts_with('#') {
return Ok(());
}
let (action, pattern) = if let Some(pattern) = rule.strip_prefix("+ ") {
(FilterAction::Include, pattern.trim())
} else if let Some(pattern) = rule.strip_prefix("+") {
(FilterAction::Include, pattern.trim())
} else if let Some(pattern) = rule.strip_prefix("- ") {
(FilterAction::Exclude, pattern.trim())
} else if let Some(pattern) = rule.strip_prefix("-") {
(FilterAction::Exclude, pattern.trim())
} else {
(FilterAction::Exclude, rule)
};
if pattern.is_empty() {
anyhow::bail!("Empty filter pattern");
}
let rule = FilterRule::new(action, pattern)?;
self.rules.push(rule);
Ok(())
}
pub fn add_include(&mut self, pattern: &str) -> Result<()> {
let rule = FilterRule::new(FilterAction::Include, pattern)?;
self.rules.push(rule);
Ok(())
}
pub fn add_exclude(&mut self, pattern: &str) -> Result<()> {
let rule = FilterRule::new(FilterAction::Exclude, pattern)?;
self.rules.push(rule);
Ok(())
}
pub fn add_rules_from_file(&mut self, file_path: &Path) -> Result<()> {
let file = File::open(file_path).with_context(|| format!("Failed to open filter file: {}", file_path.display()))?;
let reader = BufReader::new(file);
for (line_num, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("Failed to read line {} from {}", line_num + 1, file_path.display()))?;
self.add_rule(&line).with_context(|| format!("Invalid rule at line {} in {}", line_num + 1, file_path.display()))?;
}
Ok(())
}
pub fn add_template(&mut self, template_name: &str) -> Result<()> {
let config_dir = dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
let template_dir = config_dir.join("sy").join("templates");
let template_file = template_dir.join(format!("{}.syignore", template_name));
if !template_file.exists() {
anyhow::bail!("Template '{}' not found at {}", template_name, template_file.display());
}
self.add_rules_from_file(&template_file).with_context(|| format!("Failed to load template '{}'", template_name))
}
pub fn add_syignore_if_exists(&mut self, directory: &Path) -> Result<bool> {
let syignore_path = directory.join(".syignore");
if !syignore_path.exists() {
return Ok(false);
}
self.add_rules_from_file(&syignore_path)?;
Ok(true)
}
pub fn should_include(&self, path: &Path, is_dir: bool) -> bool {
if self.rules.is_empty() {
return true;
}
for rule in &self.rules {
if rule.matches(path, is_dir) {
return rule.action == FilterAction::Include;
}
}
true
}
pub fn should_exclude(&self, path: &Path, is_dir: bool) -> bool {
!self.should_include(path, is_dir)
}
#[allow(dead_code)] pub fn rule_count(&self) -> usize {
self.rules.len()
}
#[allow(dead_code)] pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
impl Default for FilterEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_filter_includes_all() {
let filter = FilterEngine::new();
assert!(filter.should_include(Path::new("foo.txt"), false));
assert!(filter.should_include(Path::new("bar/baz.rs"), false));
}
#[test]
fn test_exclude_pattern() {
let mut filter = FilterEngine::new();
filter.add_exclude("*.log").unwrap();
assert!(!filter.should_include(Path::new("test.log"), false));
assert!(filter.should_include(Path::new("test.txt"), false));
}
#[test]
fn test_include_pattern() {
let mut filter = FilterEngine::new();
filter.add_exclude("*.log").unwrap();
filter.add_include("important.log").unwrap();
assert!(!filter.should_include(Path::new("test.log"), false));
assert!(filter.should_include(Path::new("test.txt"), false));
assert!(!filter.should_include(Path::new("important.log"), false));
}
#[test]
fn test_rule_order_matters() {
let mut filter = FilterEngine::new();
filter.add_include("important.log").unwrap();
filter.add_exclude("*.log").unwrap();
assert!(filter.should_include(Path::new("important.log"), false));
assert!(!filter.should_include(Path::new("test.log"), false));
assert!(filter.should_include(Path::new("test.txt"), false));
}
#[test]
fn test_rsync_style_syntax() {
let mut filter = FilterEngine::new();
filter.add_rule("+ *.rs").unwrap();
filter.add_rule("- *.log").unwrap();
filter.add_rule("*.tmp").unwrap();
assert!(filter.should_include(Path::new("foo.rs"), false));
assert!(!filter.should_include(Path::new("bar.log"), false));
assert!(!filter.should_include(Path::new("baz.tmp"), false));
assert!(filter.should_include(Path::new("qux.txt"), false));
}
#[test]
fn test_directory_patterns() {
let mut filter = FilterEngine::new();
filter.add_exclude("target/*").unwrap();
filter.add_exclude("node_modules/*").unwrap();
assert!(!filter.should_include(Path::new("target/debug"), false));
assert!(!filter.should_include(Path::new("node_modules/foo"), false));
assert!(filter.should_include(Path::new("src/main.rs"), false));
}
#[test]
fn test_comments_and_empty_lines() {
let mut filter = FilterEngine::new();
filter.add_rule("# This is a comment").unwrap();
filter.add_rule("").unwrap();
filter.add_rule(" ").unwrap();
filter.add_rule("*.log").unwrap();
assert_eq!(filter.rule_count(), 1);
assert!(!filter.should_include(Path::new("test.log"), false));
}
#[test]
fn test_invalid_pattern() {
let mut filter = FilterEngine::new();
let result = filter.add_exclude("[invalid");
assert!(result.is_err());
}
#[test]
fn test_default_action() {
let mut filter = FilterEngine::new();
filter.add_rule("*.log").unwrap();
assert!(!filter.should_include(Path::new("test.log"), false));
assert!(filter.should_include(Path::new("test.txt"), false));
}
#[test]
fn test_glob_wildcards() {
let mut filter = FilterEngine::new();
filter.add_exclude("**/*.log").unwrap();
filter.add_exclude("temp/**").unwrap();
assert!(!filter.should_include(Path::new("foo/bar/test.log"), false));
assert!(!filter.should_include(Path::new("temp/foo/bar"), false));
assert!(filter.should_include(Path::new("src/main.rs"), false));
}
#[test]
fn test_rsync_basename_matching() {
let mut filter = FilterEngine::new();
filter.add_include("important.rs").unwrap();
filter.add_exclude("*.rs").unwrap();
assert!(filter.should_include(Path::new("dir/important.rs"), false));
assert!(filter.should_include(Path::new("deep/nested/important.rs"), false));
assert!(!filter.should_include(Path::new("code.rs"), false));
assert!(!filter.should_include(Path::new("dir/code.rs"), false));
}
#[test]
fn test_rsync_path_matching() {
let mut filter = FilterEngine::new();
filter.add_exclude("dir1/*.rs").unwrap();
filter.add_exclude("**/temp/*.log").unwrap();
assert!(!filter.should_include(Path::new("dir1/code.rs"), false));
assert!(filter.should_include(Path::new("dir2/code.rs"), false));
assert!(!filter.should_include(Path::new("temp/test.log"), false));
assert!(!filter.should_include(Path::new("foo/temp/test.log"), false));
assert!(filter.should_include(Path::new("temp/test.txt"), false)); }
#[test]
fn test_directory_only_patterns() {
let mut filter = FilterEngine::new();
filter.add_exclude("dir1/").unwrap();
filter.add_include("*.txt").unwrap();
filter.add_exclude("*").unwrap();
assert!(!filter.should_include(Path::new("dir1/keep.txt"), false));
assert!(!filter.should_include(Path::new("dir1/subdir/file.rs"), false));
assert!(filter.should_include(Path::new("keep.txt"), false));
assert!(filter.should_include(Path::new("dir2/keep.txt"), false));
assert!(!filter.should_include(Path::new("exclude.log"), false));
}
#[test]
fn test_rsync_exact_scenario() {
let mut filter = FilterEngine::new();
filter.add_exclude("dir1/").unwrap();
filter.add_include("*.txt").unwrap();
filter.add_exclude("*").unwrap();
assert!(filter.should_include(Path::new("keep.txt"), false), "keep.txt should be included");
assert!(!filter.should_include(Path::new("dir1"), true), "dir1 directory should be excluded");
assert!(!filter.should_include(Path::new("dir1/keep.txt"), false), "dir1/keep.txt should be excluded");
assert!(!filter.should_include(Path::new("dir1/subdir/file.txt"), false), "dir1/subdir/file.txt should be excluded");
assert!(filter.should_include(Path::new("dir2/keep.txt"), false), "dir2/keep.txt should be included");
assert!(!filter.should_include(Path::new("exclude.log"), false), "exclude.log should be excluded");
}
#[test]
fn test_directory_pattern_vs_file_pattern() {
let mut filter = FilterEngine::new();
filter.add_exclude("build/").unwrap();
assert!(!filter.should_include(Path::new("build/output.txt"), false));
assert!(!filter.should_include(Path::new("build/nested/file.rs"), false));
let mut filter2 = FilterEngine::new();
filter2.add_exclude("build").unwrap();
assert!(!filter2.should_include(Path::new("build"), false)); assert!(!filter2.should_include(Path::new("build"), true)); assert!(!filter2.should_include(Path::new("other/build"), false)); assert!(filter2.should_include(Path::new("build/output.txt"), false)); assert!(filter2.should_include(Path::new("building"), false)); }
}