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