use crate::db::jira_inbox::{Include, JiraInbox, JiraInboxItem, RuledMark};
use crate::db::tags::Tags;
use crate::db::tasks::Tasks;
use crate::libs::config::{Config, JiraInboxConfig};
use crate::libs::inbox_filter::{InboxFilter, Reason, priority_cut_in};
use crate::libs::task::TaskFilter;
use anyhow::Result;
use chrono::Local;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuleWhen {
#[serde(default)]
pub priority: Option<String>,
#[serde(default)]
pub min_score: Option<f64>,
#[serde(default, deserialize_with = "one_or_many")]
pub status: Vec<String>,
#[serde(default, rename = "type", deserialize_with = "one_or_many")]
pub types: Vec<String>,
#[serde(default, rename = "project", deserialize_with = "one_or_many")]
pub projects: Vec<String>,
}
impl RuleWhen {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if let Some(priority) = &self.priority {
parts.push(format!("priority {priority}"));
}
if let Some(score) = self.min_score {
parts.push(format!("score ≥ {score}"));
}
for (what, names) in [("status", &self.status), ("type", &self.types), ("project", &self.projects)] {
if !names.is_empty() {
parts.push(format!("{what} {}", names.join(" or ")));
}
}
if parts.is_empty() { "every issue".to_string() } else { parts.join(", ") }
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuleThen {
#[serde(default)]
pub pin: bool,
#[serde(default)]
pub urgent: bool,
#[serde(default)]
pub dismiss: bool,
#[serde(default, deserialize_with = "one_or_many")]
pub tag: Vec<String>,
}
impl RuleThen {
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if self.pin {
parts.push("pin".to_string());
}
if self.urgent {
parts.push("urgent toast".to_string());
}
if self.dismiss {
parts.push("dismiss".to_string());
}
if !self.tag.is_empty() {
parts.push(format!("tag {}", self.tag.join(", ")));
}
parts.join(", ")
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InboxRule {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub when: RuleWhen,
pub then: RuleThen,
}
impl InboxRule {
pub fn parse(value: &Value) -> std::result::Result<InboxRule, String> {
let rule: InboxRule = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
let then = &rule.then;
if !then.pin && !then.urgent && !then.dismiss && then.tag.is_empty() {
return Err("it does nothing: `then` needs pin, urgent, dismiss or tag".to_string());
}
if then.dismiss && (then.pin || then.urgent) {
return Err("it both dismisses and pins or announces; a hidden issue is neither - split it into two rules".to_string());
}
if then.dismiss && rule.when.is_empty() {
return Err("it would dismiss every issue; give `when` at least one condition".to_string());
}
if then.tag.iter().any(|tag| tag.trim().is_empty()) {
return Err("a tag name cannot be empty".to_string());
}
if rule.when.priority.as_deref().is_some_and(|p| p.trim_end_matches('+').trim().is_empty()) {
return Err("`priority` names no priority".to_string());
}
Ok(rule)
}
pub fn label(&self) -> String {
match self.name.as_deref().map(str::trim) {
Some(name) if !name.is_empty() => name.to_string(),
_ => self.when.describe(),
}
}
}
fn one_or_many<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
match Value::deserialize(deserializer)? {
Value::String(one) => Ok(vec![one]),
Value::Array(many) => many
.into_iter()
.map(|v| match v {
Value::String(s) => Ok(s),
other => Err(serde::de::Error::custom(format!("expected a name, found {other}"))),
})
.collect(),
other => Err(serde::de::Error::custom(format!("expected a name or a list of names, found {other}"))),
}
}
#[derive(Debug, Clone)]
pub struct CompiledRule {
pub number: usize,
pub rule: InboxRule,
pub label: String,
filter: InboxFilter,
pub dormant: Option<String>,
}
impl CompiledRule {
pub fn matches(&self, item: &JiraInboxItem) -> bool {
self.dormant.is_none() && self.filter.keeps(item, item.last_seen)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleProblem {
pub number: usize,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct RuleBook {
pub rules: Vec<CompiledRule>,
pub problems: Vec<RuleProblem>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Verdict {
pub pin: Option<String>,
pub urgent: Option<String>,
pub dismiss: Option<String>,
pub tags: Vec<String>,
}
impl RuleBook {
pub fn compile(values: &[Value], priorities: &[(String, i32)]) -> RuleBook {
let mut book = RuleBook::default();
for (index, value) in values.iter().enumerate() {
let number = index + 1;
let rule = match InboxRule::parse(value) {
Ok(rule) => rule,
Err(reason) => {
book.problems.push(RuleProblem { number, reason });
continue;
}
};
let (priority, dormant) = match rule.when.priority.as_deref().map(|name| priority_cut_in(priorities, name)) {
Some(Ok(cut)) => (Some(cut), None),
Some(Err(why)) => (None, Some(why.to_string())),
None => (None, None),
};
let filter = InboxFilter {
priority,
min_score: rule.when.min_score,
status: rule.when.status.clone(),
types: rule.when.types.clone(),
projects: rule.when.projects.clone(),
..InboxFilter::default()
};
book.rules.push(CompiledRule {
number,
label: rule.label(),
rule,
filter,
dormant,
});
}
book
}
pub fn of_inbox(config: &JiraInboxConfig, db: &JiraInbox) -> Result<RuleBook> {
if config.rules.is_empty() {
return Ok(RuleBook::default());
}
Ok(Self::compile(&config.rules, &db.priorities()?))
}
pub fn from_config(db: &JiraInbox) -> Result<RuleBook> {
match Config::read().ok().and_then(|c| c.jira_inbox) {
Some(inbox) => Self::of_inbox(&inbox, db),
None => Ok(RuleBook::default()),
}
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn verdict(&self, item: &JiraInboxItem) -> Verdict {
self.collect(|rule| rule.matches(item))
}
pub fn entering(&self, before: Option<&JiraInboxItem>, now: &JiraInboxItem) -> Verdict {
self.collect(|rule| rule.matches(now) && !before.is_some_and(|old| rule.matches(old)))
}
fn collect(&self, counts: impl Fn(&CompiledRule) -> bool) -> Verdict {
let mut verdict = Verdict::default();
for rule in self.rules.iter().filter(|rule| counts(rule)) {
let then = &rule.rule.then;
if then.pin && verdict.pin.is_none() {
verdict.pin = Some(rule.label.clone());
}
if then.urgent && verdict.urgent.is_none() {
verdict.urgent = Some(rule.label.clone());
}
if then.dismiss && verdict.dismiss.is_none() {
verdict.dismiss = Some(rule.label.clone());
}
for tag in &then.tag {
let tag = tag.trim().to_string();
if !verdict.tags.contains(&tag) {
verdict.tags.push(tag);
}
}
}
verdict
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Ruled {
pub issue_key: String,
pub pinned: Option<String>,
pub dismissed: Option<String>,
pub urgent: Option<String>,
pub woke: bool,
pub tagged: Vec<String>,
}
impl Ruled {
fn new(key: &str) -> Self {
Ruled {
issue_key: key.to_string(),
..Default::default()
}
}
fn acted(&self) -> bool {
self.pinned.is_some() || self.dismissed.is_some() || self.urgent.is_some() || !self.tagged.is_empty()
}
}
pub fn apply_entering(db: &JiraInbox, book: &RuleBook, before: &HashMap<String, JiraInboxItem>, keys: &[String]) -> Result<Vec<Ruled>> {
let now = Local::now().naive_local();
let mut done = Vec::new();
for key in keys {
let Some(item) = db.get_by_key(key)? else { continue };
if item.dismissed {
continue;
}
let verdict = book.entering(before.get(key), &item);
let mut ruled = Ruled::new(key);
match verdict.dismiss.filter(|_| item.taken_at.is_none()) {
Some(rule) => {
db.set_ruled(key, RuledMark::Dismiss, &rule)?;
ruled.dismissed = Some(rule);
}
None => {
if let Some(rule) = verdict.pin
&& !item.pinned
{
db.set_ruled(key, RuledMark::Pin, &rule)?;
ruled.pinned = Some(rule);
}
if let Some(rule) = verdict.urgent {
if item.snoozed_until.is_some_and(|until| until > now) {
db.set_snoozed(key, None)?;
ruled.woke = true;
}
ruled.urgent = Some(rule);
}
}
}
if ruled.acted() {
done.push(ruled);
}
}
Ok(done)
}
pub fn apply_now(db: &JiraInbox, book: &RuleBook) -> Result<Vec<Ruled>> {
let items = db.list(Include {
snoozed: true,
..Include::default()
})?;
let mut done = Vec::new();
for item in items {
let verdict = book.verdict(&item);
let mut ruled = Ruled::new(&item.issue_key);
match verdict.dismiss.filter(|_| item.taken_at.is_none()) {
Some(rule) => {
db.set_ruled(&item.issue_key, RuledMark::Dismiss, &rule)?;
ruled.dismissed = Some(rule);
}
None => {
if let Some(rule) = verdict.pin
&& !item.pinned
{
db.set_ruled(&item.issue_key, RuledMark::Pin, &rule)?;
ruled.pinned = Some(rule);
}
}
}
if item.taken_at.is_some() && !verdict.tags.is_empty() {
ruled.tagged = tag_task(&item.issue_key, &verdict.tags)?;
}
if ruled.acted() {
done.push(ruled);
}
}
Ok(done)
}
pub fn tags_for(item: &JiraInboxItem) -> Vec<String> {
match JiraInbox::new().and_then(|db| RuleBook::from_config(&db)) {
Ok(book) => book.verdict(item).tags,
Err(_) => Vec::new(),
}
}
pub fn tag_task(key: &str, names: &[String]) -> Result<Vec<String>> {
let rows = Tasks::new()?.fetch(TaskFilter::ByJiraKey(key.to_string()))?;
let Some(task_id) = rows
.iter()
.find(|row| row.id.is_some() && row.id == row.task_id)
.or_else(|| rows.iter().min_by_key(|row| row.id))
.and_then(|row| row.id)
else {
return Ok(Vec::new());
};
let mut tags = Tags::new()?;
let had: Vec<String> = tags.get_tags_by_task(task_id)?.into_iter().map(|tag| tag.name).collect();
let new: Vec<String> = names.iter().filter(|name| !had.contains(name)).cloned().collect();
for id in tags.get_or_create_tags(&new)? {
tags.add_tag_to_task(task_id, id)?;
}
Ok(new)
}
pub fn sentence(ruled: &Ruled) -> String {
let mut parts = Vec::new();
if let Some(rule) = &ruled.dismissed {
parts.push(format!("dismissed by '{rule}'"));
}
if let Some(rule) = &ruled.pinned {
parts.push(format!("pinned by '{rule}'"));
}
if let Some(rule) = &ruled.urgent {
parts.push(format!("urgent by '{rule}'"));
}
if ruled.woke {
parts.push("woken from its snooze".to_string());
}
if !ruled.tagged.is_empty() {
parts.push(format!("its task tagged {}", ruled.tagged.join(", ")));
}
format!("{}: {}", ruled.issue_key, parts.join("; "))
}
pub fn explain(book: &RuleBook, item: &JiraInboxItem) -> Vec<Reason> {
book.rules
.iter()
.filter(|rule| rule.matches(item))
.map(|rule| Reason {
what: "rule",
value: rule.label.clone(),
because: format!(
"rule {} matches it now and says: {}; a rule acts when an issue arrives or comes to match it",
rule.number,
rule.rule.then.describe()
),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleStanding {
pub number: usize,
pub name: Option<String>,
pub when: String,
pub then: String,
pub now: String,
pub waiting: usize,
}
pub fn standing(rule: &CompiledRule, items: &[JiraInboxItem]) -> RuleStanding {
let mut standing = RuleStanding {
number: rule.number,
name: rule.rule.name.clone().filter(|name| !name.trim().is_empty()),
when: rule.rule.when.describe(),
then: rule.rule.then.describe(),
now: String::new(),
waiting: 0,
};
if let Some(why) = &rule.dormant {
standing.now = format!("matches nothing yet: {why}");
return standing;
}
let matching: Vec<&JiraInboxItem> = items.iter().filter(|item| rule.matches(item)).collect();
let count = |keep: fn(&JiraInboxItem) -> bool| matching.iter().filter(|item| keep(item)).count();
let mut parts = vec![format!("{} match", matching.len())];
let then = &rule.rule.then;
let mut say = |n: usize, what: &str| {
if n > 0 {
parts.push(format!("{n} {what}"));
}
};
if then.dismiss {
say(count(|i| i.dismissed), "dismissed");
let not_yet = count(|i| !i.dismissed && i.taken_at.is_none());
say(not_yet, "not yet dismissed");
say(count(|i| !i.dismissed && i.taken_at.is_some()), "kept as taken");
standing.waiting += not_yet;
}
if then.pin {
say(count(|i| !i.dismissed && i.pinned), "pinned");
let not_yet = count(|i| !i.dismissed && !i.pinned);
say(not_yet, "not yet pinned");
standing.waiting += not_yet;
}
if !then.tag.is_empty() {
say(count(|i| i.taken_at.is_some()), "taken");
}
standing.now = parts.join(", ");
standing
}