1use std::str::FromStr;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Filter {
12 Id(String),
13 File {
16 path: String,
17 line_range: Option<(u32, u32)>,
18 },
19 Parent(String),
20 Status(String),
21}
22
23#[derive(Debug, PartialEq, Eq)]
24pub enum FilterParseError {
25 MissingEquals { input: String },
27 UnknownKey { key: String },
29 EmptyValue { key: String },
31 BadLineRange { input: String, detail: String },
34}
35
36impl std::fmt::Display for FilterParseError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 FilterParseError::MissingEquals { input } => write!(
40 f,
41 "filter `{input}` is missing `=`; expected one of: \
42 id=<id>, file=<path>[:<LO>-<HI>], parent=<id>, status=<state>"
43 ),
44 FilterParseError::UnknownKey { key } => write!(
45 f,
46 "unknown filter key `{key}`; expected one of: id, file, parent, status"
47 ),
48 FilterParseError::EmptyValue { key } => {
49 write!(f, "filter `{key}=` has no value")
50 }
51 FilterParseError::BadLineRange { input, detail } => write!(
52 f,
53 "filter `{input}` has a bad line range: {detail} \
54 (expected `file=<path>:<LO>-<HI>` where LO and HI are positive \
55 integers and LO ≤ HI)"
56 ),
57 }
58 }
59}
60
61impl std::error::Error for FilterParseError {}
62
63impl FromStr for Filter {
64 type Err = FilterParseError;
65
66 fn from_str(s: &str) -> Result<Self, Self::Err> {
67 let (key, value) = s
68 .split_once('=')
69 .ok_or_else(|| FilterParseError::MissingEquals {
70 input: s.to_string(),
71 })?;
72 if value.is_empty() {
73 return Err(FilterParseError::EmptyValue {
74 key: key.to_string(),
75 });
76 }
77 match key {
78 "id" => Ok(Filter::Id(value.to_string())),
79 "file" => parse_file_value(s, value),
80 "parent" => Ok(Filter::Parent(value.to_string())),
81 "status" => Ok(Filter::Status(value.to_string())),
82 other => Err(FilterParseError::UnknownKey {
83 key: other.to_string(),
84 }),
85 }
86 }
87}
88
89fn parse_file_value(full_input: &str, value: &str) -> Result<Filter, FilterParseError> {
94 if let Some((path, range_str)) = split_off_trailing_range(value) {
96 let (lo, hi) =
97 parse_line_range(range_str).map_err(|detail| FilterParseError::BadLineRange {
98 input: full_input.to_string(),
99 detail,
100 })?;
101 Ok(Filter::File {
102 path: path.to_string(),
103 line_range: Some((lo, hi)),
104 })
105 } else {
106 Ok(Filter::File {
107 path: value.to_string(),
108 line_range: None,
109 })
110 }
111}
112
113fn split_off_trailing_range(value: &str) -> Option<(&str, &str)> {
117 let colon = value.rfind(':')?;
118 let candidate = &value[colon + 1..];
119 let dash = candidate.find('-')?;
121 let (lo_str, hi_str) = (&candidate[..dash], &candidate[dash + 1..]);
122 if lo_str.is_empty() || hi_str.is_empty() {
123 return None;
124 }
125 if !lo_str.bytes().all(|b| b.is_ascii_digit()) || !hi_str.bytes().all(|b| b.is_ascii_digit()) {
126 return None;
127 }
128 Some((&value[..colon], candidate))
129}
130
131fn parse_line_range(range_str: &str) -> Result<(u32, u32), String> {
132 let (lo_str, hi_str) = range_str
133 .split_once('-')
134 .ok_or_else(|| format!("range `{range_str}` is missing `-` separator"))?;
135 let lo: u32 = lo_str
136 .parse()
137 .map_err(|e| format!("LO `{lo_str}` is not a u32: {e}"))?;
138 let hi: u32 = hi_str
139 .parse()
140 .map_err(|e| format!("HI `{hi_str}` is not a u32: {e}"))?;
141 if lo == 0 || hi == 0 {
142 return Err("LO and HI must be ≥ 1 (line numbers are 1-indexed)".into());
143 }
144 if lo > hi {
145 return Err(format!("LO ({lo}) is greater than HI ({hi})"));
146 }
147 Ok((lo, hi))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn parses_id() {
156 assert_eq!(
157 "id=foo".parse::<Filter>().unwrap(),
158 Filter::Id("foo".into())
159 );
160 }
161
162 #[test]
163 fn parses_file_with_slashes() {
164 assert_eq!(
165 "file=src/lib.rs".parse::<Filter>().unwrap(),
166 Filter::File {
167 path: "src/lib.rs".into(),
168 line_range: None,
169 }
170 );
171 }
172
173 #[test]
174 fn parses_file_with_line_range() {
175 assert_eq!(
176 "file=src/lib.rs:10-50".parse::<Filter>().unwrap(),
177 Filter::File {
178 path: "src/lib.rs".into(),
179 line_range: Some((10, 50)),
180 }
181 );
182 }
183
184 #[test]
185 fn parses_file_with_single_line_range() {
186 assert_eq!(
188 "file=src/lib.rs:42-42".parse::<Filter>().unwrap(),
189 Filter::File {
190 path: "src/lib.rs".into(),
191 line_range: Some((42, 42)),
192 }
193 );
194 }
195
196 #[test]
197 fn file_with_colon_but_no_range_treated_as_path() {
198 assert_eq!(
202 "file=C:Users/foo.rs".parse::<Filter>().unwrap(),
203 Filter::File {
204 path: "C:Users/foo.rs".into(),
205 line_range: None,
206 }
207 );
208 }
209
210 #[test]
211 fn file_range_zero_rejected() {
212 let err = "file=src/x.rs:0-10".parse::<Filter>().unwrap_err();
213 assert!(matches!(err, FilterParseError::BadLineRange { .. }));
214 assert!(err.to_string().contains("≥ 1"));
215 }
216
217 #[test]
218 fn file_range_inverted_rejected() {
219 let err = "file=src/x.rs:50-10".parse::<Filter>().unwrap_err();
220 assert!(matches!(err, FilterParseError::BadLineRange { .. }));
221 assert!(err.to_string().contains("greater than"));
222 }
223
224 #[test]
225 fn parses_parent() {
226 assert_eq!(
227 "parent=root_invariants".parse::<Filter>().unwrap(),
228 Filter::Parent("root_invariants".into())
229 );
230 }
231
232 #[test]
233 fn parses_status() {
234 assert_eq!(
235 "status=verified".parse::<Filter>().unwrap(),
236 Filter::Status("verified".into())
237 );
238 }
239
240 #[test]
241 fn value_may_contain_equals_sign() {
242 assert_eq!(
245 "id=foo=bar".parse::<Filter>().unwrap(),
246 Filter::Id("foo=bar".into())
247 );
248 }
249
250 #[test]
251 fn aristos_namespaced_id_parses() {
252 assert_eq!(
254 "id=aristos:my_thing".parse::<Filter>().unwrap(),
255 Filter::Id("aristos:my_thing".into())
256 );
257 }
258
259 #[test]
260 fn missing_equals_rejected() {
261 let err = "id".parse::<Filter>().unwrap_err();
262 assert!(matches!(err, FilterParseError::MissingEquals { .. }));
263 assert!(err.to_string().contains("missing `=`"));
264 }
265
266 #[test]
267 fn unknown_key_rejected_with_helpful_message() {
268 let err = "kind=intent".parse::<Filter>().unwrap_err();
269 assert!(matches!(err, FilterParseError::UnknownKey { .. }));
270 let msg = err.to_string();
271 assert!(msg.contains("kind"));
272 assert!(msg.contains("id, file, parent, status"));
273 }
274
275 #[test]
276 fn empty_value_rejected() {
277 let err = "id=".parse::<Filter>().unwrap_err();
278 assert!(matches!(err, FilterParseError::EmptyValue { .. }));
279 }
280}