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)]
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    /// Parent chain, outermost plan first, without this issue.
263    pub plan: Vec<WalkHit>,
264    /// What this issue waits on and where it came from, each with its products.
265    pub inputs: Vec<RecallInput>,
266    /// Deed accessions this issue has already cited.
267    pub produced: Vec<String>,
268    /// Heading body: the dispatch note the work is done from.
269    pub body: String,
270}
271
272/// What a plan's children hold, child by child.
273///
274/// A report rather than an average. Weighting children is a judgement the
275/// tracker has no basis for, a child that settled split has no single position
276/// to fold in, and a child nobody voted on is absent rather than neutral, so
277/// there is no honest number to reduce these rows to.
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
279pub struct PlanConsensus {
280    /// The plan the children hang under.
281    pub plan: String,
282    /// Heading title of the plan.
283    pub title: String,
284    /// One row per child, in the order `children` walks them.
285    pub children: Vec<ChildConsensus>,
286}
287
288/// One child of a plan, and what its own ballots settled on.
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
290pub struct ChildConsensus {
291    /// Issue id.
292    pub id: String,
293    /// TODO keyword on the heading.
294    pub state: String,
295    /// Heading title.
296    pub title: String,
297    /// Ballots cast on this child.
298    pub ballots: usize,
299    /// How the child settled, when anyone voted on it.
300    pub settling: Option<crate::consensus::Settling>,
301    /// The choice the child holds and its share, when one leads.
302    pub holds: Option<(String, f64)>,
303}
304
305impl PlanConsensus {
306    /// Children nobody has voted on.
307    #[must_use]
308    pub fn unvoted(&self) -> Vec<&ChildConsensus> {
309        self.children.iter().filter(|c| c.ballots == 0).collect()
310    }
311
312    /// Children whose own reviewers split into groups that do not listen to
313    /// each other.
314    #[must_use]
315    pub fn split(&self) -> Vec<&ChildConsensus> {
316        self.children
317            .iter()
318            .filter(|c| c.settling == Some(crate::consensus::Settling::Split))
319            .collect()
320    }
321
322    /// Whether a gate over this plan should pass.
323    ///
324    /// False when any child settled split or carries no ballots. Both are rows
325    /// a person has to go read, and neither is something a parent can decide
326    /// on their behalf, which is the whole argument for this being a report.
327    #[must_use]
328    pub fn settled(&self) -> bool {
329        self.split().is_empty() && self.unvoted().is_empty()
330    }
331
332    /// The distinct choices the settled children hold.
333    ///
334    /// One entry means the children that were voted on point the same way.
335    /// More than one means they disagree with each other, which is the case a
336    /// per-child report exists to make visible and an average would hide.
337    #[must_use]
338    pub fn positions(&self) -> Vec<&str> {
339        let mut seen: Vec<&str> = Vec::new();
340        for child in &self.children {
341            if let Some((choice, _)) = &child.holds
342                && !seen.contains(&choice.as_str())
343            {
344                seen.push(choice.as_str());
345            }
346        }
347        seen
348    }
349}
350
351/// One declared input to an issue, and the deeds that input produced.
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353pub struct RecallInput {
354    /// Issue id.
355    pub id: String,
356    /// Project the heading lives in.
357    pub project: String,
358    /// TODO keyword on the heading.
359    pub state: String,
360    /// Heading title.
361    pub title: String,
362    /// Which declared edge made this an input (`blocked-by`, `discovered-from`).
363    pub relation: String,
364    /// Deed accessions cited on that heading.
365    pub deeds: Vec<String>,
366    /// A capped excerpt of that input's heading, when one was asked for.
367    ///
368    /// What the input concluded lives in its body: `append` writes the report
369    /// there, and the deed names the product rather than the reasoning. Off
370    /// unless asked, because the common case wants the accessions and a working
371    /// set that pastes four screens of prose is one nobody reads.
372    ///
373    /// Screened and capped by the same path `body-excerpt` uses, so an input
374    /// whose body looks like credential material is suppressed here too.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub excerpt: Option<String>,
377    /// The most recent note in that input's logbook, when it has one.
378    ///
379    /// What happened to the input, for the case where it produced no deed. A
380    /// blocker that closed without naming a product would otherwise hand the
381    /// next unit its title and nothing else.
382    pub last_note: Option<String>,
383}
384
385/// One related heading from a walk: children, ancestors, impact, or backlinks.
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387pub struct WalkHit {
388    /// Issue id.
389    pub id: String,
390    /// Project the heading lives in.
391    pub project: String,
392    /// TODO keyword on the heading.
393    pub state: String,
394    /// Heading title.
395    pub title: String,
396    /// How this heading relates to the walk root (`child`, `ancestor`, ...).
397    pub relation: String,
398}