1use 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
18pub 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}