1use crate::config::Config;
5use anyhow::{bail, Result};
6use chrono::{DateTime, Duration, Utc};
7
8#[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#[derive(Debug, Clone, PartialEq)]
32pub enum AttrFilter {
33 Idle(Cmp, Duration),
34 Ahead(Cmp, u32),
35 Behind(Cmp, u32),
36}
37
38#[derive(Debug, Clone, Default, PartialEq)]
40pub struct ScanPlan {
41 pub terms: Vec<String>,
43 pub repo_filters: Vec<String>,
44 pub branch_filters: Vec<String>,
45 pub key_filters: Vec<String>,
46 pub root_filters: Vec<String>,
48 pub attr_filters: Vec<AttrFilter>,
49 pub include_ignored: bool,
51 pub need_ahead_behind: bool,
54}
55
56pub 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
67pub 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 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
121pub struct ResolveOptions<'a> {
123 pub current_context: Option<&'a str>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum ContextPersistence {
130 Set(String),
132 Clear,
134 Unchanged,
136}
137
138const CONTEXT_RESET_NAMES: [&str; 2] = ["none", "all"];
141
142fn is_context_reset(name: &str) -> bool {
144 CONTEXT_RESET_NAMES.contains(&name)
145}
146
147fn 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
158pub 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
174pub 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
224pub 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#[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 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
296fn split_attr(tok: &str) -> Option<(Attr, &str)> {
299 let (name, val) = tok.split_once(':')?;
300 Some((Attr::parse(name)?, val))
301}
302
303fn 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
328pub 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 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 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 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); }
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}