Skip to main content

vissue_tui/
control.rs

1//! Socket-backed board. Unix only; clients never bind the control socket.
2
3use 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, Error as RpcError, INVALID_STATE, IdParams, InitializeResult,
12    IssueListParams, IssueListResult, MutResult as WireMut, NOT_FOUND, NoteParams, Notification,
13    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/// JSON-RPC client after a matching `initialize`.
25#[derive(Debug)]
26pub struct ControlBackend {
27    layout: Layout,
28    identity: String,
29    client: Mutex<Client>,
30    generation: AtomicU64,
31    revision: AtomicU64,
32    /// Revision of the last full list/ready page, not the last mut/notify.
33    page_revision: AtomicU64,
34    since: SinceGate,
35    last_query: Mutex<Option<ListQuery>>,
36    last_since: Mutex<Option<Option<u64>>>,
37}
38
39impl ControlBackend {
40    /// Connect, `initialize` with a required agent, and refuse a root/prefix
41    /// mismatch so mutations never hit the wrong vault.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the socket cannot be reached, `initialize` fails, or
46    /// the serve root/prefix does not match `layout`.
47    pub fn connect(path: &Path, layout: &Layout, agent: &str) -> Result<Self, ControlAttachError> {
48        Self::connect_as(path, layout, agent, "vissue-tui")
49    }
50
51    /// Same as [`Self::connect`] with an explicit `initialize.client` name.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the socket cannot be reached, `initialize` fails, or
56    /// the serve root/prefix does not match `layout`.
57    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        // Serve `unchanged` is catalog-wide. Only send since_revision when
102        // this is the same ready/project/query as the last full page.
103        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/// Why attach refused the live socket.
162#[derive(Debug)]
163pub enum ControlAttachError {
164    /// Socket, framing, or JSON-RPC failure.
165    Rpc(RpcError),
166    /// Serve answered for a different vault than `layout`.
167    Mismatch {
168        /// Board layout root.
169        want_root: String,
170        /// Board layout prefix.
171        want_prefix: String,
172        /// Root `initialize` returned.
173        got_root: String,
174        /// Prefix `initialize` returned.
175        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 => Error::ClaimConflict {
211                id: rpc
212                    .data
213                    .as_ref()
214                    .and_then(|d| d.get("id"))
215                    .and_then(Value::as_str)
216                    .unwrap_or("")
217                    .to_string(),
218                holder: rpc
219                    .data
220                    .as_ref()
221                    .and_then(|d| d.get("holder"))
222                    .and_then(Value::as_str)
223                    .unwrap_or("")
224                    .to_string(),
225                claimed_at: None,
226            },
227            CYCLE => Error::BlockerCycle {
228                blocker: rpc
229                    .data
230                    .as_ref()
231                    .and_then(|d| d.get("block"))
232                    .and_then(Value::as_str)
233                    .unwrap_or("")
234                    .to_string(),
235                issue: rpc
236                    .data
237                    .as_ref()
238                    .and_then(|d| d.get("id"))
239                    .and_then(Value::as_str)
240                    .unwrap_or("")
241                    .to_string(),
242            },
243            INVALID_STATE => Error::InvalidState {
244                id: rpc
245                    .data
246                    .as_ref()
247                    .and_then(|d| d.get("id"))
248                    .and_then(Value::as_str)
249                    .unwrap_or("")
250                    .to_string(),
251                state: rpc
252                    .data
253                    .as_ref()
254                    .and_then(|d| d.get("state"))
255                    .and_then(Value::as_str)
256                    .unwrap_or("")
257                    .to_string(),
258            },
259            _ => Error::Other(anyhow::anyhow!("{}", rpc.message)),
260        },
261        other => Error::Other(anyhow::anyhow!("{other}")),
262    }
263}
264
265fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, Error> {
266    serde_json::from_value(value).map_err(|e| Error::Other(e.into()))
267}
268
269impl BoardBackend for ControlBackend {
270    fn layout(&self) -> &Layout {
271        &self.layout
272    }
273
274    fn generation(&self) -> u64 {
275        self.generation.load(Ordering::SeqCst)
276    }
277
278    fn revision(&self) -> u64 {
279        self.revision.load(Ordering::SeqCst)
280    }
281
282    fn live(&self) -> BackendKind {
283        BackendKind::Control
284    }
285
286    fn identity(&self) -> &str {
287        &self.identity
288    }
289
290    fn list(&self, q: ListQuery) -> Result<ListPage, Error> {
291        let params = self.list_params(q);
292        let value = self.call(&Request::IssueList(params))?;
293        Ok(self.apply_list(decode(value)?))
294    }
295
296    fn ready(&self, project: Option<&str>) -> Result<ListPage, Error> {
297        let params = self.list_params(ListQuery {
298            project: project.map(str::to_string),
299            ready: true,
300            ..ListQuery::default()
301        });
302        let value = self.call(&Request::IssueReady(params))?;
303        Ok(self.apply_list(decode(value)?))
304    }
305
306    fn get(&self, id: &str) -> Result<IssueDetail, Error> {
307        let value = self.call(&Request::IssueGet(IdParams { id: id.to_string() }))?;
308        let row: vissue_control::rpc::IssueGetResult = decode(value)?;
309        Ok(row.issue)
310    }
311
312    fn excerpt(&self, id: &str) -> Result<Excerpt, Error> {
313        let value = self.call(&Request::IssueExcerpt(IdParams { id: id.to_string() }))?;
314        decode(value)
315    }
316
317    fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, Error> {
318        let value = self.call(&Request::IssueSearch(SearchParams {
319            query: query.to_string(),
320            limit: Some(limit),
321        }))?;
322        decode(value)
323    }
324
325    fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>, Error> {
326        let value = self.call(&Request::IssueClaims(vissue_control::rpc::ClaimsParams {
327            holder: holder.map(str::to_string),
328            project: project.map(str::to_string),
329        }))?;
330        decode(value)
331    }
332
333    fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>, Error> {
334        let value = self.call(&Request::IssueAgenda(vissue_control::rpc::AgendaParams {
335            days: Some(days),
336            project: project.map(str::to_string),
337        }))?;
338        decode(value)
339    }
340
341    fn tree(&self, id: &str) -> Result<TreeNode, Error> {
342        let value = self.call(&Request::IssueTree(TreeParams {
343            id: id.to_string(),
344            format: Some("nodes".into()),
345        }))?;
346        match decode::<vissue_control::rpc::TreeResult>(value)? {
347            vissue_control::rpc::TreeResult::Nodes(node) => Ok(node),
348            vissue_control::rpc::TreeResult::Text { text } => Err(Error::Other(anyhow::anyhow!(
349                "serve returned tree text, not nodes: {text}"
350            ))),
351        }
352    }
353
354    fn related(&self, id: &str, depth: usize, limit: usize) -> Result<Vec<RelatedHit>, Error> {
355        let value = self.call(&Request::IssueRelated(RelatedParams {
356            id: id.to_string(),
357            depth: Some(depth),
358            limit: Some(limit),
359        }))?;
360        decode(value)
361    }
362
363    fn projects(&self) -> Result<Vec<String>, Error> {
364        let value = self.call(&Request::ProjectList)?;
365        let row: vissue_control::rpc::ProjectListResult = decode(value)?;
366        Ok(row.projects)
367    }
368
369    fn claim(&self, id: &str, force: bool) -> Result<MutResult, Error> {
370        let value = self.call(&Request::IssueClaim(ClaimParams {
371            id: id.to_string(),
372            force,
373            agent: None,
374        }))?;
375        Ok(self.apply_mut(decode(value)?))
376    }
377
378    fn note(&self, id: &str, text: &str) -> Result<MutResult, Error> {
379        let value = self.call(&Request::IssueNote(NoteParams {
380            id: id.to_string(),
381            text: text.to_string(),
382        }))?;
383        Ok(self.apply_mut(decode(value)?))
384    }
385
386    fn update(&self, req: UpdateReq) -> Result<MutResult, Error> {
387        let value = self.call(&Request::IssueUpdate(UpdateParams {
388            id: req.id,
389            state: req.state,
390            priority: req.priority.map(|c| c.to_string()),
391            block: req.block,
392            unblock: req.unblock,
393            agent: None,
394        }))?;
395        Ok(self.apply_mut(decode(value)?))
396    }
397
398    fn open(&self, id: &str) -> Result<IssueDetail, Error> {
399        let value = self.call(&Request::IssueOpen(IdParams { id: id.to_string() }))?;
400        let row: vissue_control::rpc::IssueGetResult = decode(value)?;
401        Ok(row.issue)
402    }
403
404    /// # Panics
405    ///
406    /// Panics if the control client lock is poisoned.
407    fn wait(&self, last: u64, timeout_ms: u64) -> Result<u64, Error> {
408        let mut client = self.client.lock().expect("control client");
409        match client.wait_notification(Duration::from_millis(timeout_ms.max(1))) {
410            Ok(Notification::VaultChanged(changed)) => {
411                self.revision.store(changed.revision, Ordering::SeqCst);
412                self.generation.store(changed.generation, Ordering::SeqCst);
413                Ok(changed.revision)
414            }
415            Ok(_) => Ok(self.revision.load(Ordering::SeqCst)),
416            Err(_) => Ok(last),
417        }
418    }
419
420    /// # Panics
421    ///
422    /// Panics if the since lock is poisoned.
423    fn last_since_revision(&self) -> Option<Option<u64>> {
424        *self.last_since.lock().expect("since")
425    }
426
427    /// # Panics
428    ///
429    /// Panics if the query lock is poisoned.
430    fn invalidate_since(&self) {
431        self.since.invalidate();
432        *self.last_query.lock().expect("query") = None;
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::backend::{BoardBackend, UpdateReq};
440    use serde_json::json;
441    use std::io::{BufReader, Write};
442    use std::os::unix::net::UnixListener;
443    use std::sync::{Arc, Mutex};
444    use std::thread;
445    use vissue_control::frame::{read_message, write_message};
446    use vissue_control::rpc::JsonRpcRequest;
447    use vissue_core::views::ListQuery;
448
449    #[test]
450    fn after_initialize_the_next_list_omits_since_revision() {
451        let dir = tempfile::tempdir().unwrap();
452        let sock = dir.path().join("control.sock");
453        let layout = Layout::new(dir.path().join("vault"), "Software");
454        let seen = Arc::new(Mutex::new(Vec::new()));
455        let seen_cb = Arc::clone(&seen);
456        let root = layout.root().display().to_string();
457        let listener = UnixListener::bind(&sock).unwrap();
458        thread::spawn(move || {
459            let (stream, _) = listener.accept().unwrap();
460            let mut reader = BufReader::new(stream.try_clone().unwrap());
461            let mut writer = stream;
462            while let Ok((payload, framing)) = read_message(&mut reader) {
463                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
464                let body = if req.method == "initialize" {
465                    json!({
466                        "jsonrpc": "2.0",
467                        "id": req.id,
468                        "result": {
469                            "protocolVersion": 1,
470                            "capabilities": [],
471                            "root": root,
472                            "prefix": "Software",
473                            "generation": 9,
474                            "revision": 41,
475                            "identity": "tui"
476                        }
477                    })
478                } else {
479                    let since = req
480                        .params
481                        .as_ref()
482                        .and_then(|p| p.get("since_revision"))
483                        .cloned();
484                    seen_cb.lock().unwrap().push(since);
485                    json!({
486                        "jsonrpc": "2.0",
487                        "id": req.id,
488                        "result": {
489                            "issues": [],
490                            "total": 0,
491                            "matched": 0,
492                            "revision": 41,
493                            "generation": 9,
494                            "unchanged": false
495                        }
496                    })
497                };
498                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
499                writer.flush().unwrap();
500            }
501        });
502
503        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
504        assert_eq!(backend.revision(), 41);
505        assert_eq!(backend.live(), BackendKind::Control);
506        backend.ready(None).unwrap();
507        assert_eq!(backend.last_since_revision(), Some(None));
508        backend.ready(None).unwrap();
509        assert_eq!(backend.last_since_revision(), Some(Some(41)));
510        backend.list(ListQuery::default()).unwrap();
511        assert_eq!(backend.last_since_revision(), Some(None));
512        let seen = seen.lock().unwrap();
513        assert_eq!(seen.len(), 3);
514        assert_eq!(seen[0], None);
515        assert_eq!(seen[1], Some(json!(41)));
516        assert_eq!(seen[2], None);
517    }
518
519    fn serve_methods(path: &std::path::Path, root: String) {
520        let listener = UnixListener::bind(path).unwrap();
521        thread::spawn(move || {
522            let (stream, _) = listener.accept().unwrap();
523            let mut reader = BufReader::new(stream.try_clone().unwrap());
524            let mut writer = stream;
525            while let Ok((payload, framing)) = read_message(&mut reader) {
526                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
527                let result = match req.method.as_str() {
528                    "initialize" => json!({
529                        "protocolVersion":1,"capabilities":[],"root":root,
530                        "prefix":"Software","generation":2,"revision":3,"identity":"tui"
531                    }),
532                    "issue/get" | "issue/show" | "issue/open" => json!({
533                        "id":"atlas-1a2b","project":"atlas","title":"t","state":"TODO",
534                        "priority":"B","properties":{},"org_tags":[],"tags":[],
535                        "blocked_by":[],"parent":null,"claimed_by":null,"claimed_at":null,
536                        "file":"f","line_start":1,"line_end":2,"revision":3
537                    }),
538                    "issue/excerpt" => json!({
539                        "id":"atlas-1a2b","file":"f","line_start":1,"line_end":2,
540                        "text":"body","suppressed":false
541                    }),
542                    "issue/search" | "issue/claims" | "issue/agenda" | "issue/related" => {
543                        json!([])
544                    }
545                    "issue/tree" => json!({
546                        "id":"atlas-1a2b","state":"TODO","title":"t",
547                        "children":[],"blocked_by":[]
548                    }),
549                    "project/list" => json!({"projects":["atlas"],"revision":3}),
550                    "issue/claim" | "issue/note" | "issue/update" => json!({
551                        "ok":true,"report":"ok","issue":null,"revision":4,"generation":3
552                    }),
553                    "issue/list" | "issue/ready" => json!({
554                        "issues":[],"total":0,"matched":0,"revision":3,
555                        "generation":2,"unchanged":false
556                    }),
557                    other => panic!("unexpected {other}"),
558                };
559                let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
560                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
561                writer.flush().unwrap();
562            }
563        });
564    }
565
566    #[test]
567    fn control_verbs_roundtrip() {
568        let dir = tempfile::tempdir().unwrap();
569        let sock = dir.path().join("control.sock");
570        let layout = Layout::new(dir.path().join("vault"), "Software");
571        serve_methods(&sock, layout.root().display().to_string());
572        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
573        assert_eq!(backend.get("atlas-1a2b").unwrap().id, "atlas-1a2b");
574        assert_eq!(backend.excerpt("atlas-1a2b").unwrap().text, "body");
575        assert!(backend.search("x", 5).unwrap().is_empty());
576        assert!(backend.claims(None, None).unwrap().is_empty());
577        assert!(backend.agenda(14, None).unwrap().is_empty());
578        assert_eq!(backend.tree("atlas-1a2b").unwrap().id, "atlas-1a2b");
579        assert!(backend.related("atlas-1a2b", 2, 5).unwrap().is_empty());
580        assert_eq!(backend.projects().unwrap(), ["atlas"]);
581        assert!(backend.claim("atlas-1a2b", false).unwrap().ok);
582        assert!(backend.note("atlas-1a2b", "hi").unwrap().ok);
583        assert!(
584            backend
585                .update(UpdateReq {
586                    id: "atlas-1a2b".into(),
587                    state: Some("STARTED".into()),
588                    ..UpdateReq::default()
589                })
590                .unwrap()
591                .ok
592        );
593        assert_eq!(backend.open("atlas-1a2b").unwrap().id, "atlas-1a2b");
594        assert_eq!(backend.wait(3, 5).unwrap(), 3);
595    }
596
597    #[test]
598    fn after_claim_next_list_sends_page_revision_not_head() {
599        let dir = tempfile::tempdir().unwrap();
600        let sock = dir.path().join("control.sock");
601        let layout = Layout::new(dir.path().join("vault"), "Software");
602        let seen = Arc::new(Mutex::new(Vec::new()));
603        let seen_cb = Arc::clone(&seen);
604        let root = layout.root().display().to_string();
605        let listener = UnixListener::bind(&sock).unwrap();
606        thread::spawn(move || {
607            let (stream, _) = listener.accept().unwrap();
608            let mut reader = BufReader::new(stream.try_clone().unwrap());
609            let mut writer = stream;
610            while let Ok((payload, framing)) = read_message(&mut reader) {
611                let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
612                let result = match req.method.as_str() {
613                    "initialize" => json!({
614                        "protocolVersion":1,"capabilities":[],"root":root,
615                        "prefix":"Software","generation":2,"revision":10,"identity":"tui"
616                    }),
617                    "issue/ready" | "issue/list" => {
618                        let since = req
619                            .params
620                            .as_ref()
621                            .and_then(|p| p.get("since_revision"))
622                            .cloned();
623                        seen_cb.lock().unwrap().push(since);
624                        json!({
625                            "issues":[{
626                                "id":"atlas-2c3d","state":"TODO","priority":"B",
627                                "title":"Emit a summary table","project":"atlas",
628                                "blocked_by":[],"claimed_by":null,"claimed_at":null
629                            }],
630                            "total":1,"matched":1,"revision":10,
631                            "generation":2,"unchanged":false
632                        })
633                    }
634                    "issue/claim" => json!({
635                        "ok":true,"report":"claimed","issue":null,
636                        "revision":11,"generation":3
637                    }),
638                    other => panic!("unexpected {other}"),
639                };
640                let body = json!({"jsonrpc":"2.0","id":req.id,"result":result});
641                write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
642                writer.flush().unwrap();
643            }
644        });
645
646        let backend = ControlBackend::connect(&sock, &layout, "tui").unwrap();
647        let page = backend.ready(None).unwrap();
648        assert_eq!(page.issues[0].id, "atlas-2c3d");
649        assert_eq!(backend.last_since_revision(), Some(None));
650        assert!(backend.claim("atlas-2c3d", false).unwrap().ok);
651        assert_eq!(backend.revision(), 11);
652        backend.ready(None).unwrap();
653        assert_eq!(backend.last_since_revision(), Some(Some(10)));
654        let seen = seen.lock().unwrap();
655        assert_eq!(seen[0], None);
656        assert_eq!(seen[1], Some(json!(10)));
657    }
658
659    #[test]
660    fn root_mismatch_refuses_the_socket() {
661        let dir = tempfile::tempdir().unwrap();
662        let sock = dir.path().join("control.sock");
663        let layout = Layout::new(dir.path().join("vault"), "Software");
664        let listener = UnixListener::bind(&sock).unwrap();
665        thread::spawn(move || {
666            let (stream, _) = listener.accept().unwrap();
667            let mut reader = BufReader::new(stream.try_clone().unwrap());
668            let mut writer = stream;
669            let (payload, framing) = read_message(&mut reader).unwrap();
670            let req: JsonRpcRequest = serde_json::from_slice(&payload).unwrap();
671            let body = json!({
672                "jsonrpc": "2.0",
673                "id": req.id,
674                "result": {
675                    "protocolVersion": 1,
676                    "capabilities": [],
677                    "root": "/other/vault",
678                    "prefix": "Software",
679                    "generation": 1,
680                    "revision": 1,
681                    "identity": "tui"
682                }
683            });
684            write_message(&mut writer, &serde_json::to_vec(&body).unwrap(), framing).unwrap();
685            writer.flush().unwrap();
686        });
687        let err = match ControlBackend::connect(&sock, &layout, "tui") {
688            Ok(_) => panic!("expected root mismatch"),
689            Err(err) => err,
690        };
691        match err {
692            ControlAttachError::Mismatch { got_root, .. } => {
693                assert_eq!(got_root, "/other/vault");
694            }
695            other => panic!("{other:?}"),
696        }
697    }
698}