Skip to main content

vissue_tui/
core_backend.rs

1//! File-backed board: `CatalogService` plus `ops` with an explicit identity.
2
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use vissue_core::catalog::{CatalogService, load_recs};
7use vissue_core::config::Layout;
8use vissue_core::error::Error;
9use vissue_core::events;
10use vissue_core::ops::{self, UpdateOutcome};
11use vissue_core::store;
12use vissue_core::views::{
13    AgendaRow, ClaimRow, Excerpt, IssueDetail, IssueRec, ListQuery, Recall, RelatedHit, SearchHit,
14    TreeNode,
15};
16
17use crate::backend::{BackendKind, BoardBackend, ListPage, MutResult, UpdateReq};
18
19/// One-shot parse of the tracker, refreshed after mutations and `wait`.
20#[derive(Debug)]
21pub struct CoreBackend {
22    layout: Layout,
23    identity: String,
24    recs: Mutex<Vec<IssueRec>>,
25    generation: AtomicU64,
26}
27
28impl CoreBackend {
29    /// Parse the vault once and stamp `identity` on later mutations.
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if a project file cannot be read or parsed.
34    pub fn open(layout: Layout, identity: impl Into<String>) -> Result<Self, Error> {
35        let recs = load_recs(&layout)?;
36        let generation = events::generation(&layout);
37        Ok(Self {
38            layout,
39            identity: identity.into(),
40            recs: Mutex::new(recs),
41            generation: AtomicU64::new(generation),
42        })
43    }
44
45    fn reload(&self) -> Result<(), Error> {
46        let recs = load_recs(&self.layout)?;
47        *self.recs.lock().expect("core recs") = recs;
48        self.generation
49            .store(events::generation(&self.layout), Ordering::SeqCst);
50        Ok(())
51    }
52
53    fn with_service<T>(
54        &self,
55        f: impl FnOnce(&CatalogService<'_>) -> Result<T, Error>,
56    ) -> Result<T, Error> {
57        let recs = self.recs.lock().expect("core recs");
58        f(&CatalogService::from_recs(&recs))
59    }
60
61    fn mut_result(&self, report: String, id: &str) -> Result<MutResult, Error> {
62        self.reload()?;
63        let issue = self.with_service(|svc| svc.detail(id)).ok();
64        Ok(MutResult {
65            ok: true,
66            report,
67            issue,
68            revision: 0,
69            generation: self.generation.load(Ordering::SeqCst),
70        })
71    }
72}
73
74impl BoardBackend for CoreBackend {
75    fn layout(&self) -> &Layout {
76        &self.layout
77    }
78
79    fn generation(&self) -> u64 {
80        self.generation.load(Ordering::SeqCst)
81    }
82
83    fn revision(&self) -> u64 {
84        0
85    }
86
87    fn live(&self) -> BackendKind {
88        BackendKind::Core
89    }
90
91    fn identity(&self) -> &str {
92        &self.identity
93    }
94
95    fn list(&self, q: ListQuery) -> Result<ListPage, Error> {
96        self.with_service(|svc| {
97            let issues = svc.issues_rows(q)?;
98            let matched = issues.len() as u64;
99            Ok(ListPage {
100                issues,
101                total: matched,
102                matched,
103                revision: 0,
104                generation: self.generation.load(Ordering::SeqCst),
105                unchanged: false,
106            })
107        })
108    }
109
110    fn ready(&self, project: Option<&str>) -> Result<ListPage, Error> {
111        self.with_service(|svc| {
112            let issues = svc.ready(project)?;
113            let matched = issues.len() as u64;
114            Ok(ListPage {
115                issues,
116                total: matched,
117                matched,
118                revision: 0,
119                generation: self.generation.load(Ordering::SeqCst),
120                unchanged: false,
121            })
122        })
123    }
124
125    fn get(&self, id: &str) -> Result<IssueDetail, Error> {
126        self.with_service(|svc| svc.detail(id))
127    }
128
129    fn excerpt(&self, id: &str) -> Result<Excerpt, Error> {
130        self.with_service(|svc| svc.excerpt(id))
131    }
132
133    fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error> {
134        self.with_service(|svc| svc.search(query, limit))
135    }
136
137    fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error> {
138        self.with_service(|svc| svc.claims(holder, project))
139    }
140
141    fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error> {
142        self.with_service(|svc| svc.agenda(days, project))
143    }
144
145    fn tree(&self, id: &str) -> Result<TreeNode, Error> {
146        self.with_service(|svc| svc.tree(id))
147    }
148
149    fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error> {
150        self.with_service(|svc| svc.related(id, depth, limit))
151    }
152
153    fn recall(&self, id: &str, depth: usize) -> Result<Recall, Error> {
154        // The board paints the detail pane beside this, so it does not need the
155        // inputs' prose spliced into the working set as well.
156        self.with_service(|svc| svc.recall(id, depth, false))
157    }
158
159    fn deed(&self, id: &str, add: &[String]) -> Result<MutResult, Error> {
160        let report = ops::deed(&self.layout, id, add, &[])?;
161        self.mut_result(report, id)
162    }
163
164    fn projects(&self) -> Result<Vec<String>, Error> {
165        store::list_projects(&self.layout)
166    }
167
168    fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error> {
169        let report = ops::claim_as(&self.layout, id, force, &self.identity)?;
170        self.mut_result(report, id)
171    }
172
173    fn note(&self, id: &str, text: &str) -> Result<MutResult, Error> {
174        let report = ops::note(&self.layout, id, text)?;
175        self.mut_result(report, id)
176    }
177
178    fn update(&self, req: UpdateReq) -> Result<MutResult, Error> {
179        let UpdateOutcome { report, .. } = ops::update_as_pred(
180            &self.layout,
181            &req.id,
182            req.state.as_deref(),
183            req.priority,
184            req.block.as_deref(),
185            req.unblock.as_deref(),
186            &self.identity,
187            vissue_core::UpdatePred {
188                if_state: req.if_state.as_deref(),
189                if_gen: req.if_gen,
190            },
191        )?;
192        self.mut_result(report, &req.id)
193    }
194
195    fn create(&self, project: &str, title: &str) -> Result<MutResult, Error> {
196        let report = ops::create(
197            &self.layout,
198            project,
199            title,
200            ops::CreateOpts {
201                quiet: true,
202                ..ops::CreateOpts::default()
203            },
204        )?;
205        let id = report.split_whitespace().next().unwrap_or("").to_string();
206        self.mut_result(report, &id)
207    }
208
209    fn open(&self, id: &str) -> Result<IssueDetail, Error> {
210        self.get(id)
211    }
212
213    fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
214        let generation = events::wait_generation(&self.layout, last, 200, timeout_ms)?;
215        if generation > last {
216            self.reload()?;
217        }
218        Ok(generation)
219    }
220
221    fn refresh(&self) -> Result<(), Error> {
222        self.reload()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::backend::{BackendKind, BoardBackend, UpdateReq};
230    use std::path::Path;
231    use vissue_core::config::DEFAULT_PREFIX;
232    use vissue_core::views::ListQuery;
233
234    fn fixture() -> Layout {
235        Layout::new(
236            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault"),
237            DEFAULT_PREFIX,
238        )
239    }
240
241    #[test]
242    fn core_revision_is_zero_and_ready_matches_fixture() {
243        let backend = CoreBackend::open(fixture(), "snap").unwrap();
244        assert_eq!(backend.revision(), 0);
245        assert_eq!(backend.live(), BackendKind::Core);
246        assert_eq!(backend.identity(), "snap");
247        let page = backend.ready(None).unwrap();
248        let ids: Vec<_> = page.issues.iter().map(|r| r.id.as_str()).collect();
249        assert_eq!(ids, ["atlas-1a2b", "atlas-2c3d", "beacon-5j6k"]);
250        assert_eq!(backend.wait(backend.generation(), 1).unwrap(), 0);
251        assert!(!backend.projects().unwrap().is_empty());
252        assert_eq!(
253            backend
254                .list(ListQuery {
255                    project: Some("beacon".into()),
256                    ..ListQuery::default()
257                })
258                .unwrap()
259                .issues
260                .len(),
261            2
262        );
263        assert_eq!(backend.search("retry", 5).unwrap().len(), 1);
264        assert_eq!(backend.claims(None, None).unwrap().len(), 1);
265        assert!(!backend.agenda(400, None).unwrap().is_empty());
266        let tree = backend.tree("atlas-1a2b").unwrap();
267        assert_eq!(tree.id, "atlas-1a2b");
268        assert!(!backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
269        assert!(
270            backend
271                .excerpt("atlas-2c3d")
272                .unwrap()
273                .text
274                .contains("summary")
275        );
276        assert_eq!(backend.open("atlas-2c3d").unwrap().id, "atlas-2c3d");
277        assert!(backend.get("missing").is_err());
278    }
279
280    #[test]
281    fn core_mutations_reload_the_catalog() {
282        let dir = tempfile::tempdir().unwrap();
283        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault");
284        copy_tree(&src.join(DEFAULT_PREFIX), &dir.path().join(DEFAULT_PREFIX));
285        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
286        let backend = CoreBackend::open(layout, "tui-test").unwrap();
287        assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
288        assert_eq!(
289            backend.get("atlas-2c3d").unwrap().claimed_by.as_deref(),
290            Some("tui-test")
291        );
292        assert!(backend.note("atlas-2c3d", "hi").unwrap().ok);
293        assert!(
294            backend
295                .update(UpdateReq {
296                    id: "atlas-2c3d".into(),
297                    state: Some("BLOCKED".into()),
298                    ..UpdateReq::default()
299                })
300                .unwrap()
301                .ok
302        );
303        assert_eq!(backend.get("atlas-2c3d").unwrap().state, "BLOCKED");
304    }
305
306    fn copy_tree(src: &Path, dest: &Path) {
307        std::fs::create_dir_all(dest).unwrap();
308        for entry in std::fs::read_dir(src).unwrap() {
309            let entry = entry.unwrap();
310            let target = dest.join(entry.file_name());
311            if entry.file_type().unwrap().is_dir() {
312                copy_tree(&entry.path(), &target);
313            } else {
314                std::fs::copy(entry.path(), &target).unwrap();
315            }
316        }
317    }
318}