use crate::error::{Error, Result};
#[derive(Debug)]
pub enum PathSpec {
Literal(String),
Regex(regex::Regex),
}
pub fn parse_path_spec(s: &str) -> Result<PathSpec> {
if s.len() >= 4 && s.starts_with("//") && s.ends_with("//") {
let inner = &s[2..s.len() - 2];
let re = regex::Regex::new(inner)
.map_err(|e| Error::Merge(format!("invalid regex in path `{s}`: {e}")))?;
return Ok(PathSpec::Regex(re));
}
Ok(PathSpec::Literal(s.to_string()))
}
#[derive(Debug, PartialEq, Eq)]
pub enum PathSeg {
Key(String),
KeyIndex(String, usize),
}
pub fn parse_segments(s: &str) -> Result<Vec<PathSeg>> {
let mut out = Vec::new();
for raw in s.split('.') {
if raw.is_empty() {
return Err(Error::Merge(format!(
"empty segment in path `{s}` (e.g. trailing dot)"
)));
}
if let Some(open) = raw.find('[') {
if !raw.ends_with(']') {
return Err(Error::Merge(format!(
"malformed path segment `{raw}` in `{s}` (expected `name[N]`)"
)));
}
let name = &raw[..open];
let idx_str = &raw[open + 1..raw.len() - 1];
if name.is_empty() {
return Err(Error::Merge(format!(
"empty key before `[` in segment `{raw}` (in `{s}`)"
)));
}
let idx: usize = idx_str.parse().map_err(|e| {
Error::Merge(format!(
"invalid array index `{idx_str}` in segment `{raw}` (in `{s}`): {e}"
))
})?;
out.push(PathSeg::KeyIndex(name.to_string(), idx));
} else {
out.push(PathSeg::Key(raw.to_string()));
}
}
Ok(out)
}
pub fn shallowest_matches(all_paths: &[String], re: ®ex::Regex) -> Vec<String> {
let mut matched: Vec<&String> = all_paths.iter().filter(|p| re.is_match(p)).collect();
if matched.len() <= 1 {
return matched.into_iter().cloned().collect();
}
matched.sort_by_key(|p| p.len());
let mut keep: Vec<&String> = Vec::with_capacity(matched.len());
for p in matched {
let has_ancestor_in_keep = keep.iter().any(|k| {
if k.len() >= p.len() || !p.starts_with(k.as_str()) {
return false;
}
let next = p.as_bytes()[k.len()];
next == b'.' || next == b'['
});
if !has_ancestor_in_keep {
keep.push(p);
}
}
keep.into_iter().cloned().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_path_round_trips() {
let spec = parse_path_spec("tasks.test").unwrap();
match spec {
PathSpec::Literal(s) => assert_eq!(s, "tasks.test"),
PathSpec::Regex(_) => panic!("literal should not parse as regex"),
}
}
#[test]
fn regex_path_strips_delimiters() {
let spec = parse_path_spec(r"//^tasks\..+$//").unwrap();
match spec {
PathSpec::Regex(re) => {
assert!(re.is_match("tasks.test"));
assert!(re.is_match("tasks.lock-check"));
assert!(!re.is_match("dependencies.serde"));
}
PathSpec::Literal(_) => panic!("//...// should parse as regex"),
}
}
#[test]
fn double_slash_inside_a_literal_is_not_a_regex() {
let spec = parse_path_spec("//").unwrap();
assert!(matches!(spec, PathSpec::Literal(s) if s == "//"));
}
#[test]
fn invalid_regex_surfaces_an_error() {
let err = parse_path_spec(r"//[unbalanced//").unwrap_err();
assert!(matches!(err, Error::Merge(_)));
}
#[test]
fn shallowest_matches_drops_descendants_when_ancestor_already_matches() {
let paths = vec![
"tasks".to_string(),
"tasks.default".to_string(),
"tasks.default.deps".to_string(),
"tasks.check".to_string(),
"dependencies".to_string(),
"dependencies.serde".to_string(),
];
let re = regex::Regex::new(".+").unwrap();
let mut kept = shallowest_matches(&paths, &re);
kept.sort();
assert_eq!(kept, vec!["dependencies".to_string(), "tasks".to_string()],);
}
#[test]
fn shallowest_matches_does_not_treat_sibling_prefixes_as_ancestors() {
let paths = vec!["tasks".to_string(), "tasks-clean.foo".to_string()];
let re = regex::Regex::new(".+").unwrap();
let mut kept = shallowest_matches(&paths, &re);
kept.sort();
assert_eq!(
kept,
vec!["tasks".to_string(), "tasks-clean.foo".to_string()],
);
}
#[test]
fn shallowest_matches_keeps_lone_leaf_when_ancestor_not_matched() {
let paths = vec!["tasks".to_string(), "tasks.test".to_string()];
let re = regex::Regex::new(r"\.test$").unwrap();
let kept = shallowest_matches(&paths, &re);
assert_eq!(kept, vec!["tasks.test".to_string()]);
}
#[test]
fn shallowest_matches_treats_open_bracket_as_step_boundary() {
let paths = vec![
"hooks.post_create".to_string(),
"hooks.post_create[0]".to_string(),
"hooks.post_create[0].cwd".to_string(),
];
let re = regex::Regex::new(".+").unwrap();
let kept = shallowest_matches(&paths, &re);
assert_eq!(kept, vec!["hooks.post_create".to_string()]);
}
#[test]
fn parse_segments_handles_plain_dotted_path() {
let segs = parse_segments("tasks.test").unwrap();
assert_eq!(
segs,
vec![
PathSeg::Key("tasks".to_string()),
PathSeg::Key("test".to_string()),
],
);
}
#[test]
fn parse_segments_recognises_trailing_index() {
let segs = parse_segments("hooks.post_create[0]").unwrap();
assert_eq!(
segs,
vec![
PathSeg::Key("hooks".to_string()),
PathSeg::KeyIndex("post_create".to_string(), 0),
],
);
}
#[test]
fn parse_segments_supports_index_in_middle_of_path() {
let segs = parse_segments("hooks.post_create[0].cwd").unwrap();
assert_eq!(
segs,
vec![
PathSeg::Key("hooks".to_string()),
PathSeg::KeyIndex("post_create".to_string(), 0),
PathSeg::Key("cwd".to_string()),
],
);
}
#[test]
fn parse_segments_rejects_empty_segment() {
let err = parse_segments("a..b").unwrap_err();
assert!(matches!(err, Error::Merge(_)));
}
#[test]
fn parse_segments_rejects_malformed_bracket() {
assert!(matches!(
parse_segments("hooks.post_create[0").unwrap_err(),
Error::Merge(_),
));
assert!(matches!(
parse_segments("hooks.post_create[0]extra").unwrap_err(),
Error::Merge(_),
));
assert!(matches!(
parse_segments("[0]").unwrap_err(),
Error::Merge(_),
));
assert!(matches!(
parse_segments("hooks.post_create[oops]").unwrap_err(),
Error::Merge(_),
));
}
}