use std::path::{Path, PathBuf};
use crate::helpers::error::SwdirError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
Symlink,
}
impl EntryKind {
pub fn from_file_type(ft: std::fs::FileType) -> Option<Self> {
if ft.is_symlink() {
Some(Self::Symlink)
} else if ft.is_dir() {
Some(Self::Dir)
} else if ft.is_file() {
Some(Self::File)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Decision {
pub include: bool,
pub descend: bool,
}
impl Decision {
pub const PASS: Self = Self {
include: true,
descend: true,
};
pub const DROP: Self = Self {
include: false,
descend: false,
};
pub const HIDDEN_PASSTHROUGH: Self = Self {
include: false,
descend: true,
};
pub const fn new(include: bool, descend: bool) -> Self {
Self { include, descend }
}
pub const fn and(self, other: Self) -> Self {
Self {
include: self.include && other.include,
descend: self.descend && other.descend,
}
}
}
#[derive(Debug)]
pub struct FilterContext<'a> {
pub path: &'a Path,
pub kind: EntryKind,
pub depth: usize,
pub is_hidden: bool,
}
impl<'a> FilterContext<'a> {
pub(crate) fn new(path: &'a Path, kind: EntryKind, depth: usize, is_hidden: bool) -> Self {
Self {
path,
kind,
depth,
is_hidden,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FilterRule {
SkipHidden,
OnlyKinds(Vec<EntryKind>),
ExtensionAllowlist(Vec<String>),
ExtensionDenylist(Vec<String>),
UnderPath(PathBuf),
NotUnderPath(PathBuf),
MaxDepth(usize),
}
impl FilterRule {
pub fn skip_hidden() -> Self {
Self::SkipHidden
}
pub fn only_kind(kind: EntryKind) -> Self {
Self::OnlyKinds(vec![kind])
}
pub fn only_kinds<I: IntoIterator<Item = EntryKind>>(kinds: I) -> Self {
Self::OnlyKinds(kinds.into_iter().collect())
}
pub fn extension_allowlist<S, I>(exts: I) -> Result<Self, SwdirError>
where
S: Into<String>,
I: IntoIterator<Item = S>,
{
let list = normalize_extensions(exts)?;
Ok(Self::ExtensionAllowlist(list))
}
pub fn extension_denylist<S, I>(exts: I) -> Result<Self, SwdirError>
where
S: Into<String>,
I: IntoIterator<Item = S>,
{
let list = normalize_extensions(exts)?;
Ok(Self::ExtensionDenylist(list))
}
pub fn under_path<P: Into<PathBuf>>(path: P) -> Self {
Self::UnderPath(path.into())
}
pub fn not_under_path<P: Into<PathBuf>>(path: P) -> Self {
Self::NotUnderPath(path.into())
}
pub fn max_depth(n: usize) -> Self {
Self::MaxDepth(n)
}
pub fn evaluate(&self, ctx: &FilterContext<'_>) -> Decision {
match self {
Self::SkipHidden => {
if ctx.is_hidden {
Decision::DROP
} else {
Decision::PASS
}
}
Self::OnlyKinds(kinds) => {
if kinds.contains(&ctx.kind) {
Decision::PASS
} else {
if ctx.kind == EntryKind::Dir {
Decision::HIDDEN_PASSTHROUGH
} else {
Decision::DROP
}
}
}
Self::ExtensionAllowlist(exts) => {
if ctx.kind != EntryKind::File {
return Decision::PASS;
}
if extension_matches(ctx.path, exts) {
Decision::PASS
} else {
Decision::DROP
}
}
Self::ExtensionDenylist(exts) => {
if ctx.kind != EntryKind::File {
return Decision::PASS;
}
if extension_matches(ctx.path, exts) {
Decision::DROP
} else {
Decision::PASS
}
}
Self::UnderPath(prefix) => {
let under = ctx.path.starts_with(prefix);
if under {
Decision::PASS
} else {
let maybe_ancestor = ctx.kind == EntryKind::Dir && prefix.starts_with(ctx.path);
Decision::new(false, maybe_ancestor)
}
}
Self::NotUnderPath(prefix) => {
if ctx.path.starts_with(prefix) {
Decision::DROP
} else {
Decision::PASS
}
}
Self::MaxDepth(max) => Decision {
include: ctx.depth <= *max,
descend: ctx.depth < *max,
},
}
}
}
fn normalize_extensions<S, I>(exts: I) -> Result<Vec<String>, SwdirError>
where
S: Into<String>,
I: IntoIterator<Item = S>,
{
let list: Vec<String> = exts.into_iter().map(Into::into).collect();
for e in &list {
if e.starts_with('.') {
return Err(SwdirError::InvalidExtensionListItem(e.clone()));
}
}
Ok(list)
}
fn extension_matches(path: &Path, exts: &[String]) -> bool {
match path.extension() {
Some(ext) => {
let ext = ext.to_string_lossy();
exts.iter().any(|e| e.as_str() == ext.as_ref())
}
None => false,
}
}
pub(crate) fn evaluate_all(rules: &[FilterRule], ctx: &FilterContext<'_>) -> Decision {
rules
.iter()
.fold(Decision::PASS, |acc, r| acc.and(r.evaluate(ctx)))
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx<'a>(path: &'a Path, kind: EntryKind, depth: usize, hidden: bool) -> FilterContext<'a> {
FilterContext::new(path, kind, depth, hidden)
}
#[test]
fn decision_and_passes_only_when_both_pass() {
assert_eq!(Decision::PASS.and(Decision::PASS), Decision::PASS);
assert_eq!(Decision::PASS.and(Decision::DROP), Decision::DROP);
let hp = Decision::HIDDEN_PASSTHROUGH;
assert_eq!(Decision::PASS.and(hp), hp);
assert_eq!(hp.and(hp), hp);
}
#[test]
fn skip_hidden_drops_hidden_entries() {
let r = FilterRule::skip_hidden();
let p = PathBuf::from(".hidden");
let c = ctx(&p, EntryKind::File, 0, true);
assert_eq!(r.evaluate(&c), Decision::DROP);
let p2 = PathBuf::from("visible");
let c2 = ctx(&p2, EntryKind::File, 0, false);
assert_eq!(r.evaluate(&c2), Decision::PASS);
}
#[test]
fn only_kinds_keeps_dirs_traversable() {
let r = FilterRule::only_kind(EntryKind::File);
let p = PathBuf::from("some/dir");
let c = ctx(&p, EntryKind::Dir, 0, false);
assert_eq!(r.evaluate(&c), Decision::HIDDEN_PASSTHROUGH);
}
#[test]
fn extension_allowlist_validates_leading_dot() {
let err = FilterRule::extension_allowlist([".md"]).unwrap_err();
assert_eq!(err, SwdirError::InvalidExtensionListItem(".md".into()));
}
#[test]
fn extension_allowlist_drops_non_matching() {
let r = FilterRule::extension_allowlist(["md"]).unwrap();
let p = PathBuf::from("a.txt");
let c = ctx(&p, EntryKind::File, 0, false);
assert_eq!(r.evaluate(&c), Decision::DROP);
}
#[test]
fn max_depth_splits_include_and_descend() {
let r = FilterRule::max_depth(1);
let p = PathBuf::from("x");
let at_1 = r.evaluate(&ctx(&p, EntryKind::File, 1, false));
assert_eq!(at_1, Decision::new(true, false));
let at_2 = r.evaluate(&ctx(&p, EntryKind::File, 2, false));
assert_eq!(at_2, Decision::DROP);
}
#[test]
fn under_path_allows_ancestor_descent() {
let r = FilterRule::under_path("/a/b");
let anc = PathBuf::from("/a");
let c = ctx(&anc, EntryKind::Dir, 0, false);
let d = r.evaluate(&c);
assert!(!d.include);
assert!(d.descend);
}
}