kasl-cli 1.16.0

Work activity tracker CLI: automatic workday and break detection, task management with Jira/GitLab integration, productivity reports and exports
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
//! Jira inbox sync and desktop toast helpers.
//!
//! Polls assigned open issues, upserts them into `jira_inbox`, and optionally
//! shows desktop toast notifications for newly discovered keys. Toast click
//! opens the issue browse URL (Windows: win-toast-notify protocol activation;
//! other platforms: notify-rust action callback).
//!
//! A toast about an issue also carries the three decisions worth making about
//! it - Take, Snooze, Dismiss - so the pile can be triaged without opening a
//! terminal. How a button press gets back to the daemon is
//! [`crate::libs::toast_action`]; what it then does is
//! [`crate::libs::toast_apply`].
//!
//! Each poll also runs the inbox rules ([`crate::libs::inbox_rules`]) over
//! the issues it brought: a rule may pin an issue, hide it, or turn its toast
//! into an urgent one that stays on screen.

use crate::api::jira::{Jira, SessionMode};
use crate::db::jira_inbox::{ChangedIssue, Include, JiraInbox, JiraInboxItem, JiraInboxUpsert, LeftReason, UpsertBatchResult};
use crate::db::jira_statuses::JiraStatuses;
use crate::libs::config::{Config, JiraInboxConfig};
use crate::libs::inbox_rules::{self, RuleBook, RuleProblem, Ruled};
use crate::libs::messages::Message;
use crate::libs::toast_action::{Mailbox, ToastAction};
use crate::libs::toast_apply;
use crate::{msg_info, msg_warning};
use anyhow::Result;
use chrono::{Duration, Local, NaiveDateTime};
use std::collections::{HashMap, VecDeque};
use std::process::Command;
use tracing::{debug, warn};

/// Above this many toasts, they collapse into one summary toast.
///
/// The poller counts against it over an hour ([`ToastBudget`]); waking
/// snoozed issues, which the user scheduled, counts per wake.
///
/// A first sync of two hundred open issues, or a Jira-side re-scoring of all
/// of them, is one event, not two hundred; two hundred toasts teach the user
/// to switch notifications off, after which the one toast that matters is
/// never seen.
pub const TOAST_STORM_THRESHOLD: usize = 5;

/// Whether `count` toasts of one kind should become a single summary toast.
pub fn toasts_collapse(count: usize) -> bool {
    count > TOAST_STORM_THRESHOLD
}

/// The span over which [`ToastBudget`] counts the toasts it let through.
pub const TOAST_WINDOW_MINUTES: i64 = 60;

/// What one poll's toasts turn into.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastPlan {
    /// Each issue gets its own toast.
    Each,
    /// One toast stands in for all of them.
    Summary,
    /// Nothing is shown; the list in `kasl inbox` carries the news.
    Quiet,
}

/// How many issue toasts the poller may show, counted over time rather than per poll.
///
/// A limit per poll does not hold against a trickle. Jira-side automation
/// that rescores two hundred issues a few at a time, or a list that keeps
/// losing and regaining issues, arrives as three or four changes on every
/// poll - under any per-poll threshold, and all of them toasted, poll after
/// poll, for as long as it lasts. Counting over the last hour makes the
/// total what is bounded: at most [`TOAST_STORM_THRESHOLD`] single toasts,
/// then one summary, then quiet until the hour has room again.
///
/// Urgent toasts - the ones a rule asks for - count in a lane of their own,
/// with the same bound: an afternoon of ordinary changes must not have spent
/// the room an escalation needs, and an escalation of fifty issues at once is
/// still a storm.
///
/// Held in memory by the poller. A restart forgets the count, which costs at
/// most one more budget's worth, and keeps the budget from outliving the
/// process that spends it.
#[derive(Debug, Default)]
pub struct ToastBudget {
    ordinary: Lane,
    urgent: Lane,
}

impl ToastBudget {
    pub fn new() -> Self {
        Self::default()
    }

    /// Decides how `count` ordinary toasts from one poll at `now` are shown, and spends the budget.
    pub fn admit(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
        self.ordinary.admit(now, count)
    }

    /// The same decision for urgent toasts, from their own lane.
    pub fn admit_urgent(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
        self.urgent.admit(now, count)
    }
}

/// One kind of toast's spending over the last hour.
#[derive(Debug, Default)]
struct Lane {
    shown: VecDeque<NaiveDateTime>,
    summary_at: Option<NaiveDateTime>,
}

impl Lane {
    fn admit(&mut self, now: NaiveDateTime, count: usize) -> ToastPlan {
        let since = now - Duration::minutes(TOAST_WINDOW_MINUTES);
        while self.shown.front().is_some_and(|t| *t <= since) {
            self.shown.pop_front();
        }
        if self.summary_at.is_some_and(|t| t <= since) {
            self.summary_at = None;
        }

        if count == 0 {
            return ToastPlan::Each;
        }
        if self.shown.len() + count <= TOAST_STORM_THRESHOLD {
            self.shown.extend(std::iter::repeat_n(now, count));
            return ToastPlan::Each;
        }
        if self.summary_at.is_none() {
            self.summary_at = Some(now);
            return ToastPlan::Summary;
        }
        ToastPlan::Quiet
    }
}

/// A toast one poll would like to show.
enum Pending {
    New(JiraInboxItem),
    Changed(JiraInboxItem, String),
    Gone(JiraInboxItem),
    /// A taken issue Jira resolved while its task is still open.
    Resolved(JiraInboxItem),
    /// An issue a rule calls urgent, with the rule's name.
    Urgent(JiraInboxItem, String),
}

/// What a poll has to say about one issue that is still in the inbox.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
    /// It arrived.
    New,
    /// A person moved it: the change in words.
    Changed(String),
    /// A rule calls it urgent: the rule's name.
    Urgent(String),
}

/// Which toast each issue of a poll gets: one at most.
///
/// `fresh` are new issues not yet announced, `changed` the notable changes
/// worth a toast, `urgent` the issues a rule has just called urgent. An urgent
/// issue gets the urgent toast in place of its new or changed one - two toasts
/// about one issue in the same breath would be one too many, and the urgent
/// one says more. Urgent is not held back by `notify_changes`: the rule is an
/// explicit ask for this toast.
pub fn notices(fresh: &[String], changed: &[(String, String)], urgent: &[(String, String)]) -> Vec<(String, Notice)> {
    let urgent_by = |key: &str| urgent.iter().find(|(k, _)| k == key).map(|(_, rule)| rule.clone());
    let mut out: Vec<(String, Notice)> = Vec::new();
    for key in fresh {
        let notice = urgent_by(key).map(Notice::Urgent).unwrap_or(Notice::New);
        out.push((key.clone(), notice));
    }
    for (key, change) in changed {
        if !fresh.contains(key) && urgent_by(key).is_none() {
            out.push((key.clone(), Notice::Changed(change.clone())));
        }
    }
    for (key, rule) in urgent {
        if !fresh.contains(key) {
            out.push((key.clone(), Notice::Urgent(rule.clone())));
        }
    }
    out
}

/// The line a summary toast opens with: what arrived, by kind.
fn summary_line(pending: &[Pending]) -> String {
    let count = |f: fn(&Pending) -> bool| pending.iter().filter(|p| f(p)).count();
    let parts = [
        (count(|p| matches!(p, Pending::New(_))), "new"),
        (count(|p| matches!(p, Pending::Changed(..))), "changed"),
        (count(|p| matches!(p, Pending::Gone(_))), "left the inbox"),
        (count(|p| matches!(p, Pending::Resolved(_))), "resolved with your task open"),
        (count(|p| matches!(p, Pending::Urgent(..))), "urgent"),
    ];
    let said: Vec<String> = parts.iter().filter(|(n, _)| *n > 0).map(|(n, what)| format!("{n} {what}")).collect();
    format!("Issues: {}", said.join(", "))
}

/// Outcome of a single inbox sync pass.
#[derive(Debug, Default)]
pub struct SyncOutcome {
    pub fetched: usize,
    pub new_keys: Vec<String>,
    pub updated: usize,
    /// Existing issues whose status/priority/score visibly changed.
    pub changed: Vec<ChangedIssue>,
    /// Issues that stopped appearing in the poll this pass.
    pub gone_keys: Vec<String>,
    /// Taken issues Jira said were resolved this pass, whose tasks are still open.
    ///
    /// The ones to offer marking done. Learnt once per departure, so a sync
    /// that reports one here will not report it again.
    pub resolved: Vec<ResolvedIssue>,
    /// What the inbox rules did this pass.
    pub ruled: Vec<Ruled>,
    /// Rules that could not be read, and were skipped.
    pub rule_problems: Vec<RuleProblem>,
    pub notified: usize,
    /// True when sync was skipped (no jira config / disabled / no credentials).
    pub skipped: bool,
}

/// A taken issue Jira resolved, with the task that is still open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedIssue {
    pub issue_key: String,
    pub summary: String,
    /// The resolution Jira gave ("Done", "Won't Fix").
    pub resolution: Option<String>,
}

/// How many departed taken issues one poll asks Jira about.
///
/// The first poll after an upgrade finds every taken issue that ever left
/// unexplained; one request each would hold that poll for minutes. The rest
/// are asked about on the polls that follow.
const DEPARTURES_PER_POLL: usize = 20;

/// Asks Jira why each taken issue that left the inbox left, and keeps the answer.
///
/// Returns the ones resolved while their task is still open. An answer that
/// does not come - VPN down, Jira busy - leaves the row unexplained, and the
/// next poll asks again; a sync is not failed for it, because the issues that
/// did arrive are already reconciled and that is the part that matters most.
async fn explain_departures(jira: &mut Jira, mode: SessionMode) -> Result<Vec<ResolvedIssue>> {
    let db = JiraInbox::new()?;
    let mut resolved = Vec::new();
    for key in db.unexplained_departures(DEPARTURES_PER_POLL)? {
        let state = match jira.issue_state(&key, mode).await {
            Ok(state) => state,
            Err(e) => {
                warn!("Jira inbox: could not learn why {} left: {}", key, e);
                continue;
            }
        };
        match state.and_then(|s| s.resolution) {
            Some(resolution) => {
                db.set_left(&key, LeftReason::Resolved, Some(&resolution))?;
                if toast_apply::task_open(&key)?
                    && let Some(item) = db.get_by_key(&key)?
                {
                    resolved.push(ResolvedIssue {
                        issue_key: key.clone(),
                        summary: item.summary,
                        resolution: Some(resolution),
                    });
                }
            }
            None => {
                db.set_left(&key, LeftReason::Moved, None)?;
            }
        }
    }
    Ok(resolved)
}

/// Runs one interactive sync (may prompt for Jira password).
///
/// `--sync` always fetches even when the inbox poller is disabled in config;
/// toasts respect `jira_inbox.notify` when that section exists. A sync run
/// by hand gets a budget of its own: it is one poll, asked for.
pub async fn sync_interactive(notify: bool) -> Result<SyncOutcome> {
    let config = Config::read()?;
    let Some(jira_config) = config.jira.clone() else {
        msg_warning!(Message::JiraInboxRequiresJiraConfig);
        return Ok(SyncOutcome {
            skipped: true,
            ..Default::default()
        });
    };

    let inbox_cfg = config.jira_inbox.clone().unwrap_or_default();
    let allow_toast = notify && inbox_cfg.notify;

    let mut jira = Jira::new(&jira_config);
    let issues = jira.get_assigned_open_issues(&inbox_cfg.extra_field_ids()).await?;
    // The caller is a terminal: resolved issues are asked about there, not toasted.
    let poll = Poll {
        mode: SessionMode::Interactive,
        notify: allow_toast,
        offer_by_toast: false,
    };
    apply_issues(&mut jira, &issues, &inbox_cfg, poll, &mut ToastBudget::new()).await
}

/// Who is polling, and so where its news goes.
#[derive(Debug, Clone, Copy)]
struct Poll {
    mode: SessionMode,
    /// Whether toasts are shown at all.
    notify: bool,
    /// Whether a resolved issue's offer goes on a toast, or back to the caller.
    offer_by_toast: bool,
}

/// Runs one non-interactive sync for the background watcher.
///
/// `budget` is the poller's, carried from poll to poll, so the toasts it
/// allows are counted across polls rather than within one.
pub async fn sync_noninteractive(inbox_cfg: &JiraInboxConfig, budget: &mut ToastBudget) -> Result<SyncOutcome> {
    if !inbox_cfg.enabled {
        return Ok(SyncOutcome {
            skipped: true,
            ..Default::default()
        });
    }

    let config = Config::read()?;
    let Some(jira_config) = config.jira.clone() else {
        return Ok(SyncOutcome {
            skipped: true,
            ..Default::default()
        });
    };

    let mut jira = Jira::new(&jira_config);
    let Some(issues) = jira.get_assigned_open_issues_noninteractive(&inbox_cfg.extra_field_ids()).await? else {
        warn!("Jira inbox poll skipped: no cached session or secret");
        return Ok(SyncOutcome {
            skipped: true,
            ..Default::default()
        });
    };

    let poll = Poll {
        mode: SessionMode::Unattended,
        notify: inbox_cfg.notify,
        offer_by_toast: true,
    };
    apply_issues(&mut jira, &issues, inbox_cfg, poll, budget).await
}

async fn apply_issues(
    jira: &mut Jira,
    issues: &[crate::api::jira::JiraIssue],
    inbox_cfg: &JiraInboxConfig,
    poll: Poll,
    budget: &mut ToastBudget,
) -> Result<SyncOutcome> {
    let notify = poll.notify;
    let statuses = JiraStatuses::new()?;
    let sort_field = inbox_cfg.sort_by_field.as_deref().map(str::trim).filter(|s| !s.is_empty());

    let mut upserts = Vec::with_capacity(issues.len());
    for issue in issues {
        if !issue.fields.status.id.is_empty() {
            statuses.upsert(&issue.fields.status.id, &issue.fields.status.name)?;
        }

        let status_id = if issue.fields.status.id.is_empty() {
            None
        } else {
            Some(issue.fields.status.id.clone())
        };

        let sort_value = sort_field.and_then(|id| Jira::sort_value_from_issue(issue, id));

        upserts.push(JiraInboxUpsert {
            issue_key: issue.key.clone(),
            issue_id: issue.id.clone(),
            summary: issue.fields.summary.clone(),
            status_id,
            status_name: issue.fields.status.name.clone(),
            priority: issue.fields.priority.as_ref().map(|p| p.name.clone()),
            priority_rank: Jira::priority_rank(&issue.fields.priority),
            sort_value,
            url: jira.issue_browse_url(&issue.key),
            raw_updated: issue.fields.updated.clone(),
            issue_type: issue.fields.issuetype.as_ref().map(|t| t.name.clone()),
            // Jira's own answer, falling back to the key's prefix, which is
            // what a Jira key is.
            project: issue
                .fields
                .project
                .as_ref()
                .map(|p| p.key.clone())
                .or_else(|| issue.key.split_once('-').map(|(project, _)| project.to_string())),
        });
    }

    let db = JiraInbox::new()?;
    // Rules act on what an issue has just come to be, so what it was is
    // read before the poll overwrites it.
    let before: HashMap<String, JiraInboxItem> = if inbox_cfg.rules.is_empty() {
        HashMap::new()
    } else {
        db.list(Include::EVERYTHING)?.into_iter().map(|item| (item.issue_key.clone(), item)).collect()
    };
    let UpsertBatchResult { new_keys, updated, changed } = db.upsert_batch(&upserts)?;

    // Reconcile: issues missing from this poll are gone (closed, reassigned),
    // not frozen in the list forever.
    let present_keys: Vec<String> = upserts.iter().map(|u| u.issue_key.clone()).collect();
    let gone_keys = db.mark_gone(&present_keys)?;

    // Compiled after the upsert, so a priority name first seen in this very
    // poll is already known to a rule that names it.
    let book = RuleBook::of_inbox(inbox_cfg, &db)?;
    for problem in &book.problems {
        debug!("Jira inbox: rule {} is skipped: {}", problem.number, problem.reason);
    }
    let ruled = inbox_rules::apply_entering(&db, &book, &before, &present_keys)?;

    // A taken issue that left is either finished or no longer yours, and only
    // Jira knows which. The answer decides whether its task is offered as done.
    let resolved = match explain_departures(jira, poll.mode).await {
        Ok(resolved) => resolved,
        Err(e) => {
            warn!("Jira inbox: could not explain departures: {}", e);
            Vec::new()
        }
    };

    // Every toast this poll would show is gathered first and shown under
    // one decision per lane, so the budget sees the poll as a whole.
    let mut pending = Vec::new();
    if notify {
        // A new issue a rule dismissed is not listed here at all: hiding it
        // was the rule's whole point.
        let to_notify = db.list_unnotified_new(&new_keys)?;
        let fresh: Vec<String> = to_notify.iter().map(|i| i.issue_key.clone()).collect();
        let moved: Vec<(String, String)> = if inbox_cfg.notify_changes {
            changed
                .iter()
                .filter(|c| c.notable && !c.dismissed)
                .map(|c| (c.issue_key.clone(), c.change.clone()))
                .collect()
        } else {
            Vec::new()
        };
        let urgent: Vec<(String, String)> = ruled.iter().filter_map(|r| r.urgent.clone().map(|rule| (r.issue_key.clone(), rule))).collect();
        for (key, notice) in notices(&fresh, &moved, &urgent) {
            // Read again: a rule may have dismissed the issue in this very poll.
            if let Ok(Some(item)) = db.get_by_key(&key)
                && !item.dismissed
            {
                pending.push(match notice {
                    Notice::New => Pending::New(item),
                    Notice::Changed(change) => Pending::Changed(item, change),
                    Notice::Urgent(rule) => Pending::Urgent(item, rule),
                });
            }
        }
        // Notified means announced or deliberately not: a new issue held back
        // by the budget is in the list with its NEW badge, and toasting it an
        // hour later would be old news.
        db.mark_notified(&fresh)?;
    }
    if notify && inbox_cfg.notify_gone {
        // A resolved issue with an open task gets its own toast below; a
        // second one saying it left would be the same news twice.
        let offered: Vec<&str> = resolved.iter().map(|r| r.issue_key.as_str()).collect();
        for key in gone_keys.iter().filter(|k| !(poll.offer_by_toast && offered.contains(&k.as_str()))) {
            if let Ok(Some(item)) = db.get_by_key(key) {
                pending.push(Pending::Gone(item));
            }
        }
    }
    // Not gated by `notify_gone`: this is not news that an issue left, it is a
    // question about the person's own task.
    if notify && poll.offer_by_toast {
        for issue in &resolved {
            if let Ok(Some(item)) = db.get_by_key(&issue.issue_key) {
                pending.push(Pending::Resolved(item));
            }
        }
    }

    let (urgent_pending, ordinary): (Vec<Pending>, Vec<Pending>) = pending.into_iter().partition(|p| matches!(p, Pending::Urgent(..)));
    let now = Local::now().naive_local();
    let mut notified = 0;
    notified += show_pending(jira, &urgent_pending, budget.admit_urgent(now, urgent_pending.len()));
    notified += show_pending(jira, &ordinary, budget.admit(now, ordinary.len()));

    Ok(SyncOutcome {
        fetched: issues.len(),
        new_keys,
        updated,
        changed,
        gone_keys,
        resolved,
        ruled,
        rule_problems: book.problems,
        notified,
        skipped: false,
    })
}

/// Shows one lane's toasts as its budget decided; returns how many appeared.
fn show_pending(jira: &Jira, pending: &[Pending], plan: ToastPlan) -> usize {
    match plan {
        ToastPlan::Each => pending
            .iter()
            .filter(|toast| match toast {
                Pending::New(item) => show_toast(item),
                Pending::Changed(item, change) => show_change_toast(item, change),
                Pending::Gone(item) => show_gone_toast(item),
                Pending::Resolved(item) => show_resolved_toast(item),
                Pending::Urgent(item, rule) => show_urgent_toast(item, rule),
            })
            .count(),
        ToastPlan::Summary => usize::from(show_summary_toast(jira, &summary_line(pending))),
        ToastPlan::Quiet => {
            debug!("Jira inbox: held back {} toast(s); the hour's budget is spent", pending.len());
            0
        }
    }
}

/// Returns every issue whose snooze has run out, toasting each one.
///
/// Returns how many came back. A snooze the user set is a promise to be
/// reminded, so the return is announced even though the issue was already
/// known - that reminder is the whole reason to snooze rather than dismiss.
pub fn wake_snoozed(notify: bool) -> Result<usize> {
    let woken = JiraInbox::new()?.wake_due()?;
    if woken.is_empty() {
        return Ok(0);
    }
    if notify {
        if toasts_collapse(woken.len()) {
            // A summary toast whose click did nothing would be the worst of
            // both: the base comes off an issue's own browse URL, so the list
            // opens without this reaching for the Jira config.
            let list_url = open_issues_url_from(&woken[0].url);
            show_raw_toast(
                "Jira inbox",
                &format!("{} snoozed issues are back - see `kasl inbox`", woken.len()),
                &list_url,
                "inbox",
                &[],
            );
        } else {
            for item in &woken {
                show_snoozed_toast(item);
            }
        }
    }
    Ok(woken.len())
}

/// The assigned-open-issues list, derived from an issue's browse URL.
///
/// Waking is local, so it must not need the Jira config to be readable; the
/// base is already carried on every row as `{base}/browse/{key}`. A URL that
/// does not have that shape yields the row's own URL, which still opens
/// something true.
fn open_issues_url_from(issue_url: &str) -> String {
    match issue_url.rsplit_once("/browse/") {
        Some((base, _)) => format!("{base}/issues/?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20is%20EMPTY"),
        None => issue_url.to_string(),
    }
}

/// Shows a toast for an issue whose snooze has run out.
pub fn show_snoozed_toast(item: &JiraInboxItem) -> bool {
    let body = format!("Back from snooze - {}", item.summary);
    show_issue_toast(item, &body)
}

/// Shows a desktop toast for a newly discovered inbox item.
///
/// Clicking the toast body opens [`JiraInboxItem::url`] in the default
/// browser; the buttons decide the issue without leaving the desktop.
pub fn show_toast(item: &JiraInboxItem) -> bool {
    show_issue_toast(item, &toast_body(item))
}

/// Shows a toast that stays: an issue a rule calls urgent.
///
/// The same three buttons as any issue toast, and the rule named in the body,
/// so the person can tell an escalation from a slow afternoon - and knows
/// which rule to change if it keeps being wrong. On Windows the toast stays
/// on screen until acted on; on Linux it is sent as critical.
pub fn show_urgent_toast(item: &JiraInboxItem, rule: &str) -> bool {
    let body = format!("Urgent by the rule '{rule}' - {}", toast_body(item));
    show_toast_as(
        &format!("Jira {} - urgent", item.issue_key),
        &body,
        &item.url,
        &item.issue_key,
        &ToastAction::ALL,
        Urgency::Urgent,
    )
}

/// Shows a toast for a visible change on an existing inbox item.
pub fn show_change_toast(item: &JiraInboxItem, change: &str) -> bool {
    let body = format!("{change} — {}", item.summary);
    show_issue_toast(item, &body)
}

/// Shows one toast standing in for many; clicking opens the open-issues list in Jira.
///
/// No buttons: a summary is about a pile, and there is no one issue for Take
/// to take. The pile is triaged in `kasl inbox triage`, which the body says.
pub fn show_summary_toast(jira: &Jira, what: &str) -> bool {
    show_raw_toast("Jira inbox", &format!("{what} - see `kasl inbox`"), &jira.open_issues_url(), "inbox", &[])
}

/// Shows a toast for an issue that left the inbox (closed or reassigned).
///
/// No buttons either: the issue is already gone, so all three decisions are
/// about work that is no longer there.
pub fn show_gone_toast(item: &JiraInboxItem) -> bool {
    let body = format!("Left the inbox — {}", item.summary);
    show_raw_toast(&format!("Jira {}", item.issue_key), &body, &item.url, &item.issue_key, &[])
}

/// Shows a toast for a taken issue Jira resolved while its task is open.
///
/// One button, **Mark done**: the issue's own decisions are behind it - it is
/// resolved - and the only question left is the person's task.
pub fn show_resolved_toast(item: &JiraInboxItem) -> bool {
    let resolution = item.resolution.as_deref().map(|r| format!(" ({r})")).unwrap_or_default();
    let body = format!("Resolved in Jira{resolution} - your task is still open. {}", item.summary);
    show_raw_toast(&format!("Jira {}", item.issue_key), &body, &item.url, &item.issue_key, &[ToastAction::Done])
}

/// A toast about one live issue: the body opens it, the buttons decide it.
fn show_issue_toast(item: &JiraInboxItem, body: &str) -> bool {
    show_raw_toast(&format!("Jira {}", item.issue_key), body, &item.url, &item.issue_key, &ToastAction::ALL)
}

/// How insistent a toast is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Urgency {
    /// Shows, then slides into the notification centre.
    Normal,
    /// Stays until acted on, where the platform allows it.
    Urgent,
}

/// Platform dispatch for a toast with a click-to-open URL and buttons.
fn show_raw_toast(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction]) -> bool {
    show_toast_as(title, body, url, key, actions, Urgency::Normal)
}

fn show_toast_as(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
    #[cfg(windows)]
    {
        show_toast_windows(title, body, url, key, actions, urgency)
    }
    #[cfg(not(windows))]
    {
        show_toast_other(title, body, url, key, actions, urgency)
    }
}

fn toast_body(item: &JiraInboxItem) -> String {
    let priority = item.priority.as_deref().unwrap_or("—");
    match item.sort_value {
        Some(score) => format!("[score {score}] [{priority}] {}", item.summary),
        None => format!("[{priority}] {}", item.summary),
    }
}

/// Materializes the embedded brand logo for toast notifications.
///
/// Toast XML references images by file path, so the PNG compiled into the
/// binary is written to the data directory on first use. A logo failure only
/// degrades the toast, so all errors collapse to `None`.
#[cfg(windows)]
fn toast_logo_path() -> Option<std::path::PathBuf> {
    const LOGO: &[u8] = include_bytes!("../../assets/toast-96.png");
    let path = crate::libs::data_storage::DataStorage::new().get_path("toast-logo.png").ok()?;
    // Rewrite when the embedded logo changes (e.g. after a self-update).
    if std::fs::metadata(&path).map(|m| m.len() != LOGO.len() as u64).unwrap_or(true) {
        std::fs::write(&path, LOGO).ok()?;
    }
    Some(path)
}

/// The buttons for one issue, each pointing at its own shortcut.
///
/// A shortcut that cannot be written costs that button and nothing else: a
/// toast with two buttons is still useful, and a toast that failed to appear
/// because of a file permission would not be.
#[cfg(windows)]
fn windows_buttons(key: &str, actions: &[ToastAction]) -> Vec<win_toast_notify::Action> {
    actions
        .iter()
        .filter_map(|action| match crate::libs::toast_shortcut::ensure(*action, key) {
            Ok(uri) => Some(win_toast_notify::Action {
                activation_type: win_toast_notify::ActivationType::Protocol,
                action_content: action.label().to_string(),
                arguments: uri,
                image_url: None,
            }),
            Err(e) => {
                warn!("No {} button on the {} toast: {}", action.as_str(), key, e);
                None
            }
        })
        .collect()
}

#[cfg(windows)]
fn show_toast_windows(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
    let mut toast = win_toast_notify::WinToastNotify::new().set_title(title).set_messages(vec![body]).set_open(url);
    if let Some(logo) = toast_logo_path() {
        toast = toast.set_logo(&logo.to_string_lossy(), win_toast_notify::CropCircle::False);
    }
    // The reminder scenario keeps the toast on screen until it is acted on.
    // Not the "urgent" scenario: that one is Windows 11 only and needs a
    // per-app permission an unpackaged program does not get.
    if urgency == Urgency::Urgent {
        toast = toast.set_scenario(win_toast_notify::Scenario::Reminder);
    }
    if !actions.is_empty() {
        let buttons = windows_buttons(key, actions);
        if !buttons.is_empty() {
            toast = toast.set_actions(buttons);
        }
    }

    match toast.show() {
        Ok(()) => {
            debug!("Showed toast for {}", key);
            true
        }
        Err(e) => {
            warn!("Failed to show toast for {}: {}", key, e);
            false
        }
    }
}

/// Linux and other XDG desktops: buttons are D-Bus actions on the
/// notification, so the press arrives in this process directly and there is
/// no shortcut and no courier - the mailbox is posted to in-process.
///
/// The wait runs on its own thread because `wait_for_action` blocks until the
/// notification is closed, and the poller must not stop for it.
#[cfg(all(not(windows), not(target_os = "macos")))]
fn show_toast_other(title: &str, body: &str, url: &str, key: &str, actions: &[ToastAction], urgency: Urgency) -> bool {
    let url = url.to_string();
    let owned_key = key.to_string();

    let mut notification = notify_rust::Notification::new();
    notification.summary(title).body(body).action("default", "Open");
    // Critical notifications stay until dismissed on XDG desktops.
    if urgency == Urgency::Urgent {
        notification.urgency(notify_rust::Urgency::Critical);
    }
    for action in actions {
        notification.action(action.as_str(), action.label());
    }

    match notification.show() {
        Ok(handle) => {
            // Wait for click off the poller thread so sync stays responsive.
            std::thread::spawn(move || {
                handle.wait_for_action(|action| {
                    if action == "default" {
                        if let Err(e) = open_url(&url) {
                            warn!("Failed to open {} from toast: {}", owned_key, e);
                        }
                    } else if let Some(chosen) = ToastAction::parse(action) {
                        let request = crate::libs::toast_action::ToastRequest::new(chosen, &owned_key);
                        // Posted rather than applied here so both platforms
                        // settle a decision in exactly one place: the drain.
                        match Mailbox::open().and_then(|m| m.post(&request)) {
                            Ok(()) => debug!("Posted {} {} from a toast button", action, owned_key),
                            Err(e) => warn!("Failed to post {} {} from a toast button: {}", action, owned_key, e),
                        }
                    }
                });
            });
            debug!("Showed toast for {}", key);
            true
        }
        Err(e) => {
            warn!("Failed to show toast for {}: {}", key, e);
            false
        }
    }
}

/// macOS: notify-rust cannot wait for notification clicks (no actions API),
/// so the toast is display-only, and both opening and deciding stay on the
/// CLI (`kasl inbox triage`). The buttons are not rendered rather than
/// rendered dead: a button that does nothing is worse than no button.
#[cfg(target_os = "macos")]
fn show_toast_other(title: &str, body: &str, _url: &str, key: &str, _actions: &[ToastAction], _urgency: Urgency) -> bool {
    match notify_rust::Notification::new().summary(title).body(body).show() {
        Ok(_) => {
            debug!("Showed toast for {}", key);
            true
        }
        Err(e) => {
            warn!("Failed to show toast for {}: {}", key, e);
            false
        }
    }
}

/// Opens a URL in the platform default browser / handler.
pub fn open_url(url: &str) -> Result<()> {
    #[cfg(windows)]
    {
        Command::new("cmd").args(["/C", "start", "", url]).spawn()?;
    }
    #[cfg(target_os = "macos")]
    {
        Command::new("open").arg(url).spawn()?;
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        Command::new("xdg-open").arg(url).spawn()?;
    }
    Ok(())
}

/// Performs every toast button press waiting in the mailbox.
///
/// Returns how many were carried out. Each one answers on a toast, because
/// the decision was made on a toast: a button that changes the database in
/// silence leaves the user unsure whether the press registered, and pressing
/// again is the natural response to that doubt.
///
/// One failing request does not stop the rest. A click is a user action that
/// already happened; refusing to carry out the next one because the previous
/// key was missing would lose work the user did.
pub async fn drain_toast_actions() -> Result<usize> {
    let requests = Mailbox::open()?.collect()?;
    if requests.is_empty() {
        return Ok(0);
    }

    let db = JiraInbox::new()?;
    let mut done = 0;
    for request in &requests {
        // Read the row before acting: the answering toast should still open
        // the issue it is about, and a dismiss or a take may be the last
        // moment its URL is easy to reach.
        let url = db.get_by_key(&request.issue_key).ok().flatten().map(|item| item.url).unwrap_or_default();
        match toast_apply::apply(request).await {
            Ok(outcome) => {
                debug!("Toast action {} {}: {:?}", request.action.as_str(), request.issue_key, outcome);
                if outcome.settled() {
                    // The buttons of a decided issue are shortcuts that still
                    // run; nothing points at them any more, so they go.
                    #[cfg(windows)]
                    crate::libs::toast_shortcut::forget(&request.issue_key);
                }
                msg_info!(Message::ToastActionApplied(outcome.summary(&request.issue_key)));
                // The answer opens the issue, not nothing: an empty launch
                // target makes the toast body a dead click area, which reads
                // as the toast being broken.
                show_raw_toast("Jira inbox", &outcome.summary(&request.issue_key), &url, &request.issue_key, &[]);
                done += 1;
            }
            Err(e) => {
                warn!("Toast action {} {} failed: {}", request.action.as_str(), request.issue_key, e);
                msg_warning!(Message::ToastActionFailed(request.issue_key.clone(), e.to_string()));
            }
        }
    }
    Ok(done)
}

/// How often the daemon looks in the toast mailbox.
///
/// A button has to answer in the time a person waits after pressing one, so
/// this is seconds, not the poll interval. It cannot ride on the Jira poll:
/// that runs every five minutes by default, and a button that answers in
/// five minutes is not a button - the user would press it again, or conclude
/// it does not work.
const MAILBOX_INTERVAL_SECS: u64 = 2;

/// Watches the toast mailbox and performs what lands in it.
///
/// Its own loop rather than a step of the Jira poll, and it keeps running
/// when polling is disabled or Jira is unreachable: a press on a toast that
/// is already on screen is a decision about a local row, and owes nothing to
/// the network.
pub async fn run_mailbox_watcher() {
    loop {
        match drain_toast_actions().await {
            Ok(0) => {}
            Ok(count) => debug!("Carried out {} toast action(s)", count),
            Err(e) => warn!("Jira inbox: failed to drain toast actions: {}", e),
        }
        tokio::time::sleep(std::time::Duration::from_secs(MAILBOX_INTERVAL_SECS)).await;
    }
}

/// Background poll loop used by `kasl watch`.
///
/// Idle when `jira_inbox` is absent or disabled; re-reads config each wake so
/// `kasl setup` can enable polling without restarting the watcher in most cases
/// (restart still recommended after config changes).
pub async fn run_poller() {
    let mut budget = ToastBudget::new();
    loop {
        let config = match Config::read() {
            Ok(c) => c,
            Err(e) => {
                warn!("Jira inbox: failed to read config: {}", e);
                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
                continue;
            }
        };

        let Some(inbox_cfg) = config.jira_inbox.clone() else {
            // Section not configured — do not poll until user runs init.
            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
            continue;
        };

        if !inbox_cfg.enabled || config.jira.is_none() {
            tokio::time::sleep(std::time::Duration::from_secs(inbox_cfg.poll_interval_secs.max(60))).await;
            continue;
        }

        // Waking is local bookkeeping and runs before the sync, on its own:
        // an issue deferred to Monday is due on Monday whether or not Jira
        // answers, and hanging the return off a successful poll would mean a
        // VPN outage silently held issues asleep past their moment.
        match wake_snoozed(inbox_cfg.notify) {
            Ok(count) if count > 0 => msg_info!(Message::JiraInboxWoke(count)),
            Ok(_) => {}
            Err(e) => warn!("Jira inbox wake error: {}", e),
        }

        match sync_noninteractive(&inbox_cfg, &mut budget).await {
            Ok(outcome) if !outcome.skipped => {
                if !outcome.new_keys.is_empty() {
                    msg_info!(Message::JiraInboxNewIssues(outcome.new_keys.len()));
                }
                debug!(
                    "Jira inbox sync: fetched={}, new={}, updated={}, changed={}, gone={}, notified={}",
                    outcome.fetched,
                    outcome.new_keys.len(),
                    outcome.updated,
                    outcome.changed.len(),
                    outcome.gone_keys.len(),
                    outcome.notified
                );
            }
            Ok(_) => {}
            Err(e) => warn!("Jira inbox sync error: {}", e),
        }

        let secs = inbox_cfg.poll_interval_secs.max(30);
        tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
    }
}