vissue_tui/backend.rs
1//! Sync board facade shared by the file-backed and socket-backed clients.
2
3use vissue_core::config::Layout;
4use vissue_core::error::Error;
5use vissue_core::views::{
6 AgendaRow, ClaimRow, Excerpt, IssueDetail, ListQuery, RelatedHit, SearchHit, TreeNode,
7};
8
9/// Which store the board is talking to.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BackendKind {
12 /// File-backed [`crate::CoreBackend`].
13 Core,
14 /// Socket-backed `ControlBackend`.
15 Control,
16}
17
18/// One page of list/ready rows.
19#[derive(Debug, Clone, Default)]
20pub struct ListPage {
21 /// Rows for this page. Empty when [`Self::unchanged`].
22 pub issues: Vec<vissue_core::views::IssueRow>,
23 /// Total issues in the catalog on serve; core repeats [`Self::matched`].
24 pub total: u64,
25 /// Rows that matched the query.
26 pub matched: u64,
27 /// Serve catalog revision. Core is 0.
28 pub revision: u64,
29 /// File-watcher generation.
30 pub generation: u64,
31 /// Serve says the catalog is unchanged since `since_revision`.
32 pub unchanged: bool,
33}
34
35/// Outcome of claim, note, or update.
36#[derive(Debug, Clone)]
37pub struct MutResult {
38 /// Mutation succeeded.
39 pub ok: bool,
40 /// Status text from the op. Core includes a trailing newline.
41 pub report: String,
42 /// Issue after the write, when the backend returns one.
43 pub issue: Option<IssueDetail>,
44 /// Serve revision after the write. Core is 0.
45 pub revision: u64,
46 /// File-watcher generation after the write.
47 pub generation: u64,
48}
49
50/// Fields `issue/update` accepts.
51#[derive(Debug, Clone, Default)]
52pub struct UpdateReq {
53 /// Issue to change.
54 pub id: String,
55 /// New org TODO state, if any.
56 pub state: Option<String>,
57 /// New priority letter, if any.
58 pub priority: Option<char>,
59 /// Blocker id to add.
60 pub block: Option<String>,
61 /// Blocker id to drop.
62 pub unblock: Option<String>,
63 /// Refuse unless the heading is still this state.
64 pub if_state: Option<String>,
65 /// Refuse unless the corpus generation is still this value.
66 pub if_gen: Option<u64>,
67}
68
69/// Drops `since_revision` for one fetch after attach.
70#[derive(Debug)]
71pub struct SinceGate {
72 skip_once: std::sync::atomic::AtomicBool,
73}
74
75impl SinceGate {
76 /// After `initialize`, the next list must not send a core generation.
77 pub fn after_attach() -> Self {
78 Self {
79 skip_once: std::sync::atomic::AtomicBool::new(true),
80 }
81 }
82
83 /// Consume the skip flag, or return `Some(revision)` when `revision > 0`.
84 pub fn next(&self, revision: u64) -> Option<u64> {
85 if self
86 .skip_once
87 .swap(false, std::sync::atomic::Ordering::SeqCst)
88 {
89 None
90 } else if revision > 0 {
91 Some(revision)
92 } else {
93 None
94 }
95 }
96
97 /// Next list/ready must omit `since_revision` (pane or query changed).
98 pub fn invalidate(&self) {
99 self.skip_once
100 .store(true, std::sync::atomic::Ordering::SeqCst);
101 }
102}
103
104/// Read and mutate the board. Implementations are `CoreBackend` and
105/// `ControlBackend`.
106pub trait BoardBackend: Send + Sync + std::fmt::Debug {
107 /// Vault this backend reads and writes.
108 fn layout(&self) -> &Layout;
109 /// File-watcher generation.
110 fn generation(&self) -> u64;
111 /// Serve catalog revision. Core is always 0.
112 fn revision(&self) -> u64;
113 /// Which store this backend is.
114 fn live(&self) -> BackendKind;
115 /// Claim and update identity (core: constructor; control: serve `initialize`).
116 fn identity(&self) -> &str;
117
118 /// Filtered issue list for the List pane.
119 ///
120 /// # Errors
121 ///
122 /// Returns an error if the list cannot be loaded.
123 fn list(&self, q: ListQuery) -> Result<ListPage, Error>;
124 /// Actionable ready queue, optionally scoped to `project`.
125 ///
126 /// # Errors
127 ///
128 /// Returns an error if the ready list cannot be loaded.
129 fn ready(&self, project: Option<&str>) -> Result<ListPage, Error>;
130 /// Full metadata for one issue.
131 ///
132 /// # Errors
133 ///
134 /// Returns an error if the issue does not exist or cannot be fetched.
135 fn get(&self, id: &str) -> Result<IssueDetail, Error>;
136 /// On-disk heading range, capped and screened for secrets.
137 ///
138 /// # Errors
139 ///
140 /// Returns an error if the issue does not exist or its file cannot be read.
141 fn excerpt(&self, id: &str) -> Result<Excerpt, Error>;
142 /// Title and body search hits, capped at `limit`.
143 ///
144 /// # Errors
145 ///
146 /// Returns an error if search cannot run.
147 fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error>;
148 /// Open claims, optionally filtered by holder and project.
149 ///
150 /// # Errors
151 ///
152 /// Returns an error if claims cannot be listed.
153 fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error>;
154 /// Deadlines and scheduled dates inside `days`.
155 ///
156 /// # Errors
157 ///
158 /// Returns an error if the agenda cannot be listed.
159 fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error>;
160 /// Parent and child tree rooted at `id`.
161 ///
162 /// # Errors
163 ///
164 /// Returns an error if the issue does not exist or the tree cannot be built.
165 fn tree(&self, id: &str) -> Result<TreeNode, Error>;
166 /// Related issues by graph walk and text overlap.
167 ///
168 /// # Errors
169 ///
170 /// Returns an error if the issue does not exist or related hits cannot be scored.
171 fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error>;
172 /// Project names under the layout prefix.
173 ///
174 /// # Errors
175 ///
176 /// Returns an error if the project list cannot be read.
177 fn projects(&self) -> Result<Vec<String>, Error>;
178 /// Claim `id` as [`Self::identity`]. `force` takes over another holder.
179 ///
180 /// # Errors
181 ///
182 /// Returns an error if the issue does not exist, is DONE or CANCELLED, is
183 /// held by another identity without `force`, or the write fails.
184 fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error>;
185 /// Append a one-line logbook note.
186 ///
187 /// # Errors
188 ///
189 /// Returns an error if the issue does not exist, the text is empty, or the
190 /// write fails.
191 fn note(&self, id: &str, text: &str) -> Result<MutResult, Error>;
192 /// Change state, priority, or blocker edges.
193 ///
194 /// # Errors
195 ///
196 /// Returns an error if the issue does not exist, the change is refused, or
197 /// the write fails.
198 fn update(&self, req: UpdateReq) -> Result<MutResult, Error>;
199 /// Same metadata as [`Self::get`]; control also marks the issue opened.
200 ///
201 /// # Errors
202 ///
203 /// Returns an error if the issue does not exist or cannot be fetched.
204 fn open(&self, id: &str) -> Result<IssueDetail, Error>;
205
206 /// Core: wait on the file generation. Control: wait for `vault/changed`.
207 ///
208 /// # Errors
209 ///
210 /// Returns an error if the wait cannot be started or a newer catalog cannot
211 /// be re-read.
212 fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error>;
213
214 /// Last `since_revision` sent on list/ready. `None` means the field was
215 /// omitted. Default is "not recorded".
216 fn last_since_revision(&self) -> Option<Option<u64>> {
217 None
218 }
219
220 /// Drop `since_revision` on the next list/ready. Serve `unchanged` is
221 /// catalog-wide, so a pane or project change must fetch a full page.
222 fn invalidate_since(&self) {}
223
224 /// Re-read the files. Core uses this after an out-of-band write such as
225 /// `ops::create`. Control is a no-op; serve sees the file event.
226 ///
227 /// # Errors
228 ///
229 /// Returns an error if the catalog cannot be re-read. Control never fails.
230 fn refresh(&self) -> Result<(), Error> {
231 Ok(())
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::SinceGate;
238
239 #[test]
240 fn after_attach_first_list_omits_since_revision() {
241 let gate = SinceGate::after_attach();
242 assert_eq!(gate.next(7), None);
243 assert_eq!(gate.next(7), Some(7));
244 assert_eq!(gate.next(8), Some(8));
245 }
246
247 #[test]
248 fn a_zero_revision_never_sends_since() {
249 let gate = SinceGate::after_attach();
250 assert_eq!(gate.next(0), None);
251 assert_eq!(gate.next(0), None);
252 }
253
254 #[test]
255 fn invalidate_omits_the_next_since() {
256 let gate = SinceGate::after_attach();
257 assert_eq!(gate.next(7), None);
258 assert_eq!(gate.next(7), Some(7));
259 gate.invalidate();
260 assert_eq!(gate.next(7), None);
261 assert_eq!(gate.next(7), Some(7));
262 }
263}