Skip to main content

nexcore_network/
connection.rs

1// Copyright (c) 2026 Matthew Campion, PharmD; NexVigilant
2// All Rights Reserved. See LICENSE file for details.
3
4//! Connection state machine — the lifecycle of a network connection.
5//!
6//! Tier: T2-C (ς State + σ Sequence + ∂ Boundary)
7//!
8//! Every connection — WiFi association, cellular attach, VPN tunnel —
9//! follows the same state machine. The transitions are ordered (σ),
10//! the states are discrete (ς), and the boundaries (∂) determine
11//! when transitions are valid.
12
13use crate::interface::{InterfaceId, InterfaceType, IpAddr};
14use serde::{Deserialize, Serialize};
15
16/// Connection state — the lifecycle of a network connection.
17///
18/// Tier: T2-P (ς State — connection lifecycle)
19///
20/// ```text
21/// Disconnected → Connecting → Authenticating → Configuring → Connected
22///       ↑              │              │              │           │
23///       └──────────────┴──────────────┴──────────────┴───────────┘
24///                              (any failure → Disconnected)
25/// ```
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum ConnectionState {
29    /// Not connected to any network.
30    Disconnected,
31    /// Attempting to establish link (scanning, associating).
32    Connecting,
33    /// Link established, authenticating (WPA handshake, VPN auth).
34    Authenticating,
35    /// Authenticated, configuring (DHCP, address assignment).
36    Configuring,
37    /// Fully connected with IP address and routes.
38    Connected,
39    /// Connection is being torn down gracefully.
40    Disconnecting,
41    /// Connection failed (transient — will retry or go to Disconnected).
42    Failed,
43}
44
45impl ConnectionState {
46    /// Whether this state has network connectivity.
47    pub const fn is_connected(&self) -> bool {
48        matches!(self, Self::Connected)
49    }
50
51    /// Whether a transition is in progress.
52    pub const fn is_transitioning(&self) -> bool {
53        matches!(
54            self,
55            Self::Connecting | Self::Authenticating | Self::Configuring | Self::Disconnecting
56        )
57    }
58
59    /// Whether this is a terminal failure state.
60    pub const fn is_failed(&self) -> bool {
61        matches!(self, Self::Failed)
62    }
63
64    /// Human-readable label.
65    pub const fn label(&self) -> &'static str {
66        match self {
67            Self::Disconnected => "Disconnected",
68            Self::Connecting => "Connecting",
69            Self::Authenticating => "Authenticating",
70            Self::Configuring => "Configuring",
71            Self::Connected => "Connected",
72            Self::Disconnecting => "Disconnecting",
73            Self::Failed => "Failed",
74        }
75    }
76}
77
78/// Reason for a connection failure.
79///
80/// Tier: T2-P (Σ Sum — enumeration of failure modes)
81#[non_exhaustive]
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub enum FailureReason {
84    /// Network not found (SSID doesn't exist).
85    NetworkNotFound,
86    /// Authentication failed (wrong password, certificate rejected).
87    AuthenticationFailed,
88    /// DHCP configuration failed (no address assigned).
89    ConfigurationFailed,
90    /// Connection timed out.
91    Timeout,
92    /// Signal lost during connection.
93    SignalLost,
94    /// Manually disconnected by user or system.
95    ManualDisconnect,
96    /// Hardware error.
97    HardwareError,
98    /// Other failure with description.
99    Other(String),
100}
101
102impl FailureReason {
103    /// Whether this failure is retryable.
104    pub fn is_retryable(&self) -> bool {
105        matches!(
106            self,
107            Self::Timeout | Self::SignalLost | Self::ConfigurationFailed
108        )
109    }
110
111    /// Human-readable label.
112    pub fn label(&self) -> &str {
113        match self {
114            Self::NetworkNotFound => "Network not found",
115            Self::AuthenticationFailed => "Authentication failed",
116            Self::ConfigurationFailed => "Configuration failed",
117            Self::Timeout => "Connection timed out",
118            Self::SignalLost => "Signal lost",
119            Self::ManualDisconnect => "Disconnected",
120            Self::HardwareError => "Hardware error",
121            Self::Other(msg) => msg.as_str(),
122        }
123    }
124}
125
126/// A network connection — one active link on one interface.
127///
128/// Tier: T2-C (ς + σ + ∂ + μ — stateful, ordered, bounded, typed)
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct Connection {
131    /// Which interface this connection belongs to.
132    pub interface_id: InterfaceId,
133    /// Interface type (cached for quick access).
134    pub interface_type: InterfaceType,
135    /// Network name (SSID for WiFi, APN for cellular, interface name for ethernet).
136    pub network_name: String,
137    /// Current state.
138    state: ConnectionState,
139    /// Assigned IP address (set during Configuring → Connected).
140    pub address: Option<IpAddr>,
141    /// Gateway address.
142    pub gateway: Option<IpAddr>,
143    /// DNS servers.
144    pub dns_servers: Vec<IpAddr>,
145    /// Last failure reason (set when transitioning to Failed).
146    pub last_failure: Option<FailureReason>,
147    /// Number of consecutive connection attempts.
148    pub retry_count: u32,
149    /// Maximum retries before giving up.
150    pub max_retries: u32,
151}
152
153impl Connection {
154    /// Create a new disconnected connection.
155    pub fn new(
156        interface_id: InterfaceId,
157        interface_type: InterfaceType,
158        network_name: impl Into<String>,
159    ) -> Self {
160        Self {
161            interface_id,
162            interface_type,
163            network_name: network_name.into(),
164            state: ConnectionState::Disconnected,
165            address: None,
166            gateway: None,
167            dns_servers: Vec::new(),
168            last_failure: None,
169            retry_count: 0,
170            max_retries: 3,
171        }
172    }
173
174    /// Get current state.
175    pub fn state(&self) -> ConnectionState {
176        self.state
177    }
178
179    /// Whether this connection is fully connected.
180    pub fn is_connected(&self) -> bool {
181        self.state.is_connected()
182    }
183
184    /// Whether this connection is in transition.
185    pub fn is_transitioning(&self) -> bool {
186        self.state.is_transitioning()
187    }
188
189    // ── State transitions ──
190
191    /// Begin connecting (Disconnected/Failed → Connecting).
192    pub fn connect(&mut self) -> Result<(), ConnectionError> {
193        match self.state {
194            ConnectionState::Disconnected | ConnectionState::Failed => {
195                self.state = ConnectionState::Connecting;
196                self.last_failure = None;
197                Ok(())
198            }
199            ConnectionState::Connecting
200            | ConnectionState::Authenticating
201            | ConnectionState::Configuring
202            | ConnectionState::Connected
203            | ConnectionState::Disconnecting => Err(ConnectionError::InvalidTransition {
204                from: self.state,
205                to: ConnectionState::Connecting,
206            }),
207        }
208    }
209
210    /// Authentication phase reached (Connecting → Authenticating).
211    pub fn begin_auth(&mut self) -> Result<(), ConnectionError> {
212        if self.state == ConnectionState::Connecting {
213            self.state = ConnectionState::Authenticating;
214            Ok(())
215        } else {
216            Err(ConnectionError::InvalidTransition {
217                from: self.state,
218                to: ConnectionState::Authenticating,
219            })
220        }
221    }
222
223    /// Configuration phase reached (Authenticating → Configuring).
224    pub fn begin_config(&mut self) -> Result<(), ConnectionError> {
225        if self.state == ConnectionState::Authenticating {
226            self.state = ConnectionState::Configuring;
227            Ok(())
228        } else {
229            Err(ConnectionError::InvalidTransition {
230                from: self.state,
231                to: ConnectionState::Configuring,
232            })
233        }
234    }
235
236    /// Connection established (Configuring → Connected).
237    pub fn establish(
238        &mut self,
239        address: IpAddr,
240        gateway: Option<IpAddr>,
241        dns: Vec<IpAddr>,
242    ) -> Result<(), ConnectionError> {
243        if self.state == ConnectionState::Configuring {
244            self.address = Some(address);
245            self.gateway = gateway;
246            self.dns_servers = dns;
247            self.state = ConnectionState::Connected;
248            self.retry_count = 0;
249            Ok(())
250        } else {
251            Err(ConnectionError::InvalidTransition {
252                from: self.state,
253                to: ConnectionState::Connected,
254            })
255        }
256    }
257
258    /// Begin disconnecting (Connected → Disconnecting).
259    pub fn begin_disconnect(&mut self) -> Result<(), ConnectionError> {
260        if self.state == ConnectionState::Connected {
261            self.state = ConnectionState::Disconnecting;
262            Ok(())
263        } else {
264            Err(ConnectionError::InvalidTransition {
265                from: self.state,
266                to: ConnectionState::Disconnecting,
267            })
268        }
269    }
270
271    /// Complete disconnection (Disconnecting → Disconnected).
272    pub fn complete_disconnect(&mut self) {
273        self.state = ConnectionState::Disconnected;
274        self.address = None;
275        self.gateway = None;
276        self.dns_servers.clear();
277    }
278
279    /// Mark connection as failed (any state → Failed).
280    pub fn fail(&mut self, reason: FailureReason) {
281        self.last_failure = Some(reason);
282        self.state = ConnectionState::Failed;
283        self.retry_count = self.retry_count.saturating_add(1);
284        self.address = None;
285        self.gateway = None;
286        self.dns_servers.clear();
287    }
288
289    /// Whether the connection should retry.
290    pub fn should_retry(&self) -> bool {
291        self.state == ConnectionState::Failed
292            && self.retry_count < self.max_retries
293            && self
294                .last_failure
295                .as_ref()
296                .is_some_and(FailureReason::is_retryable)
297    }
298
299    /// Force disconnect from any state.
300    pub fn force_disconnect(&mut self) {
301        self.state = ConnectionState::Disconnected;
302        self.address = None;
303        self.gateway = None;
304        self.dns_servers.clear();
305        self.retry_count = 0;
306    }
307
308    /// Summary string.
309    pub fn summary(&self) -> String {
310        let addr_str = self
311            .address
312            .as_ref()
313            .map_or("no address".to_string(), IpAddr::to_string_repr);
314        format!(
315            "{} ({}) [{}] {}",
316            self.network_name,
317            self.interface_type.label(),
318            self.state.label(),
319            addr_str,
320        )
321    }
322}
323
324/// Connection state machine errors.
325///
326/// Tier: T2-P (∂ Boundary — constraint violations)
327#[non_exhaustive]
328#[derive(Debug, Clone, nexcore_error::Error)]
329pub enum ConnectionError {
330    /// Invalid state transition attempted.
331    #[error("invalid transition: {from:?} → {to:?}")]
332    InvalidTransition {
333        from: ConnectionState,
334        to: ConnectionState,
335    },
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn make_conn() -> Connection {
343        Connection::new(
344            InterfaceId::new("wlan0"),
345            InterfaceType::WiFi,
346            "HomeNetwork",
347        )
348    }
349
350    #[test]
351    fn new_connection_disconnected() {
352        let c = make_conn();
353        assert_eq!(c.state(), ConnectionState::Disconnected);
354        assert!(!c.is_connected());
355        assert!(!c.is_transitioning());
356    }
357
358    #[test]
359    fn full_connection_lifecycle() {
360        let mut c = make_conn();
361
362        // Connect
363        assert!(c.connect().is_ok());
364        assert_eq!(c.state(), ConnectionState::Connecting);
365        assert!(c.is_transitioning());
366
367        // Authenticate
368        assert!(c.begin_auth().is_ok());
369        assert_eq!(c.state(), ConnectionState::Authenticating);
370
371        // Configure
372        assert!(c.begin_config().is_ok());
373        assert_eq!(c.state(), ConnectionState::Configuring);
374
375        // Establish
376        assert!(
377            c.establish(
378                IpAddr::v4(192, 168, 1, 100),
379                Some(IpAddr::v4(192, 168, 1, 1)),
380                vec![IpAddr::v4(8, 8, 8, 8)],
381            )
382            .is_ok()
383        );
384        assert_eq!(c.state(), ConnectionState::Connected);
385        assert!(c.is_connected());
386        assert!(c.address.is_some());
387
388        // Disconnect
389        assert!(c.begin_disconnect().is_ok());
390        assert_eq!(c.state(), ConnectionState::Disconnecting);
391        c.complete_disconnect();
392        assert_eq!(c.state(), ConnectionState::Disconnected);
393        assert!(c.address.is_none());
394    }
395
396    #[test]
397    fn invalid_transition_blocked() {
398        let mut c = make_conn();
399        // Can't authenticate from Disconnected
400        assert!(c.begin_auth().is_err());
401        // Can't establish from Disconnected
402        assert!(c.establish(IpAddr::v4(0, 0, 0, 0), None, vec![]).is_err());
403        // Can't disconnect from Disconnected
404        assert!(c.begin_disconnect().is_err());
405    }
406
407    #[test]
408    fn connection_failure() {
409        let mut c = make_conn();
410        assert!(c.connect().is_ok());
411        c.fail(FailureReason::Timeout);
412        assert_eq!(c.state(), ConnectionState::Failed);
413        assert!(c.state().is_failed());
414        assert_eq!(c.retry_count, 1);
415    }
416
417    #[test]
418    fn retry_on_retryable_failure() {
419        let mut c = make_conn();
420        assert!(c.connect().is_ok());
421        c.fail(FailureReason::Timeout);
422        assert!(c.should_retry());
423    }
424
425    #[test]
426    fn no_retry_on_auth_failure() {
427        let mut c = make_conn();
428        assert!(c.connect().is_ok());
429        c.fail(FailureReason::AuthenticationFailed);
430        assert!(!c.should_retry());
431    }
432
433    #[test]
434    fn max_retries_exceeded() {
435        let mut c = make_conn();
436        c.max_retries = 2;
437        for _ in 0..3 {
438            assert!(c.connect().is_ok());
439            c.fail(FailureReason::Timeout);
440        }
441        assert!(!c.should_retry()); // 3 retries, max is 2
442    }
443
444    #[test]
445    fn force_disconnect() {
446        let mut c = make_conn();
447        assert!(c.connect().is_ok());
448        assert!(c.begin_auth().is_ok());
449        c.force_disconnect();
450        assert_eq!(c.state(), ConnectionState::Disconnected);
451        assert_eq!(c.retry_count, 0);
452    }
453
454    #[test]
455    fn reconnect_after_failure() {
456        let mut c = make_conn();
457        assert!(c.connect().is_ok());
458        c.fail(FailureReason::SignalLost);
459        assert!(c.connect().is_ok()); // Can reconnect from Failed
460    }
461
462    #[test]
463    fn connection_summary() {
464        let mut c = make_conn();
465        assert!(c.connect().is_ok());
466        assert!(c.begin_auth().is_ok());
467        assert!(c.begin_config().is_ok());
468        assert!(c.establish(IpAddr::v4(10, 0, 0, 5), None, vec![]).is_ok());
469        let s = c.summary();
470        assert!(s.contains("HomeNetwork"));
471        assert!(s.contains("WiFi"));
472        assert!(s.contains("Connected"));
473        assert!(s.contains("10.0.0.5"));
474    }
475
476    #[test]
477    fn failure_reason_labels() {
478        assert_eq!(FailureReason::Timeout.label(), "Connection timed out");
479        assert_eq!(
480            FailureReason::AuthenticationFailed.label(),
481            "Authentication failed"
482        );
483    }
484
485    #[test]
486    fn connection_state_labels() {
487        assert_eq!(ConnectionState::Connected.label(), "Connected");
488        assert_eq!(ConnectionState::Authenticating.label(), "Authenticating");
489    }
490}