Skip to main content

open_loops/
query.rs

1//! Query parsing and in-memory evaluation. Pure: no git, no I/O.
2//! Grammar lives in ADR 0003. This module turns a query string into a
3//! `ScanPlan` and decides whether a candidate loop matches it.
4use anyhow::{bail, Result};
5use chrono::{DateTime, Duration, Utc};
6
7/// Numeric/temporal comparator for `idle`/`ahead`/`behind`.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Cmp {
10    Gt,
11    Lt,
12    Ge,
13    Le,
14    Eq,
15}
16
17impl Cmp {
18    fn test_i64(self, lhs: i64, rhs: i64) -> bool {
19        match self {
20            Cmp::Gt => lhs > rhs,
21            Cmp::Lt => lhs < rhs,
22            Cmp::Ge => lhs >= rhs,
23            Cmp::Le => lhs <= rhs,
24            Cmp::Eq => lhs == rhs,
25        }
26    }
27}
28
29/// An attribute filter evaluated in memory after the scan.
30#[derive(Debug, Clone, PartialEq)]
31pub enum AttrFilter {
32    Idle(Cmp, Duration),
33    Ahead(Cmp, u32),
34    Behind(Cmp, u32),
35}
36
37/// The parsed query, derived before any heavy I/O.
38#[derive(Debug, Clone, Default, PartialEq)]
39pub struct ScanPlan {
40    /// Bare terms; each must substring-match repo, branch, or key (AND across terms).
41    pub terms: Vec<String>,
42    pub repo_filter: Option<String>,
43    pub branch_filter: Option<String>,
44    pub key_filter: Option<String>,
45    /// Raw `root:` value; resolved against configured roots in Phase 2 push-down.
46    pub root_filter: Option<String>,
47    pub attr_filters: Vec<AttrFilter>,
48    /// `+ignored` includes dismissed loops; default hides them.
49    pub include_ignored: bool,
50    /// True when AHEAD/BEHIND must be available (query references them, or the
51    /// caller renders the columns — the caller ORs in the render need).
52    pub need_ahead_behind: bool,
53}
54
55/// A loop as seen by the evaluator. Borrowed to keep `matches` allocation-free.
56pub struct Candidate<'a> {
57    pub repo_name: &'a str,
58    pub branch: &'a str,
59    pub key: &'a str,
60    pub last_commit: DateTime<Utc>,
61    pub ahead: Option<u32>,
62    pub behind: Option<u32>,
63    pub ignored: bool,
64}
65
66/// Parses a query string into a `ScanPlan`. Tokens split on whitespace only —
67/// a `/` is literal inside a term.
68pub fn parse(input: &str) -> Result<ScanPlan> {
69    let mut plan = ScanPlan::default();
70    for tok in input.split_whitespace() {
71        match tok {
72            "+ignored" => {
73                plan.include_ignored = true;
74                continue;
75            }
76            "-ignored" => {
77                plan.include_ignored = false;
78                continue;
79            }
80            "+stale" => bail!("'+stale' is not supported yet (ADR 0003 phase 5)"),
81            _ => {}
82        }
83        if let Some(name) = tok.strip_prefix('@') {
84            bail!(
85                "contexts (@{}) are not supported yet (ADR 0003 phase 4)",
86                name
87            );
88        }
89        if tok.starts_with(':') {
90            bail!("reports ({tok}) are not supported yet (ADR 0003 phase 5)");
91        }
92        if let Some((name, val)) = split_attr(tok) {
93            match name {
94                "repo" => plan.repo_filter = Some(val.to_string()),
95                "branch" => plan.branch_filter = Some(val.to_string()),
96                "key" => plan.key_filter = Some(val.to_string()),
97                "root" => plan.root_filter = Some(val.to_string()),
98                "idle" => {
99                    let (cmp, rest) = split_cmp(val, true)
100                        .ok_or_else(|| anyhow::anyhow!("idle needs a comparator, e.g. idle:>7d"))?;
101                    plan.attr_filters
102                        .push(AttrFilter::Idle(cmp, parse_duration(rest)?));
103                }
104                "ahead" => {
105                    // split_cmp(val, false) always returns Some — defaults to Cmp::Eq when no operator
106                    let (cmp, rest) = split_cmp(val, false).expect("optional op never None");
107                    plan.attr_filters
108                        .push(AttrFilter::Ahead(cmp, parse_count(rest)?));
109                    plan.need_ahead_behind = true;
110                }
111                "behind" => {
112                    // split_cmp(val, false) always returns Some — defaults to Cmp::Eq when no operator
113                    let (cmp, rest) = split_cmp(val, false).expect("optional op never None");
114                    plan.attr_filters
115                        .push(AttrFilter::Behind(cmp, parse_count(rest)?));
116                    plan.need_ahead_behind = true;
117                }
118                _ => unreachable!("split_attr only returns known names"),
119            }
120        } else {
121            plan.terms.push(tok.to_string());
122        }
123    }
124    Ok(plan)
125}
126
127/// Returns `(name, value)` when `tok` is `name:value` and `name` is a known
128/// attribute; otherwise `None` (the caller treats the token as a bare term).
129fn split_attr(tok: &str) -> Option<(&str, &str)> {
130    let (name, val) = tok.split_once(':')?;
131    matches!(
132        name,
133        "repo" | "branch" | "key" | "root" | "idle" | "ahead" | "behind"
134    )
135    .then_some((name, val))
136}
137
138/// Splits a leading comparator off a value. When `require_op` and none is
139/// present, returns `None`; otherwise defaults to `Cmp::Eq`.
140fn split_cmp(val: &str, require_op: bool) -> Option<(Cmp, &str)> {
141    for (prefix, cmp) in [
142        (">=", Cmp::Ge),
143        ("<=", Cmp::Le),
144        (">", Cmp::Gt),
145        ("<", Cmp::Lt),
146    ] {
147        if let Some(rest) = val.strip_prefix(prefix) {
148            return Some((cmp, rest));
149        }
150    }
151    if require_op {
152        None
153    } else {
154        Some((Cmp::Eq, val))
155    }
156}
157
158fn parse_count(s: &str) -> Result<u32> {
159    s.parse::<u32>()
160        .map_err(|_| anyhow::anyhow!("expected a number, got '{s}'"))
161}
162
163/// Parses `<N><unit>` where unit is one of m/h/d/w.
164pub fn parse_duration(s: &str) -> Result<Duration> {
165    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
166    let n: i64 = num
167        .parse()
168        .map_err(|_| anyhow::anyhow!("invalid duration '{s}' (expected e.g. 7d)"))?;
169    match unit {
170        "m" => Ok(Duration::minutes(n)),
171        "h" => Ok(Duration::hours(n)),
172        "d" => Ok(Duration::days(n)),
173        "w" => Ok(Duration::weeks(n)),
174        other => bail!("invalid duration unit '{other}' (use m, h, d, or w)"),
175    }
176}
177
178impl ScanPlan {
179    /// True when the candidate satisfies every term, substring filter, and
180    /// attribute. `root_filter` is intentionally ignored here (push-down).
181    pub fn matches(&self, c: &Candidate, now: DateTime<Utc>) -> bool {
182        if c.ignored && !self.include_ignored {
183            return false;
184        }
185        let contains_ci =
186            |hay: &str, needle: &str| hay.to_lowercase().contains(&needle.to_lowercase());
187        for t in &self.terms {
188            if !(contains_ci(c.repo_name, t) || contains_ci(c.branch, t) || contains_ci(c.key, t)) {
189                return false;
190            }
191        }
192        if let Some(f) = &self.repo_filter {
193            if !contains_ci(c.repo_name, f) {
194                return false;
195            }
196        }
197        if let Some(f) = &self.branch_filter {
198            if !contains_ci(c.branch, f) {
199                return false;
200            }
201        }
202        if let Some(f) = &self.key_filter {
203            if !contains_ci(c.key, f) {
204                return false;
205            }
206        }
207        for attr in &self.attr_filters {
208            let ok = match attr {
209                AttrFilter::Idle(cmp, dur) => {
210                    cmp.test_i64((now - c.last_commit).num_seconds(), dur.num_seconds())
211                }
212                AttrFilter::Ahead(cmp, n) => {
213                    c.ahead.is_some_and(|a| cmp.test_i64(a.into(), (*n).into()))
214                }
215                AttrFilter::Behind(cmp, n) => c
216                    .behind
217                    .is_some_and(|b| cmp.test_i64(b.into(), (*n).into())),
218            };
219            if !ok {
220                return false;
221            }
222        }
223        true
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn parse_bare_terms_and_substring_attrs() {
233        let p = parse("api feat/login repo:billing branch:fix/ key:work/api root:~/work").unwrap();
234        assert_eq!(p.terms, vec!["api".to_string(), "feat/login".to_string()]);
235        assert_eq!(p.repo_filter.as_deref(), Some("billing"));
236        assert_eq!(p.branch_filter.as_deref(), Some("fix/"));
237        assert_eq!(p.key_filter.as_deref(), Some("work/api"));
238        assert_eq!(p.root_filter.as_deref(), Some("~/work"));
239        assert!(!p.need_ahead_behind);
240    }
241
242    #[test]
243    fn unknown_attr_prefix_is_a_bare_term() {
244        // a stray colon on an unknown name is not an error; it is a term
245        let p = parse("foo:bar").unwrap();
246        assert_eq!(p.terms, vec!["foo:bar".to_string()]);
247    }
248
249    #[test]
250    fn parse_numeric_and_duration_attrs() {
251        let p = parse("idle:>7d behind:>0 ahead:0").unwrap();
252        assert_eq!(
253            p.attr_filters,
254            vec![
255                AttrFilter::Idle(Cmp::Gt, Duration::days(7)),
256                AttrFilter::Behind(Cmp::Gt, 0),
257                AttrFilter::Ahead(Cmp::Eq, 0),
258            ]
259        );
260        // ahead/behind attrs force the heavy phase
261        assert!(p.need_ahead_behind);
262    }
263
264    #[test]
265    fn idle_without_operator_is_an_error() {
266        let err = parse("idle:7d").unwrap_err().to_string();
267        assert!(err.contains("idle"), "got: {err}");
268    }
269
270    #[test]
271    fn bad_duration_unit_is_an_error() {
272        let err = parse("idle:>7y").unwrap_err().to_string();
273        assert!(err.contains("duration"), "got: {err}");
274    }
275
276    #[test]
277    fn duration_units_minutes_hours_days_weeks() {
278        assert_eq!(parse_duration("30m").unwrap(), Duration::minutes(30));
279        assert_eq!(parse_duration("6h").unwrap(), Duration::hours(6));
280        assert_eq!(parse_duration("2d").unwrap(), Duration::days(2));
281        assert_eq!(parse_duration("3w").unwrap(), Duration::weeks(3));
282    }
283
284    #[test]
285    fn parse_ignored_tags() {
286        assert!(parse("+ignored").unwrap().include_ignored);
287        assert!(!parse("-ignored").unwrap().include_ignored);
288        assert!(!parse("api").unwrap().include_ignored); // default hides
289    }
290
291    #[test]
292    fn reserved_context_report_stale_error_clearly() {
293        assert!(parse("@work").unwrap_err().to_string().contains("context"));
294        assert!(parse(":hot").unwrap_err().to_string().contains("report"));
295        assert!(parse("+stale").unwrap_err().to_string().contains("stale"));
296    }
297
298    fn cand<'a>(repo: &'a str, branch: &'a str, key: &'a str, days_idle: i64) -> Candidate<'a> {
299        Candidate {
300            repo_name: repo,
301            branch,
302            key,
303            last_commit: Utc::now() - Duration::days(days_idle),
304            ahead: Some(1),
305            behind: Some(0),
306            ignored: false,
307        }
308    }
309
310    #[test]
311    fn matches_terms_case_insensitive_over_repo_branch_key() {
312        let p = parse("API").unwrap();
313        let c = cand("my-api", "feat/x", "work/my-api/feat/x", 1);
314        assert!(p.matches(&c, Utc::now()));
315        let p2 = parse("nope").unwrap();
316        assert!(!p2.matches(&c, Utc::now()));
317    }
318
319    #[test]
320    fn matches_idle_and_numeric_attrs() {
321        let now = Utc::now();
322        let c = cand("api", "feat/x", "w/api/feat/x", 10);
323        assert!(parse("idle:>7d").unwrap().matches(&c, now));
324        assert!(!parse("idle:<7d").unwrap().matches(&c, now));
325        assert!(parse("behind:0").unwrap().matches(&c, now));
326        assert!(!parse("behind:>0").unwrap().matches(&c, now));
327    }
328
329    #[test]
330    fn matches_excludes_ignored_unless_plus_ignored() {
331        let now = Utc::now();
332        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
333        c.ignored = true;
334        assert!(!parse("api").unwrap().matches(&c, now));
335        assert!(parse("api +ignored").unwrap().matches(&c, now));
336    }
337
338    #[test]
339    fn matches_none_ahead_behind_fails_the_attr() {
340        let now = Utc::now();
341        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
342        c.behind = None;
343        assert!(!parse("behind:0").unwrap().matches(&c, now));
344    }
345}