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, RelatedHit, SearchHit, TreeNode,
14};
15
16use crate::backend::{BackendKind, BoardBackend, ListPage, MutResult, UpdateReq};
17
18#[derive(Debug)]
20pub struct CoreBackend {
21 layout: Layout,
22 identity: String,
23 recs: Mutex<Vec<IssueRec>>,
24 generation: AtomicU64,
25}
26
27impl CoreBackend {
28 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}