use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilePattern {
Exact(String),
Extensions {
base: String,
extensions: Vec<String>,
},
Glob(String),
Any(Vec<Self>),
}
impl FilePattern {
pub fn exact(name: impl Into<String>) -> Self {
Self::Exact(name.into())
}
pub fn extensions(base: impl Into<String>, exts: &[&str]) -> Self {
Self::Extensions {
base: base.into(),
extensions: exts.iter().map(|&s| s.to_string()).collect(),
}
}
pub fn glob(pattern: impl Into<String>) -> Self {
Self::Glob(pattern.into())
}
#[must_use]
pub fn matches(&self, path: &Path) -> bool {
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
match self {
Self::Exact(name) => filename == name,
Self::Extensions { base, extensions } => extensions
.iter()
.any(|ext| filename == format!("{base}.{ext}")),
Self::Glob(pattern) => glob_match(pattern, filename),
Self::Any(patterns) => patterns.iter().any(|p| p.matches(path)),
}
}
#[must_use]
pub fn concrete_filenames(&self) -> Option<Vec<String>> {
match self {
Self::Exact(name) => Some(vec![name.clone()]),
Self::Extensions { base, extensions } => Some(
extensions
.iter()
.map(|ext| format!("{base}.{ext}"))
.collect(),
),
Self::Glob(_) => None,
Self::Any(patterns) => {
let mut result = Vec::new();
for pattern in patterns {
if let Some(names) = pattern.concrete_filenames() {
result.extend(names);
} else {
return None;
}
}
Some(result)
}
}
}
pub fn has_recursive_glob(&self) -> bool {
match self {
Self::Glob(pattern) => pattern.contains("**"),
Self::Any(patterns) => patterns.iter().any(Self::has_recursive_glob),
_ => false,
}
}
}
impl Default for FilePattern {
fn default() -> Self {
Self::exact("config")
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let pattern = pattern.replace("**", "*");
glob_match_inner(&pattern, text)
}
fn glob_match_inner(pattern: &str, text: &str) -> bool {
let mut pat_chars = pattern.chars();
let mut text_chars = text.chars();
while let Some(p) = pat_chars.next() {
match p {
'*' => {
let remaining_pattern: String = pat_chars.collect();
let remaining_text: String = text_chars.clone().collect();
if remaining_pattern.is_empty() {
return true;
}
for i in 0..=remaining_text.len() {
let suffix = &remaining_text[i..];
if glob_match_inner(&remaining_pattern, suffix) {
return true;
}
}
return false;
}
'?' => {
if text_chars.next().is_none() {
return false;
}
}
c => {
if text_chars.next() != Some(c) {
return false;
}
}
}
}
text_chars.next().is_none()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let pattern = FilePattern::exact("config.toml");
assert!(pattern.matches(Path::new("/path/config.toml")));
assert!(!pattern.matches(Path::new("/path/config.json")));
}
#[test]
fn test_extensions_match() {
let pattern = FilePattern::extensions("config", &["toml", "json", "yaml"]);
assert!(pattern.matches(Path::new("/path/config.toml")));
assert!(pattern.matches(Path::new("/path/config.json")));
assert!(!pattern.matches(Path::new("/path/config.yml")));
assert!(!pattern.matches(Path::new("/path/other.toml")));
}
#[test]
fn test_glob_match_simple() {
let pattern = FilePattern::glob("*.toml");
assert!(pattern.matches(Path::new("/path/config.toml")));
assert!(pattern.matches(Path::new("/path/app.toml")));
assert!(!pattern.matches(Path::new("/path/config.json")));
}
#[test]
fn test_concrete_filenames() {
let pattern = FilePattern::extensions("config", &["toml", "json"]);
let names = pattern.concrete_filenames().unwrap();
assert_eq!(names, vec!["config.toml", "config.json"]);
let glob = FilePattern::glob("*.toml");
assert!(glob.concrete_filenames().is_none());
}
#[test]
fn test_has_recursive_glob() {
assert!(FilePattern::glob("**/*.toml").has_recursive_glob());
assert!(!FilePattern::glob("*.toml").has_recursive_glob());
assert!(!FilePattern::exact("config.toml").has_recursive_glob());
}
#[test]
fn test_any_pattern() {
let pattern = FilePattern::Any(vec![
FilePattern::exact("config.toml"),
FilePattern::exact("config.json"),
]);
assert!(pattern.matches(Path::new("/path/config.toml")));
assert!(pattern.matches(Path::new("/path/config.json")));
assert!(!pattern.matches(Path::new("/path/config.yaml")));
}
}