Skip to main content

prt_core/
model.rs

1//! Core data types for the port monitor.
2//!
3//! This module defines all shared types used across the scanner, session,
4//! and UI layers. The key types are:
5//!
6//! - [`PortEntry`] — a single network connection with process info
7//! - [`TrackedEntry`] — a `PortEntry` enriched with change-tracking status
8//! - [`SortState`] — current sort column and direction
9//! - [`ExportFormat`] — output format for CLI export
10
11use serde::Serialize;
12use std::fmt;
13use std::net::SocketAddr;
14use std::path::PathBuf;
15use std::time::{Duration, Instant};
16
17/// Auto-refresh interval for the TUI. The UI polls for new scan data
18/// at this rate.
19pub const TICK_RATE: Duration = Duration::from_secs(2);
20
21/// How long a "Gone" entry stays visible before removal.
22/// Gives the user time to notice a connection disappeared.
23pub const GONE_RETENTION: Duration = Duration::from_secs(5);
24
25/// Network transport protocol.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
27pub enum Protocol {
28    Tcp,
29    Udp,
30}
31
32impl fmt::Display for Protocol {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Protocol::Tcp => write!(f, "TCP"),
36            Protocol::Udp => write!(f, "UDP"),
37        }
38    }
39}
40
41/// TCP connection state.
42///
43/// Matches standard TCP FSM states plus `Unknown` for UDP or unparsable states.
44/// Display format uses uppercase with underscores (e.g. `TIME_WAIT`).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
46pub enum ConnectionState {
47    Listen,
48    Established,
49    TimeWait,
50    CloseWait,
51    SynSent,
52    SynRecv,
53    FinWait1,
54    FinWait2,
55    Closing,
56    LastAck,
57    Closed,
58    Unknown,
59}
60
61impl fmt::Display for ConnectionState {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let s = match self {
64            ConnectionState::Listen => "LISTEN",
65            ConnectionState::Established => "ESTABLISHED",
66            ConnectionState::TimeWait => "TIME_WAIT",
67            ConnectionState::CloseWait => "CLOSE_WAIT",
68            ConnectionState::SynSent => "SYN_SENT",
69            ConnectionState::SynRecv => "SYN_RECV",
70            ConnectionState::FinWait1 => "FIN_WAIT1",
71            ConnectionState::FinWait2 => "FIN_WAIT2",
72            ConnectionState::Closing => "CLOSING",
73            ConnectionState::LastAck => "LAST_ACK",
74            ConnectionState::Closed => "CLOSED",
75            ConnectionState::Unknown => "UNKNOWN",
76        };
77        write!(f, "{s}")
78    }
79}
80
81/// Information about the process that owns a network connection.
82///
83/// Fields like `path`, `cmdline`, `parent_pid`, and `parent_name` are
84/// populated via a secondary `ps` call (macOS) or `/proc` (Linux)
85/// and may be `None` if the process has exited or access is denied.
86#[derive(Debug, Clone, Serialize)]
87pub struct ProcessInfo {
88    /// Process ID.
89    pub pid: u32,
90    /// Short process name (e.g. "nginx").
91    pub name: String,
92    /// Full path to the executable, if available.
93    pub path: Option<PathBuf>,
94    /// Full command line, if available.
95    pub cmdline: Option<String>,
96    /// Username of the process owner.
97    pub user: Option<String>,
98    /// Parent process ID.
99    pub parent_pid: Option<u32>,
100    /// Parent process name, resolved from `parent_pid`.
101    pub parent_name: Option<String>,
102}
103
104/// A single network port entry.
105///
106/// This is the fundamental data unit — one row in the port table.
107/// Identity key for change tracking is `(local_port, pid)`.
108#[derive(Debug, Clone, Serialize)]
109pub struct PortEntry {
110    /// Transport protocol (TCP or UDP).
111    pub protocol: Protocol,
112    /// Local socket address (ip:port).
113    pub local_addr: SocketAddr,
114    /// Remote socket address, if connected.
115    pub remote_addr: Option<SocketAddr>,
116    /// Connection state (LISTEN, ESTABLISHED, etc.).
117    pub state: ConnectionState,
118    /// Process that owns this connection.
119    pub process: ProcessInfo,
120}
121
122impl PortEntry {
123    /// Returns the local port number.
124    pub fn local_port(&self) -> u16 {
125        self.local_addr.port()
126    }
127}
128
129/// Change-tracking status for a port entry between scan cycles.
130///
131/// Used by [`crate::core::scanner::diff_entries`] to compute what changed.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum EntryStatus {
134    /// Entry existed in previous scan and still exists.
135    Unchanged,
136    /// Entry is new since last scan.
137    New,
138    /// Entry disappeared since last scan. Will be retained for
139    /// [`GONE_RETENTION`] seconds before removal.
140    Gone,
141}
142
143/// A [`PortEntry`] with change-tracking metadata.
144///
145/// The `status` field indicates whether the entry is new, unchanged,
146/// or gone since the last scan. `seen_at` records when the status
147/// was last updated.
148#[derive(Debug, Clone)]
149pub struct TrackedEntry {
150    /// The underlying port entry.
151    pub entry: PortEntry,
152    /// Current change status.
153    pub status: EntryStatus,
154    /// When this status was assigned.
155    pub seen_at: Instant,
156}
157
158/// Column by which the port table can be sorted.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum SortColumn {
161    Port,
162    Protocol,
163    State,
164    Pid,
165    ProcessName,
166    User,
167}
168
169/// Current sorting configuration: which column and direction.
170///
171/// Toggle behavior: pressing the same column flips direction;
172/// pressing a different column switches to it ascending.
173#[derive(Debug, Clone, Copy)]
174pub struct SortState {
175    /// Column to sort by.
176    pub column: SortColumn,
177    /// `true` = ascending (A→Z, 0→9), `false` = descending.
178    pub ascending: bool,
179}
180
181impl Default for SortState {
182    fn default() -> Self {
183        Self {
184            column: SortColumn::Port,
185            ascending: true,
186        }
187    }
188}
189
190impl SortState {
191    /// Toggle sorting: same column flips direction, different column
192    /// switches to ascending.
193    pub fn toggle(&mut self, col: SortColumn) {
194        if self.column == col {
195            self.ascending = !self.ascending;
196        } else {
197            self.column = col;
198            self.ascending = true;
199        }
200    }
201}
202
203/// Tab in the detail panel below the port table.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum DetailTab {
206    /// Process tree view.
207    Tree,
208    /// Network interface info.
209    Interface,
210    /// Connection details.
211    Connection,
212}
213
214impl DetailTab {
215    /// Cycle to the next tab: Tree → Interface → Connection → Tree.
216    pub fn next(self) -> Self {
217        match self {
218            Self::Tree => Self::Interface,
219            Self::Interface => Self::Connection,
220            Self::Connection => Self::Tree,
221        }
222    }
223
224    /// Cycle to the previous tab: Tree → Connection → Interface → Tree.
225    pub fn prev(self) -> Self {
226        match self {
227            Self::Tree => Self::Connection,
228            Self::Interface => Self::Tree,
229            Self::Connection => Self::Interface,
230        }
231    }
232}
233
234/// Output format for CLI export mode (`--export`).
235///
236/// Note: this enum intentionally does not derive `clap::ValueEnum` to keep
237/// `prt-core` free of CLI dependencies. The binary crate wraps it with
238/// `CliExportFormat`.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ExportFormat {
241    Json,
242    Csv,
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
249
250    fn make_process() -> ProcessInfo {
251        ProcessInfo {
252            pid: 1,
253            name: "test".into(),
254            path: None,
255            cmdline: None,
256            user: None,
257            parent_pid: None,
258            parent_name: None,
259        }
260    }
261
262    #[test]
263    fn local_port_returns_port_from_addr() {
264        let entry = PortEntry {
265            protocol: Protocol::Tcp,
266            local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
267            remote_addr: None,
268            state: ConnectionState::Listen,
269            process: make_process(),
270        };
271        assert_eq!(entry.local_port(), 8080);
272    }
273
274    #[test]
275    fn sort_state_default_is_port_ascending() {
276        let s = SortState::default();
277        assert_eq!(s.column, SortColumn::Port);
278        assert!(s.ascending);
279    }
280
281    #[test]
282    fn sort_state_toggle_same_column_flips_direction() {
283        let mut s = SortState::default();
284        s.toggle(SortColumn::Port);
285        assert!(!s.ascending);
286        s.toggle(SortColumn::Port);
287        assert!(s.ascending);
288    }
289
290    #[test]
291    fn sort_state_toggle_different_column_resets_ascending() {
292        let mut s = SortState::default();
293        s.toggle(SortColumn::Port);
294        s.toggle(SortColumn::Pid);
295        assert_eq!(s.column, SortColumn::Pid);
296        assert!(s.ascending);
297    }
298
299    #[test]
300    fn protocol_display() {
301        assert_eq!(Protocol::Tcp.to_string(), "TCP");
302        assert_eq!(Protocol::Udp.to_string(), "UDP");
303    }
304
305    #[test]
306    fn connection_state_display() {
307        let cases = [
308            (ConnectionState::Listen, "LISTEN"),
309            (ConnectionState::Established, "ESTABLISHED"),
310            (ConnectionState::TimeWait, "TIME_WAIT"),
311            (ConnectionState::CloseWait, "CLOSE_WAIT"),
312            (ConnectionState::SynSent, "SYN_SENT"),
313            (ConnectionState::SynRecv, "SYN_RECV"),
314            (ConnectionState::FinWait1, "FIN_WAIT1"),
315            (ConnectionState::FinWait2, "FIN_WAIT2"),
316            (ConnectionState::Closing, "CLOSING"),
317            (ConnectionState::LastAck, "LAST_ACK"),
318            (ConnectionState::Closed, "CLOSED"),
319            (ConnectionState::Unknown, "UNKNOWN"),
320        ];
321        for (state, expected) in cases {
322            assert_eq!(state.to_string(), expected, "state {:?}", state);
323        }
324    }
325
326    // ── DetailTab cycling ─────────────────────────────────────────
327
328    #[test]
329    fn detail_tab_next_cycles_forward() {
330        let cases = [
331            (DetailTab::Tree, DetailTab::Interface),
332            (DetailTab::Interface, DetailTab::Connection),
333            (DetailTab::Connection, DetailTab::Tree),
334        ];
335        for (from, expected) in cases {
336            assert_eq!(from.next(), expected, "next of {:?}", from);
337        }
338    }
339
340    #[test]
341    fn detail_tab_prev_cycles_backward() {
342        let cases = [
343            (DetailTab::Tree, DetailTab::Connection),
344            (DetailTab::Interface, DetailTab::Tree),
345            (DetailTab::Connection, DetailTab::Interface),
346        ];
347        for (from, expected) in cases {
348            assert_eq!(from.prev(), expected, "prev of {:?}", from);
349        }
350    }
351
352    #[test]
353    fn detail_tab_next_prev_roundtrip() {
354        for tab in [DetailTab::Tree, DetailTab::Interface, DetailTab::Connection] {
355            assert_eq!(tab.next().prev(), tab, "roundtrip {:?}", tab);
356            assert_eq!(tab.prev().next(), tab, "reverse roundtrip {:?}", tab);
357        }
358    }
359
360    // ── SortState toggle table ────────────────────────────────────
361
362    #[test]
363    fn sort_state_toggle_all_columns() {
364        let columns = [
365            SortColumn::Port,
366            SortColumn::Protocol,
367            SortColumn::State,
368            SortColumn::Pid,
369            SortColumn::ProcessName,
370            SortColumn::User,
371        ];
372        for col in columns {
373            let mut s = SortState::default();
374            s.toggle(col);
375            if col == SortColumn::Port {
376                assert!(!s.ascending, "toggling same column should flip");
377            } else {
378                assert_eq!(s.column, col);
379                assert!(s.ascending, "switching to {:?} should be ascending", col);
380            }
381        }
382    }
383}