1use std::path::Path;
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7
8use serde_json::Value;
9use vissue_control::client::Client;
10use vissue_control::rpc::{
11 CONFLICT, CYCLE, ClaimParams, CreateParams, Error as RpcError, INVALID_STATE, IdParams,
12 InitializeResult, IssueListParams, IssueListResult, MutResult as WireMut, NOT_FOUND,
13 NoteParams, Notification, RelatedParams, Request, SearchParams, TreeParams, UpdateParams,
14};
15use vissue_control::{InitializeParams, PROTOCOL_VERSION};
16use vissue_core::config::Layout;
17use vissue_core::error::Error;
18use vissue_core::views::{
19 AgendaRow, ClaimRow, Excerpt, IssueDetail, ListQuery, RelatedHit, SearchHit, TreeNode,
20};
21
22use crate::backend::{BackendKind, BoardBackend, ListPage, MutResult, SinceGate, UpdateReq};
23
24#[derive(Debug)]
26pub struct ControlBackend {
27 layout: Layout,
28 identity: String,
29 client: Mutex<Client>,
30 generation: AtomicU64,
31 revision: AtomicU64,
32 page_revision: AtomicU64,
34 since: SinceGate,
35 last_query: Mutex<Option<ListQuery>>,
36 last_since: Mutex<Option<Option<u64>>>,
37}
38
39impl ControlBackend {
40 pub fn connect(path: &Path, layout: &Layout, agent: &str) -> Result<Self, ControlAttachError> {
48 Self::connect_as(path, layout, agent, "vissue-tui")
49 }
50
51 pub fn connect_as(
58 path: &Path,
59 layout: &Layout,
60 agent: &str,
61 client: &str,
62 ) -> Result<Self, ControlAttachError> {
63 let mut client_conn = Client::connect(path).map_err(ControlAttachError::Rpc)?;
64 let params = InitializeParams {
65 protocol_version: PROTOCOL_VERSION,
66 client: client.into(),
67 agent: agent.to_string(),
68 };
69 let value = client_conn
70 .request_typed(&Request::Initialize(params))
71 .map_err(ControlAttachError::Rpc)?;
72 let init: InitializeResult = serde_json::from_value(value)
73 .map_err(|e| ControlAttachError::Rpc(RpcError::Json(e)))?;
74 if !roots_match(layout, &init.root, &init.prefix) {
75 return Err(ControlAttachError::Mismatch {
76 want_root: layout.root().display().to_string(),
77 want_prefix: layout.prefix().to_string(),
78 got_root: init.root,
79 got_prefix: init.prefix,
80 });
81 }
82 Ok(Self {
83 layout: layout.clone(),
84 identity: init.identity,
85 client: Mutex::new(client_conn),
86 generation: AtomicU64::new(init.generation),
87 revision: AtomicU64::new(init.revision),
88 page_revision: AtomicU64::new(0),
89 since: SinceGate::after_attach(),
90 last_query: Mutex::new(None),
91 last_since: Mutex::new(None),
92 })
93 }
94
95 fn call(&self, req: &Request) -> Result<Value, Error> {
96 let mut client = self.client.lock().expect("control client");
97 client.request_typed(req).map_err(map_rpc)
98 }
99
100 fn list_params(&self, q: ListQuery) -> IssueListParams {
101 let mut last_query = self.last_query.lock().expect("query");
104 let same = last_query.as_ref() == Some(&q);
105 *last_query = Some(q.clone());
106 drop(last_query);
107 let page = self.page_revision.load(Ordering::SeqCst);
108 let since = if same {
109 self.since.next(page)
110 } else {
111 self.since.invalidate();
112 let _ = self.since.next(page);
113 None
114 };
115 *self.last_since.lock().expect("since") = Some(since);
116 IssueListParams {
117 project: q.project,
118 state: q.state,
119 ready: if q.ready { Some(true) } else { None },
120 query: q.query,
121 limit: q.limit,
122 offset: q.offset,
123 since_revision: since,
124 }
125 }
126
127 fn apply_list(&self, result: IssueListResult) -> ListPage {
128 if !result.unchanged {
129 self.page_revision.store(result.revision, Ordering::SeqCst);
130 self.revision.store(result.revision, Ordering::SeqCst);
131 self.generation.store(result.generation, Ordering::SeqCst);
132 }
133 ListPage {
134 issues: result.issues,
135 total: result.total,
136 matched: result.matched,
137 revision: result.revision,
138 generation: result.generation,
139 unchanged: result.unchanged,
140 }
141 }
142
143 fn apply_mut(&self, wire: WireMut) -> MutResult {
144 self.revision.store(wire.revision, Ordering::SeqCst);
145 self.generation.store(wire.generation, Ordering::SeqCst);
146 MutResult {
147 ok: wire.ok,
148 report: wire.report,
149 issue: wire.issue,
150 revision: wire.revision,
151 generation: wire.generation,
152 }
153 }
154}
155
156fn roots_match(layout: &Layout, root: &str, prefix: &str) -> bool {
157 let want_root = layout.root().display().to_string();
158 (root == want_root || Path::new(root) == layout.root()) && prefix == layout.prefix()
159}
160
161#[derive(Debug)]
163pub enum ControlAttachError {
164 Rpc(RpcError),
166 Mismatch {
168 want_root: String,
170 want_prefix: String,
172 got_root: String,
174 got_prefix: String,
176 },
177}
178
179impl std::fmt::Display for ControlAttachError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 match self {
182 Self::Rpc(err) => write!(f, "{err}"),
183 Self::Mismatch {
184 want_root,
185 want_prefix,
186 got_root,
187 got_prefix,
188 } => write!(
189 f,
190 "serve root/prefix mismatch: want {want_root} {want_prefix}, got {got_root} {got_prefix}"
191 ),
192 }
193 }
194}
195
196impl std::error::Error for ControlAttachError {}
197
198fn map_rpc(err: RpcError) -> Error {
199 match err {
200 RpcError::Rpc(rpc) => match rpc.code {
201 NOT_FOUND => Error::IssueNotFound {
202 id: rpc
203 .data
204 .as_ref()
205 .and_then(|d| d.get("id"))
206 .and_then(Value::as_str)
207 .unwrap_or("")
208 .to_string(),
209 },
210 CONFLICT
211 if rpc
212 .data
213 .as_ref()
214 .and_then(|d| d.get("code"))
215 .and_then(Value::as_str)
216 == Some("duplicate_id") =>
217 {
218 Error::DuplicateId {
219 id: rpc
220 .data
221 .as_ref()
222 .and_then(|d| d.get("id"))
223 .and_then(Value::as_str)
224 .unwrap_or("")
225 .to_string(),
226 paths: rpc
227 .data
228 .as_ref()
229 .and_then(|d| d.get("paths"))
230 .and_then(Value::as_array)
231 .map(|arr| {
232 arr.iter()
233 .filter_map(Value::as_str)
234 .map(std::path::PathBuf::from)
235 .collect()
236 })
237 .unwrap_or_default(),
238 }
239 }
240 CONFLICT => Error::ClaimConflict {
241 id: rpc
242 .data
243 .as_ref()
244 .and_then(|d| d.get("id"))
245 .and_then(Value::as_str)
246 .unwrap_or("")
247 .to_string(),
248 holder: rpc
249 .data
250 .as_ref()
251 .and_then(|d| d.get("holder"))
252 .and_then(Value::as_str)
253 .unwrap_or("")
254 .to_string(),
255 claimed_at: None,
256 },
257 CYCLE => Error::BlockerCycle {
258 blocker: rpc
259 .data
260 .as_ref()
261 .and_then(|d| d.get("block"))
262 .and_then(Value::as_str)
263 .unwrap_or("")
264 .to_string(),
265 issue: rpc
266 .data
267 .as_ref()
268 .and_then(|d| d.get("id"))
269 .and_then(Value::as_str)
270 .unwrap_or("")
271 .to_string(),
272 },
273 INVALID_STATE => Error::InvalidState {
274 id: rpc
275 .data
276 .as_ref()
277 .and_then(|d| d.get("id"))
278 .and_then(Value::as_str)
279 .unwrap_or("")
280 .to_string(),
281 state: rpc
282 .data
283 .as_ref()
284 .and_then(|d| d.get("state"))
285 .and_then(Value::as_str)
286 .unwrap_or("")
287 .to_string(),
288 },
289 _ => Error::Other(anyhow::anyhow!("{}", rpc.message)),
290 },
291 other => Error::Other(anyhow::anyhow!("{other}")),
292 }
293}
294
295fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, Error> {
296 serde_json::from_value(value).map_err(|e| Error::Other(e.into()))
297}
298
299impl BoardBackend for ControlBackend {
300 fn layout(&self) -> &Layout {
301 &self.layout
302 }
303
304 fn generation(&self) -> u64 {
305 self.generation.load(Ordering::SeqCst)
306 }
307
308 fn revision(&self) -> u64 {
309 self.revision.load(Ordering::SeqCst)
310 }
311
312 fn live(&self) -> BackendKind {
313 BackendKind::Control
314 }
315
316 fn identity(&self) -> &str {
317 &self.identity
318 }
319
320 fn list(&self, q: ListQuery) -> Result<ListPage, Error> {
321 let params = self.list_params(q);
322 let value = self.call(&Request::IssueList(params))?;
323 Ok(self.apply_list(decode(value)?))
324 }
325
326 fn ready(&self, project: Option<&str>) -> Result<ListPage, Error> {
327 let params = self.list_params(ListQuery {
328 project: project.map(str::to_string),
329 ready: true,
330 ..ListQuery::default()
331 });
332 let value = self.call(&Request::IssueReady(params))?;
333 Ok(self.apply_list(decode(value)?))
334 }
335
336 fn get(&self, id: &str) -> Result<IssueDetail, Error> {
337 let value = self.call(&Request::IssueGet(IdParams { id: id.to_string() }))?;
338 let row: vissue_control::rpc::IssueGetResult = decode(value)?;
339 Ok(row.issue)
340 }
341
342 fn excerpt(&self, id: &str) -> Result<Excerpt, Error> {
343 let value = self.call(&Request::IssueExcerpt(IdParams { id: id.to_string() }))?;
344 decode(value)
345 }
346
347 fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error> {
348 let value = self.call(&Request::IssueSearch(SearchParams {
349 query: query.to_string(),
350 limit: Some(limit),
351 }))?;
352 decode(value)
353 }
354
355 fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error> {
356 let value = self.call(&Request::IssueClaims(vissue_control::rpc::ClaimsParams {
357 holder: holder.map(str::to_string),
358 project: project.map(str::to_string),
359 }))?;
360 decode(value)
361 }
362
363 fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error> {
364 let value = self.call(&Request::IssueAgenda(vissue_control::rpc::AgendaParams {
365 days: Some(days),
366 project: project.map(str::to_string),
367 }))?;
368 decode(value)
369 }
370
371 fn tree(&self, id: &str) -> Result<TreeNode, Error> {
372 let value = self.call(&Request::IssueTree(TreeParams {
373 id: id.to_string(),
374 format: Some("nodes".into()),
375 }))?;
376 match decode::<vissue_control::rpc::TreeResult>(value)? {
377 vissue_control::rpc::TreeResult::Nodes(node) => Ok(node),
378 vissue_control::rpc::TreeResult::Text { text } => Err(Error::Other(anyhow::anyhow!(
379 "serve returned tree text, not nodes: {text}"
380 ))),
381 }
382 }
383
384 fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error> {
385 let value = self.call(&Request::IssueRelated(RelatedParams {
386 id: id.to_string(),
387 depth: Some(depth),
388 limit: Some(limit),
389 }))?;
390 decode(value)
391 }
392
393 fn projects(&self) -> Result<Vec<String>, Error> {
394 let value = self.call(&Request::ProjectList)?;
395 let row: vissue_control::rpc::ProjectListResult = decode(value)?;
396 Ok(row.projects)
397 }
398
399 fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error> {
400 let value = self.call(&Request::IssueClaim(ClaimParams {
401 id: id.to_string(),
402 force,
403 agent: None,
404 }))?;
405 Ok(self.apply_mut(decode(value)?))
406 }
407
408 fn note(&self, id: &str, text: &str) -> Result<MutResult, Error> {
409 let value = self.call(&Request::IssueNote(NoteParams {
410 id: id.to_string(),
411 text: text.to_string(),
412 }))?;
413 Ok(self.apply_mut(decode(value)?))
414 }
415
416 fn update(&self, req: UpdateReq) -> Result<MutResult, Error> {
417 let value = self.call(&Request::IssueUpdate(UpdateParams {
418 id: req.id,
419 state: req.state,
420 priority: req.priority.map(|c| c.to_string()),
421 block: req.block,
422 unblock: req.unblock,
423 if_state: req.if_state,
424 if_gen: req.if_gen,
425 agent: None,
426 }))?;
427 Ok(self.apply_mut(decode(value)?))
428 }
429
430 fn create(&self, project: &str, title: &str) -> Result<MutResult, Error> {
431 let value = self.call(&Request::IssueCreate(CreateParams {
432 project: project.to_string(),
433 title: title.to_string(),
434 agent: None,
435 priority: None,
436 issue_type: None,
437 deadline: None,
438 scheduled: None,
439 tags: None,
440 parent: None,
441 body: None,
442 }))?;
443 Ok(self.apply_mut(decode(value)?))
444 }
445
446 fn open(&self, id: &str) -> Result<IssueDetail, Error> {
447 let value = self.call(&Request::IssueOpen(IdParams { id: id.to_string() }))?;
448 let row: vissue_control::rpc::IssueGetResult = decode(value)?;
449 Ok(row.issue)
450 }
451
452 fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
456 let mut client = self.client.lock().expect("control client");
457 match client.wait_notification(Duration::from_millis(timeout_ms.max(1))) {
458 Ok(Notification::VaultChanged(changed)) => {
459 self.revision.store(changed.revision, Ordering::SeqCst);
460 self.generation.store(changed.generation, Ordering::SeqCst);
461 Ok(changed.revision)
462 }
463 Ok(_) => Ok(self.revision.load(Ordering::SeqCst)),
464 Err(_) => Ok(last),
465 }
466 }
467
468 fn last_since_revision(&self) -> Option<Option<u64>> {
472 *self.last_since.lock().expect("since")
473 }
474
475 fn invalidate_since(&self) {
479 self.since.invalidate();
480 *self.last_query.lock().expect("query") = None;
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::backend::{BoardBackend, UpdateReq};
488 use serde_json::json;
489 use std::io::{BufReader, Write};
490 use std::os::unix::net::UnixListener;
491 use std::sync::{Arc, Mutex};
492 use std::thread;
493 use vissue_control::frame::{read_message, write_message};
494 use vissue_control::rpc::JsonRpcRequest;
495 use vissue_core::views::ListQuery;
496
497 #[test]
498 fn after_initialize_the_next_list_omits_since_revision() {
499 let dir = tempfile::tempdir().unwrap();
500 let sock = dir.path().join("control.sock");
501 let layout = Layout::new(dir.path().join("vault"), "Software");
502 let seen = Arc::new(Mutex::new(Vec::new()));
503 let seen_cb = Arc::clone(&seen);
504 let root = layout.root().display().to_string();
505 let listener = UnixListener::bind(&sock).unwrap();
506 thread::spawn(move || {
507 let (stream, _) = listener.accept().unwrap();
508 let mut reader = BufReader::new(stream.try_clone().unwrap());
509 let mut writer = stream;
510 while let Ok((payload, framing)) = read_message(&mut reader) {
511 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
512 let body = if req.method == "initialize" {
513 json!({
514 "jsonrpc": "2.0",
515 "id": req.id,
516 "result": {
517 "protocolVersion": 1,
518 "capabilities": [],
519 "root": root,
520 "prefix": "Software",
521 "generation": 9,
522 "revision": 41,
523 "identity": "tui"
524 }
525 })
526 } else {
527 let since = req
528 .params
529 .as_ref()
530 .and_then(|p| p.get("since_revision"))
531 .cloned();
532 seen_cb.lock().unwrap().push(since);
533 json!({
534 "jsonrpc": "2.0",
535 "id": req.id,
536 "result": {
537 "issues": [],
538 "total": 0,
539 "matched": 0,
540 "revision": 41,
541 "generation": 9,
542 "unchanged": false
543 }
544 })
545 };
546 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
547 writer.flush().unwrap();
548 }
549 });
550
551 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
552 assert_eq!(backend.revision(), 41);
553 assert_eq!(backend.live(), BackendKind::Control);
554 backend.ready(None).unwrap();
555 assert_eq!(backend.last_since_revision(), Some(None));
556 backend.ready(None).unwrap();
557 assert_eq!(backend.last_since_revision(), Some(Some(41)));
558 backend.list(ListQuery::default()).unwrap();
559 assert_eq!(backend.last_since_revision(), Some(None));
560 let seen = seen.lock().unwrap();
561 assert_eq!(seen.len(), 3);
562 assert_eq!(seen[0], None);
563 assert_eq!(seen[1], Some(json!(41)));
564 assert_eq!(seen[2], None);
565 }
566
567 fn serve_methods(path: &std::path::Path, root: String) {
568 let listener = UnixListener::bind(path).unwrap();
569 thread::spawn(move || {
570 let (stream, _) = listener.accept().unwrap();
571 let mut reader = BufReader::new(stream.try_clone().unwrap());
572 let mut writer = stream;
573 while let Ok((payload, framing)) = read_message(&mut reader) {
574 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
575 let result = match req.method.as_str() {
576 "initialize" => json!({
577 "protocolVersion":1,"capabilities":[],"root":root,
578 "prefix":"Software","generation":2,"revision":3,"identity":"tui"
579 }),
580 "issue/get" | "issue/show" | "issue/open" => json!({
581 "id":"atlas-1a2b","project":"atlas","title":"t","state":"TODO",
582 "priority":"B","properties":{},"org_tags":[],"tags":[],
583 "blocked_by":[],"parent":null,"claimed_by":null,"claimed_at":null,
584 "file":"f","line_start":1,"line_end":2,"revision":3
585 }),
586 "issue/excerpt" => json!({
587 "id":"atlas-1a2b","file":"f","line_start":1,"line_end":2,
588 "text":"body","suppressed":false
589 }),
590 "issue/search" | "issue/claims" | "issue/agenda" | "issue/related" => {
591 json!([])
592 }
593 "issue/tree" => json!({
594 "id":"atlas-1a2b","state":"TODO","title":"t",
595 "children":[],"blocked_by":[]
596 }),
597 "project/list" => json!({"projects":["atlas"],"revision":3}),
598 "issue/claim" | "issue/note" | "issue/update" => json!({
599 "ok":true,"report":"ok","issue":null,"revision":4,"generation":3
600 }),
601 "issue/list" | "issue/ready" => json!({
602 "issues":[],"total":0,"matched":0,"revision":3,
603 "generation":2,"unchanged":false
604 }),
605 other => panic!("unexpected {other}"),
606 };
607 let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
608 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
609 writer.flush().unwrap();
610 }
611 });
612 }
613
614 #[test]
615 fn control_verbs_roundtrip() {
616 let dir = tempfile::tempdir().unwrap();
617 let sock = dir.path().join("control.sock");
618 let layout = Layout::new(dir.path().join("vault"), "Software");
619 serve_methods(&sock, layout.root().display().to_string());
620 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
621 assert_eq!(backend.get("atlas-1a2b").unwrap().id, "atlas-1a2b");
622 assert_eq!(backend.excerpt("atlas-1a2b").unwrap().text, "body");
623 assert!(backend.search("x", 5).unwrap().is_empty());
624 assert!(backend.claims(None, None).unwrap().is_empty());
625 assert!(backend.agenda(14, None).unwrap().is_empty());
626 assert_eq!(backend.tree("atlas-1a2b").unwrap().id, "atlas-1a2b");
627 assert!(backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
628 assert_eq!(backend.projects().unwrap(), ["atlas"]);
629 assert!(backend.claim("atlas-1a2b", false).unwrap().ok);
630 assert!(backend.note("atlas-1a2b", "hi").unwrap().ok);
631 assert!(
632 backend
633 .update(UpdateReq {
634 id: "atlas-1a2b".into(),
635 state: Some("STARTED".into()),
636 ..UpdateReq::default()
637 })
638 .unwrap()
639 .ok
640 );
641 assert_eq!(backend.open("atlas-1a2b").unwrap().id, "atlas-1a2b");
642 assert_eq!(backend.wait(3, 5).unwrap(), 3);
643 }
644
645 #[test]
646 fn after_claim_next_list_sends_page_revision_not_head() {
647 let dir = tempfile::tempdir().unwrap();
648 let sock = dir.path().join("control.sock");
649 let layout = Layout::new(dir.path().join("vault"), "Software");
650 let seen = Arc::new(Mutex::new(Vec::new()));
651 let seen_cb = Arc::clone(&seen);
652 let root = layout.root().display().to_string();
653 let listener = UnixListener::bind(&sock).unwrap();
654 thread::spawn(move || {
655 let (stream, _) = listener.accept().unwrap();
656 let mut reader = BufReader::new(stream.try_clone().unwrap());
657 let mut writer = stream;
658 while let Ok((payload, framing)) = read_message(&mut reader) {
659 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
660 let result = match req.method.as_str() {
661 "initialize" => json!({
662 "protocolVersion":1,"capabilities":[],"root":root,
663 "prefix":"Software","generation":2,"revision":10,"identity":"tui"
664 }),
665 "issue/ready" | "issue/list" => {
666 let since = req
667 .params
668 .as_ref()
669 .and_then(|p| p.get("since_revision"))
670 .cloned();
671 seen_cb.lock().unwrap().push(since);
672 json!({
673 "issues":[{
674 "id":"atlas-2c3d","state":"TODO","priority":"B",
675 "title":"Emit a summary table","project":"atlas",
676 "blocked_by":[],"claimed_by":null,"claimed_at":null
677 }],
678 "total":1,"matched":1,"revision":10,
679 "generation":2,"unchanged":false
680 })
681 }
682 "issue/claim" => json!({
683 "ok":true,"report":"claimed","issue":null,
684 "revision":11,"generation":3
685 }),
686 other => panic!("unexpected {other}"),
687 };
688 let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
689 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
690 writer.flush().unwrap();
691 }
692 });
693
694 let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
695 let page = backend.ready(None).unwrap();
696 assert_eq!(page.issues[0].id, "atlas-2c3d");
697 assert_eq!(backend.last_since_revision(), Some(None));
698 assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
699 assert_eq!(backend.revision(), 11);
700 backend.ready(None).unwrap();
701 assert_eq!(backend.last_since_revision(), Some(Some(10)));
702 let seen = seen.lock().unwrap();
703 assert_eq!(seen[0], None);
704 assert_eq!(seen[1], Some(json!(10)));
705 }
706
707 #[test]
708 fn root_mismatch_refuses_the_socket() {
709 let dir = tempfile::tempdir().unwrap();
710 let sock = dir.path().join("control.sock");
711 let layout = Layout::new(dir.path().join("vault"), "Software");
712 let listener = UnixListener::bind(&sock).unwrap();
713 thread::spawn(move || {
714 let (stream, _) = listener.accept().unwrap();
715 let mut reader = BufReader::new(stream.try_clone().unwrap());
716 let mut writer = stream;
717 let (payload, framing) = read_message(&mut reader).unwrap();
718 let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
719 let body = json!({
720 "jsonrpc": "2.0",
721 "id": req.id,
722 "result": {
723 "protocolVersion": 1,
724 "capabilities": [],
725 "root": "/other/vault",
726 "prefix": "Software",
727 "generation": 1,
728 "revision": 1,
729 "identity": "tui"
730 }
731 });
732 write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
733 writer.flush().unwrap();
734 });
735 let err = match ControlBackend::connect(&sock, &layout, "tui") {
736 Ok(_) => panic!("expected root mismatch"),
737 Err(err) => err,
738 };
739 match err {
740 ControlAttachError::Mismatch { got_root, .. } => {
741 assert_eq!(got_root, "/other/vault");
742 }
743 other => panic!("{other:?}"),
744 }
745 }
746}