monitr 0.3.51

A lightweight macOS activity monitor TUI built with Rust and Ratatui
use std::{fmt::Write as _, process::Command};

use serde::Serialize;

use crate::{
    error::{self, Result},
    format,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortOptions {
    pub port: Option<u16>,
    pub json: bool,
    pub all: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PortEntry {
    pub pid: u32,
    pub command: String,
    pub user: String,
    pub fd: String,
    pub protocol: String,
    pub local: String,
    pub remote: Option<String>,
    pub state: Option<String>,
}

pub fn lookup(options: PortOptions) -> Result<Vec<PortEntry>> {
    let mut args = vec!["-nP".to_string(), "-F".to_string(), "pPcLfnT".to_string()];
    match (options.port, options.all) {
        (Some(port), true) => {
            args.push(format!("-iTCP:{port}"));
            args.push(format!("-iUDP:{port}"));
        }
        (Some(port), false) => {
            args.push(format!("-iTCP:{port}"));
            args.push("-sTCP:LISTEN".to_string());
        }
        (None, true) => {
            args.push("-iTCP".to_string());
            args.push("-iUDP".to_string());
        }
        (None, false) => {
            args.push("-iTCP".to_string());
            args.push("-sTCP:LISTEN".to_string());
        }
    }

    crate::inspect::ensure_lsof_available()?;
    let output = Command::new("lsof").args(&args).output()?;
    if !output.status.success() && output.stdout.is_empty() {
        if output.status.code() == Some(1) {
            return Ok(Vec::new());
        }
        return Err(error::message(crate::inspect::lsof_failure_message(
            "ports", &output,
        )));
    }

    Ok(parse_lsof_fields(&String::from_utf8_lossy(&output.stdout)))
}

pub fn render(entries: &[PortEntry], options: PortOptions) -> Result<String> {
    if options.json {
        return Ok(serde_json::to_string_pretty(entries)?);
    }

    let mut out = String::new();
    let scope = match (options.port, options.all) {
        (Some(port), true) => format!("all sockets on port {port}"),
        (Some(port), false) => format!("listening TCP sockets on port {port}"),
        (None, true) => "all TCP/UDP sockets".to_string(),
        (None, false) => "listening TCP sockets".to_string(),
    };
    let _ = writeln!(out, "{scope}");
    if entries.is_empty() {
        let _ = writeln!(out, "No matching sockets found.");
        return Ok(out);
    }
    let _ = writeln!(
        out,
        "{:>7} {:<20} {:<13} {:<5} {:<5} {:<26} REMOTE/STATE",
        "PID", "COMMAND", "USER", "FD", "PROTO", "LOCAL"
    );
    for entry in entries {
        let remote_or_state = entry
            .remote
            .as_deref()
            .or(entry.state.as_deref())
            .unwrap_or("-");
        let _ = writeln!(
            out,
            "{:>7} {:<20} {:<13} {:<5} {:<5} {:<26} {}",
            entry.pid,
            format::truncate_middle(&entry.command, 20),
            format::truncate_middle(&entry.user, 13),
            format::truncate_middle(&entry.fd, 5),
            entry.protocol,
            format::truncate_middle(&entry.local, 26),
            remote_or_state,
        );
    }
    Ok(out)
}

fn parse_lsof_fields(output: &str) -> Vec<PortEntry> {
    let mut entries = Vec::new();
    let mut process = ProcessFields::default();
    let mut socket = SocketFields::default();

    for line in output.lines().filter(|line| !line.is_empty()) {
        let mut chars = line.chars();
        let Some(field) = chars.next() else {
            continue;
        };
        let value = chars.as_str();
        match field {
            'p' => {
                flush_socket(&process, &mut socket, &mut entries);
                process = ProcessFields {
                    pid: value.parse().ok(),
                    ..ProcessFields::default()
                };
            }
            'c' => process.command = value.to_string(),
            'L' => process.user = value.to_string(),
            'f' => {
                flush_socket(&process, &mut socket, &mut entries);
                socket.fd = value.to_string();
            }
            'P' => socket.protocol = value.to_string(),
            'n' => {
                let (local, remote) = value
                    .split_once("->")
                    .map(|(local, remote)| (local.to_string(), Some(remote.to_string())))
                    .unwrap_or_else(|| (value.to_string(), None));
                socket.local = local;
                socket.remote = remote;
            }
            'T' => {
                if let Some(state) = value.strip_prefix("ST=") {
                    socket.state = Some(state.to_string());
                }
            }
            _ => {}
        }
    }
    flush_socket(&process, &mut socket, &mut entries);
    entries
}

fn flush_socket(process: &ProcessFields, socket: &mut SocketFields, entries: &mut Vec<PortEntry>) {
    let Some(pid) = process.pid else {
        *socket = SocketFields::default();
        return;
    };
    if socket.fd.is_empty() || socket.local.is_empty() {
        *socket = SocketFields::default();
        return;
    }

    entries.push(PortEntry {
        pid,
        command: value_or_dash(&process.command),
        user: value_or_dash(&process.user),
        fd: socket.fd.clone(),
        protocol: value_or_dash(&socket.protocol),
        local: socket.local.clone(),
        remote: socket.remote.clone(),
        state: socket.state.clone(),
    });
    *socket = SocketFields::default();
}

fn value_or_dash(value: &str) -> String {
    if value.is_empty() {
        "-".to_string()
    } else {
        value.to_string()
    }
}

#[derive(Default)]
struct ProcessFields {
    pid: Option<u32>,
    command: String,
    user: String,
}

#[derive(Default)]
struct SocketFields {
    fd: String,
    protocol: String,
    local: String,
    remote: Option<String>,
    state: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::parse_lsof_fields;

    #[test]
    fn parses_lsof_field_output_into_socket_entries() {
        let entries = parse_lsof_fields(
            "\
p4286
cnode
Lmiloevans
f18
PTCP
n127.0.0.1:18789
TST=LISTEN
f24
PUDP
n[::1]:5353->[ff02::fb]:5353
",
        );

        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].pid, 4286);
        assert_eq!(entries[0].command, "node");
        assert_eq!(entries[0].protocol, "TCP");
        assert_eq!(entries[0].local, "127.0.0.1:18789");
        assert_eq!(entries[0].state.as_deref(), Some("LISTEN"));
        assert_eq!(entries[1].protocol, "UDP");
        assert_eq!(entries[1].remote.as_deref(), Some("[ff02::fb]:5353"));
    }
}