1use 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#[derive(Debug)]
21pub struct CoreBackend {
22 layout: Layout,
23 identity: String,
24 recs: Mutex<Vec<IssueRec>>,
25 generation: AtomicU64,
26}
27
28impl CoreBackend {
29 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 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}