1use 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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuleWhen {
46 #[serde(default)]
48 pub priority: Option<String>,
49 #[serde(default)]
51 pub min_score: Option<f64>,
52 #[serde(default, deserialize_with = "one_or_many")]
54 pub status: Vec<String>,
55 #[serde(default, rename = "type", deserialize_with = "one_or_many")]
57 pub types: Vec<String>,
58 #[serde(default, rename = "project", deserialize_with = "one_or_many")]
60 pub projects: Vec<String>,
61}
62
63impl RuleWhen {
64 pub fn is_empty(&self) -> bool {
66 *self == Self::default()
67 }
68
69 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#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct RuleThen {
91 #[serde(default)]
93 pub pin: bool,
94 #[serde(default)]
96 pub urgent: bool,
97 #[serde(default)]
99 pub dismiss: bool,
100 #[serde(default, deserialize_with = "one_or_many")]
102 pub tag: Vec<String>,
103}
104
105impl RuleThen {
106 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#[derive(Debug, Clone, PartialEq, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct InboxRule {
129 #[serde(default)]
132 pub name: Option<String>,
133 #[serde(default)]
134 pub when: RuleWhen,
135 pub then: RuleThen,
136}
137
138impl InboxRule {
139 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 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
174fn 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#[derive(Debug, Clone)]
194pub struct CompiledRule {
195 pub number: usize,
197 pub rule: InboxRule,
198 pub label: String,
199 filter: InboxFilter,
200 pub dormant: Option<String>,
202}
203
204impl CompiledRule {
205 pub fn matches(&self, item: &JiraInboxItem) -> bool {
207 self.dormant.is_none() && self.filter.keeps(item, item.last_seen)
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct RuleProblem {
215 pub number: usize,
217 pub reason: String,
218}
219
220#[derive(Debug, Clone, Default)]
222pub struct RuleBook {
223 pub rules: Vec<CompiledRule>,
224 pub problems: Vec<RuleProblem>,
225}
226
227#[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 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 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 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 pub fn is_empty(&self) -> bool {
296 self.rules.is_empty()
297 }
298
299 pub fn verdict(&self, item: &JiraInboxItem) -> Verdict {
301 self.collect(|rule| rule.matches(item))
302 }
303
304 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
338pub struct Ruled {
339 pub issue_key: String,
340 pub pinned: Option<String>,
342 pub dismissed: Option<String>,
344 pub urgent: Option<String>,
346 pub woke: bool,
348 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
365pub 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 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
412pub 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
451pub 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
459pub 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
484pub 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
505pub 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#[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 pub now: String,
531 pub waiting: usize,
533}
534
535pub 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 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}