use std::path::Path;
use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use thiserror::Error;
use crate::workspace::{CreateRuleSetting, DeleteRuleSetting};
#[derive(Debug, Error)]
pub enum MatcherSetError {
#[error("invalid glob pattern {entry:?}: {source}")]
ParseEntry {
entry: String,
#[source]
source: globset::Error,
},
#[error("glob set build failed: {0}")]
Build(#[source] globset::Error),
}
#[derive(Debug, Clone)]
pub struct MatcherSet {
patterns: Vec<String>,
set: GlobSet,
}
impl MatcherSet {
pub fn new<I, S>(entries: I) -> Result<Self, MatcherSetError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut builder = GlobSetBuilder::new();
let mut patterns: Vec<String> = Vec::new();
for entry in entries {
let entry_str = entry.as_ref();
let glob: Glob = GlobBuilder::new(entry_str)
.literal_separator(true)
.build()
.map_err(|source| MatcherSetError::ParseEntry {
entry: entry_str.to_string(),
source,
})?;
builder.add(glob);
patterns.push(entry_str.to_string());
}
let set = builder.build().map_err(MatcherSetError::Build)?;
Ok(Self { patterns, set })
}
pub fn matches(&self, candidate: &Path) -> bool {
self.set.is_match(candidate)
}
pub fn patterns(&self) -> &[String] {
&self.patterns
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
}
impl Default for MatcherSet {
fn default() -> Self {
Self::new::<_, &str>(std::iter::empty::<&str>()).expect("empty MatcherSet is always valid")
}
}
#[derive(Debug, Clone)]
pub struct CreateRuleSet {
rules: Vec<CreateRuleSetting>,
set: GlobSet,
}
#[derive(Debug, Clone)]
pub struct DeleteRuleSet {
rules: Vec<DeleteRuleSetting>,
set: GlobSet,
}
impl CreateRuleSet {
pub fn new(rules: Vec<CreateRuleSetting>) -> Result<Self, MatcherSetError> {
let mut builder = GlobSetBuilder::new();
for r in &rules {
let glob = GlobBuilder::new(&r.pattern)
.literal_separator(true)
.build()
.map_err(|source| MatcherSetError::ParseEntry {
entry: r.pattern.clone(),
source,
})?;
builder.add(glob);
}
let set = builder.build().map_err(MatcherSetError::Build)?;
Ok(Self { rules, set })
}
pub fn first_match(&self, candidate: &Path) -> Option<&CreateRuleSetting> {
let matched = self.set.matches(candidate);
matched.into_iter().next().map(|i| &self.rules[i])
}
pub fn rules(&self) -> &[CreateRuleSetting] {
&self.rules
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn patterns(&self) -> Vec<String> {
self.rules.iter().map(|r| r.pattern.clone()).collect()
}
pub fn matches(&self, candidate: &Path) -> bool {
self.first_match(candidate).is_some()
}
}
impl DeleteRuleSet {
pub fn new(rules: Vec<DeleteRuleSetting>) -> Result<Self, MatcherSetError> {
let mut builder = GlobSetBuilder::new();
for r in &rules {
let glob = GlobBuilder::new(&r.pattern)
.literal_separator(true)
.build()
.map_err(|source| MatcherSetError::ParseEntry {
entry: r.pattern.clone(),
source,
})?;
builder.add(glob);
}
let set = builder.build().map_err(MatcherSetError::Build)?;
Ok(Self { rules, set })
}
pub fn first_match(&self, candidate: &Path) -> Option<&DeleteRuleSetting> {
let matched = self.set.matches(candidate);
matched.into_iter().next().map(|i| &self.rules[i])
}
pub fn rules(&self) -> &[DeleteRuleSetting] {
&self.rules
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn patterns(&self) -> Vec<String> {
self.rules.iter().map(|r| r.pattern.clone()).collect()
}
pub fn matches(&self, candidate: &Path) -> bool {
self.first_match(candidate).is_some()
}
}
impl Default for CreateRuleSet {
fn default() -> Self {
Self::new(Vec::new()).expect("empty rule set always compiles")
}
}
impl Default for DeleteRuleSet {
fn default() -> Self {
Self::new(Vec::new()).expect("empty rule set always compiles")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn cr(pattern: &str, schemas: &[&str]) -> CreateRuleSetting {
CreateRuleSetting {
pattern: pattern.to_string(),
schemas: schemas.iter().map(|s| s.to_string()).collect(),
default_cross_links: None,
}
}
fn dr(pattern: &str) -> DeleteRuleSetting {
DeleteRuleSetting {
pattern: pattern.to_string(),
}
}
#[test]
fn empty_matcher_rejects_everything() {
let m = MatcherSet::new::<_, &str>(std::iter::empty::<&str>()).unwrap();
assert!(m.is_empty());
assert_eq!(m.patterns().len(), 0);
assert!(!m.matches(Path::new("anything")));
}
#[test]
fn single_segment_star_does_not_cross_slash() {
let m = MatcherSet::new(["memstead/*"]).unwrap();
assert!(m.matches(Path::new("memstead/engine")));
assert!(!m.matches(Path::new("memstead/engine/nested")));
assert!(!m.matches(Path::new("other/engine")));
}
#[test]
fn double_star_matches_any_segments() {
let m = MatcherSet::new(["memstead/**"]).unwrap();
assert!(m.matches(Path::new("memstead/engine")));
assert!(m.matches(Path::new("memstead/engine/nested")));
assert!(!m.matches(Path::new("other/engine")));
}
#[test]
fn malformed_entry_returns_parse_entry_error() {
let err = MatcherSet::new(["[unclosed"]).unwrap_err();
match err {
MatcherSetError::ParseEntry { entry, .. } => assert_eq!(entry, "[unclosed"),
MatcherSetError::Build(_) => panic!("expected ParseEntry, got Build"),
}
}
#[test]
fn case_sensitive_by_default() {
let m = MatcherSet::new(["MEMSTEAD/*"]).unwrap();
assert!(m.matches(Path::new("MEMSTEAD/foo")));
assert!(!m.matches(Path::new("memstead/foo")));
}
#[test]
fn empty_rule_set_matches_nothing() {
let cs = CreateRuleSet::new(vec![]).unwrap();
assert!(cs.is_empty());
assert!(cs.first_match(Path::new("anything")).is_none());
}
#[test]
fn first_match_resolves_in_declaration_order() {
let cs = CreateRuleSet::new(vec![
cr("planning/plan-*", &["default@1.0.0"]),
cr("planning/**", &["*"]),
])
.unwrap();
let m = cs.first_match(Path::new("planning/plan-foo")).unwrap();
assert_eq!(m.pattern, "planning/plan-*");
}
#[test]
fn second_rule_picked_when_first_does_not_match() {
let cs = CreateRuleSet::new(vec![
cr("planning/plan-*", &["default@1.0.0"]),
cr("exec-*", &["default@1.0.0"]),
])
.unwrap();
let m = cs.first_match(Path::new("exec-foo")).unwrap();
assert_eq!(m.pattern, "exec-*");
}
#[test]
fn flat_candidate_matches_flat_pattern() {
let cs = CreateRuleSet::new(vec![cr("exec-*", &["default@1.0.0"])]).unwrap();
assert!(cs.first_match(Path::new("exec-foo")).is_some());
assert!(cs.first_match(Path::new("nested/exec-foo")).is_none());
}
#[test]
fn hierarchical_candidate_requires_path_prefix() {
let cs = CreateRuleSet::new(vec![cr("planning/plan-*", &["default@1.0.0"])]).unwrap();
assert!(cs.first_match(Path::new("planning/plan-q4")).is_some());
assert!(cs.first_match(Path::new("plan-q4")).is_none());
assert!(cs.first_match(Path::new("other/plan-q4")).is_none());
}
#[test]
fn create_rule_set_malformed_pattern_returns_parse_entry_error() {
let err = CreateRuleSet::new(vec![cr("[unclosed", &["*"])]).unwrap_err();
match err {
MatcherSetError::ParseEntry { entry, .. } => {
assert_eq!(entry, "[unclosed")
}
_ => panic!("expected ParseEntry, got {err:?}"),
}
}
#[test]
fn create_rule_set_carries_default_cross_links_in_matched_rule() {
use memstead_schema::workspace_config::CrossLinkValue;
let rule = CreateRuleSetting {
pattern: "exec-*".to_string(),
schemas: vec!["default".to_string()],
default_cross_links: Some(CrossLinkValue::Wildcard),
};
let cs = CreateRuleSet::new(vec![rule]).unwrap();
let m = cs.first_match(Path::new("exec-foo")).unwrap();
assert_eq!(m.default_cross_links, Some(CrossLinkValue::Wildcard));
}
#[test]
fn delete_rule_set_resolves_by_pattern_only() {
let ds = DeleteRuleSet::new(vec![dr("planning/plan-*"), dr("exec-*")]).unwrap();
assert!(ds.first_match(Path::new("planning/plan-foo")).is_some());
assert!(ds.first_match(Path::new("exec-bar")).is_some());
assert!(ds.first_match(Path::new("engine")).is_none());
}
#[test]
fn delete_rule_set_default_is_empty() {
let ds = DeleteRuleSet::default();
assert!(ds.is_empty());
assert!(!ds.matches(Path::new("anything")));
}
#[test]
fn create_rule_set_default_is_empty() {
let cs = CreateRuleSet::default();
assert!(cs.is_empty());
assert!(!cs.matches(Path::new("anything")));
}
}