Skip to main content

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