use std::path::Path;
use fancy_regex::Regex as FancyRegex;
#[derive(Debug)]
pub enum GroupPattern {
Literal(String),
Std(regex::Regex),
Fancy(FancyRegex),
}
impl GroupPattern {
#[must_use]
pub fn is_match(&self, path: &str) -> bool {
match self {
Self::Literal(prefix) => {
if !path.starts_with(prefix) {
return false;
}
let rest = &path[prefix.len()..];
rest.is_empty() || rest.starts_with('/')
}
Self::Std(r) => r.is_match(path),
Self::Fancy(r) => r.is_match(path).unwrap_or(false),
}
}
}
#[derive(Debug)]
pub struct GroupRule {
pub pattern: GroupPattern,
pub name: String,
pub raw: String,
}
#[derive(Debug, Default)]
pub struct GroupMap {
pub rules: Vec<GroupRule>,
pub strict: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum GroupParseError {
#[error("group file line {line}: missing `=>` separator")]
MissingSeparator { line: usize },
#[error("group file line {line}: empty pattern")]
EmptyPattern { line: usize },
#[error("group file line {line}: empty group name")]
EmptyName { line: usize },
#[error("group file line {line}: invalid regex {pattern:?}: {source}")]
InvalidRegex {
line: usize,
pattern: String,
#[source]
source: Box<fancy_regex::Error>,
},
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl GroupMap {
pub fn from_file(path: &Path, strict: bool) -> Result<Self, GroupParseError> {
let text = std::fs::read_to_string(path)?;
Self::parse(&text, strict)
}
pub fn parse(text: &str, strict: bool) -> Result<Self, GroupParseError> {
let mut rules = Vec::new();
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let line_no = i + 1;
let (lhs, rhs) = trimmed
.split_once("=>")
.ok_or(GroupParseError::MissingSeparator { line: line_no })?;
let path = lhs.trim();
let name = rhs.trim();
if path.is_empty() {
return Err(GroupParseError::EmptyPattern { line: line_no });
}
if name.is_empty() {
return Err(GroupParseError::EmptyName { line: line_no });
}
let pattern = if path.starts_with('^') {
if let Ok(r) = regex::Regex::new(path) {
GroupPattern::Std(r)
} else {
let fancy =
FancyRegex::new(path).map_err(|e| GroupParseError::InvalidRegex {
line: line_no,
pattern: path.to_string(),
source: Box::new(e),
})?;
GroupPattern::Fancy(fancy)
}
} else {
GroupPattern::Literal(path.to_string())
};
rules.push(GroupRule {
pattern,
name: name.to_string(),
raw: path.to_string(),
});
}
Ok(Self { rules, strict })
}
#[must_use]
pub fn map_entity(&self, path: &str) -> Option<&str> {
for rule in &self.rules {
if rule.pattern.is_match(path) {
return Some(&rule.name);
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_plain_text_mapping() {
let g = GroupMap::parse("src/auth => Auth\n", false).expect("parse");
assert_eq!(g.rules.len(), 1);
assert_eq!(g.map_entity("src/auth/login.rs"), Some("Auth"));
assert_eq!(g.map_entity("src/authbar/foo.rs"), None);
assert_eq!(g.map_entity("unrelated/path.rs"), None);
}
#[test]
fn parse_regex_with_anchors() {
let g = GroupMap::parse("^src\\/.*Tests\\.cs$ => CS Tests\n", false).expect("parse");
assert_eq!(g.map_entity("src/foo/FooTests.cs"), Some("CS Tests"));
assert_eq!(g.map_entity("src/foo/Foo.cs"), None);
}
#[test]
fn parse_regex_with_lookaround_via_fancy_regex() {
let g = GroupMap::parse("^src\\/((?!.*Test.*).).*$ => Production\n", false).expect("parse");
assert_eq!(g.map_entity("src/lib.rs"), Some("Production"));
assert_eq!(g.map_entity("src/foo/bar.rs"), Some("Production"));
assert_eq!(
g.map_entity("src/foo/Test.rs"),
None,
"lookaround excludes Test paths"
);
}
#[test]
fn parse_first_match_wins() {
let g = GroupMap::parse(
"src/auth/login => LoginSpecial\n\
src/auth => Auth\n",
false,
)
.expect("parse");
assert_eq!(
g.map_entity("src/auth/login/handler.rs"),
Some("LoginSpecial")
);
assert_eq!(g.map_entity("src/auth/session.rs"), Some("Auth"));
}
#[test]
fn parse_skips_blank_and_comment_lines() {
let g = GroupMap::parse(
"# comment 1\n\
\n\
src/a => A\n\
\n\
# comment 2\n\
src/b => B\n",
false,
)
.expect("parse");
assert_eq!(g.rules.len(), 2);
assert_eq!(g.map_entity("src/a/foo.rs"), Some("A"));
assert_eq!(g.map_entity("src/b/foo.rs"), Some("B"));
}
#[test]
fn parse_errors_on_missing_separator() {
let err = GroupMap::parse("just a line\n", false).expect_err("must fail");
match err {
GroupParseError::MissingSeparator { line } => assert_eq!(line, 1),
other => panic!("wrong error variant: {other:?}"),
}
}
#[test]
fn parse_errors_on_empty_pattern() {
let err = GroupMap::parse(" => Name\n", false).expect_err("must fail");
assert!(matches!(err, GroupParseError::EmptyPattern { .. }));
}
#[test]
fn parse_errors_on_empty_name() {
let err = GroupMap::parse("src/foo => \n", false).expect_err("must fail");
assert!(matches!(err, GroupParseError::EmptyName { .. }));
}
#[test]
fn from_file_reads_repo_root() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("groups.txt"), "src/auth => Auth\n").expect("write");
let g = GroupMap::from_file(&tmp.path().join("groups.txt"), false).expect("read");
assert_eq!(g.map_entity("src/auth/x.rs"), Some("Auth"));
}
}