Skip to main content

vissue_tui/
core_backend.rs

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