use std::collections::BTreeMap;
use keel_core_api::policy::FlowMatchRule;
pub(crate) struct CompiledCmdRule {
pub(crate) entrypoint: String,
patterns: Vec<Vec<String>>,
wildcards: usize,
literal: usize,
}
pub(crate) fn compile_cmd_rules(
match_table: &BTreeMap<String, FlowMatchRule>,
) -> Vec<CompiledCmdRule> {
let mut out: Vec<CompiledCmdRule> = match_table
.iter()
.filter(|(_, rule)| !rule.argv.is_empty())
.map(|(entrypoint, rule)| {
let patterns: Vec<Vec<String>> = rule
.argv
.iter()
.map(|p| p.split('*').map(str::to_owned).collect())
.collect();
let wildcards: usize = rule.argv.iter().map(|p| p.matches('*').count()).sum();
let literal: usize = rule
.argv
.iter()
.map(|p| p.chars().count() - p.matches('*').count())
.sum();
CompiledCmdRule {
entrypoint: entrypoint.clone(),
patterns,
wildcards,
literal,
}
})
.collect();
out.sort_by(|a, b| {
a.wildcards
.cmp(&b.wildcards)
.then(b.literal.cmp(&a.literal))
.then(a.entrypoint.cmp(&b.entrypoint))
});
out
}
fn segments_match(segments: &[String], value: &str) -> bool {
if segments.len() == 1 {
return segments[0] == value;
}
let Some(mut rest) = value.strip_prefix(segments[0].as_str()) else {
return false;
};
let last = segments.last().expect("len() > 1 checked above");
let Some(mid) = rest.strip_suffix(last.as_str()) else {
return false;
};
rest = mid;
for seg in &segments[1..segments.len() - 1] {
if seg.is_empty() {
continue;
}
match rest.find(seg.as_str()) {
Some(idx) => rest = &rest[idx + seg.len()..],
None => return false,
}
}
true
}
pub(crate) fn match_argv<'a>(rules: &'a [CompiledCmdRule], argv: &[String]) -> Option<&'a str> {
rules
.iter()
.find(|rule| {
argv.len() >= rule.patterns.len()
&& rule
.patterns
.iter()
.zip(argv)
.all(|(pat, val)| segments_match(pat, val))
})
.map(|rule| rule.entrypoint.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn rules(entries: &[(&str, &[&str])]) -> Vec<CompiledCmdRule> {
let table: BTreeMap<String, FlowMatchRule> = entries
.iter()
.map(|(name, argv)| {
(
(*name).to_owned(),
FlowMatchRule {
argv: argv.iter().map(|s| (*s).to_owned()).collect(),
},
)
})
.collect();
compile_cmd_rules(&table)
}
#[test]
fn exact_argv_matches() {
let r = rules(&[("cmd:etl", &["etl", "run"])]);
let argv: Vec<String> = vec!["etl".into(), "run".into()];
assert_eq!(match_argv(&r, &argv), Some("cmd:etl"));
}
#[test]
fn mismatched_position_does_not_match() {
let r = rules(&[("cmd:etl", &["etl", "run"])]);
let argv: Vec<String> = vec!["etl".into(), "backfill".into()];
assert_eq!(match_argv(&r, &argv), None);
}
#[test]
fn shorter_argv_than_pattern_does_not_match() {
let r = rules(&[("cmd:etl", &["etl", "run"])]);
let argv: Vec<String> = vec!["etl".into()];
assert_eq!(match_argv(&r, &argv), None);
}
#[test]
fn trailing_unconstrained_args_still_match() {
let r = rules(&[("cmd:etl", &["etl", "run"])]);
let argv: Vec<String> = vec!["etl".into(), "run".into(), "--verbose".into()];
assert_eq!(match_argv(&r, &argv), Some("cmd:etl"));
}
#[test]
fn single_wildcard_position_matches_anything() {
let r = rules(&[("cmd:etl", &["etl", "*"])]);
let argv: Vec<String> = vec!["etl".into(), "backfill".into()];
assert_eq!(match_argv(&r, &argv), Some("cmd:etl"));
}
#[test]
fn wildcard_is_anchored_and_case_sensitive() {
let r = rules(&[("cmd:etl", &["etl-*"])]);
assert_eq!(
match_argv(&r, &["etl-run".to_owned()]),
Some("cmd:etl"),
"prefix+wildcard matches"
);
assert_eq!(
match_argv(&r, &["ETL-run".to_owned()]),
None,
"case-sensitive: differently-cased prefix does not match"
);
assert_eq!(
match_argv(&r, &["xetl-run".to_owned()]),
None,
"anchored: extra leading text before the literal prefix does not match"
);
}
#[test]
fn fewer_wildcards_wins_specificity_tie_break() {
let r = rules(&[("cmd:wild", &["*"]), ("cmd:exact", &["etl"])]);
let argv: Vec<String> = vec!["etl".into()];
assert_eq!(
match_argv(&r, &argv),
Some("cmd:exact"),
"the zero-wildcard rule is tried first even though both match"
);
}
#[test]
fn more_literal_chars_wins_when_wildcard_counts_tie() {
let r = rules(&[("cmd:short", &["e*"]), ("cmd:long", &["etl*"])]);
let argv: Vec<String> = vec!["etl-run".into()];
assert_eq!(
match_argv(&r, &argv),
Some("cmd:long"),
"both have one wildcard; the more-literal pattern is tried first"
);
}
#[test]
fn entrypoint_name_breaks_final_tie() {
let r = rules(&[("cmd:bbb", &["etl"]), ("cmd:aaa", &["etl"])]);
let argv: Vec<String> = vec!["etl".into()];
assert_eq!(match_argv(&r, &argv), Some("cmd:aaa"));
}
#[test]
fn empty_argv_pattern_rule_never_matches() {
let r = rules(&[("cmd:unset", &[])]);
assert!(
r.is_empty(),
"a rule with no argv patterns compiles to nothing"
);
let argv: Vec<String> = vec!["anything".into()];
assert_eq!(match_argv(&r, &argv), None);
}
}