1use anyhow::{bail, Result};
5use chrono::{DateTime, Duration, Utc};
6
7#[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#[derive(Debug, Clone, PartialEq)]
31pub enum AttrFilter {
32 Idle(Cmp, Duration),
33 Ahead(Cmp, u32),
34 Behind(Cmp, u32),
35}
36
37#[derive(Debug, Clone, Default, PartialEq)]
39pub struct ScanPlan {
40 pub terms: Vec<String>,
42 pub repo_filter: Option<String>,
43 pub branch_filter: Option<String>,
44 pub key_filter: Option<String>,
45 pub root_filter: Option<String>,
47 pub attr_filters: Vec<AttrFilter>,
48 pub include_ignored: bool,
50 pub need_ahead_behind: bool,
53}
54
55pub 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
66pub 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 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 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
127fn 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
138fn 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
163pub 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 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 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 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); }
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}