Skip to main content

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    /// Create a TODO in `project` with `title`.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the project does not exist, the title is empty, or
204    /// the write fails.
205    fn create(&self, project: &str, title: &str) -> Result<MutResult, Error>;
206    /// Same metadata as [`Self::get`]; control also marks the issue opened.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the issue does not exist or cannot be fetched.
211    fn open(&self, id: &str) -> Result<IssueDetail, Error>;
212
213    /// Core: wait on the file generation. Control: wait for `vault/changed`.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the wait cannot be started or a newer catalog cannot
218    /// be re-read.
219    fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error>;
220
221    /// Last `since_revision` sent on list/ready. `None` means the field was
222    /// omitted. Default is "not recorded".
223    fn last_since_revision(&self) -> Option<Option<u64>> {
224        None
225    }
226
227    /// Drop `since_revision` on the next list/ready. Serve `unchanged` is
228    /// catalog-wide, so a pane or project change must fetch a full page.
229    fn invalidate_since(&self) {}
230
231    /// Re-read the files. Core uses this after an out-of-band write. Control
232    /// is a no-op; serve sees the file event.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the catalog cannot be re-read. Control never fails.
237    fn refresh(&self) -> Result<(), Error> {
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::SinceGate;
245
246    #[test]
247    fn after_attach_first_list_omits_since_revision() {
248        let gate = SinceGate::after_attach();
249        assert_eq!(gate.next(7), None);
250        assert_eq!(gate.next(7), Some(7));
251        assert_eq!(gate.next(8), Some(8));
252    }
253
254    #[test]
255    fn a_zero_revision_never_sends_since() {
256        let gate = SinceGate::after_attach();
257        assert_eq!(gate.next(0), None);
258        assert_eq!(gate.next(0), None);
259    }
260
261    #[test]
262    fn invalidate_omits_the_next_since() {
263        let gate = SinceGate::after_attach();
264        assert_eq!(gate.next(7), None);
265        assert_eq!(gate.next(7), Some(7));
266        gate.invalidate();
267        assert_eq!(gate.next(7), None);
268        assert_eq!(gate.next(7), Some(7));
269    }
270}