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, 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/// 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
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    /// # Panics
453    ///
454    /// Panics if the control client lock is poisoned.
455    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    /// # Panics
469    ///
470    /// Panics if the since lock is poisoned.
471    fn last_since_revision(&self) -> Option<Option<u64>> {
472        *self.last_since.lock().expect("since")
473    }
474
475    /// # Panics
476    ///
477    /// Panics if the query lock is poisoned.
478    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}