vissue_core/views.rs
1//! Typed issue views shared by JSON output and later control clients.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6
7use crate::model::IssueHeading;
8
9/// One parsed heading plus the `issues.org` it came from.
10#[derive(Debug, Clone)]
11pub struct IssueRec {
12 /// Project directory name the heading lives under.
13 pub project: String,
14 /// Parsed heading, including body and logbook.
15 pub heading: IssueHeading,
16 /// Absolute path of the project's `issues.org`.
17 pub path: PathBuf,
18 /// File-level tags and `#+TAGS:` groups from the preamble.
19 pub tag_settings: crate::org::TagSettings,
20}
21
22/// Filters for [`crate::catalog::CatalogService::issues_rows`].
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct ListQuery {
25 /// Restrict to this project name (case-insensitive).
26 pub project: Option<String>,
27 /// Restrict to this TODO keyword.
28 pub state: Option<String>,
29 /// Keep only TODO or STARTED issues with no open blocker.
30 pub ready: bool,
31 /// Case-insensitive substring over id, title, tags, and properties.
32 pub query: Option<String>,
33 /// Cap the result after sorting.
34 pub limit: Option<usize>,
35 /// Drop this many leading rows after sorting.
36 pub offset: Option<usize>,
37}
38
39/// One list/ready row: the fields a board or JSON client paints.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct IssueRow {
43 /// Issue id, `<project>-<suffix>`.
44 pub id: String,
45 /// TODO keyword on the heading.
46 pub state: String,
47 /// Priority cookie as a one-character string.
48 pub priority: String,
49 /// Heading title, without tags.
50 pub title: String,
51 /// Project the heading lives in.
52 pub project: String,
53 /// Ids listed in `:BLOCKED_BY:`.
54 pub blocked_by: Vec<String>,
55 /// Identity holding the issue, when claimed.
56 pub claimed_by: Option<String>,
57 /// Org timestamp of the claim.
58 pub claimed_at: Option<String>,
59 /// `:PARENT:` id, when set.
60 #[serde(default)]
61 pub parent: Option<String>,
62}
63
64/// One issue as a detail card: properties, tags, file range, body, and logbook.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67pub struct IssueDetail {
68 /// Issue id, `<project>-<suffix>`.
69 pub id: String,
70 /// Project the heading lives in.
71 pub project: String,
72 /// Heading title, without tags.
73 pub title: String,
74 /// TODO keyword on the heading.
75 pub state: String,
76 /// Priority cookie as a one-character string.
77 pub priority: String,
78 /// Property drawer, including planning keys held in the map.
79 pub properties: BTreeMap<String, String>,
80 /// Tags written on the heading itself.
81 pub org_tags: Vec<String>,
82 /// Combined heading tags and `:VISSUE_TAGS:`.
83 pub tags: Vec<String>,
84 /// Ids listed in `:BLOCKED_BY:`.
85 pub blocked_by: Vec<String>,
86 /// Deed accessions listed in `:DEEDS:`.
87 ///
88 /// Typed beside `blocked_by` rather than left in `properties` for the same
89 /// reason: a client that paints what an issue produced should not have to
90 /// know how the drawer spells a list.
91 #[serde(default)]
92 pub deeds: Vec<String>,
93 /// `:PARENT:` id, when set.
94 pub parent: Option<String>,
95 /// Identity holding the issue, when claimed.
96 pub claimed_by: Option<String>,
97 /// Org timestamp of the claim.
98 pub claimed_at: Option<String>,
99 /// `path:line_start-line_end` of the heading in its `issues.org`.
100 pub file: String,
101 /// 1-based first line of the heading in the file.
102 pub line_start: usize,
103 /// 1-based last line of the heading in the file.
104 pub line_end: usize,
105 /// Prose under the heading, without the property drawer or logbook.
106 ///
107 /// Carried here so a caller that fetched the detail has what the issue
108 /// asks for, rather than a file path and a line range to go read.
109 #[serde(default)]
110 pub body: String,
111 /// Logbook lines on the heading, newest first.
112 #[serde(default)]
113 pub logbook: Vec<LogbookLine>,
114}
115
116/// One logbook line on a detail card: note, state flip, or raw CLOCK.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct LogbookLine {
120 /// Inactive org timestamp on the line, or empty for a raw CLOCK row.
121 #[serde(default)]
122 pub timestamp: String,
123 /// Previous TODO keyword on a state flip.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub from_state: Option<String>,
126 /// New TODO keyword on a state flip.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub to_state: Option<String>,
129 /// Folded note text, when the line is a note rather than a state flip.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub note: Option<String>,
132 /// Opaque drawer line preserved verbatim (a `CLOCK:` entry, say).
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub raw: Option<String>,
135}
136
137/// One live claim: who holds the issue and for how long.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct ClaimRow {
140 /// Issue id.
141 pub id: String,
142 /// Project the heading lives in.
143 pub project: String,
144 /// TODO keyword on the heading.
145 pub state: String,
146 /// Priority cookie as a one-character string.
147 pub priority: String,
148 /// Identity holding the issue.
149 pub holder: Option<String>,
150 /// Org timestamp of the claim.
151 pub claimed_at: Option<String>,
152 /// Whole days since the claim; `-1` when the stamp does not parse.
153 pub age_days: i64,
154 /// Heading title.
155 pub title: String,
156}
157
158/// A capped, secret-screened slice of a heading's on-disk range.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
160pub struct Excerpt {
161 /// Issue id.
162 pub id: String,
163 /// Path of the `issues.org` the heading lives in.
164 pub file: String,
165 /// 1-based first line of the heading.
166 pub line_start: usize,
167 /// 1-based last line of the heading.
168 pub line_end: usize,
169 /// Excerpt text, or a suppression notice when credential-shaped.
170 pub text: String,
171 /// Whether `text` is a suppression notice rather than the heading.
172 pub suppressed: bool,
173}
174
175/// One search match: the heading plus a short snippet of the hit.
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177pub struct SearchHit {
178 /// Issue id.
179 pub id: String,
180 /// Project the heading lives in.
181 pub project: String,
182 /// TODO keyword on the heading.
183 pub state: String,
184 /// Priority cookie as a one-character string.
185 pub priority: String,
186 /// Heading title.
187 pub title: String,
188 /// First matching line, capped.
189 pub snippet: String,
190}
191
192/// One dated row: a deadline or scheduled date on an open issue.
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct AgendaRow {
195 /// Calendar date as `YYYY-MM-DD`.
196 pub date: String,
197 /// `deadline`, `scheduled`, or `appointment` (a plain active stamp).
198 pub kind: String,
199 /// Days past the date; `0` when it is today or still upcoming.
200 pub overdue_days: i64,
201 /// Issue id.
202 pub id: String,
203 /// Project the heading lives in.
204 pub project: String,
205 /// TODO keyword on the heading.
206 pub state: String,
207 /// Priority cookie as a one-character string.
208 pub priority: String,
209 /// Heading title.
210 pub title: String,
211}
212
213/// A parent/child subtree node, with the issue's own blockers.
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215pub struct TreeNode {
216 /// Issue id.
217 pub id: String,
218 /// TODO keyword on the heading.
219 pub state: String,
220 /// Heading title.
221 pub title: String,
222 /// Direct children by `:PARENT:`.
223 pub children: Vec<TreeNode>,
224 /// Ids listed in `:BLOCKED_BY:`.
225 pub blocked_by: Vec<String>,
226}
227
228/// One ranked related-issue hit, with the evidence that produced the score.
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct RelatedHit {
231 /// Issue id.
232 pub id: String,
233 /// Project the heading lives in.
234 pub project: String,
235 /// TODO keyword on the heading.
236 pub state: String,
237 /// Heading title.
238 pub title: String,
239 /// Combined evidence score; higher is a closer match.
240 pub score: f64,
241 /// Named reasons (`blocked_by`, `term:foo`, `org_distance:1`, ...).
242 pub evidence: Vec<String>,
243}
244
245/// The working set for one issue: the plan it sits in, the products of the work
246/// it waits on, and what it has produced so far.
247///
248/// Assembled from declared edges rather than from similarity, so the set is the
249/// answer and not a ranked guess at it. Nothing here is scored, and nothing is
250/// dropped for being far away: the partial order already said what this issue
251/// needs.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct Recall {
254 /// Issue the working set is for.
255 pub id: String,
256 /// Project the heading lives in.
257 pub project: String,
258 /// TODO keyword on the heading.
259 pub state: String,
260 /// Heading title.
261 pub title: String,
262 /// Org deadline stamp, when the heading carries one.
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub deadline: Option<String>,
265 /// Org scheduled stamp, when the heading carries one.
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub scheduled: Option<String>,
268 /// Parent chain, outermost plan first, without this issue.
269 pub plan: Vec<WalkHit>,
270 /// What this issue waits on and where it came from, each with its products.
271 pub inputs: Vec<RecallInput>,
272 /// Deed accessions this issue has already cited.
273 pub produced: Vec<String>,
274 /// Heading body: the dispatch note the work is done from.
275 pub body: String,
276}
277
278/// What a plan's children hold, child by child.
279///
280/// A report rather than an average. Weighting children is a judgement the
281/// tracker has no basis for, a child that settled split has no single position
282/// to fold in, and a child nobody voted on is absent rather than neutral, so
283/// there is no honest number to reduce these rows to.
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
285pub struct PlanConsensus {
286 /// The plan the children hang under.
287 pub plan: String,
288 /// Heading title of the plan.
289 pub title: String,
290 /// One row per child, in the order `children` walks them.
291 pub children: Vec<ChildConsensus>,
292}
293
294/// One child of a plan, and what its own ballots settled on.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct ChildConsensus {
297 /// Issue id.
298 pub id: String,
299 /// TODO keyword on the heading.
300 pub state: String,
301 /// Heading title.
302 pub title: String,
303 /// Ballots cast on this child.
304 pub ballots: usize,
305 /// How the child settled, when anyone voted on it.
306 pub settling: Option<crate::consensus::Settling>,
307 /// The choice the child holds and its share, when one leads.
308 pub holds: Option<(String, f64)>,
309}
310
311impl PlanConsensus {
312 /// Children nobody has voted on.
313 #[must_use]
314 pub fn unvoted(&self) -> Vec<&ChildConsensus> {
315 self.children.iter().filter(|c| c.ballots == 0).collect()
316 }
317
318 /// Children whose own reviewers split into groups that do not listen to
319 /// each other.
320 #[must_use]
321 pub fn split(&self) -> Vec<&ChildConsensus> {
322 self.children
323 .iter()
324 .filter(|c| c.settling == Some(crate::consensus::Settling::Split))
325 .collect()
326 }
327
328 /// Whether a gate over this plan should pass.
329 ///
330 /// False when any child settled split or carries no ballots. Both are rows
331 /// a person has to go read, and neither is something a parent can decide
332 /// on their behalf, which is the whole argument for this being a report.
333 #[must_use]
334 pub fn settled(&self) -> bool {
335 self.split().is_empty() && self.unvoted().is_empty()
336 }
337
338 /// The distinct choices the settled children hold.
339 ///
340 /// One entry means the children that were voted on point the same way.
341 /// More than one means they disagree with each other, which is the case a
342 /// per-child report exists to make visible and an average would hide.
343 #[must_use]
344 pub fn positions(&self) -> Vec<&str> {
345 let mut seen: Vec<&str> = Vec::new();
346 for child in &self.children {
347 if let Some((choice, _)) = &child.holds
348 && !seen.contains(&choice.as_str())
349 {
350 seen.push(choice.as_str());
351 }
352 }
353 seen
354 }
355}
356
357/// One declared input to an issue, and the deeds that input produced.
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
359pub struct RecallInput {
360 /// Issue id.
361 pub id: String,
362 /// Project the heading lives in.
363 pub project: String,
364 /// TODO keyword on the heading.
365 pub state: String,
366 /// Heading title.
367 pub title: String,
368 /// Which declared edge made this an input (`blocked-by`, `discovered-from`).
369 pub relation: String,
370 /// Deed accessions cited on that heading.
371 pub deeds: Vec<String>,
372 /// A capped excerpt of that input's heading, when one was asked for.
373 ///
374 /// What the input concluded lives in its body: `append` writes the report
375 /// there, and the deed names the product rather than the reasoning. Off
376 /// unless asked, because the common case wants the accessions and a working
377 /// set that pastes four screens of prose is one nobody reads.
378 ///
379 /// Screened and capped by the same path `body-excerpt` uses, so an input
380 /// whose body looks like credential material is suppressed here too.
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub excerpt: Option<String>,
383 /// The most recent note in that input's logbook, when it has one.
384 ///
385 /// What happened to the input, for the case where it produced no deed. A
386 /// blocker that closed without naming a product would otherwise hand the
387 /// next unit its title and nothing else.
388 pub last_note: Option<String>,
389}
390
391/// One related heading from a walk: children, ancestors, impact, or backlinks.
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
393pub struct WalkHit {
394 /// Issue id.
395 pub id: String,
396 /// Project the heading lives in.
397 pub project: String,
398 /// TODO keyword on the heading.
399 pub state: String,
400 /// Heading title.
401 pub title: String,
402 /// How this heading relates to the walk root (`child`, `ancestor`, ...).
403 pub relation: String,
404}