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((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 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 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
122pub struct ResolveOptions<'a> {
124 pub current_context: Option<&'a str>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ContextPersistence {
131 Set(String),
133 Clear,
135 Unchanged,
137}
138
139pub 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
159pub 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
209pub 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
256fn 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
267fn 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
292pub 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 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 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 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); }
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}