Skip to main content

kasl/libs/
json.rs

1//! The documents `--json` prints: one contract for scripts, status bars and
2//! the MCP server that reads the same functions.
3//!
4//! Rules every document keeps, so a reader learns them once:
5//!
6//! - The top level is an object, never a bare list, so a field can be added
7//!   without breaking anyone who reads the ones already there.
8//! - Every key is always present. A value that does not exist is `null`, an
9//!   empty list is `[]` - a reader never has to tell "absent" from "missing".
10//! - Moments are local time, `YYYY-MM-DDTHH:MM:SS` (see
11//!   [`crate::libs::output::moment`]); dates are `YYYY-MM-DD`.
12//! - Durations are whole seconds, in a field whose name ends in `_seconds`.
13//! - Percentages are numbers from 0 to 100, rounded to one decimal.
14//!
15//! The shapes are built from the domain types here rather than derived on
16//! them: a table can change shape without the contract noticing, and the
17//! contract changes only on purpose - with its page in the docs.
18
19use crate::db::jira_inbox::{JiraInboxItem, LeftReason};
20use crate::libs::output::moment;
21use chrono::{Duration, Local, NaiveDate, NaiveDateTime};
22use serde::Serialize;
23
24/// A percentage rounded to one decimal, the precision kasl shows it at.
25pub fn percent(value: f64) -> f64 {
26    (value * 10.0).round() / 10.0
27}
28
29/// Whole seconds of a duration.
30pub fn seconds(duration: &Duration) -> i64 {
31    duration.num_seconds()
32}
33
34/// One task, as every listing of tasks prints it.
35#[derive(Debug, Clone, Serialize)]
36pub struct Task {
37    pub id: Option<i32>,
38    /// The first task of the chain this one continues across days (the table's
39    /// `TASK ID`); the task's own id when it starts one.
40    pub chain_id: Option<i32>,
41    /// When the row was written, local time.
42    pub created_at: Option<String>,
43    pub name: String,
44    pub comment: String,
45    /// Progress from 0 to 100; a task with none recorded counts as done, as the table shows it.
46    pub completeness: i32,
47    pub tags: Vec<String>,
48    /// The Jira issue the task was taken from.
49    pub jira_key: Option<String>,
50}
51
52impl From<&crate::libs::task::Task> for Task {
53    fn from(task: &crate::libs::task::Task) -> Self {
54        Task {
55            id: task.id,
56            chain_id: task.task_id,
57            // Stored as `YYYY-MM-DD HH:MM:SS`; the contract spells moments with a `T`.
58            created_at: task.timestamp.as_ref().map(|at| at.replacen(' ', "T", 1)),
59            name: task.name.clone(),
60            comment: task.comment.clone(),
61            completeness: task.completeness.unwrap_or(100),
62            tags: task.tags.iter().map(|tag| tag.name.clone()).collect(),
63            jira_key: task.jira_key.clone(),
64        }
65    }
66}
67
68/// A list of tasks: `kasl task list`, `task show`.
69#[derive(Debug, Serialize)]
70pub struct Tasks {
71    pub tasks: Vec<Task>,
72}
73
74impl Tasks {
75    pub fn of(tasks: &[crate::libs::task::Task]) -> Self {
76        Tasks {
77            tasks: tasks.iter().map(Task::from).collect(),
78        }
79    }
80}
81
82/// One stretch of work between pauses.
83#[derive(Debug, Serialize)]
84pub struct Interval {
85    #[serde(serialize_with = "moment::local")]
86    pub start: NaiveDateTime,
87    #[serde(serialize_with = "moment::local")]
88    pub end: NaiveDateTime,
89    pub seconds: i64,
90}
91
92impl From<&crate::libs::report::WorkInterval> for Interval {
93    fn from(interval: &crate::libs::report::WorkInterval) -> Self {
94        Interval {
95            start: interval.start,
96            end: interval.end,
97            seconds: seconds(&interval.duration),
98        }
99    }
100}
101
102/// When a workday began and, once it has, ended.
103#[derive(Debug, Serialize)]
104pub struct Workday {
105    #[serde(serialize_with = "moment::local")]
106    pub start: NaiveDateTime,
107    #[serde(serialize_with = "moment::local_opt")]
108    pub end: Option<NaiveDateTime>,
109}
110
111/// Short intervals left out of a report, and how much time they held.
112#[derive(Debug, Default, Serialize)]
113pub struct Dropped {
114    pub count: usize,
115    pub seconds: i64,
116}
117
118/// The inbox at a glance: what is waiting, what is new, what is in hand.
119#[derive(Debug, Default, Serialize)]
120pub struct InboxCounts {
121    pub total: i64,
122    pub fresh: i64,
123    pub taken: i64,
124}
125
126impl From<crate::db::jira_inbox::InboxCounts> for InboxCounts {
127    fn from(counts: crate::db::jira_inbox::InboxCounts) -> Self {
128        InboxCounts {
129            total: counts.total,
130            fresh: counts.fresh,
131            taken: counts.taken,
132        }
133    }
134}
135
136/// One day: `kasl report`.
137#[derive(Debug, Serialize)]
138pub struct Day {
139    pub date: NaiveDate,
140    /// `null` for a day nothing was recorded on.
141    pub workday: Option<Workday>,
142    /// The intervals the report counts, short ones already left out.
143    pub intervals: Vec<Interval>,
144    /// The sum of `intervals`.
145    pub work_seconds: i64,
146    pub productivity: Option<f64>,
147    /// Whether productivity is below the configured minimum - the gate `report --send` enforces.
148    pub below_threshold: bool,
149    /// The short intervals `intervals` leaves out.
150    pub dropped: Dropped,
151    pub tasks: Vec<Task>,
152    pub inbox: InboxCounts,
153}
154
155/// One day of a month's summary.
156#[derive(Debug, Serialize)]
157pub struct MonthDay {
158    pub date: NaiveDate,
159    pub work_seconds: i64,
160    pub productivity: f64,
161    /// A company rest day counted as a full day, not a recorded one.
162    pub rest_day: bool,
163}
164
165/// A month: `kasl sum`.
166#[derive(Debug, Serialize)]
167pub struct Month {
168    /// `YYYY-MM`.
169    pub month: String,
170    pub days: Vec<MonthDay>,
171    pub total_seconds: i64,
172    pub average_seconds: i64,
173    /// The average over recorded days; `null` while there are none.
174    pub productivity: Option<f64>,
175}
176
177/// One pause.
178#[derive(Debug, Serialize)]
179pub struct Pause {
180    pub id: i32,
181    #[serde(serialize_with = "moment::local")]
182    pub start: NaiveDateTime,
183    /// `null` while the pause is still running.
184    #[serde(serialize_with = "moment::local_opt")]
185    pub end: Option<NaiveDateTime>,
186    pub seconds: Option<i64>,
187    /// Recorded by hand (`pauses add`), and exempt from filtering and merging.
188    pub kept: bool,
189}
190
191impl From<&crate::libs::pause::Pause> for Pause {
192    fn from(pause: &crate::libs::pause::Pause) -> Self {
193        Pause {
194            id: pause.id,
195            start: pause.start,
196            end: pause.end,
197            seconds: pause.duration.as_ref().map(seconds),
198            kept: pause.protected,
199        }
200    }
201}
202
203/// A day's pauses: `kasl pauses list`.
204#[derive(Debug, Serialize)]
205pub struct Pauses {
206    pub date: NaiveDate,
207    /// The shortest pause listed; shorter ones are left out unless kept.
208    pub min_minutes: u64,
209    pub pauses: Vec<Pause>,
210    pub total_seconds: i64,
211}
212
213/// One tag.
214#[derive(Debug, Serialize)]
215pub struct Tag {
216    pub id: Option<i32>,
217    pub name: String,
218    pub color: Option<String>,
219}
220
221impl From<&crate::db::tags::Tag> for Tag {
222    fn from(tag: &crate::db::tags::Tag) -> Self {
223        Tag {
224            id: tag.id,
225            name: tag.name.clone(),
226            color: tag.color.clone(),
227        }
228    }
229}
230
231/// Every tag: `kasl tag list`.
232#[derive(Debug, Serialize)]
233pub struct Tags {
234    pub tags: Vec<Tag>,
235}
236
237/// A tag and the tasks carrying it: `kasl tag show`.
238#[derive(Debug, Serialize)]
239pub struct TagTasks {
240    pub tag: Tag,
241    pub tasks: Vec<Task>,
242}
243
244/// One template.
245#[derive(Debug, Serialize)]
246pub struct Template {
247    pub name: String,
248    pub task_name: String,
249    pub comment: String,
250    pub completeness: i32,
251}
252
253impl From<&crate::db::templates::TaskTemplate> for Template {
254    fn from(template: &crate::db::templates::TaskTemplate) -> Self {
255        Template {
256            name: template.name.clone(),
257            task_name: template.task_name.clone(),
258            comment: template.comment.clone(),
259            completeness: template.completeness,
260        }
261    }
262}
263
264/// One template: `kasl template show`.
265#[derive(Debug, Serialize)]
266pub struct TemplateShown {
267    pub template: Template,
268}
269
270/// A list of templates: `kasl template list`, `search`.
271#[derive(Debug, Serialize)]
272pub struct Templates {
273    pub templates: Vec<Template>,
274}
275
276impl Templates {
277    pub fn of(templates: &[crate::db::templates::TaskTemplate]) -> Self {
278        Templates {
279            templates: templates.iter().map(Template::from).collect(),
280        }
281    }
282}
283
284/// One inbox issue.
285///
286/// The fields rigger reads (`key`, `summary`, `status`, `priority`, `score`,
287/// `url`, `first_seen`, `taken_at`, `pinned`, `snoozed_until`, `gone_at`) are
288/// the floor; the rest is what `kasl inbox show` prints.
289#[derive(Debug, Serialize)]
290pub struct Issue {
291    pub key: String,
292    pub summary: String,
293    /// The status's name; `null` until kasl has learnt the name for its id.
294    pub status: Option<String>,
295    pub status_id: Option<String>,
296    pub priority: Option<String>,
297    /// The ranking field's value (e.g. Scoring), when one is configured.
298    pub score: Option<f64>,
299    #[serde(rename = "type")]
300    pub issue_type: Option<String>,
301    pub project: Option<String>,
302    pub url: String,
303    pub pinned: bool,
304    pub dismissed: bool,
305    /// The rule behind the pin or the dismissal; `null` when a hand set it, or nothing did.
306    pub ruled_by: Option<String>,
307    /// The freshness mark the list shows: `NEW`, `gone`, `status→In Progress`.
308    pub badge: Option<String>,
309    #[serde(serialize_with = "moment::local")]
310    pub first_seen: NaiveDateTime,
311    #[serde(serialize_with = "moment::local")]
312    pub last_seen: NaiveDateTime,
313    #[serde(serialize_with = "moment::local_opt")]
314    pub changed_at: Option<NaiveDateTime>,
315    #[serde(serialize_with = "moment::local_opt")]
316    pub taken_at: Option<NaiveDateTime>,
317    #[serde(serialize_with = "moment::local_opt")]
318    pub snoozed_until: Option<NaiveDateTime>,
319    #[serde(serialize_with = "moment::local_opt")]
320    pub gone_at: Option<NaiveDateTime>,
321    /// Why a gone issue left: `resolved` or `moved`; `null` while present or not yet known.
322    pub left: Option<&'static str>,
323    /// The resolution Jira gave, when the issue left resolved.
324    pub resolution: Option<String>,
325}
326
327impl Issue {
328    /// The issue as it reads at `now` (the badge depends on it).
329    pub fn at(item: &JiraInboxItem, now: NaiveDateTime) -> Self {
330        Issue {
331            key: item.issue_key.clone(),
332            summary: item.summary.clone(),
333            status: Some(item.status_name.clone()).filter(|name| !name.is_empty()),
334            status_id: item.status_id.clone(),
335            priority: item.priority.clone(),
336            score: item.sort_value,
337            issue_type: item.issue_type.clone(),
338            project: item.project.clone(),
339            url: item.url.clone(),
340            pinned: item.pinned,
341            dismissed: item.dismissed,
342            ruled_by: item.ruled_by.clone(),
343            badge: item.badge(now),
344            first_seen: item.first_seen,
345            last_seen: item.last_seen,
346            changed_at: item.changed_at,
347            taken_at: item.taken_at,
348            snoozed_until: item.snoozed_until,
349            gone_at: item.gone_at,
350            left: item.left_reason.map(|reason| match reason {
351                LeftReason::Resolved => "resolved",
352                LeftReason::Moved => "moved",
353            }),
354            resolution: item.resolution.clone(),
355        }
356    }
357
358    /// The issue as it reads now.
359    pub fn now(item: &JiraInboxItem) -> Self {
360        Self::at(item, Local::now().naive_local())
361    }
362}
363
364/// The inbox, cut and ordered as asked: `kasl inbox list`.
365#[derive(Debug, Serialize)]
366pub struct InboxList {
367    /// In the order the list shows them.
368    pub issues: Vec<Issue>,
369    /// How many issues the inbox holds before the filter and the limit; a
370    /// shorter `issues` is a slice of this many.
371    pub total: usize,
372    /// The filter in words, as the list's header says it; `[]` for the whole inbox.
373    pub filter: Vec<String>,
374}
375
376/// One line of where an issue's place comes from.
377#[derive(Debug, Serialize)]
378pub struct Reason {
379    pub what: String,
380    pub value: String,
381    pub because: String,
382}
383
384impl From<&crate::libs::inbox_filter::Reason> for Reason {
385    fn from(reason: &crate::libs::inbox_filter::Reason) -> Self {
386        Reason {
387            what: reason.what.to_string(),
388            value: reason.value.clone(),
389            because: reason.because.clone(),
390        }
391    }
392}
393
394/// One issue in full: `kasl inbox show`.
395#[derive(Debug, Serialize)]
396pub struct IssueShown {
397    pub issue: Issue,
398    /// Where its place in the list comes from, with `--why`; `null` without it.
399    pub why: Option<Vec<Reason>>,
400}
401
402/// One inbox rule and where it stands.
403#[derive(Debug, Serialize)]
404pub struct Rule {
405    /// Position in `jira_inbox.rules`, counting from 1.
406    pub number: usize,
407    pub name: Option<String>,
408    /// The conditions, in the list's filter words.
409    pub when: String,
410    /// The actions.
411    pub then: String,
412    /// What it matches in the inbox now, in words.
413    pub matches: String,
414    /// Matching issues its pin or dismissal has not reached - what `--apply` would change.
415    pub waiting: usize,
416}
417
418impl From<&crate::libs::inbox_rules::RuleStanding> for Rule {
419    fn from(standing: &crate::libs::inbox_rules::RuleStanding) -> Self {
420        Rule {
421            number: standing.number,
422            name: standing.name.clone(),
423            when: standing.when.clone(),
424            then: standing.then.clone(),
425            matches: standing.now.clone(),
426            waiting: standing.waiting,
427        }
428    }
429}
430
431/// A rule kasl could not read, and why.
432#[derive(Debug, Serialize)]
433pub struct RuleSkipped {
434    pub number: usize,
435    pub reason: String,
436}
437
438/// What the rules did to one issue.
439#[derive(Debug, Serialize)]
440pub struct RuleActed {
441    pub key: String,
442    /// The rule that pinned it.
443    pub pinned: Option<String>,
444    /// The rule that dismissed it.
445    pub dismissed: Option<String>,
446    /// The rule that asks for an urgent toast about it.
447    pub urgent: Option<String>,
448    /// Whether an urgent rule woke it from a snooze.
449    pub woke: bool,
450}
451
452impl From<&crate::libs::inbox_rules::Ruled> for RuleActed {
453    fn from(ruled: &crate::libs::inbox_rules::Ruled) -> Self {
454        RuleActed {
455            key: ruled.issue_key.clone(),
456            pinned: ruled.pinned.clone(),
457            dismissed: ruled.dismissed.clone(),
458            urgent: ruled.urgent.clone(),
459            woke: ruled.woke,
460        }
461    }
462}
463
464/// The inbox rules: `kasl inbox rules`.
465#[derive(Debug, Serialize)]
466pub struct Rules {
467    pub rules: Vec<Rule>,
468    pub skipped: Vec<RuleSkipped>,
469    /// What `--apply` changed; `null` for a dry run.
470    pub applied: Option<Vec<RuleActed>>,
471}
472
473/// One day the server is still owed.
474#[derive(Debug, Serialize)]
475pub struct OwedDay {
476    pub date: NaiveDate,
477    #[serde(serialize_with = "moment::local")]
478    pub queued_at: NaiveDateTime,
479    pub attempts: i32,
480    #[serde(serialize_with = "moment::local_opt")]
481    pub last_attempt_at: Option<NaiveDateTime>,
482    pub last_error: Option<String>,
483}
484
485impl From<&crate::db::server_outbox::OwedDay> for OwedDay {
486    fn from(day: &crate::db::server_outbox::OwedDay) -> Self {
487        OwedDay {
488            date: day.date,
489            queued_at: day.queued_at,
490            attempts: day.attempts,
491            last_attempt_at: day.last_attempt_at,
492            last_error: day.last_error.clone(),
493        }
494    }
495}
496
497/// The days waiting to reach the server: `kasl server queue`.
498#[derive(Debug, Serialize)]
499pub struct Queue {
500    pub days: Vec<OwedDay>,
501}
502
503/// How the pulse is going, from what the watcher last recorded.
504#[derive(Debug, Serialize)]
505pub struct Pulse {
506    pub enabled: bool,
507    /// When a pulse last arrived.
508    #[serde(serialize_with = "moment::utc_opt")]
509    pub sent_at: Option<chrono::DateTime<chrono::Utc>>,
510    /// What that pulse said: `working`, `paused`, `idle`.
511    pub state: Option<&'static str>,
512    /// When the watcher last tried, whatever came of it.
513    #[serde(serialize_with = "moment::utc_opt")]
514    pub attempted_at: Option<chrono::DateTime<chrono::Utc>>,
515    /// Why the last try did not arrive; `null` when it did.
516    pub error: Option<String>,
517    /// What `server status` warns about, in its words: failing, silent, shown offline, clock skew.
518    pub warnings: Vec<String>,
519}
520
521/// The connection to a kasl-server: `kasl server status`.
522///
523/// Fields the checks did not reach are `null`: no token means the server was
524/// never asked, an unreachable server means nobody was identified.
525#[derive(Debug, Serialize)]
526pub struct ServerStatus {
527    pub connected: bool,
528    pub url: Option<String>,
529    /// Whether the agent token is in the OS keyring.
530    pub token: Option<bool>,
531    /// Whether the server answered its health check.
532    pub reachable: Option<bool>,
533    pub server_version: Option<String>,
534    pub api_version: Option<String>,
535    /// Whose token it is, by the server's account.
536    pub user: Option<String>,
537    pub agent: Option<String>,
538    /// The first check that failed, in its words.
539    pub problem: Option<String>,
540    /// `null` when not connected.
541    pub pulse: Option<Pulse>,
542}
543
544/// One kind of data the server stores.
545#[derive(Debug, Serialize)]
546pub struct Stored {
547    pub what: String,
548    pub detail: String,
549}
550
551/// What the connected server stores about this person: `kasl server manifest`.
552#[derive(Debug, Serialize)]
553pub struct Manifest {
554    pub level: String,
555    pub summary: String,
556    pub stored: Vec<Stored>,
557    pub never_collected: Vec<String>,
558    pub visible_to: Vec<String>,
559    pub retention: String,
560    pub on_change: String,
561    #[serde(serialize_with = "moment::utc_opt")]
562    pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
563}
564
565impl From<crate::api::kasl_server::PrivacyManifest> for Manifest {
566    fn from(manifest: crate::api::kasl_server::PrivacyManifest) -> Self {
567        Manifest {
568            level: manifest.level,
569            summary: manifest.summary,
570            stored: manifest
571                .stored
572                .into_iter()
573                .map(|stored| Stored {
574                    what: stored.what,
575                    detail: stored.detail,
576                })
577                .collect(),
578            never_collected: manifest.never_collected,
579            visible_to: manifest.visible_to,
580            retention: manifest.retention,
581            on_change: manifest.on_change,
582            updated_at: manifest.updated_at,
583        }
584    }
585}
586
587/// Whether kasl starts with the system: `kasl autostart status`.
588#[derive(Debug, Serialize)]
589pub struct Autostart {
590    pub enabled: bool,
591}
592
593/// One field of the daily report's form.
594#[derive(Debug, Serialize)]
595pub struct Field {
596    pub name: String,
597    pub value: String,
598}
599
600/// What `report --send` would post: `kasl report --send --show`.
601#[derive(Debug, Serialize)]
602pub struct Payload {
603    pub url: String,
604    pub date: NaiveDate,
605    /// Every field of the form, in order, as sent.
606    pub fields: Vec<Field>,
607    /// The `tasks` field's JSON, parsed.
608    pub tasks: serde_json::Value,
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn moments_are_whole_local_seconds_with_a_t() {
617        let at = NaiveDate::from_ymd_opt(2026, 10, 15).unwrap().and_hms_nano_opt(9, 1, 2, 345_678_901).unwrap();
618        let json = serde_json::to_value(Workday { start: at, end: None }).unwrap();
619        assert_eq!(json, serde_json::json!({ "start": "2026-10-15T09:01:02", "end": null }));
620    }
621
622    #[test]
623    fn a_task_timestamp_takes_the_t_and_a_task_without_completeness_reads_done() {
624        let mut task = crate::libs::task::Task::new("Ship", "", None);
625        task.timestamp = Some("2026-10-15 09:01:02".to_string());
626        task.completeness = None;
627        let json = Task::from(&task);
628        assert_eq!(json.created_at.as_deref(), Some("2026-10-15T09:01:02"));
629        assert_eq!(json.completeness, 100);
630    }
631
632    #[test]
633    fn percentages_keep_one_decimal() {
634        assert_eq!(percent(87.345), 87.3);
635        assert_eq!(percent(100.0), 100.0);
636    }
637}