use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(
Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
)]
pub struct TaskPath(String);
#[derive(Debug)]
pub enum TaskPathError {
Empty,
NotAbsolute,
EmptyComponent,
InvalidCharacter(char),
}
impl fmt::Display for TaskPathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TaskPathError::Empty => write!(f, "path is empty"),
TaskPathError::NotAbsolute => write!(f, "path must start with '/'"),
TaskPathError::EmptyComponent => {
write!(f, "path contains empty component")
}
TaskPathError::InvalidCharacter(c) => {
write!(f, "path contains invalid character: '{}'", c)
}
}
}
}
impl std::error::Error for TaskPathError {}
fn is_valid_component_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
}
impl TaskPath {
pub fn new(s: impl Into<String>) -> Result<Self, TaskPathError> {
let s = s.into();
if s.is_empty() {
return Err(TaskPathError::Empty);
}
if !s.starts_with('/') {
return Err(TaskPathError::NotAbsolute);
}
if s == "/" {
return Ok(TaskPath(s));
}
for component in s[1..].split('/') {
if component.is_empty() {
return Err(TaskPathError::EmptyComponent);
}
for c in component.chars() {
if !is_valid_component_char(c) {
return Err(TaskPathError::InvalidCharacter(c));
}
}
}
Ok(TaskPath(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parent(&self) -> Option<TaskPath> {
if self.0 == "/" {
return None;
}
match self.0.rfind('/') {
Some(0) => Some(TaskPath("/".to_string())),
Some(pos) => Some(TaskPath(self.0[..pos].to_string())),
None => None,
}
}
pub fn name(&self) -> &str {
if self.0 == "/" {
return "";
}
match self.0.rfind('/') {
Some(pos) => &self.0[pos + 1..],
None => &self.0,
}
}
pub fn components(&self) -> impl Iterator<Item = &str> {
let s = if self.0 == "/" { "" } else { &self.0[1..] };
s.split('/').filter(|c| !c.is_empty())
}
pub fn depth(&self) -> usize {
self.components().count()
}
pub fn matches_glob(&self, pattern: &str) -> bool {
if !pattern.starts_with('/') {
return false;
}
let path_parts: Vec<&str> = self.components().collect();
let pat_parts: Vec<&str> =
pattern[1..].split('/').filter(|c| !c.is_empty()).collect();
glob_match(&path_parts, &pat_parts)
}
}
fn glob_match(path: &[&str], pattern: &[&str]) -> bool {
if pattern.is_empty() {
return path.is_empty();
}
let pat = pattern[0];
let rest_pat = &pattern[1..];
if pat == "**" {
for i in 0..=path.len() {
if glob_match(&path[i..], rest_pat) {
return true;
}
}
false
} else if pat == "*" {
if path.is_empty() {
return false;
}
glob_match(&path[1..], rest_pat)
} else {
if path.is_empty() || path[0] != pat {
return false;
}
glob_match(&path[1..], rest_pat)
}
}
impl fmt::Display for TaskPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_paths() {
assert!(TaskPath::new("/").is_ok());
assert!(TaskPath::new("/web").is_ok());
assert!(TaskPath::new("/services/api").is_ok());
assert!(TaskPath::new("/tools/my-watcher").is_ok());
assert!(TaskPath::new("/a/b.c/d_e").is_ok());
}
#[test]
fn test_invalid_paths() {
assert!(TaskPath::new("").is_err());
assert!(TaskPath::new("web").is_err());
assert!(TaskPath::new("/web/").is_err());
assert!(TaskPath::new("//web").is_err());
assert!(TaskPath::new("/web//api").is_err());
assert!(TaskPath::new("/web server").is_err());
assert!(TaskPath::new("/web@home").is_err());
}
#[test]
fn test_parent() {
assert_eq!(TaskPath::new("/").unwrap().parent(), None);
assert_eq!(
TaskPath::new("/web").unwrap().parent().unwrap().as_str(),
"/"
);
assert_eq!(
TaskPath::new("/services/api")
.unwrap()
.parent()
.unwrap()
.as_str(),
"/services"
);
}
#[test]
fn test_name() {
assert_eq!(TaskPath::new("/").unwrap().name(), "");
assert_eq!(TaskPath::new("/web").unwrap().name(), "web");
assert_eq!(TaskPath::new("/services/api").unwrap().name(), "api");
}
#[test]
fn test_components() {
let p = TaskPath::new("/").unwrap();
assert_eq!(p.components().collect::<Vec<_>>(), Vec::<&str>::new());
let p = TaskPath::new("/web").unwrap();
assert_eq!(p.components().collect::<Vec<_>>(), vec!["web"]);
let p = TaskPath::new("/services/api").unwrap();
assert_eq!(p.components().collect::<Vec<_>>(), vec!["services", "api"]);
}
#[test]
fn test_depth() {
assert_eq!(TaskPath::new("/").unwrap().depth(), 0);
assert_eq!(TaskPath::new("/web").unwrap().depth(), 1);
assert_eq!(TaskPath::new("/a/b/c").unwrap().depth(), 3);
}
#[test]
fn test_glob_exact() {
let p = TaskPath::new("/services/api").unwrap();
assert!(p.matches_glob("/services/api"));
assert!(!p.matches_glob("/services/web"));
assert!(!p.matches_glob("/services"));
}
#[test]
fn test_glob_star() {
let p1 = TaskPath::new("/services/api").unwrap();
let p2 = TaskPath::new("/services/web").unwrap();
let p3 = TaskPath::new("/services/api/v2").unwrap();
let p4 = TaskPath::new("/tools/lint").unwrap();
assert!(p1.matches_glob("/services/*"));
assert!(p2.matches_glob("/services/*"));
assert!(!p3.matches_glob("/services/*")); assert!(!p4.matches_glob("/services/*"));
}
#[test]
fn test_glob_double_star() {
let p1 = TaskPath::new("/services/api").unwrap();
let p2 = TaskPath::new("/services/api/v2").unwrap();
let p3 = TaskPath::new("/services/web").unwrap();
let p4 = TaskPath::new("/tools/lint").unwrap();
assert!(p1.matches_glob("/services/**"));
assert!(p2.matches_glob("/services/**"));
assert!(p3.matches_glob("/services/**"));
assert!(!p4.matches_glob("/services/**"));
assert!(p1.matches_glob("/**"));
assert!(p4.matches_glob("/**"));
}
#[test]
fn test_glob_mixed() {
let p = TaskPath::new("/a/b/c/d").unwrap();
assert!(p.matches_glob("/**/c/d"));
assert!(p.matches_glob("/a/**/d"));
assert!(p.matches_glob("/a/*/c/*"));
assert!(!p.matches_glob("/a/*/d"));
}
}