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 crate::config::Config;
5use anyhow::{bail, Result};
6use chrono::{DateTime, Duration, Utc};
7
8/// Numeric/temporal comparator for `idle`/`ahead`/`behind`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Cmp {
11    Gt,
12    Lt,
13    Ge,
14    Le,
15    Eq,
16}
17
18impl Cmp {
19    fn test_i64(self, lhs: i64, rhs: i64) -> bool {
20        match self {
21            Cmp::Gt => lhs > rhs,
22            Cmp::Lt => lhs < rhs,
23            Cmp::Ge => lhs >= rhs,
24            Cmp::Le => lhs <= rhs,
25            Cmp::Eq => lhs == rhs,
26        }
27    }
28}
29
30/// An attribute filter evaluated in memory after the scan.
31#[derive(Debug, Clone, PartialEq)]
32pub enum AttrFilter {
33    Idle(Cmp, Duration),
34    Ahead(Cmp, u32),
35    Behind(Cmp, u32),
36}
37
38/// The parsed query, derived before any heavy I/O.
39#[derive(Debug, Clone, Default, PartialEq)]
40pub struct ScanPlan {
41    /// Bare terms; each must substring-match repo, branch, or key (AND across terms).
42    pub terms: Vec<String>,
43    pub repo_filters: Vec<String>,
44    pub branch_filters: Vec<String>,
45    pub key_filters: Vec<String>,
46    /// Raw `root:` values; resolved against configured roots in Phase 2 push-down.
47    pub root_filters: Vec<String>,
48    pub attr_filters: Vec<AttrFilter>,
49    /// `+ignored` includes dismissed loops; default hides them.
50    pub include_ignored: bool,
51    /// True when AHEAD/BEHIND must be available (query references them, or the
52    /// caller renders the columns — the caller ORs in the render need).
53    pub need_ahead_behind: bool,
54}
55
56/// A loop as seen by the evaluator. Borrowed to keep `matches` allocation-free.
57pub struct Candidate<'a> {
58    pub repo_name: &'a str,
59    pub branch: &'a str,
60    pub key: &'a str,
61    pub last_commit: DateTime<Utc>,
62    pub ahead: Option<u32>,
63    pub behind: Option<u32>,
64    pub ignored: bool,
65}
66
67/// Parses a query string into a `ScanPlan`. Tokens split on whitespace only —
68/// a `/` is literal inside a term.
69pub fn parse(input: &str) -> Result<ScanPlan> {
70    let mut plan = ScanPlan::default();
71    for tok in input.split_whitespace() {
72        match tok {
73            "+ignored" => {
74                plan.include_ignored = true;
75                continue;
76            }
77            "-ignored" => {
78                plan.include_ignored = false;
79                continue;
80            }
81            "+stale" => bail!("'+stale' is not supported yet (ADR 0003 phase 5)"),
82            _ => {}
83        }
84        if tok.starts_with(':') {
85            bail!("reports ({tok}) are not supported yet (ADR 0003 phase 5)");
86        }
87        if let Some((name, val)) = split_attr(tok) {
88            match name {
89                "repo" => plan.repo_filters.push(val.to_string()),
90                "branch" => plan.branch_filters.push(val.to_string()),
91                "key" => plan.key_filters.push(val.to_string()),
92                "root" => plan.root_filters.push(val.to_string()),
93                "idle" => {
94                    let (cmp, rest) = split_cmp(val, true)
95                        .ok_or_else(|| anyhow::anyhow!("idle needs a comparator, e.g. idle:>7d"))?;
96                    plan.attr_filters
97                        .push(AttrFilter::Idle(cmp, parse_duration(rest)?));
98                }
99                "ahead" => {
100                    // split_cmp(val, false) always returns Some — defaults to Cmp::Eq when no operator
101                    let (cmp, rest) = split_cmp(val, false).expect("optional op never None");
102                    plan.attr_filters
103                        .push(AttrFilter::Ahead(cmp, parse_count(rest)?));
104                    plan.need_ahead_behind = true;
105                }
106                "behind" => {
107                    // split_cmp(val, false) always returns Some — defaults to Cmp::Eq when no operator
108                    let (cmp, rest) = split_cmp(val, false).expect("optional op never None");
109                    plan.attr_filters
110                        .push(AttrFilter::Behind(cmp, parse_count(rest)?));
111                    plan.need_ahead_behind = true;
112                }
113                _ => unreachable!("split_attr only returns known names"),
114            }
115        } else {
116            plan.terms.push(tok.to_string());
117        }
118    }
119    Ok(plan)
120}
121
122/// Options for [`resolve_plan`].
123pub struct ResolveOptions<'a> {
124    /// Active context from `state.toml` — used when the query has no `@`.
125    pub current_context: Option<&'a str>,
126}
127
128/// Whether an explicit `@` token in the query should update persisted config.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ContextPersistence {
131    /// `@name` — save to `state.toml`.
132    Set(String),
133    /// `@none` / `@all` — clear `state.toml`.
134    Clear,
135    /// No `@` token — leave config unchanged.
136    Unchanged,
137}
138
139/// Parses `@` usage for config persistence (call after [`resolve_plan`] succeeds).
140pub fn context_persistence_from_query(input: &str) -> Result<ContextPersistence> {
141    let tokens: Vec<&str> = input.split_whitespace().collect();
142    let at_count = tokens.iter().filter(|t| t.starts_with('@')).count();
143    if at_count > 1 {
144        bail!("only one @context per query");
145    }
146    match tokens.iter().find(|t| t.starts_with('@')) {
147        None => Ok(ContextPersistence::Unchanged),
148        Some(tok) => {
149            let name = tok.strip_prefix('@').unwrap();
150            if name == "none" || name == "all" {
151                Ok(ContextPersistence::Clear)
152            } else {
153                Ok(ContextPersistence::Set(name.to_string()))
154            }
155        }
156    }
157}
158
159/// Merges two plans with AND semantics across filters and OR across flags.
160pub fn merge_scan_plans(base: ScanPlan, overlay: ScanPlan) -> ScanPlan {
161    ScanPlan {
162        terms: {
163            let mut terms = base.terms;
164            terms.extend(overlay.terms);
165            terms
166        },
167        repo_filters: {
168            let mut filters = base.repo_filters;
169            filters.extend(overlay.repo_filters);
170            filters
171        },
172        branch_filters: {
173            let mut filters = base.branch_filters;
174            filters.extend(overlay.branch_filters);
175            filters
176        },
177        key_filters: {
178            let mut filters = base.key_filters;
179            filters.extend(overlay.key_filters);
180            filters
181        },
182        root_filters: {
183            let mut filters = base.root_filters;
184            filters.extend(overlay.root_filters);
185            filters
186        },
187        attr_filters: {
188            let mut filters = base.attr_filters;
189            filters.extend(overlay.attr_filters);
190            filters
191        },
192        include_ignored: base.include_ignored || overlay.include_ignored,
193        need_ahead_behind: base.need_ahead_behind || overlay.need_ahead_behind,
194    }
195}
196
197fn validate_context_filter(name: &str, filter: &str) -> Result<()> {
198    if filter.contains('@') {
199        bail!("context '{name}' filter cannot contain '@' or ':' (reports are phase 5)");
200    }
201    for tok in filter.split_whitespace() {
202        if tok.starts_with(':') {
203            bail!("context '{name}' filter cannot contain '@' or ':' (reports are phase 5)");
204        }
205    }
206    Ok(())
207}
208
209/// Resolves `@context` tokens and default context into a single [`ScanPlan`].
210pub fn resolve_plan(input: &str, cfg: &Config, opts: &ResolveOptions) -> Result<ScanPlan> {
211    let tokens: Vec<&str> = input.split_whitespace().collect();
212    let at_count = tokens.iter().filter(|t| t.starts_with('@')).count();
213    if at_count > 1 {
214        bail!("only one @context per query");
215    }
216
217    let has_at = at_count == 1;
218    let mut plans = Vec::new();
219
220    if !has_at {
221        if let Some(ctx) = opts.current_context {
222            let filter = cfg.context_filter(ctx)?;
223            validate_context_filter(ctx, filter)?;
224            plans.push(parse(filter)?);
225        }
226    }
227
228    let mut user_tokens = Vec::new();
229    for tok in tokens {
230        if let Some(name) = tok.strip_prefix('@') {
231            if name == "none" || name == "all" {
232                continue;
233            }
234            let filter = cfg.context_filter(name)?;
235            validate_context_filter(name, filter)?;
236            plans.push(parse(filter)?);
237        } else {
238            user_tokens.push(tok);
239        }
240    }
241
242    if !user_tokens.is_empty() {
243        plans.push(parse(&user_tokens.join(" "))?);
244    }
245
246    match plans.len() {
247        0 => Ok(ScanPlan::default()),
248        1 => Ok(plans.remove(0)),
249        _ => Ok(plans
250            .into_iter()
251            .reduce(merge_scan_plans)
252            .expect("len checked >= 2")),
253    }
254}
255
256/// Returns `(name, value)` when `tok` is `name:value` and `name` is a known
257/// attribute; otherwise `None` (the caller treats the token as a bare term).
258fn split_attr(tok: &str) -> Option<(&str, &str)> {
259    let (name, val) = tok.split_once(':')?;
260    matches!(
261        name,
262        "repo" | "branch" | "key" | "root" | "idle" | "ahead" | "behind"
263    )
264    .then_some((name, val))
265}
266
267/// Splits a leading comparator off a value. When `require_op` and none is
268/// present, returns `None`; otherwise defaults to `Cmp::Eq`.
269fn split_cmp(val: &str, require_op: bool) -> Option<(Cmp, &str)> {
270    for (prefix, cmp) in [
271        (">=", Cmp::Ge),
272        ("<=", Cmp::Le),
273        (">", Cmp::Gt),
274        ("<", Cmp::Lt),
275    ] {
276        if let Some(rest) = val.strip_prefix(prefix) {
277            return Some((cmp, rest));
278        }
279    }
280    if require_op {
281        None
282    } else {
283        Some((Cmp::Eq, val))
284    }
285}
286
287fn parse_count(s: &str) -> Result<u32> {
288    s.parse::<u32>()
289        .map_err(|_| anyhow::anyhow!("expected a number, got '{s}'"))
290}
291
292/// Parses `<N><unit>` where unit is one of m/h/d/w.
293pub fn parse_duration(s: &str) -> Result<Duration> {
294    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
295    let n: i64 = num
296        .parse()
297        .map_err(|_| anyhow::anyhow!("invalid duration '{s}' (expected e.g. 7d)"))?;
298    match unit {
299        "m" => Ok(Duration::minutes(n)),
300        "h" => Ok(Duration::hours(n)),
301        "d" => Ok(Duration::days(n)),
302        "w" => Ok(Duration::weeks(n)),
303        other => bail!("invalid duration unit '{other}' (use m, h, d, or w)"),
304    }
305}
306
307impl ScanPlan {
308    /// True when the candidate satisfies every term, substring filter, and
309    /// attribute. `root_filters` are intentionally ignored here (push-down).
310    pub fn matches(&self, c: &Candidate, now: DateTime<Utc>) -> bool {
311        if c.ignored && !self.include_ignored {
312            return false;
313        }
314        let contains_ci =
315            |hay: &str, needle: &str| hay.to_lowercase().contains(&needle.to_lowercase());
316        for t in &self.terms {
317            if !(contains_ci(c.repo_name, t) || contains_ci(c.branch, t) || contains_ci(c.key, t)) {
318                return false;
319            }
320        }
321        for f in &self.repo_filters {
322            if !contains_ci(c.repo_name, f) {
323                return false;
324            }
325        }
326        for f in &self.branch_filters {
327            if !contains_ci(c.branch, f) {
328                return false;
329            }
330        }
331        for f in &self.key_filters {
332            if !contains_ci(c.key, f) {
333                return false;
334            }
335        }
336        for attr in &self.attr_filters {
337            let ok = match attr {
338                AttrFilter::Idle(cmp, dur) => {
339                    cmp.test_i64((now - c.last_commit).num_seconds(), dur.num_seconds())
340                }
341                AttrFilter::Ahead(cmp, n) => {
342                    c.ahead.is_some_and(|a| cmp.test_i64(a.into(), (*n).into()))
343                }
344                AttrFilter::Behind(cmp, n) => c
345                    .behind
346                    .is_some_and(|b| cmp.test_i64(b.into(), (*n).into())),
347            };
348            if !ok {
349                return false;
350            }
351        }
352        true
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::config::{Config, ContextDef};
360    use std::collections::BTreeMap;
361
362    fn test_cfg() -> Config {
363        Config {
364            contexts: BTreeMap::from([
365                (
366                    "work".into(),
367                    ContextDef {
368                        filter: "root:~/work".into(),
369                    },
370                ),
371                (
372                    "personal".into(),
373                    ContextDef {
374                        filter: "root:~/personal".into(),
375                    },
376                ),
377                (
378                    "recent-work".into(),
379                    ContextDef {
380                        filter: "root:~/work idle:<=30d".into(),
381                    },
382                ),
383            ]),
384            ..Config::default()
385        }
386    }
387
388    #[test]
389    fn parse_bare_terms_and_substring_attrs() {
390        let p = parse("api feat/login repo:billing branch:fix/ key:work/api root:~/work").unwrap();
391        assert_eq!(p.terms, vec!["api".to_string(), "feat/login".to_string()]);
392        assert_eq!(p.repo_filters, vec!["billing".to_string()]);
393        assert_eq!(p.branch_filters, vec!["fix/".to_string()]);
394        assert_eq!(p.key_filters, vec!["work/api".to_string()]);
395        assert_eq!(p.root_filters, vec!["~/work".to_string()]);
396        assert!(!p.need_ahead_behind);
397    }
398
399    #[test]
400    fn unknown_attr_prefix_is_a_bare_term() {
401        // a stray colon on an unknown name is not an error; it is a term
402        let p = parse("foo:bar").unwrap();
403        assert_eq!(p.terms, vec!["foo:bar".to_string()]);
404    }
405
406    #[test]
407    fn parse_numeric_and_duration_attrs() {
408        let p = parse("idle:>7d behind:>0 ahead:0").unwrap();
409        assert_eq!(
410            p.attr_filters,
411            vec![
412                AttrFilter::Idle(Cmp::Gt, Duration::days(7)),
413                AttrFilter::Behind(Cmp::Gt, 0),
414                AttrFilter::Ahead(Cmp::Eq, 0),
415            ]
416        );
417        // ahead/behind attrs force the heavy phase
418        assert!(p.need_ahead_behind);
419    }
420
421    #[test]
422    fn idle_without_operator_is_an_error() {
423        let err = parse("idle:7d").unwrap_err().to_string();
424        assert!(err.contains("idle"), "got: {err}");
425    }
426
427    #[test]
428    fn bad_duration_unit_is_an_error() {
429        let err = parse("idle:>7y").unwrap_err().to_string();
430        assert!(err.contains("duration"), "got: {err}");
431    }
432
433    #[test]
434    fn duration_units_minutes_hours_days_weeks() {
435        assert_eq!(parse_duration("30m").unwrap(), Duration::minutes(30));
436        assert_eq!(parse_duration("6h").unwrap(), Duration::hours(6));
437        assert_eq!(parse_duration("2d").unwrap(), Duration::days(2));
438        assert_eq!(parse_duration("3w").unwrap(), Duration::weeks(3));
439    }
440
441    #[test]
442    fn parse_ignored_tags() {
443        assert!(parse("+ignored").unwrap().include_ignored);
444        assert!(!parse("-ignored").unwrap().include_ignored);
445        assert!(!parse("api").unwrap().include_ignored); // default hides
446    }
447
448    #[test]
449    fn reserved_report_and_stale_error_clearly() {
450        assert!(parse(":hot").unwrap_err().to_string().contains("report"));
451        assert!(parse("+stale").unwrap_err().to_string().contains("stale"));
452    }
453
454    #[test]
455    fn merge_scan_plans_combines_root_filters() {
456        let a = parse("root:~/work").unwrap();
457        let b = parse("root:~/personal").unwrap();
458        let merged = merge_scan_plans(a, b);
459        assert_eq!(
460            merged.root_filters,
461            vec!["~/work".to_string(), "~/personal".to_string()]
462        );
463    }
464
465    #[test]
466    fn resolve_plan_applies_current_context() {
467        let cfg = test_cfg();
468        let opts = ResolveOptions {
469            current_context: Some("work"),
470        };
471        let expected = parse("root:~/work api").unwrap();
472        let got = resolve_plan("api", &cfg, &opts).unwrap();
473        assert_eq!(got, expected);
474    }
475
476    #[test]
477    fn resolve_plan_explicit_context_replaces_current() {
478        let cfg = test_cfg();
479        let opts = ResolveOptions {
480            current_context: Some("work"),
481        };
482        let expected = parse("root:~/personal api").unwrap();
483        let got = resolve_plan("@personal api", &cfg, &opts).unwrap();
484        assert_eq!(got, expected);
485    }
486
487    #[test]
488    fn resolve_plan_none_clears_current() {
489        let cfg = test_cfg();
490        let opts = ResolveOptions {
491            current_context: Some("work"),
492        };
493        let expected = parse("api").unwrap();
494        let got = resolve_plan("@none api", &cfg, &opts).unwrap();
495        assert_eq!(got, expected);
496    }
497
498    #[test]
499    fn resolve_plan_unknown_context_errors() {
500        let cfg = test_cfg();
501        let opts = ResolveOptions {
502            current_context: Some("work"),
503        };
504        let err = resolve_plan("@missing", &cfg, &opts)
505            .unwrap_err()
506            .to_string();
507        assert!(err.contains("unknown context '@missing'"), "got: {err}");
508    }
509
510    #[test]
511    fn resolve_plan_context_with_idle_filter() {
512        let cfg = test_cfg();
513        let opts = ResolveOptions {
514            current_context: None,
515        };
516        let plan = resolve_plan("@recent-work", &cfg, &opts).unwrap();
517        assert_eq!(plan.root_filters, vec!["~/work".to_string()]);
518        assert_eq!(
519            plan.attr_filters,
520            vec![AttrFilter::Idle(Cmp::Le, Duration::days(30))]
521        );
522    }
523
524    #[test]
525    fn resolve_plan_rejects_nested_context_in_filter() {
526        let cfg = Config {
527            contexts: BTreeMap::from([(
528                "bad".into(),
529                ContextDef {
530                    filter: "@work".into(),
531                },
532            )]),
533            ..Config::default()
534        };
535        let opts = ResolveOptions {
536            current_context: None,
537        };
538        let err = resolve_plan("@bad", &cfg, &opts).unwrap_err().to_string();
539        assert!(err.contains("cannot contain '@' or ':'"), "got: {err}");
540    }
541
542    #[test]
543    fn resolve_plan_rejects_two_context_tokens() {
544        let cfg = test_cfg();
545        let opts = ResolveOptions {
546            current_context: None,
547        };
548        let err = resolve_plan("@work @personal", &cfg, &opts)
549            .unwrap_err()
550            .to_string();
551        assert!(err.contains("only one @context per query"), "got: {err}");
552    }
553
554    #[test]
555    fn context_persistence_from_explicit_context() {
556        assert_eq!(
557            context_persistence_from_query("@work api").unwrap(),
558            ContextPersistence::Set("work".into())
559        );
560    }
561
562    #[test]
563    fn context_persistence_none_clears() {
564        assert_eq!(
565            context_persistence_from_query("@none").unwrap(),
566            ContextPersistence::Clear
567        );
568        assert_eq!(
569            context_persistence_from_query("@all").unwrap(),
570            ContextPersistence::Clear
571        );
572    }
573
574    #[test]
575    fn context_persistence_unchanged_without_at() {
576        assert_eq!(
577            context_persistence_from_query("api idle:>7d").unwrap(),
578            ContextPersistence::Unchanged
579        );
580    }
581
582    #[test]
583    fn resolve_plan_report_still_errors() {
584        let cfg = test_cfg();
585        let opts = ResolveOptions {
586            current_context: None,
587        };
588        let err = resolve_plan(":hot", &cfg, &opts).unwrap_err().to_string();
589        assert!(err.contains("report"), "got: {err}");
590    }
591
592    fn cand<'a>(repo: &'a str, branch: &'a str, key: &'a str, days_idle: i64) -> Candidate<'a> {
593        Candidate {
594            repo_name: repo,
595            branch,
596            key,
597            last_commit: Utc::now() - Duration::days(days_idle),
598            ahead: Some(1),
599            behind: Some(0),
600            ignored: false,
601        }
602    }
603
604    #[test]
605    fn matches_terms_case_insensitive_over_repo_branch_key() {
606        let p = parse("API").unwrap();
607        let c = cand("my-api", "feat/x", "work/my-api/feat/x", 1);
608        assert!(p.matches(&c, Utc::now()));
609        let p2 = parse("nope").unwrap();
610        assert!(!p2.matches(&c, Utc::now()));
611    }
612
613    #[test]
614    fn matches_idle_and_numeric_attrs() {
615        let now = Utc::now();
616        let c = cand("api", "feat/x", "w/api/feat/x", 10);
617        assert!(parse("idle:>7d").unwrap().matches(&c, now));
618        assert!(!parse("idle:<7d").unwrap().matches(&c, now));
619        assert!(parse("behind:0").unwrap().matches(&c, now));
620        assert!(!parse("behind:>0").unwrap().matches(&c, now));
621    }
622
623    #[test]
624    fn matches_excludes_ignored_unless_plus_ignored() {
625        let now = Utc::now();
626        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
627        c.ignored = true;
628        assert!(!parse("api").unwrap().matches(&c, now));
629        assert!(parse("api +ignored").unwrap().matches(&c, now));
630    }
631
632    #[test]
633    fn matches_none_ahead_behind_fails_the_attr() {
634        let now = Utc::now();
635        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
636        c.behind = None;
637        assert!(!parse("behind:0").unwrap().matches(&c, now));
638    }
639}