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((attr, val)) = split_attr(tok) {
88            match attr {
89                Attr::Repo => plan.repo_filters.push(val.to_string()),
90                Attr::Branch => plan.branch_filters.push(val.to_string()),
91                Attr::Key => plan.key_filters.push(val.to_string()),
92                Attr::Root => plan.root_filters.push(val.to_string()),
93                Attr::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/behind take an optional operator, so split_cmp(_, false)
100                // never returns None: a bare `ahead:N` means `ahead == N`.
101                Attr::Ahead => {
102                    let (cmp, rest) = split_cmp(val, false).expect("optional op never None");
103                    plan.attr_filters
104                        .push(AttrFilter::Ahead(cmp, parse_count(rest)?));
105                    plan.need_ahead_behind = true;
106                }
107                Attr::Behind => {
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            }
114        } else {
115            plan.terms.push(tok.to_string());
116        }
117    }
118    Ok(plan)
119}
120
121/// Options for [`resolve_plan`].
122pub struct ResolveOptions<'a> {
123    /// Active context from `state.toml` — used when the query has no `@`.
124    pub current_context: Option<&'a str>,
125}
126
127/// Whether an explicit `@` token in the query should update persisted config.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum ContextPersistence {
130    /// `@name` — save to `state.toml`.
131    Set(String),
132    /// `@none` / `@all` — clear `state.toml`.
133    Clear,
134    /// No `@` token — leave config unchanged.
135    Unchanged,
136}
137
138/// Reserved `@`-names that CLEAR the active context rather than naming a real
139/// one; both map to [`ContextPersistence::Clear`].
140const CONTEXT_RESET_NAMES: [&str; 2] = ["none", "all"];
141
142/// True for the reserved names that clear (not select) the active context.
143fn is_context_reset(name: &str) -> bool {
144    CONTEXT_RESET_NAMES.contains(&name)
145}
146
147/// Enforces the at-most-one-`@context` rule and returns the lone `@`-token
148/// (including its `@` prefix), or `None` when the query has no context token.
149fn single_context_token<'a>(tokens: &[&'a str]) -> Result<Option<&'a str>> {
150    let mut at_tokens = tokens.iter().filter(|t| t.starts_with('@'));
151    let first = at_tokens.next();
152    if at_tokens.next().is_some() {
153        bail!("only one @context per query");
154    }
155    Ok(first.copied())
156}
157
158/// Parses `@` usage for config persistence (call after [`resolve_plan`] succeeds).
159pub fn context_persistence_from_query(input: &str) -> Result<ContextPersistence> {
160    let tokens: Vec<&str> = input.split_whitespace().collect();
161    match single_context_token(&tokens)? {
162        None => Ok(ContextPersistence::Unchanged),
163        Some(tok) => {
164            let name = tok.strip_prefix('@').unwrap();
165            if is_context_reset(name) {
166                Ok(ContextPersistence::Clear)
167            } else {
168                Ok(ContextPersistence::Set(name.to_string()))
169            }
170        }
171    }
172}
173
174/// Merges two plans with AND semantics across filters and OR across flags.
175pub fn merge_scan_plans(base: ScanPlan, overlay: ScanPlan) -> ScanPlan {
176    ScanPlan {
177        terms: {
178            let mut terms = base.terms;
179            terms.extend(overlay.terms);
180            terms
181        },
182        repo_filters: {
183            let mut filters = base.repo_filters;
184            filters.extend(overlay.repo_filters);
185            filters
186        },
187        branch_filters: {
188            let mut filters = base.branch_filters;
189            filters.extend(overlay.branch_filters);
190            filters
191        },
192        key_filters: {
193            let mut filters = base.key_filters;
194            filters.extend(overlay.key_filters);
195            filters
196        },
197        root_filters: {
198            let mut filters = base.root_filters;
199            filters.extend(overlay.root_filters);
200            filters
201        },
202        attr_filters: {
203            let mut filters = base.attr_filters;
204            filters.extend(overlay.attr_filters);
205            filters
206        },
207        include_ignored: base.include_ignored || overlay.include_ignored,
208        need_ahead_behind: base.need_ahead_behind || overlay.need_ahead_behind,
209    }
210}
211
212fn validate_context_filter(name: &str, filter: &str) -> Result<()> {
213    for tok in filter.split_whitespace() {
214        if tok.contains('@') {
215            bail!("context '{name}' filter token '{tok}' cannot contain '@' (reports are ADR 0003 phase 5)");
216        }
217        if tok.starts_with(':') {
218            bail!("context '{name}' filter token '{tok}' cannot contain ':' (reports are ADR 0003 phase 5)");
219        }
220    }
221    Ok(())
222}
223
224/// Resolves `@context` tokens and default context into a single [`ScanPlan`].
225pub fn resolve_plan(input: &str, cfg: &Config, opts: &ResolveOptions) -> Result<ScanPlan> {
226    let tokens: Vec<&str> = input.split_whitespace().collect();
227    let has_at = single_context_token(&tokens)?.is_some();
228    let mut plans = Vec::new();
229
230    if !has_at {
231        if let Some(ctx) = opts.current_context {
232            let filter = cfg.context_filter(ctx)?;
233            validate_context_filter(ctx, filter)?;
234            plans.push(parse(filter)?);
235        }
236    }
237
238    let mut user_tokens = Vec::new();
239    for tok in tokens {
240        if let Some(name) = tok.strip_prefix('@') {
241            if is_context_reset(name) {
242                continue;
243            }
244            let filter = cfg.context_filter(name)?;
245            validate_context_filter(name, filter)?;
246            plans.push(parse(filter)?);
247        } else {
248            user_tokens.push(tok);
249        }
250    }
251
252    if !user_tokens.is_empty() {
253        plans.push(parse(&user_tokens.join(" "))?);
254    }
255
256    match plans.len() {
257        0 => Ok(ScanPlan::default()),
258        1 => Ok(plans.remove(0)),
259        _ => Ok(plans
260            .into_iter()
261            .reduce(merge_scan_plans)
262            .expect("len checked >= 2")),
263    }
264}
265
266/// The closed set of recognized attribute names. Single source of truth so the
267/// name->kind mapping lives in exactly one place (see [`Attr::parse`]).
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269enum Attr {
270    Repo,
271    Branch,
272    Key,
273    Root,
274    Idle,
275    Ahead,
276    Behind,
277}
278
279impl Attr {
280    /// Maps an attribute name to its kind. `None` for anything outside the set,
281    /// which lets `split_attr` fall through to treating the token as a bare term.
282    fn parse(name: &str) -> Option<Attr> {
283        match name {
284            "repo" => Some(Attr::Repo),
285            "branch" => Some(Attr::Branch),
286            "key" => Some(Attr::Key),
287            "root" => Some(Attr::Root),
288            "idle" => Some(Attr::Idle),
289            "ahead" => Some(Attr::Ahead),
290            "behind" => Some(Attr::Behind),
291            _ => None,
292        }
293    }
294}
295
296/// Returns `(attr, value)` when `tok` is `name:value` and `name` is a known
297/// attribute; otherwise `None` (the caller treats the token as a bare term).
298fn split_attr(tok: &str) -> Option<(Attr, &str)> {
299    let (name, val) = tok.split_once(':')?;
300    Some((Attr::parse(name)?, val))
301}
302
303/// Splits a leading comparator off a value. When `require_op` and none is
304/// present, returns `None`; otherwise defaults to `Cmp::Eq`.
305fn split_cmp(val: &str, require_op: bool) -> Option<(Cmp, &str)> {
306    for (prefix, cmp) in [
307        (">=", Cmp::Ge),
308        ("<=", Cmp::Le),
309        (">", Cmp::Gt),
310        ("<", Cmp::Lt),
311    ] {
312        if let Some(rest) = val.strip_prefix(prefix) {
313            return Some((cmp, rest));
314        }
315    }
316    if require_op {
317        None
318    } else {
319        Some((Cmp::Eq, val))
320    }
321}
322
323fn parse_count(s: &str) -> Result<u32> {
324    s.parse::<u32>()
325        .map_err(|_| anyhow::anyhow!("expected a number, got '{s}'"))
326}
327
328/// Parses `<N><unit>` where unit is one of m/h/d/w.
329pub fn parse_duration(s: &str) -> Result<Duration> {
330    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
331    let n: i64 = num
332        .parse()
333        .map_err(|_| anyhow::anyhow!("invalid duration '{s}' (expected e.g. 7d)"))?;
334    match unit {
335        "m" => Ok(Duration::minutes(n)),
336        "h" => Ok(Duration::hours(n)),
337        "d" => Ok(Duration::days(n)),
338        "w" => Ok(Duration::weeks(n)),
339        other => bail!("invalid duration unit '{other}' (use m, h, d, or w)"),
340    }
341}
342
343impl ScanPlan {
344    /// True when the candidate satisfies every term, substring filter, and
345    /// attribute. `root_filters` are intentionally ignored here (push-down).
346    pub fn matches(&self, c: &Candidate, now: DateTime<Utc>) -> bool {
347        if c.ignored && !self.include_ignored {
348            return false;
349        }
350        let contains_ci =
351            |hay: &str, needle: &str| hay.to_lowercase().contains(&needle.to_lowercase());
352        for t in &self.terms {
353            if !(contains_ci(c.repo_name, t) || contains_ci(c.branch, t) || contains_ci(c.key, t)) {
354                return false;
355            }
356        }
357        for f in &self.repo_filters {
358            if !contains_ci(c.repo_name, f) {
359                return false;
360            }
361        }
362        for f in &self.branch_filters {
363            if !contains_ci(c.branch, f) {
364                return false;
365            }
366        }
367        for f in &self.key_filters {
368            if !contains_ci(c.key, f) {
369                return false;
370            }
371        }
372        for attr in &self.attr_filters {
373            let ok = match attr {
374                AttrFilter::Idle(cmp, dur) => {
375                    cmp.test_i64((now - c.last_commit).num_seconds(), dur.num_seconds())
376                }
377                AttrFilter::Ahead(cmp, n) => {
378                    c.ahead.is_some_and(|a| cmp.test_i64(a.into(), (*n).into()))
379                }
380                AttrFilter::Behind(cmp, n) => c
381                    .behind
382                    .is_some_and(|b| cmp.test_i64(b.into(), (*n).into())),
383            };
384            if !ok {
385                return false;
386            }
387        }
388        true
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::config::{Config, ContextDef};
396    use std::collections::BTreeMap;
397
398    fn test_cfg() -> Config {
399        Config {
400            contexts: BTreeMap::from([
401                (
402                    "work".into(),
403                    ContextDef {
404                        filter: "root:~/work".into(),
405                    },
406                ),
407                (
408                    "personal".into(),
409                    ContextDef {
410                        filter: "root:~/personal".into(),
411                    },
412                ),
413                (
414                    "recent-work".into(),
415                    ContextDef {
416                        filter: "root:~/work idle:<=30d".into(),
417                    },
418                ),
419            ]),
420            ..Config::default()
421        }
422    }
423
424    #[test]
425    fn parse_bare_terms_and_substring_attrs() {
426        let p = parse("api feat/login repo:billing branch:fix/ key:work/api root:~/work").unwrap();
427        assert_eq!(p.terms, vec!["api".to_string(), "feat/login".to_string()]);
428        assert_eq!(p.repo_filters, vec!["billing".to_string()]);
429        assert_eq!(p.branch_filters, vec!["fix/".to_string()]);
430        assert_eq!(p.key_filters, vec!["work/api".to_string()]);
431        assert_eq!(p.root_filters, vec!["~/work".to_string()]);
432        assert!(!p.need_ahead_behind);
433    }
434
435    #[test]
436    fn unknown_attr_prefix_is_a_bare_term() {
437        // a stray colon on an unknown name is not an error; it is a term
438        let p = parse("foo:bar").unwrap();
439        assert_eq!(p.terms, vec!["foo:bar".to_string()]);
440    }
441
442    #[test]
443    fn parse_numeric_and_duration_attrs() {
444        let p = parse("idle:>7d behind:>0 ahead:0").unwrap();
445        assert_eq!(
446            p.attr_filters,
447            vec![
448                AttrFilter::Idle(Cmp::Gt, Duration::days(7)),
449                AttrFilter::Behind(Cmp::Gt, 0),
450                AttrFilter::Ahead(Cmp::Eq, 0),
451            ]
452        );
453        // ahead/behind attrs force the heavy phase
454        assert!(p.need_ahead_behind);
455    }
456
457    #[test]
458    fn idle_without_operator_is_an_error() {
459        let err = parse("idle:7d").unwrap_err().to_string();
460        assert!(err.contains("idle"), "got: {err}");
461    }
462
463    #[test]
464    fn bad_duration_unit_is_an_error() {
465        let err = parse("idle:>7y").unwrap_err().to_string();
466        assert!(err.contains("duration"), "got: {err}");
467    }
468
469    #[test]
470    fn duration_units_minutes_hours_days_weeks() {
471        assert_eq!(parse_duration("30m").unwrap(), Duration::minutes(30));
472        assert_eq!(parse_duration("6h").unwrap(), Duration::hours(6));
473        assert_eq!(parse_duration("2d").unwrap(), Duration::days(2));
474        assert_eq!(parse_duration("3w").unwrap(), Duration::weeks(3));
475    }
476
477    #[test]
478    fn parse_ignored_tags() {
479        assert!(parse("+ignored").unwrap().include_ignored);
480        assert!(!parse("-ignored").unwrap().include_ignored);
481        assert!(!parse("api").unwrap().include_ignored); // default hides
482    }
483
484    #[test]
485    fn reserved_report_and_stale_error_clearly() {
486        assert!(parse(":hot").unwrap_err().to_string().contains("report"));
487        assert!(parse("+stale").unwrap_err().to_string().contains("stale"));
488    }
489
490    #[test]
491    fn merge_scan_plans_combines_root_filters() {
492        let a = parse("root:~/work").unwrap();
493        let b = parse("root:~/personal").unwrap();
494        let merged = merge_scan_plans(a, b);
495        assert_eq!(
496            merged.root_filters,
497            vec!["~/work".to_string(), "~/personal".to_string()]
498        );
499    }
500
501    #[test]
502    fn resolve_plan_applies_current_context() {
503        let cfg = test_cfg();
504        let opts = ResolveOptions {
505            current_context: Some("work"),
506        };
507        let expected = parse("root:~/work api").unwrap();
508        let got = resolve_plan("api", &cfg, &opts).unwrap();
509        assert_eq!(got, expected);
510    }
511
512    #[test]
513    fn resolve_plan_explicit_context_replaces_current() {
514        let cfg = test_cfg();
515        let opts = ResolveOptions {
516            current_context: Some("work"),
517        };
518        let expected = parse("root:~/personal api").unwrap();
519        let got = resolve_plan("@personal api", &cfg, &opts).unwrap();
520        assert_eq!(got, expected);
521    }
522
523    #[test]
524    fn resolve_plan_none_clears_current() {
525        let cfg = test_cfg();
526        let opts = ResolveOptions {
527            current_context: Some("work"),
528        };
529        let expected = parse("api").unwrap();
530        let got = resolve_plan("@none api", &cfg, &opts).unwrap();
531        assert_eq!(got, expected);
532    }
533
534    #[test]
535    fn resolve_plan_unknown_context_errors() {
536        let cfg = test_cfg();
537        let opts = ResolveOptions {
538            current_context: Some("work"),
539        };
540        let err = resolve_plan("@missing", &cfg, &opts)
541            .unwrap_err()
542            .to_string();
543        assert!(err.contains("unknown context '@missing'"), "got: {err}");
544    }
545
546    #[test]
547    fn resolve_plan_context_with_idle_filter() {
548        let cfg = test_cfg();
549        let opts = ResolveOptions {
550            current_context: None,
551        };
552        let plan = resolve_plan("@recent-work", &cfg, &opts).unwrap();
553        assert_eq!(plan.root_filters, vec!["~/work".to_string()]);
554        assert_eq!(
555            plan.attr_filters,
556            vec![AttrFilter::Idle(Cmp::Le, Duration::days(30))]
557        );
558    }
559
560    #[test]
561    fn resolve_plan_rejects_nested_context_in_filter() {
562        let cfg = Config {
563            contexts: BTreeMap::from([(
564                "bad".into(),
565                ContextDef {
566                    filter: "@work".into(),
567                },
568            )]),
569            ..Config::default()
570        };
571        let opts = ResolveOptions {
572            current_context: None,
573        };
574        let err = resolve_plan("@bad", &cfg, &opts).unwrap_err().to_string();
575        assert!(err.contains("cannot contain '@'"), "got: {err}");
576        assert!(err.contains("@work"), "got: {err}");
577    }
578
579    #[test]
580    fn resolve_plan_rejects_two_context_tokens() {
581        let cfg = test_cfg();
582        let opts = ResolveOptions {
583            current_context: None,
584        };
585        let err = resolve_plan("@work @personal", &cfg, &opts)
586            .unwrap_err()
587            .to_string();
588        assert!(err.contains("only one @context per query"), "got: {err}");
589    }
590
591    #[test]
592    fn context_persistence_from_explicit_context() {
593        assert_eq!(
594            context_persistence_from_query("@work api").unwrap(),
595            ContextPersistence::Set("work".into())
596        );
597    }
598
599    #[test]
600    fn context_persistence_none_clears() {
601        assert_eq!(
602            context_persistence_from_query("@none").unwrap(),
603            ContextPersistence::Clear
604        );
605        assert_eq!(
606            context_persistence_from_query("@all").unwrap(),
607            ContextPersistence::Clear
608        );
609    }
610
611    #[test]
612    fn context_persistence_unchanged_without_at() {
613        assert_eq!(
614            context_persistence_from_query("api idle:>7d").unwrap(),
615            ContextPersistence::Unchanged
616        );
617    }
618
619    #[test]
620    fn resolve_plan_report_still_errors() {
621        let cfg = test_cfg();
622        let opts = ResolveOptions {
623            current_context: None,
624        };
625        let err = resolve_plan(":hot", &cfg, &opts).unwrap_err().to_string();
626        assert!(err.contains("report"), "got: {err}");
627    }
628
629    fn cand<'a>(repo: &'a str, branch: &'a str, key: &'a str, days_idle: i64) -> Candidate<'a> {
630        Candidate {
631            repo_name: repo,
632            branch,
633            key,
634            last_commit: Utc::now() - Duration::days(days_idle),
635            ahead: Some(1),
636            behind: Some(0),
637            ignored: false,
638        }
639    }
640
641    #[test]
642    fn matches_terms_case_insensitive_over_repo_branch_key() {
643        let p = parse("API").unwrap();
644        let c = cand("my-api", "feat/x", "work/my-api/feat/x", 1);
645        assert!(p.matches(&c, Utc::now()));
646        let p2 = parse("nope").unwrap();
647        assert!(!p2.matches(&c, Utc::now()));
648    }
649
650    #[test]
651    fn matches_idle_and_numeric_attrs() {
652        let now = Utc::now();
653        let c = cand("api", "feat/x", "w/api/feat/x", 10);
654        assert!(parse("idle:>7d").unwrap().matches(&c, now));
655        assert!(!parse("idle:<7d").unwrap().matches(&c, now));
656        assert!(parse("behind:0").unwrap().matches(&c, now));
657        assert!(!parse("behind:>0").unwrap().matches(&c, now));
658    }
659
660    #[test]
661    fn matches_excludes_ignored_unless_plus_ignored() {
662        let now = Utc::now();
663        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
664        c.ignored = true;
665        assert!(!parse("api").unwrap().matches(&c, now));
666        assert!(parse("api +ignored").unwrap().matches(&c, now));
667    }
668
669    #[test]
670    fn matches_none_ahead_behind_fails_the_attr() {
671        let now = Utc::now();
672        let mut c = cand("api", "feat/x", "w/api/feat/x", 1);
673        c.behind = None;
674        assert!(!parse("behind:0").unwrap().matches(&c, now));
675    }
676}