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_pred(
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            vissue_core::UpdatePred {
176                if_state: req.if_state.as_deref(),
177                if_gen: req.if_gen,
178            },
179        )?;
180        self.mut_result(report, &req.id)
181    }
182
183    fn open(&self, id: &str) -> Result<IssueDetail, Error> {
184        self.get(id)
185    }
186
187    fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
188        let generation = events::wait_generation(&self.layout, last, 200, timeout_ms)?;
189        if generation > last {
190            self.reload()?;
191        }
192        Ok(generation)
193    }
194
195    fn refresh(&self) -> Result<(), Error> {
196        self.reload()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::backend::{BackendKind, BoardBackend, UpdateReq};
204    use std::path::Path;
205    use vissue_core::config::DEFAULT_PREFIX;
206    use vissue_core::views::ListQuery;
207
208    fn fixture() -> Layout {
209        Layout::new(
210            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault"),
211            DEFAULT_PREFIX,
212        )
213    }
214
215    #[test]
216    fn core_revision_is_zero_and_ready_matches_fixture() {
217        let backend = CoreBackend::open(fixture(), "snap").unwrap();
218        assert_eq!(backend.revision(), 0);
219        assert_eq!(backend.live(), BackendKind::Core);
220        assert_eq!(backend.identity(), "snap");
221        let page = backend.ready(None).unwrap();
222        let ids: Vec<_> = page.issues.iter().map(|r| r.id.as_str()).collect();
223        assert_eq!(ids, ["atlas-1a2b", "atlas-2c3d", "beacon-5j6k"]);
224        assert_eq!(backend.wait(backend.generation(), 1).unwrap(), 0);
225        assert!(!backend.projects().unwrap().is_empty());
226        assert_eq!(
227            backend
228                .list(ListQuery {
229                    project: Some("beacon".into()),
230                    ..ListQuery::default()
231                })
232                .unwrap()
233                .issues
234                .len(),
235            2
236        );
237        assert_eq!(backend.search("retry", 5).unwrap().len(), 1);
238        assert_eq!(backend.claims(None, None).unwrap().len(), 1);
239        assert!(!backend.agenda(400, None).unwrap().is_empty());
240        let tree = backend.tree("atlas-1a2b").unwrap();
241        assert_eq!(tree.id, "atlas-1a2b");
242        assert!(!backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
243        assert!(
244            backend
245                .excerpt("atlas-2c3d")
246                .unwrap()
247                .text
248                .contains("summary")
249        );
250        assert_eq!(backend.open("atlas-2c3d").unwrap().id, "atlas-2c3d");
251        assert!(backend.get("missing").is_err());
252    }
253
254    #[test]
255    fn core_mutations_reload_the_catalog() {
256        let dir = tempfile::tempdir().unwrap();
257        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixture_vault");
258        copy_tree(&src.join(DEFAULT_PREFIX), &dir.path().join(DEFAULT_PREFIX));
259        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
260        let backend = CoreBackend::open(layout, "tui-test").unwrap();
261        assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
262        assert_eq!(
263            backend.get("atlas-2c3d").unwrap().claimed_by.as_deref(),
264            Some("tui-test")
265        );
266        assert!(backend.note("atlas-2c3d", "hi").unwrap().ok);
267        assert!(
268            backend
269                .update(UpdateReq {
270                    id: "atlas-2c3d".into(),
271                    state: Some("BLOCKED".into()),
272                    ..UpdateReq::default()
273                })
274                .unwrap()
275                .ok
276        );
277        assert_eq!(backend.get("atlas-2c3d").unwrap().state, "BLOCKED");
278    }
279
280    fn copy_tree(src: &Path, dest: &Path) {
281        std::fs::create_dir_all(dest).unwrap();
282        for entry in std::fs::read_dir(src).unwrap() {
283            let entry = entry.unwrap();
284            let target = dest.join(entry.file_name());
285            if entry.file_type().unwrap().is_dir() {
286                copy_tree(&entry.path(), &target);
287            } else {
288                std::fs::copy(entry.path(), &target).unwrap();
289            }
290        }
291    }
292}