Skip to main content

aristo_cli/
filter.rs

1//! J2 unified filter grammar shared across `aristo list` / `verify` /
2//! `graph` / `critique`.
3//!
4//! Form: `<key>=<value>`. Allowed keys: `id`, `file`, `parent`, `status`.
5//! Multiple `--filter` flags AND together at the call site (not modeled
6//! here — this type represents a single filter clause).
7
8use std::str::FromStr;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Filter {
12    Id(String),
13    /// Match by file path, optionally restricted to a closed line
14    /// range. Syntax: `file=<path>` or `file=<path>:<LO>-<HI>`.
15    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    /// No `=` separator in the filter expression.
26    MissingEquals { input: String },
27    /// Unknown left-hand side (typo or unsupported key).
28    UnknownKey { key: String },
29    /// Right-hand side is empty (e.g. `id=`).
30    EmptyValue { key: String },
31    /// `file=<path>:<LO>-<HI>` parse failed (LO/HI not integers, or
32    /// LO > HI, or syntax doesn't match).
33    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
89/// Split `<path>[:<LO>-<HI>]` into a `Filter::File`. The `:` separator
90/// for the range is the LAST `:` in the string so paths containing `:`
91/// (Windows drive letters, future namespacing) are accepted as-is when
92/// no range follows.
93fn parse_file_value(full_input: &str, value: &str) -> Result<Filter, FilterParseError> {
94    // Detect range presence via a trailing `:N-M` suffix on the value.
95    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
113/// Returns Some((path, "LO-HI")) iff value ends with `:<digits>-<digits>`.
114/// Returns None otherwise (treat as path-only — including paths that
115/// contain `:` not followed by a digit-range).
116fn split_off_trailing_range(value: &str) -> Option<(&str, &str)> {
117    let colon = value.rfind(':')?;
118    let candidate = &value[colon + 1..];
119    // Range suffix must contain a `-` separating two digit sequences.
120    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        // LO == HI is a one-line range; useful for `file=x.rs:42-42`.
187        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        // Path containing `:` not followed by a digit-range is taken
199        // verbatim (rare on Unix, but Windows drive letters & future
200        // namespacing shouldn't be eaten by the range parser).
201        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        // split_once('=') is greedy on the first `=`, so values with an `=`
243        // inside (rare for ids/paths but possible) survive.
244        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        // `aristos:` prefix contains a colon, not an equals — must round-trip.
253        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}