Skip to main content

kanbrain_core/api/
json.rs

1//! Entries as JSON, for `--json`. A separate shape from `Task`, so the file
2//! format and the JSON can evolve independently, and timestamps read the same as in the files.
3
4use crate::brain::Brain;
5use crate::model::{Asked, Author, Comment, Kind, Task, fmt_ts, now};
6use serde::Serialize;
7
8/// An entry with everything an agent needs to act on it.
9#[derive(Debug, Serialize)]
10pub struct Entry<'a> {
11    /// The id to pass to other commands.
12    pub id: u32,
13    /// `task`, `note` or `decision`.
14    pub kind: &'static str,
15    /// One-line title.
16    pub title: &'a str,
17    /// Tasks only: `todo`, `doing`, `parked` or `done`.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub status: Option<&'static str>,
20    /// Decisions only: `accepted`, `proposed` or `rejected`.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub decision: Option<&'static str>,
23    /// A parked task's question: `waiting for an answer` or `answered`.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub asked: Option<&'static str>,
26    /// Lowercase, without `#`.
27    pub tags: &'a [String],
28    /// Ids this entry links to. Empty fields are left out, as every token counts for an agent.
29    #[serde(skip_serializing_if = "<[u32]>::is_empty")]
30    pub links: &'a [u32],
31    /// Entries of the project's other repositories this one links to, as `shop-api#3`.
32    #[serde(skip_serializing_if = "Vec::is_empty")]
33    pub repo_links: Vec<String>,
34    /// Ids of entries linking to this one.
35    #[serde(skip_serializing_if = "Vec::is_empty")]
36    pub linked_from: Vec<u32>,
37    /// RFC 3339.
38    pub created: String,
39    /// Free text (markdown).
40    #[serde(skip_serializing_if = "str::is_empty")]
41    pub description: &'a str,
42    /// Oldest first.
43    #[serde(skip_serializing_if = "Vec::is_empty")]
44    pub comments: Vec<EntryComment<'a>>,
45    /// Tracked time in minutes, if the task was ever tracked.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub tracked_minutes: Option<i64>,
48    /// Whether the task's clock is running now; only present when it is.
49    #[serde(skip_serializing_if = "std::ops::Not::not")]
50    pub running: bool,
51    /// The markdown file, to read or edit directly.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub path: Option<String>,
54}
55
56/// A comment on an entry.
57#[derive(Debug, Serialize)]
58pub struct EntryComment<'a> {
59    /// RFC 3339.
60    pub created: String,
61    /// RFC 3339, if edited after posting.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub edited: Option<String>,
64    /// The comment text (markdown).
65    pub body: &'a str,
66    /// `agent` for an agent's; left out for a person's.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub author: Option<&'static str>,
69    /// Whether it's a question to the user; only present when it is.
70    #[serde(skip_serializing_if = "std::ops::Not::not")]
71    pub question: bool,
72}
73
74/// A short form for lists and search results: no description or comments.
75#[derive(Debug, Serialize)]
76pub struct Summary<'a> {
77    /// The id to pass to other commands.
78    pub id: u32,
79    /// `task`, `note` or `decision`.
80    pub kind: &'static str,
81    /// One-line title.
82    pub title: &'a str,
83    /// Tasks only.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub status: Option<&'static str>,
86    /// Decisions only.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub decision: Option<&'static str>,
89    /// A parked task's question, as in [`Entry`].
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub asked: Option<&'static str>,
92    /// Lowercase, without `#`.
93    pub tags: &'a [String],
94}
95
96impl<'a> Entry<'a> {
97    /// The full JSON shape of `task`, with its backlinks from `brain`.
98    pub fn new(brain: &Brain, task: &'a Task) -> Self {
99        let now = now();
100        Self {
101            id: task.id,
102            kind: task.kind.as_str(),
103            title: &task.title,
104            status: task.is_task().then(|| task.status.as_str()),
105            decision: decision(task),
106            asked: asked(task),
107            tags: &task.tags,
108            links: &task.links,
109            repo_links: task.repo_links.iter().map(ToString::to_string).collect(),
110            linked_from: brain.backlinks(task.id).iter().map(|t| t.id).collect(),
111            created: fmt_ts(task.created),
112            description: &task.description,
113            comments: task.comments.iter().map(EntryComment::new).collect(),
114            tracked_minutes: (!task.sessions.is_empty()).then(|| task.total(now).num_minutes()),
115            running: task.is_running(),
116            path: task.path.as_ref().map(|p| p.display().to_string()),
117        }
118    }
119}
120
121impl<'a> EntryComment<'a> {
122    /// The JSON shape of `comment`.
123    fn new(comment: &'a Comment) -> Self {
124        Self {
125            created: fmt_ts(comment.created),
126            edited: comment.edited.map(fmt_ts),
127            body: &comment.body,
128            author: (comment.author != Author::Human).then(|| comment.author.as_str()),
129            question: comment.question,
130        }
131    }
132}
133
134impl<'a> Summary<'a> {
135    /// The short JSON shape of `task`.
136    pub fn new(task: &'a Task) -> Self {
137        Self {
138            id: task.id,
139            kind: task.kind.as_str(),
140            title: &task.title,
141            status: task.is_task().then(|| task.status.as_str()),
142            decision: decision(task),
143            asked: asked(task),
144            tags: &task.tags,
145        }
146    }
147}
148
149/// A decision's state; `None` for tasks and notes.
150fn decision(task: &Task) -> Option<&'static str> {
151    (task.kind == Kind::Decision).then(|| task.decision.as_str())
152}
153
154/// Where a parked task's question stands; `None` without one.
155fn asked(task: &Task) -> Option<&'static str> {
156    task.asked().map(Asked::label)
157}