use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Filter {
Id(String),
File {
path: String,
line_range: Option<(u32, u32)>,
},
Parent(String),
Status(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum FilterParseError {
MissingEquals { input: String },
UnknownKey { key: String },
EmptyValue { key: String },
BadLineRange { input: String, detail: String },
}
impl std::fmt::Display for FilterParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FilterParseError::MissingEquals { input } => write!(
f,
"filter `{input}` is missing `=`; expected one of: \
id=<id>, file=<path>[:<LO>-<HI>], parent=<id>, status=<state>"
),
FilterParseError::UnknownKey { key } => write!(
f,
"unknown filter key `{key}`; expected one of: id, file, parent, status"
),
FilterParseError::EmptyValue { key } => {
write!(f, "filter `{key}=` has no value")
}
FilterParseError::BadLineRange { input, detail } => write!(
f,
"filter `{input}` has a bad line range: {detail} \
(expected `file=<path>:<LO>-<HI>` where LO and HI are positive \
integers and LO ≤ HI)"
),
}
}
}
impl std::error::Error for FilterParseError {}
impl FromStr for Filter {
type Err = FilterParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| FilterParseError::MissingEquals {
input: s.to_string(),
})?;
if value.is_empty() {
return Err(FilterParseError::EmptyValue {
key: key.to_string(),
});
}
match key {
"id" => Ok(Filter::Id(value.to_string())),
"file" => parse_file_value(s, value),
"parent" => Ok(Filter::Parent(value.to_string())),
"status" => Ok(Filter::Status(value.to_string())),
other => Err(FilterParseError::UnknownKey {
key: other.to_string(),
}),
}
}
}
fn parse_file_value(full_input: &str, value: &str) -> Result<Filter, FilterParseError> {
if let Some((path, range_str)) = split_off_trailing_range(value) {
let (lo, hi) =
parse_line_range(range_str).map_err(|detail| FilterParseError::BadLineRange {
input: full_input.to_string(),
detail,
})?;
Ok(Filter::File {
path: path.to_string(),
line_range: Some((lo, hi)),
})
} else {
Ok(Filter::File {
path: value.to_string(),
line_range: None,
})
}
}
fn split_off_trailing_range(value: &str) -> Option<(&str, &str)> {
let colon = value.rfind(':')?;
let candidate = &value[colon + 1..];
let dash = candidate.find('-')?;
let (lo_str, hi_str) = (&candidate[..dash], &candidate[dash + 1..]);
if lo_str.is_empty() || hi_str.is_empty() {
return None;
}
if !lo_str.bytes().all(|b| b.is_ascii_digit()) || !hi_str.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some((&value[..colon], candidate))
}
fn parse_line_range(range_str: &str) -> Result<(u32, u32), String> {
let (lo_str, hi_str) = range_str
.split_once('-')
.ok_or_else(|| format!("range `{range_str}` is missing `-` separator"))?;
let lo: u32 = lo_str
.parse()
.map_err(|e| format!("LO `{lo_str}` is not a u32: {e}"))?;
let hi: u32 = hi_str
.parse()
.map_err(|e| format!("HI `{hi_str}` is not a u32: {e}"))?;
if lo == 0 || hi == 0 {
return Err("LO and HI must be ≥ 1 (line numbers are 1-indexed)".into());
}
if lo > hi {
return Err(format!("LO ({lo}) is greater than HI ({hi})"));
}
Ok((lo, hi))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_id() {
assert_eq!(
"id=foo".parse::<Filter>().unwrap(),
Filter::Id("foo".into())
);
}
#[test]
fn parses_file_with_slashes() {
assert_eq!(
"file=src/lib.rs".parse::<Filter>().unwrap(),
Filter::File {
path: "src/lib.rs".into(),
line_range: None,
}
);
}
#[test]
fn parses_file_with_line_range() {
assert_eq!(
"file=src/lib.rs:10-50".parse::<Filter>().unwrap(),
Filter::File {
path: "src/lib.rs".into(),
line_range: Some((10, 50)),
}
);
}
#[test]
fn parses_file_with_single_line_range() {
assert_eq!(
"file=src/lib.rs:42-42".parse::<Filter>().unwrap(),
Filter::File {
path: "src/lib.rs".into(),
line_range: Some((42, 42)),
}
);
}
#[test]
fn file_with_colon_but_no_range_treated_as_path() {
assert_eq!(
"file=C:Users/foo.rs".parse::<Filter>().unwrap(),
Filter::File {
path: "C:Users/foo.rs".into(),
line_range: None,
}
);
}
#[test]
fn file_range_zero_rejected() {
let err = "file=src/x.rs:0-10".parse::<Filter>().unwrap_err();
assert!(matches!(err, FilterParseError::BadLineRange { .. }));
assert!(err.to_string().contains("≥ 1"));
}
#[test]
fn file_range_inverted_rejected() {
let err = "file=src/x.rs:50-10".parse::<Filter>().unwrap_err();
assert!(matches!(err, FilterParseError::BadLineRange { .. }));
assert!(err.to_string().contains("greater than"));
}
#[test]
fn parses_parent() {
assert_eq!(
"parent=root_invariants".parse::<Filter>().unwrap(),
Filter::Parent("root_invariants".into())
);
}
#[test]
fn parses_status() {
assert_eq!(
"status=verified".parse::<Filter>().unwrap(),
Filter::Status("verified".into())
);
}
#[test]
fn value_may_contain_equals_sign() {
assert_eq!(
"id=foo=bar".parse::<Filter>().unwrap(),
Filter::Id("foo=bar".into())
);
}
#[test]
fn aristos_namespaced_id_parses() {
assert_eq!(
"id=aristos:my_thing".parse::<Filter>().unwrap(),
Filter::Id("aristos:my_thing".into())
);
}
#[test]
fn missing_equals_rejected() {
let err = "id".parse::<Filter>().unwrap_err();
assert!(matches!(err, FilterParseError::MissingEquals { .. }));
assert!(err.to_string().contains("missing `=`"));
}
#[test]
fn unknown_key_rejected_with_helpful_message() {
let err = "kind=intent".parse::<Filter>().unwrap_err();
assert!(matches!(err, FilterParseError::UnknownKey { .. }));
let msg = err.to_string();
assert!(msg.contains("kind"));
assert!(msg.contains("id, file, parent, status"));
}
#[test]
fn empty_value_rejected() {
let err = "id=".parse::<Filter>().unwrap_err();
assert!(matches!(err, FilterParseError::EmptyValue { .. }));
}
}