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