use crate::content::node::NodeState;
use crate::index::{FilterPathSet, IndexError, IndexResult, strict_string, strict_strings};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PathVerdict {
Include,
Exclude,
Traverse,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PathFilter {
included_paths: Vec<String>,
excluded_paths: Vec<String>,
}
impl Default for PathFilter {
fn default() -> Self {
Self {
included_paths: vec!["/".to_owned()],
excluded_paths: Vec::new(),
}
}
}
impl PathFilter {
pub fn from_definition(definition: &NodeState<'_>, definition_path: &str) -> IndexResult<Self> {
let include_property = definition.property("includedPaths")?;
let exclude_property = definition.property("excludedPaths")?;
if include_property.is_none() && exclude_property.is_none() {
return Ok(Self::default());
}
let includes =
string_or_strings(include_property.as_ref()).unwrap_or_else(|| vec!["/".to_owned()]);
let excludes = string_or_strings(exclude_property.as_ref()).unwrap_or_default();
Self::new(&includes, &excludes, definition_path)
}
pub fn new(
includes: &[String],
excludes: &[String],
definition_path: &str,
) -> IndexResult<Self> {
refuse_relative(includes, FilterPathSet::Included, definition_path)?;
refuse_relative(excludes, FilterPathSet::Excluded, definition_path)?;
let (included_paths, excluded_paths) = unify(includes, excludes);
if included_paths.is_empty() {
return Err(IndexError::EmptyIncludeSet {
definition_path: definition_path.to_owned(),
});
}
Ok(Self {
included_paths,
excluded_paths,
})
}
#[must_use]
pub fn filter(&self, path: &str) -> PathVerdict {
if self
.excluded_paths
.iter()
.any(|excluded| excluded == path || is_ancestor(excluded, path))
{
return PathVerdict::Exclude;
}
if self
.included_paths
.iter()
.any(|included| included == path || is_ancestor(included, path))
{
return PathVerdict::Include;
}
if self
.included_paths
.iter()
.any(|included| is_ancestor(path, included))
{
return PathVerdict::Traverse;
}
PathVerdict::Exclude
}
#[must_use]
pub fn include_paths(&self) -> &[String] {
&self.included_paths
}
#[must_use]
pub fn exclude_paths(&self) -> &[String] {
&self.excluded_paths
}
#[must_use]
pub fn includes_everything(&self) -> bool {
self.excluded_paths.is_empty()
&& self.included_paths.len() == 1
&& self.included_paths[0] == "/"
}
}
fn string_or_strings(
property: Option<&crate::content::node::PropertyState>,
) -> Option<Vec<String>> {
strict_strings(property).or_else(|| strict_string(property).map(|text| vec![text.to_owned()]))
}
fn refuse_relative(
paths: &[String],
path_set: FilterPathSet,
definition_path: &str,
) -> IndexResult<()> {
for path in paths {
if !path.starts_with('/') {
return Err(IndexError::RelativeFilterPath {
definition_path: definition_path.to_owned(),
path_set,
value: path.clone(),
});
}
}
Ok(())
}
fn unify(includes: &[String], excludes: &[String]) -> (Vec<String>, Vec<String>) {
let mut removed: Vec<&String> = Vec::new();
let mut retained: Vec<&String> = Vec::new();
for include in includes {
for exclude in excludes {
if exclude == include || is_ancestor(exclude, include) {
removed.push(include);
} else if is_ancestor(include, exclude) {
retained.push(exclude);
}
}
for other in includes {
if is_ancestor(include, other) {
removed.push(other);
}
}
}
let mut surviving_includes: Vec<String> = includes
.iter()
.filter(|include| !removed.contains(include))
.cloned()
.collect();
let mut surviving_excludes: Vec<String> = excludes
.iter()
.filter(|exclude| retained.contains(exclude))
.cloned()
.collect();
surviving_includes.sort();
surviving_includes.dedup();
surviving_excludes.sort();
surviving_excludes.dedup();
(surviving_includes, surviving_excludes)
}
fn is_ancestor(ancestor: &str, path: &str) -> bool {
if ancestor == "/" {
return path.len() > 1 && path.starts_with('/');
}
path.len() > ancestor.len()
&& path.starts_with(ancestor)
&& path.as_bytes()[ancestor.len()] == b'/'
}
#[cfg(test)]
mod tests {
use super::{PathFilter, PathVerdict, is_ancestor, unify};
fn paths(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
fn filter(includes: &[&str], excludes: &[&str]) -> PathFilter {
PathFilter::new(&paths(includes), &paths(excludes), "/oak:index/test")
.expect("a constructible filter")
}
#[test]
fn the_root_is_an_ancestor_of_everything_but_itself() {
assert!(is_ancestor("/", "/content"));
assert!(!is_ancestor("/", "/"));
}
#[test]
fn a_name_prefix_is_not_an_ancestor() {
assert!(!is_ancestor("/content", "/contentious"));
assert!(is_ancestor("/content", "/content/x"));
}
#[test]
fn empty_sets_include_everything() {
let filter = PathFilter::default();
assert!(filter.includes_everything());
assert_eq!(filter.filter("/content"), PathVerdict::Include);
}
#[test]
fn an_exclude_wins_over_the_include_that_contains_it() {
let filter = filter(&["/content"], &["/content/private"]);
assert_eq!(filter.filter("/content/public"), PathVerdict::Include);
assert_eq!(filter.filter("/content/private"), PathVerdict::Exclude);
assert_eq!(filter.filter("/content/private/x"), PathVerdict::Exclude);
}
#[test]
fn an_ancestor_of_an_include_is_traversed() {
let filter = filter(&["/content/dam"], &[]);
assert_eq!(filter.filter("/content"), PathVerdict::Traverse);
assert_eq!(filter.filter("/"), PathVerdict::Traverse);
assert_eq!(filter.filter("/etc"), PathVerdict::Exclude);
}
#[test]
fn a_redundant_include_under_another_include_is_dropped() {
let filter = filter(&["/content", "/content/dam"], &[]);
assert_eq!(filter.include_paths(), ["/content"]);
}
#[test]
fn an_exclude_under_no_include_is_dropped_entirely() {
let filter = filter(&["/content"], &["/etc/private"]);
assert!(filter.exclude_paths().is_empty());
assert_eq!(filter.filter("/etc/private"), PathVerdict::Exclude);
}
#[test]
fn an_exclude_equal_to_an_include_drops_the_include_rather_than_being_retained() {
let (included, excluded) = unify(&paths(&["/content"]), &paths(&["/content"]));
assert!(included.is_empty());
assert!(excluded.is_empty());
}
#[test]
fn excluding_every_include_is_refused() {
let error = PathFilter::new(&paths(&["/content"]), &paths(&["/"]), "/oak:index/test")
.expect_err("an empty include set is refused");
assert!(
matches!(error, crate::index::IndexError::EmptyIncludeSet { .. }),
"{error}"
);
}
#[test]
fn a_relative_include_is_refused_by_name() {
let error = PathFilter::new(&paths(&["content"]), &[], "/oak:index/test")
.expect_err("a relative path is refused");
assert!(
matches!(
&error,
crate::index::IndexError::RelativeFilterPath { value, .. } if value == "content"
),
"{error}"
);
}
#[test]
fn a_relative_exclude_is_refused_before_the_unification_runs() {
let error = PathFilter::new(&paths(&["/content"]), &paths(&["etc"]), "/oak:index/test")
.expect_err("a relative path is refused");
assert!(
matches!(
&error,
crate::index::IndexError::RelativeFilterPath {
path_set: crate::index::FilterPathSet::Excluded,
..
}
),
"{error}"
);
}
}