Skip to main content

kasl/libs/
inbox_rules.rs

1//! Inbox rules: what the inbox does by itself when an issue matches.
2//!
3//! A rule is a `when` and a `then`, written in `jira_inbox.rules`:
4//!
5//! ```json
6//! { "name": "on fire", "when": { "priority": "High+" }, "then": { "pin": true, "urgent": true } }
7//! { "when": { "type": ["Epic", "Sub-task"] }, "then": { "dismiss": true } }
8//! { "when": { "project": "PAY" }, "then": { "tag": "payments" } }
9//! ```
10//!
11//! `when` speaks the vocabulary of the list's filters - `priority` (with `+`
12//! for "and above"), `min_score`, `status`, `type`, `project` - and compiles to
13//! the same [`InboxFilter`], so a rule pins exactly what `kasl inbox list` with
14//! the same cuts would show.
15//!
16//! **When a rule acts.** Pin, urgent and dismiss act on an edge, not a level:
17//! when an issue arrives already matching, or when a change makes it match. A
18//! rule re-applied on every poll would fight the person - unpin an issue the
19//! rule pinned, and five minutes later it is pinned again. On an edge, the
20//! person's word stands until the issue leaves the rule and comes back into
21//! it, which is news. Tags act when the issue is taken, on every take path.
22//! The issues already in the inbox when a rule is written are reached by
23//! `kasl inbox rules --apply`, once and on purpose.
24//!
25//! **When rules disagree.** Every matching rule acts. Dismissal beats pin and
26//! urgent: an issue a rule hides is not also pinned and announced. A rule never
27//! dismisses a taken issue - that is work in hand, and hiding it would hide the
28//! work - and no rule touches an issue that is already dismissed.
29
30use crate::db::jira_inbox::{Include, JiraInbox, JiraInboxItem, RuledMark};
31use crate::db::tags::Tags;
32use crate::db::tasks::Tasks;
33use crate::libs::config::{Config, JiraInboxConfig};
34use crate::libs::inbox_filter::{InboxFilter, Reason, priority_cut_in};
35use crate::libs::task::TaskFilter;
36use anyhow::Result;
37use chrono::Local;
38use serde::{Deserialize, Deserializer};
39use serde_json::Value;
40use std::collections::HashMap;
41
42/// When a rule applies. Every given condition must hold; a list means any of it.
43#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuleWhen {
46    /// A priority name, with `+` for that priority and everything more urgent.
47    #[serde(default)]
48    pub priority: Option<String>,
49    /// The ranking field (e.g. Scoring) at least this.
50    #[serde(default)]
51    pub min_score: Option<f64>,
52    /// Status names or ids.
53    #[serde(default, deserialize_with = "one_or_many")]
54    pub status: Vec<String>,
55    /// Issue types.
56    #[serde(default, rename = "type", deserialize_with = "one_or_many")]
57    pub types: Vec<String>,
58    /// Projects (in Jira, the prefix of the key).
59    #[serde(default, rename = "project", deserialize_with = "one_or_many")]
60    pub projects: Vec<String>,
61}
62
63impl RuleWhen {
64    /// Whether no condition is given, so the rule would match every issue.
65    pub fn is_empty(&self) -> bool {
66        *self == Self::default()
67    }
68
69    /// The conditions in words, as the label of a rule without a name.
70    pub fn describe(&self) -> String {
71        let mut parts = Vec::new();
72        if let Some(priority) = &self.priority {
73            parts.push(format!("priority {priority}"));
74        }
75        if let Some(score) = self.min_score {
76            parts.push(format!("score ≥ {score}"));
77        }
78        for (what, names) in [("status", &self.status), ("type", &self.types), ("project", &self.projects)] {
79            if !names.is_empty() {
80                parts.push(format!("{what} {}", names.join(" or ")));
81            }
82        }
83        if parts.is_empty() { "every issue".to_string() } else { parts.join(", ") }
84    }
85}
86
87/// What a rule does to a matching issue.
88#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct RuleThen {
91    /// Pin it, so it leads the list.
92    #[serde(default)]
93    pub pin: bool,
94    /// Toast it as urgent: a toast that stays on screen until acted on.
95    #[serde(default)]
96    pub urgent: bool,
97    /// Hide it from the list, as `kasl inbox dismiss` does.
98    #[serde(default)]
99    pub dismiss: bool,
100    /// Tags for the task taken from it.
101    #[serde(default, deserialize_with = "one_or_many")]
102    pub tag: Vec<String>,
103}
104
105impl RuleThen {
106    /// The actions in words.
107    pub fn describe(&self) -> String {
108        let mut parts = Vec::new();
109        if self.pin {
110            parts.push("pin".to_string());
111        }
112        if self.urgent {
113            parts.push("urgent toast".to_string());
114        }
115        if self.dismiss {
116            parts.push("dismiss".to_string());
117        }
118        if !self.tag.is_empty() {
119            parts.push(format!("tag {}", self.tag.join(", ")));
120        }
121        parts.join(", ")
122    }
123}
124
125/// One rule as written.
126#[derive(Debug, Clone, PartialEq, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct InboxRule {
129    /// What the rule is called in messages, toasts and `show --why`; without
130    /// one, its conditions in words.
131    #[serde(default)]
132    pub name: Option<String>,
133    #[serde(default)]
134    pub when: RuleWhen,
135    pub then: RuleThen,
136}
137
138impl InboxRule {
139    /// Reads one rule, refusing anything that would act other than as written.
140    ///
141    /// Unknown keys are refused rather than ignored: `prority` silently
142    /// dropped would leave an empty `when`, which matches every issue - and
143    /// with `dismiss` that empties the inbox.
144    pub fn parse(value: &Value) -> std::result::Result<InboxRule, String> {
145        let rule: InboxRule = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
146        let then = &rule.then;
147        if !then.pin && !then.urgent && !then.dismiss && then.tag.is_empty() {
148            return Err("it does nothing: `then` needs pin, urgent, dismiss or tag".to_string());
149        }
150        if then.dismiss && (then.pin || then.urgent) {
151            return Err("it both dismisses and pins or announces; a hidden issue is neither - split it into two rules".to_string());
152        }
153        if then.dismiss && rule.when.is_empty() {
154            return Err("it would dismiss every issue; give `when` at least one condition".to_string());
155        }
156        if then.tag.iter().any(|tag| tag.trim().is_empty()) {
157            return Err("a tag name cannot be empty".to_string());
158        }
159        if rule.when.priority.as_deref().is_some_and(|p| p.trim_end_matches('+').trim().is_empty()) {
160            return Err("`priority` names no priority".to_string());
161        }
162        Ok(rule)
163    }
164
165    /// The name, or the conditions in words.
166    pub fn label(&self) -> String {
167        match self.name.as_deref().map(str::trim) {
168            Some(name) if !name.is_empty() => name.to_string(),
169            _ => self.when.describe(),
170        }
171    }
172}
173
174/// Accepts one name or a list of names, as the config may write either.
175fn one_or_many<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
176where
177    D: Deserializer<'de>,
178{
179    match Value::deserialize(deserializer)? {
180        Value::String(one) => Ok(vec![one]),
181        Value::Array(many) => many
182            .into_iter()
183            .map(|v| match v {
184                Value::String(s) => Ok(s),
185                other => Err(serde::de::Error::custom(format!("expected a name, found {other}"))),
186            })
187            .collect(),
188        other => Err(serde::de::Error::custom(format!("expected a name or a list of names, found {other}"))),
189    }
190}
191
192/// A rule ready to match, with where it sits in the config.
193#[derive(Debug, Clone)]
194pub struct CompiledRule {
195    /// Position in `jira_inbox.rules`, counting from 1.
196    pub number: usize,
197    pub rule: InboxRule,
198    pub label: String,
199    filter: InboxFilter,
200    /// Why the rule can match nothing yet: a priority the inbox has never seen.
201    pub dormant: Option<String>,
202}
203
204impl CompiledRule {
205    /// Whether the issue meets every condition.
206    pub fn matches(&self, item: &JiraInboxItem) -> bool {
207        // Rules carry no time windows, so the moment passed is never read.
208        self.dormant.is_none() && self.filter.keeps(item, item.last_seen)
209    }
210}
211
212/// A rule that could not be read, and why; it is skipped.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct RuleProblem {
215    /// Position in `jira_inbox.rules`, counting from 1.
216    pub number: usize,
217    pub reason: String,
218}
219
220/// The rules of the inbox, read and compiled.
221#[derive(Debug, Clone, Default)]
222pub struct RuleBook {
223    pub rules: Vec<CompiledRule>,
224    pub problems: Vec<RuleProblem>,
225}
226
227/// What the rules say about one issue: the rule behind each action.
228#[derive(Debug, Clone, Default, PartialEq, Eq)]
229pub struct Verdict {
230    pub pin: Option<String>,
231    pub urgent: Option<String>,
232    pub dismiss: Option<String>,
233    pub tags: Vec<String>,
234}
235
236impl RuleBook {
237    /// Reads the rules, resolving priority names against `priorities`.
238    ///
239    /// Priority names are the instance's own, so they are looked up among the
240    /// priorities the inbox has seen. A name not among them leaves its rule
241    /// dormant - matching nothing - rather than failing: "Highest" may simply
242    /// not have been assigned to anyone yet.
243    pub fn compile(values: &[Value], priorities: &[(String, i32)]) -> RuleBook {
244        let mut book = RuleBook::default();
245        for (index, value) in values.iter().enumerate() {
246            let number = index + 1;
247            let rule = match InboxRule::parse(value) {
248                Ok(rule) => rule,
249                Err(reason) => {
250                    book.problems.push(RuleProblem { number, reason });
251                    continue;
252                }
253            };
254            let (priority, dormant) = match rule.when.priority.as_deref().map(|name| priority_cut_in(priorities, name)) {
255                Some(Ok(cut)) => (Some(cut), None),
256                Some(Err(why)) => (None, Some(why.to_string())),
257                None => (None, None),
258            };
259            let filter = InboxFilter {
260                priority,
261                min_score: rule.when.min_score,
262                status: rule.when.status.clone(),
263                types: rule.when.types.clone(),
264                projects: rule.when.projects.clone(),
265                ..InboxFilter::default()
266            };
267            book.rules.push(CompiledRule {
268                number,
269                label: rule.label(),
270                rule,
271                filter,
272                dormant,
273            });
274        }
275        book
276    }
277
278    /// The rules of the configured inbox, against the priorities it has seen.
279    pub fn of_inbox(config: &JiraInboxConfig, db: &JiraInbox) -> Result<RuleBook> {
280        if config.rules.is_empty() {
281            return Ok(RuleBook::default());
282        }
283        Ok(Self::compile(&config.rules, &db.priorities()?))
284    }
285
286    /// The rules as the config on disk has them; none when it cannot be read.
287    pub fn from_config(db: &JiraInbox) -> Result<RuleBook> {
288        match Config::read().ok().and_then(|c| c.jira_inbox) {
289            Some(inbox) => Self::of_inbox(&inbox, db),
290            None => Ok(RuleBook::default()),
291        }
292    }
293
294    /// Whether there is nothing to apply.
295    pub fn is_empty(&self) -> bool {
296        self.rules.is_empty()
297    }
298
299    /// What every rule the issue matches says about it now.
300    pub fn verdict(&self, item: &JiraInboxItem) -> Verdict {
301        self.collect(|rule| rule.matches(item))
302    }
303
304    /// What the rules the issue has just come to match say about it.
305    ///
306    /// A rule counts when the issue matches it now and did not before - or
307    /// there was no before, because the issue is new.
308    pub fn entering(&self, before: Option<&JiraInboxItem>, now: &JiraInboxItem) -> Verdict {
309        self.collect(|rule| rule.matches(now) && !before.is_some_and(|old| rule.matches(old)))
310    }
311
312    fn collect(&self, counts: impl Fn(&CompiledRule) -> bool) -> Verdict {
313        let mut verdict = Verdict::default();
314        for rule in self.rules.iter().filter(|rule| counts(rule)) {
315            let then = &rule.rule.then;
316            if then.pin && verdict.pin.is_none() {
317                verdict.pin = Some(rule.label.clone());
318            }
319            if then.urgent && verdict.urgent.is_none() {
320                verdict.urgent = Some(rule.label.clone());
321            }
322            if then.dismiss && verdict.dismiss.is_none() {
323                verdict.dismiss = Some(rule.label.clone());
324            }
325            for tag in &then.tag {
326                let tag = tag.trim().to_string();
327                if !verdict.tags.contains(&tag) {
328                    verdict.tags.push(tag);
329                }
330            }
331        }
332        verdict
333    }
334}
335
336/// What the rules did to one issue.
337#[derive(Debug, Clone, Default, PartialEq, Eq)]
338pub struct Ruled {
339    pub issue_key: String,
340    /// The rule that pinned it.
341    pub pinned: Option<String>,
342    /// The rule that dismissed it.
343    pub dismissed: Option<String>,
344    /// The rule that asks for an urgent toast about it.
345    pub urgent: Option<String>,
346    /// Whether an urgent rule woke it from a snooze.
347    pub woke: bool,
348    /// Tags put on the task taken from it.
349    pub tagged: Vec<String>,
350}
351
352impl Ruled {
353    fn new(key: &str) -> Self {
354        Ruled {
355            issue_key: key.to_string(),
356            ..Default::default()
357        }
358    }
359
360    fn acted(&self) -> bool {
361        self.pinned.is_some() || self.dismissed.is_some() || self.urgent.is_some() || !self.tagged.is_empty()
362    }
363}
364
365/// Applies the rules each issue of a poll has just come to match.
366///
367/// `before` is the inbox as it stood before the poll was written, keyed by
368/// issue; an issue missing from it is new. Returns what the rules did, for
369/// the toasts and the sync's report.
370pub fn apply_entering(db: &JiraInbox, book: &RuleBook, before: &HashMap<String, JiraInboxItem>, keys: &[String]) -> Result<Vec<Ruled>> {
371    let now = Local::now().naive_local();
372    let mut done = Vec::new();
373    for key in keys {
374        let Some(item) = db.get_by_key(key)? else { continue };
375        if item.dismissed {
376            continue;
377        }
378        let verdict = book.entering(before.get(key), &item);
379        let mut ruled = Ruled::new(key);
380
381        match verdict.dismiss.filter(|_| item.taken_at.is_none()) {
382            Some(rule) => {
383                db.set_ruled(key, RuledMark::Dismiss, &rule)?;
384                ruled.dismissed = Some(rule);
385            }
386            None => {
387                if let Some(rule) = verdict.pin
388                    && !item.pinned
389                {
390                    db.set_ruled(key, RuledMark::Pin, &rule)?;
391                    ruled.pinned = Some(rule);
392                }
393                if let Some(rule) = verdict.urgent {
394                    // The snooze was "not now", judged on what the issue was
395                    // then; a toast calling it urgent about an issue the list
396                    // still hides would contradict itself.
397                    if item.snoozed_until.is_some_and(|until| until > now) {
398                        db.set_snoozed(key, None)?;
399                        ruled.woke = true;
400                    }
401                    ruled.urgent = Some(rule);
402                }
403            }
404        }
405        if ruled.acted() {
406            done.push(ruled);
407        }
408    }
409    Ok(done)
410}
411
412/// Applies the rules to the inbox as it stands, as if every issue had just arrived.
413///
414/// For the issues that were already there when a rule was written: an edge
415/// that happened before the rule existed is never seen again. Pins and
416/// dismisses, and tags the tasks of issues already taken; no toasts - none of
417/// this is news. Asleep issues count, gone ones do not.
418pub fn apply_now(db: &JiraInbox, book: &RuleBook) -> Result<Vec<Ruled>> {
419    let items = db.list(Include {
420        snoozed: true,
421        ..Include::default()
422    })?;
423    let mut done = Vec::new();
424    for item in items {
425        let verdict = book.verdict(&item);
426        let mut ruled = Ruled::new(&item.issue_key);
427        match verdict.dismiss.filter(|_| item.taken_at.is_none()) {
428            Some(rule) => {
429                db.set_ruled(&item.issue_key, RuledMark::Dismiss, &rule)?;
430                ruled.dismissed = Some(rule);
431            }
432            None => {
433                if let Some(rule) = verdict.pin
434                    && !item.pinned
435                {
436                    db.set_ruled(&item.issue_key, RuledMark::Pin, &rule)?;
437                    ruled.pinned = Some(rule);
438                }
439            }
440        }
441        if item.taken_at.is_some() && !verdict.tags.is_empty() {
442            ruled.tagged = tag_task(&item.issue_key, &verdict.tags)?;
443        }
444        if ruled.acted() {
445            done.push(ruled);
446        }
447    }
448    Ok(done)
449}
450
451/// The tags the rules give the task taken from this issue.
452pub fn tags_for(item: &JiraInboxItem) -> Vec<String> {
453    match JiraInbox::new().and_then(|db| RuleBook::from_config(&db)) {
454        Ok(book) => book.verdict(item).tags,
455        Err(_) => Vec::new(),
456    }
457}
458
459/// Puts tags on the task taken from `key`, and returns the ones it did not have.
460///
461/// The tags go on the task's first row - the one the take made - which is
462/// where `kasl task add --tags` puts them too. Tags already there are left
463/// alone, so applying the rules twice changes nothing the second time.
464pub fn tag_task(key: &str, names: &[String]) -> Result<Vec<String>> {
465    let rows = Tasks::new()?.fetch(TaskFilter::ByJiraKey(key.to_string()))?;
466    let Some(task_id) = rows
467        .iter()
468        .find(|row| row.id.is_some() && row.id == row.task_id)
469        .or_else(|| rows.iter().min_by_key(|row| row.id))
470        .and_then(|row| row.id)
471    else {
472        return Ok(Vec::new());
473    };
474
475    let mut tags = Tags::new()?;
476    let had: Vec<String> = tags.get_tags_by_task(task_id)?.into_iter().map(|tag| tag.name).collect();
477    let new: Vec<String> = names.iter().filter(|name| !had.contains(name)).cloned().collect();
478    for id in tags.get_or_create_tags(&new)? {
479        tags.add_tag_to_task(task_id, id)?;
480    }
481    Ok(new)
482}
483
484/// What the rules did to one issue, in one line.
485pub fn sentence(ruled: &Ruled) -> String {
486    let mut parts = Vec::new();
487    if let Some(rule) = &ruled.dismissed {
488        parts.push(format!("dismissed by '{rule}'"));
489    }
490    if let Some(rule) = &ruled.pinned {
491        parts.push(format!("pinned by '{rule}'"));
492    }
493    if let Some(rule) = &ruled.urgent {
494        parts.push(format!("urgent by '{rule}'"));
495    }
496    if ruled.woke {
497        parts.push("woken from its snooze".to_string());
498    }
499    if !ruled.tagged.is_empty() {
500        parts.push(format!("its task tagged {}", ruled.tagged.join(", ")));
501    }
502    format!("{}: {}", ruled.issue_key, parts.join("; "))
503}
504
505/// The rules an issue matches now, as lines of `kasl inbox show --why`.
506pub fn explain(book: &RuleBook, item: &JiraInboxItem) -> Vec<Reason> {
507    book.rules
508        .iter()
509        .filter(|rule| rule.matches(item))
510        .map(|rule| Reason {
511            what: "rule",
512            value: rule.label.clone(),
513            because: format!(
514                "rule {} matches it now and says: {}; a rule acts when an issue arrives or comes to match it",
515                rule.number,
516                rule.rule.then.describe()
517            ),
518        })
519        .collect()
520}
521
522/// One rule as `kasl inbox rules` shows it.
523#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct RuleStanding {
525    pub number: usize,
526    pub name: Option<String>,
527    pub when: String,
528    pub then: String,
529    /// What it matches in the inbox now, in words.
530    pub now: String,
531    /// Matching issues its pin or dismissal has not reached - what `--apply` would change.
532    pub waiting: usize,
533}
534
535/// Where one rule stands against the inbox: what it matches, and what it has not reached.
536///
537/// `items` is the present inbox, asleep and dismissed issues included - a
538/// dismiss rule's work is exactly the issues the ordinary list no longer shows.
539pub fn standing(rule: &CompiledRule, items: &[JiraInboxItem]) -> RuleStanding {
540    let mut standing = RuleStanding {
541        number: rule.number,
542        name: rule.rule.name.clone().filter(|name| !name.trim().is_empty()),
543        when: rule.rule.when.describe(),
544        then: rule.rule.then.describe(),
545        now: String::new(),
546        waiting: 0,
547    };
548    if let Some(why) = &rule.dormant {
549        standing.now = format!("matches nothing yet: {why}");
550        return standing;
551    }
552
553    let matching: Vec<&JiraInboxItem> = items.iter().filter(|item| rule.matches(item)).collect();
554    let count = |keep: fn(&JiraInboxItem) -> bool| matching.iter().filter(|item| keep(item)).count();
555    let mut parts = vec![format!("{} match", matching.len())];
556    let then = &rule.rule.then;
557    // Zero counts are left out: "0 pinned" next to "1 not yet pinned" says
558    // the same thing twice.
559    let mut say = |n: usize, what: &str| {
560        if n > 0 {
561            parts.push(format!("{n} {what}"));
562        }
563    };
564    if then.dismiss {
565        say(count(|i| i.dismissed), "dismissed");
566        let not_yet = count(|i| !i.dismissed && i.taken_at.is_none());
567        say(not_yet, "not yet dismissed");
568        say(count(|i| !i.dismissed && i.taken_at.is_some()), "kept as taken");
569        standing.waiting += not_yet;
570    }
571    if then.pin {
572        say(count(|i| !i.dismissed && i.pinned), "pinned");
573        let not_yet = count(|i| !i.dismissed && !i.pinned);
574        say(not_yet, "not yet pinned");
575        standing.waiting += not_yet;
576    }
577    if !then.tag.is_empty() {
578        say(count(|i| i.taken_at.is_some()), "taken");
579    }
580    standing.now = parts.join(", ");
581    standing
582}