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(
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}