Skip to main content

vissue_tui/
attach.rs

1//! Attach story: first paint is core; live serve is optional.
2
3use std::path::Path;
4
5use vissue_core::config::Layout;
6use vissue_serve::ServeConfig;
7
8use crate::backend::BoardBackend;
9use crate::core_backend::CoreBackend;
10
11/// How the status line labels the current store.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ServeStatus {
14    /// Attached to a matching `vissue serve`.
15    Live,
16    /// Core only: `--offline`, no socket, or spawn/connect failed.
17    Offline,
18    /// Socket answered for a different root/prefix.
19    Mismatch,
20}
21
22/// Returns true when the control socket already accepts connections.
23pub type ProbeFn = fn(&Path) -> bool;
24/// Spawn or reuse `vissue serve`. The `String` is a spawn failure message.
25pub type EnsureFn = fn(&ServeConfig) -> Result<vissue_serve::EnsureResult, String>;
26/// Connect a live [`BoardBackend`], or classify why attach must stay on core.
27pub type ConnectFn = fn(&Path, &Layout, &str) -> Result<Box<dyn BoardBackend>, AttachFail>;
28
29/// Hooks so `--offline` can be tested with a connector that panics.
30#[derive(Debug)]
31pub struct AttachHooks {
32    /// Whether `socket` already accepts connections.
33    pub probe: ProbeFn,
34    /// Spawn serve when the socket is free.
35    pub ensure: EnsureFn,
36    /// Dial the socket after probe or a successful ensure.
37    pub connect: ConnectFn,
38}
39
40/// Why a live connect was refused. [`try_attach`] maps this onto [`AttachOutcome`].
41#[derive(Debug)]
42pub enum AttachFail {
43    /// Serve root/prefix does not match the board layout.
44    Mismatch(String),
45    /// Socket, RPC, or platform failure.
46    Other(String),
47}
48
49impl Default for AttachHooks {
50    fn default() -> Self {
51        Self {
52            probe: default_probe,
53            ensure: default_ensure,
54            connect: default_connect,
55        }
56    }
57}
58
59fn default_probe(path: &Path) -> bool {
60    vissue_serve::socket_accepts(path)
61}
62
63fn default_ensure(cfg: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
64    vissue_serve::ensure_serve(cfg).map_err(|e| e.to_string())
65}
66
67fn default_connect(
68    path: &Path,
69    layout: &Layout,
70    agent: &str,
71) -> Result<Box<dyn BoardBackend>, AttachFail> {
72    #[cfg(unix)]
73    {
74        use crate::control::{ControlAttachError, ControlBackend};
75        match ControlBackend::connect(path, layout, agent) {
76            Ok(backend) => Ok(Box::new(backend)),
77            Err(ControlAttachError::Mismatch {
78                want_root,
79                want_prefix,
80                got_root,
81                got_prefix,
82            }) => Err(AttachFail::Mismatch(format!(
83                "want {want_root}/{want_prefix} got {got_root}/{got_prefix}"
84            ))),
85            Err(err) => Err(AttachFail::Other(err.to_string())),
86        }
87    }
88    #[cfg(not(unix))]
89    {
90        let _ = (path, layout, agent);
91        Err(AttachFail::Other("vissue tui attach is Unix-only".into()))
92    }
93}
94
95/// Result of the post-paint attach attempt. First paint always used
96/// [`CoreBackend`] already.
97#[derive(Debug)]
98pub enum AttachOutcome {
99    /// Keep [`CoreBackend`]. `message` is empty on a clean `--offline`.
100    Stay {
101        /// Status-line label after the attempt.
102        status: ServeStatus,
103        /// Reason shown on the status line.
104        message: String,
105    },
106    /// Replace the board store with a live client.
107    Switch {
108        /// Connected control backend.
109        backend: Box<dyn BoardBackend>,
110        /// Always [`ServeStatus::Live`] on this arm.
111        status: ServeStatus,
112    },
113}
114
115/// Never probes the socket when `offline`. Otherwise: accept and initialize;
116/// on a free socket, `ensure_serve` then attach; on spawn failure stay core.
117pub fn try_attach(
118    layout: &Layout,
119    socket: &Path,
120    agent: &str,
121    offline: bool,
122    hooks: &AttachHooks,
123) -> AttachOutcome {
124    if offline {
125        return AttachOutcome::Stay {
126            status: ServeStatus::Offline,
127            message: String::new(),
128        };
129    }
130
131    if (hooks.probe)(socket) {
132        return finish_connect(socket, layout, agent, hooks);
133    }
134
135    let cfg = ServeConfig {
136        layout: layout.clone(),
137        socket: socket.to_path_buf(),
138        exe: None,
139    };
140    match (hooks.ensure)(&cfg) {
141        Ok(ensured) if ensured.ok && (hooks.probe)(socket) => {
142            finish_connect(socket, layout, agent, hooks)
143        }
144        Ok(ensured) => AttachOutcome::Stay {
145            status: ServeStatus::Offline,
146            message: ensured.error.unwrap_or_else(|| "serve spawn failed".into()),
147        },
148        Err(err) => AttachOutcome::Stay {
149            status: ServeStatus::Offline,
150            message: err,
151        },
152    }
153}
154
155fn finish_connect(
156    socket: &Path,
157    layout: &Layout,
158    agent: &str,
159    hooks: &AttachHooks,
160) -> AttachOutcome {
161    match (hooks.connect)(socket, layout, agent) {
162        Ok(backend) => AttachOutcome::Switch {
163            backend,
164            status: ServeStatus::Live,
165        },
166        Err(AttachFail::Mismatch(message)) => AttachOutcome::Stay {
167            status: ServeStatus::Mismatch,
168            message,
169        },
170        Err(AttachFail::Other(message)) => AttachOutcome::Stay {
171            status: ServeStatus::Offline,
172            message,
173        },
174    }
175}
176
177/// First paint: core catalog, revision 0, no socket.
178///
179/// # Errors
180///
181/// Returns an error if a project file cannot be read or parsed.
182pub fn open_core(layout: Layout, agent: String) -> Result<CoreBackend, vissue_core::error::Error> {
183    CoreBackend::open(layout, agent)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::backend::BoardBackend;
190    use std::path::{Path, PathBuf};
191    use std::sync::atomic::{AtomicBool, Ordering};
192
193    static TOUCHED: AtomicBool = AtomicBool::new(false);
194
195    fn panic_probe(_: &Path) -> bool {
196        TOUCHED.store(true, Ordering::SeqCst);
197        panic!("--offline must not probe the socket");
198    }
199
200    fn panic_ensure(_: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
201        TOUCHED.store(true, Ordering::SeqCst);
202        panic!("--offline must not spawn serve");
203    }
204
205    fn panic_connect(_: &Path, _: &Layout, _: &str) -> Result<Box<dyn BoardBackend>, AttachFail> {
206        TOUCHED.store(true, Ordering::SeqCst);
207        panic!("--offline must not connect");
208    }
209
210    fn no_probe(_: &Path) -> bool {
211        false
212    }
213
214    fn ensure_fails(_: &ServeConfig) -> Result<vissue_serve::EnsureResult, String> {
215        Err("spawn failed".into())
216    }
217
218    fn connect_mismatch(
219        _: &Path,
220        _: &Layout,
221        _: &str,
222    ) -> Result<Box<dyn BoardBackend>, AttachFail> {
223        Err(AttachFail::Mismatch("other root".into()))
224    }
225
226    fn yes_probe(_: &Path) -> bool {
227        true
228    }
229
230    #[test]
231    fn spawn_failure_stays_core() {
232        let layout = Layout::new("/tmp/vissue-spawn", "Software");
233        let hooks = AttachHooks {
234            probe: no_probe,
235            ensure: ensure_fails,
236            connect: panic_connect,
237        };
238        match try_attach(
239            &layout,
240            &PathBuf::from("/tmp/vissue-spawn.sock"),
241            "agent",
242            false,
243            &hooks,
244        ) {
245            AttachOutcome::Stay {
246                status: ServeStatus::Offline,
247                message,
248            } => assert!(message.contains("spawn failed"), "{message}"),
249            _ => panic!("expected offline stay"),
250        }
251    }
252
253    #[test]
254    fn mismatch_stays_core() {
255        let layout = Layout::new("/tmp/vissue-mis", "Software");
256        let hooks = AttachHooks {
257            probe: yes_probe,
258            ensure: panic_ensure,
259            connect: connect_mismatch,
260        };
261        match try_attach(
262            &layout,
263            &PathBuf::from("/tmp/vissue-mis.sock"),
264            "agent",
265            false,
266            &hooks,
267        ) {
268            AttachOutcome::Stay {
269                status: ServeStatus::Mismatch,
270                message,
271            } => assert!(message.contains("other root"), "{message}"),
272            _ => panic!("expected mismatch stay"),
273        }
274    }
275
276    #[test]
277    fn offline_never_connects() {
278        TOUCHED.store(false, Ordering::SeqCst);
279        let layout = Layout::new("/tmp/vissue-offline", "Software");
280        let hooks = AttachHooks {
281            probe: panic_probe,
282            ensure: panic_ensure,
283            connect: panic_connect,
284        };
285        let outcome = try_attach(
286            &layout,
287            &PathBuf::from("/tmp/vissue-offline.sock"),
288            "agent",
289            true,
290            &hooks,
291        );
292        match outcome {
293            AttachOutcome::Stay {
294                status: ServeStatus::Offline,
295                ..
296            } => {}
297            _ => panic!("offline must stay on CoreBackend"),
298        }
299        assert!(!TOUCHED.load(Ordering::SeqCst));
300    }
301}