1use crate::db::jira_inbox::JiraInboxItem;
14use anyhow::{Result, bail};
15use chrono::{Duration, NaiveDateTime};
16
17pub fn parse_window(text: &str) -> Result<Duration> {
22 let text = text.trim();
23 let (digits, unit) = match text.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
24 Some((at, _)) => text.split_at(at),
25 None => (text, "d"),
26 };
27 let amount: i64 = match digits.parse() {
28 Ok(n) if n > 0 => n,
29 _ => bail!("'{text}' is not a window; try 1d, 7d, 12h or 2w"),
30 };
31 match unit {
32 "h" => Ok(Duration::hours(amount)),
33 "d" => Ok(Duration::days(amount)),
34 "w" => Ok(Duration::weeks(amount)),
35 _ => bail!("'{text}' is not a window; the unit is h, d or w"),
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PriorityCut {
42 Exactly(i32),
44 AtLeast(i32),
46}
47
48impl PriorityCut {
49 fn keeps(self, rank: i32) -> bool {
50 match self {
51 PriorityCut::Exactly(wanted) => rank == wanted,
52 PriorityCut::AtLeast(wanted) => rank <= wanted,
53 }
54 }
55}
56
57#[derive(Debug, Default, Clone, PartialEq)]
59pub struct InboxFilter {
60 pub since: Option<Duration>,
62 pub changed: Option<Duration>,
64 pub min_score: Option<f64>,
66 pub priority: Option<PriorityCut>,
68 pub status: Vec<String>,
70 pub types: Vec<String>,
72 pub projects: Vec<String>,
74}
75
76impl InboxFilter {
77 pub fn is_empty(&self) -> bool {
79 *self == Self::default()
80 }
81
82 pub fn keeps(&self, item: &JiraInboxItem, now: NaiveDateTime) -> bool {
84 let within = |at: Option<NaiveDateTime>, window: Duration| at.is_some_and(|at| now.signed_duration_since(at) <= window);
85 if let Some(window) = self.since
86 && !within(Some(item.first_seen), window)
87 {
88 return false;
89 }
90 if let Some(window) = self.changed
91 && !within(item.changed_at, window)
92 {
93 return false;
94 }
95 if let Some(floor) = self.min_score
96 && !item.sort_value.is_some_and(|score| score >= floor)
97 {
98 return false;
99 }
100 if let Some(cut) = self.priority
101 && !cut.keeps(item.priority_rank)
102 {
103 return false;
104 }
105 if !self.status.is_empty()
106 && !self
107 .status
108 .iter()
109 .any(|status| same_word(&item.status_name, status) || item.status_id.as_deref().is_some_and(|id| same_word(id, status)))
110 {
111 return false;
112 }
113 if !one_of(item.issue_type.as_deref(), &self.types) {
114 return false;
115 }
116 if !one_of(item.project.as_deref(), &self.projects) {
117 return false;
118 }
119 true
120 }
121
122 pub fn apply(&self, items: Vec<JiraInboxItem>, now: NaiveDateTime) -> Vec<JiraInboxItem> {
124 items.into_iter().filter(|item| self.keeps(item, now)).collect()
125 }
126}
127
128pub fn priority_cut_in(known: &[(String, i32)], wanted: &str) -> Result<PriorityCut> {
136 let (name, and_above) = match wanted.strip_suffix('+') {
137 Some(name) => (name.trim(), true),
138 None => (wanted.trim(), false),
139 };
140 let rank = known.iter().filter(|(known, _)| same_word(known, name)).map(|(_, rank)| *rank).min();
141 let Some(rank) = rank else {
142 let mut names: Vec<&str> = known.iter().map(|(name, _)| name.as_str()).collect();
143 names.sort_unstable();
144 names.dedup();
145 if names.is_empty() {
146 bail!("no priority named '{name}' in the inbox, and no issue carries a priority yet");
147 }
148 bail!("no priority named '{name}' in the inbox; known: {}", names.join(", "));
149 };
150 Ok(if and_above { PriorityCut::AtLeast(rank) } else { PriorityCut::Exactly(rank) })
151}
152
153fn same_word(a: &str, b: &str) -> bool {
158 a.trim().to_lowercase() == b.trim().to_lowercase()
159}
160
161fn one_of(value: Option<&str>, wanted: &[String]) -> bool {
164 wanted.is_empty() || value.is_some_and(|value| wanted.iter().any(|w| same_word(value, w)))
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
169pub enum InboxSort {
170 #[default]
172 Score,
173 Priority,
175 New,
177 Changed,
179}
180
181pub fn sort(items: &mut [JiraInboxItem], by: InboxSort) {
183 items.sort_by(|a, b| {
184 let frame = a
185 .gone_at
186 .is_some()
187 .cmp(&b.gone_at.is_some())
188 .then_with(|| a.dismissed.cmp(&b.dismissed))
189 .then_with(|| b.pinned.cmp(&a.pinned));
190 frame.then_with(|| match by {
191 InboxSort::Score => std::cmp::Ordering::Equal,
192 InboxSort::Priority => a.priority_rank.cmp(&b.priority_rank),
193 InboxSort::New => b.first_seen.cmp(&a.first_seen),
194 InboxSort::Changed => b.changed_at.cmp(&a.changed_at),
195 })
196 });
197}
198
199#[derive(Debug, Clone, PartialEq)]
201pub struct Reason {
202 pub what: &'static str,
204 pub value: String,
206 pub because: String,
208}
209
210pub fn explain(item: &JiraInboxItem, score_label: Option<&str>, now: NaiveDateTime) -> Vec<Reason> {
224 let mut out = Vec::new();
225 let label = score_label.unwrap_or("the ranking field");
226
227 match item.sort_value {
228 Some(score) => out.push(Reason {
229 what: "score",
230 value: format!("{score}"),
231 because: format!("read from {label} in Jira; the list is ordered by it, highest first"),
232 }),
233 None => out.push(Reason {
234 what: "score",
235 value: "—".to_string(),
236 because: format!("{label} is empty on this issue in Jira; issues without a score sort below those with one"),
237 }),
238 }
239
240 out.push(Reason {
241 what: "priority",
242 value: item.priority.clone().unwrap_or_else(|| "—".to_string()),
243 because: match item.priority.as_deref() {
244 Some(_) => format!(
245 "Jira priority id {}, which breaks ties on equal scores - lower is more urgent",
246 item.priority_rank
247 ),
248 None => "no priority set in Jira, so this sorts last among equal scores".to_string(),
249 },
250 });
251
252 if item.pinned && !item.dismissed {
253 out.push(Reason {
254 what: "pinned",
255 value: "yes".to_string(),
256 because: match &item.ruled_by {
257 Some(rule) => format!("pinned by the rule '{rule}'; pinned issues lead the list whatever the order, and `kasl inbox unpin` takes it back"),
258 None => "pinned by hand; pinned issues lead the list whatever the order".to_string(),
259 },
260 });
261 }
262
263 if item.dismissed {
264 out.push(Reason {
265 what: "dismissed",
266 value: "yes".to_string(),
267 because: match &item.ruled_by {
268 Some(rule) => format!("hidden by the rule '{rule}'; only `--dismissed` lists it, and `kasl inbox undismiss` brings it back"),
269 None => "dismissed by hand; only `--dismissed` lists it, and `kasl inbox undismiss` brings it back".to_string(),
270 },
271 });
272 }
273
274 if let Some(until) = item.snoozed_until
275 && until > now
276 {
277 out.push(Reason {
278 what: "snoozed",
279 value: until.format("%b %-d %H:%M").to_string(),
280 because: "asleep until then, so it is out of the list and out of the waiting count".to_string(),
281 });
282 }
283
284 if let Some(taken) = item.taken_at {
285 out.push(Reason {
286 what: "taken",
287 value: taken.format("%b %-d").to_string(),
288 because: "already a task; it stays in the list so what is in hand stays visible".to_string(),
289 });
290 }
291
292 if let Some(gone) = item.gone_at {
293 let because = match item.left_reason {
294 Some(crate::db::jira_inbox::LeftReason::Resolved) => "Jira resolved it, so it left the poll; only `--all` shows it",
295 Some(crate::db::jira_inbox::LeftReason::Moved) => "left the poll unresolved - reassigned or out of reach; only `--all` shows it",
296 None => "stopped appearing in the Jira poll - closed or reassigned; only `--all` shows it",
297 };
298 out.push(Reason {
299 what: "gone",
300 value: gone.format("%b %-d").to_string(),
301 because: because.to_string(),
302 });
303 }
304
305 if let (Some(change), Some(at)) = (&item.last_change, item.changed_at) {
306 out.push(Reason {
307 what: "changed",
308 value: change.clone(),
309 because: format!("seen on {}; the badge fades after a day", at.format("%b %-d %H:%M")),
310 });
311 }
312
313 out
314}