whoseportisitanyway 1.1.2

Cross-platform TUI for discovering which ports are in use, who owns them, and what they're for
Documentation
use std::fmt;
use std::path::PathBuf;

use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Protocol {
    Tcp,
    Udp,
}

impl fmt::Display for Protocol {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Protocol::Tcp => write!(f, "TCP"),
            Protocol::Udp => write!(f, "UDP"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum PortState {
    Listen,
    Established,
}

impl fmt::Display for PortState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PortState::Listen => write!(f, "LISTEN"),
            PortState::Established => write!(f, "ESTABLISHED"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Classification {
    DevServer,
    Database,
    Docker,
    BuildTool,
    LanguageServer,
    Proxy,
    Browser,
    MessageQueue,
    SshTunnel,
    System,
    Unknown,
}

impl fmt::Display for Classification {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Classification::DevServer => write!(f, "Dev Server"),
            Classification::Database => write!(f, "Database"),
            Classification::Docker => write!(f, "Docker"),
            Classification::BuildTool => write!(f, "Build Tool"),
            Classification::LanguageServer => write!(f, "Lang Server"),
            Classification::Proxy => write!(f, "Proxy"),
            Classification::Browser => write!(f, "Browser"),
            Classification::MessageQueue => write!(f, "Msg Queue"),
            Classification::SshTunnel => write!(f, "SSH Tunnel"),
            Classification::System => write!(f, "System"),
            Classification::Unknown => write!(f, "Unknown"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Framework {
    Vite,
    Next,
    Remix,
    Astro,
    SvelteKit,
    Nuxt,
    Gatsby,
    Turbopack,
    Webpack,
    Expo,
    Storybook,
    Nest,
    Express,
    Fastify,
    Rails,
    Django,
    Flask,
    FastAPI,
    Spring,
    Gin,
    Phoenix,
    Laravel,
    Hugo,
    Actix,
    Axum,
    Rocket,
}

impl fmt::Display for Framework {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Framework::Vite => write!(f, "Vite"),
            Framework::Next => write!(f, "Next.js"),
            Framework::Remix => write!(f, "Remix"),
            Framework::Astro => write!(f, "Astro"),
            Framework::SvelteKit => write!(f, "SvelteKit"),
            Framework::Nuxt => write!(f, "Nuxt"),
            Framework::Gatsby => write!(f, "Gatsby"),
            Framework::Turbopack => write!(f, "Turbopack"),
            Framework::Webpack => write!(f, "webpack"),
            Framework::Expo => write!(f, "Expo"),
            Framework::Storybook => write!(f, "Storybook"),
            Framework::Nest => write!(f, "NestJS"),
            Framework::Express => write!(f, "Express"),
            Framework::Fastify => write!(f, "Fastify"),
            Framework::Rails => write!(f, "Rails"),
            Framework::Django => write!(f, "Django"),
            Framework::Flask => write!(f, "Flask"),
            Framework::FastAPI => write!(f, "FastAPI"),
            Framework::Spring => write!(f, "Spring"),
            Framework::Gin => write!(f, "Gin"),
            Framework::Phoenix => write!(f, "Phoenix"),
            Framework::Laravel => write!(f, "Laravel"),
            Framework::Hugo => write!(f, "Hugo"),
            Framework::Actix => write!(f, "Actix"),
            Framework::Axum => write!(f, "Axum"),
            Framework::Rocket => write!(f, "Rocket"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Ownership {
    Owned,
    Blocked,
    Untracked,
}

impl fmt::Display for Ownership {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Ownership::Owned => write!(f, "Owned"),
            Ownership::Blocked => write!(f, "Blocked"),
            Ownership::Untracked => write!(f, "—"),
        }
    }
}

#[derive(Debug, Clone)]
pub struct RawPort {
    pub port: u16,
    pub protocol: Protocol,
    pub pid: u32,
    pub process_name: String,
    pub command_line: String,
    pub state: PortState,
    pub local_addr: String,
    pub parent_pid: Option<u32>,
    pub parent_command_line: Option<String>,
    pub cwd: Option<PathBuf>,
    pub uid: Option<u32>,
    pub user: Option<String>,
    pub remote_addr: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Project {
    pub name: String,
    pub root: PathBuf,
    pub framework: Option<Framework>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PortEntry {
    pub port: u16,
    pub protocol: Protocol,
    pub pid: u32,
    pub process_name: String,
    pub command_line: String,
    pub state: PortState,
    pub classification: Classification,
    pub project: Option<Project>,
    pub local_addr: String,
    pub all_addrs: Vec<String>,
    pub ownership: Ownership,
    pub uid: Option<u32>,
    pub user: Option<String>,
    pub remote_addr: Option<String>,
}

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

    #[test]
    fn protocol_display() {
        assert_eq!(Protocol::Tcp.to_string(), "TCP");
        assert_eq!(Protocol::Udp.to_string(), "UDP");
    }

    #[test]
    fn port_state_display() {
        assert_eq!(PortState::Listen.to_string(), "LISTEN");
        assert_eq!(PortState::Established.to_string(), "ESTABLISHED");
    }

    #[test]
    fn classification_display_all_variants() {
        assert_eq!(Classification::DevServer.to_string(), "Dev Server");
        assert_eq!(Classification::Database.to_string(), "Database");
        assert_eq!(Classification::Docker.to_string(), "Docker");
        assert_eq!(Classification::BuildTool.to_string(), "Build Tool");
        assert_eq!(Classification::LanguageServer.to_string(), "Lang Server");
        assert_eq!(Classification::Proxy.to_string(), "Proxy");
        assert_eq!(Classification::Browser.to_string(), "Browser");
        assert_eq!(Classification::MessageQueue.to_string(), "Msg Queue");
        assert_eq!(Classification::SshTunnel.to_string(), "SSH Tunnel");
        assert_eq!(Classification::System.to_string(), "System");
        assert_eq!(Classification::Unknown.to_string(), "Unknown");
    }

    #[test]
    fn framework_display_all_variants() {
        assert_eq!(Framework::Vite.to_string(), "Vite");
        assert_eq!(Framework::Next.to_string(), "Next.js");
        assert_eq!(Framework::Remix.to_string(), "Remix");
        assert_eq!(Framework::Astro.to_string(), "Astro");
        assert_eq!(Framework::SvelteKit.to_string(), "SvelteKit");
        assert_eq!(Framework::Nuxt.to_string(), "Nuxt");
        assert_eq!(Framework::Gatsby.to_string(), "Gatsby");
        assert_eq!(Framework::Turbopack.to_string(), "Turbopack");
        assert_eq!(Framework::Webpack.to_string(), "webpack");
        assert_eq!(Framework::Expo.to_string(), "Expo");
        assert_eq!(Framework::Storybook.to_string(), "Storybook");
        assert_eq!(Framework::Nest.to_string(), "NestJS");
        assert_eq!(Framework::Express.to_string(), "Express");
        assert_eq!(Framework::Fastify.to_string(), "Fastify");
        assert_eq!(Framework::Rails.to_string(), "Rails");
        assert_eq!(Framework::Django.to_string(), "Django");
        assert_eq!(Framework::Flask.to_string(), "Flask");
        assert_eq!(Framework::FastAPI.to_string(), "FastAPI");
        assert_eq!(Framework::Spring.to_string(), "Spring");
        assert_eq!(Framework::Gin.to_string(), "Gin");
        assert_eq!(Framework::Phoenix.to_string(), "Phoenix");
        assert_eq!(Framework::Laravel.to_string(), "Laravel");
        assert_eq!(Framework::Hugo.to_string(), "Hugo");
        assert_eq!(Framework::Actix.to_string(), "Actix");
        assert_eq!(Framework::Axum.to_string(), "Axum");
        assert_eq!(Framework::Rocket.to_string(), "Rocket");
    }

    #[test]
    fn ownership_display() {
        assert_eq!(Ownership::Owned.to_string(), "Owned");
        assert_eq!(Ownership::Blocked.to_string(), "Blocked");
        assert_eq!(Ownership::Untracked.to_string(), "—");
    }

    #[test]
    fn protocol_equality() {
        assert_eq!(Protocol::Tcp, Protocol::Tcp);
        assert_ne!(Protocol::Tcp, Protocol::Udp);
    }

    #[test]
    fn classification_equality() {
        assert_eq!(Classification::Docker, Classification::Docker);
        assert_ne!(Classification::Docker, Classification::Database);
    }

    #[test]
    fn port_state_equality() {
        assert_eq!(PortState::Listen, PortState::Listen);
        assert_ne!(PortState::Listen, PortState::Established);
    }

    #[test]
    fn ownership_equality() {
        assert_eq!(Ownership::Owned, Ownership::Owned);
        assert_ne!(Ownership::Owned, Ownership::Blocked);
    }

    #[test]
    fn framework_equality() {
        assert_eq!(Framework::Next, Framework::Next);
        assert_ne!(Framework::Next, Framework::Vite);
    }

    #[test]
    fn raw_port_clone() {
        let raw = RawPort {
            port: 3000,
            protocol: Protocol::Tcp,
            pid: 1234,
            process_name: "node".to_string(),
            command_line: "node server.js".to_string(),
            state: PortState::Listen,
            local_addr: "127.0.0.1:3000".to_string(),
            parent_pid: Some(1),
            parent_command_line: None,
            cwd: None,
            uid: Some(1000),
            user: Some("alice".to_string()),
            remote_addr: None,
        };
        let cloned = raw.clone();
        assert_eq!(cloned.port, 3000);
        assert_eq!(cloned.pid, 1234);
        assert_eq!(cloned.process_name, "node");
        assert_eq!(cloned.uid, Some(1000));
        assert_eq!(cloned.user, Some("alice".to_string()));
        assert_eq!(cloned.remote_addr, None);
    }

    #[test]
    fn raw_port_new_fields_none_by_default() {
        let raw = RawPort {
            port: 8080,
            protocol: Protocol::Tcp,
            pid: 0,
            process_name: String::new(),
            command_line: String::new(),
            state: PortState::Listen,
            local_addr: "0.0.0.0:8080".to_string(),
            parent_pid: None,
            parent_command_line: None,
            cwd: None,
            uid: None,
            user: None,
            remote_addr: None,
        };
        assert!(raw.uid.is_none());
        assert!(raw.user.is_none());
        assert!(raw.remote_addr.is_none());
    }

    #[test]
    fn port_entry_serializes() {
        let entry = PortEntry {
            port: 8080,
            protocol: Protocol::Tcp,
            pid: 42,
            process_name: "node".to_string(),
            command_line: "node index.js".to_string(),
            state: PortState::Listen,
            classification: Classification::DevServer,
            project: None,
            local_addr: "0.0.0.0:8080".to_string(),
            all_addrs: vec!["0.0.0.0:8080".to_string()],
            ownership: Ownership::Untracked,
            uid: Some(1000),
            user: Some("developer".to_string()),
            remote_addr: Some("192.168.1.1:54321".to_string()),
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"port\":8080"));
        assert!(json.contains("\"DevServer\""));
        assert!(json.contains("\"uid\":1000"));
        assert!(json.contains("\"developer\""));
        assert!(json.contains("192.168.1.1:54321"));
    }

    #[test]
    fn port_entry_serializes_with_null_new_fields() {
        let entry = PortEntry {
            port: 22,
            protocol: Protocol::Tcp,
            pid: 0,
            process_name: "sshd".to_string(),
            command_line: String::new(),
            state: PortState::Listen,
            classification: Classification::System,
            project: None,
            local_addr: "0.0.0.0:22".to_string(),
            all_addrs: vec!["0.0.0.0:22".to_string()],
            ownership: Ownership::Blocked,
            uid: None,
            user: None,
            remote_addr: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"port\":22"));
        assert!(json.contains("\"uid\":null"));
        assert!(json.contains("\"user\":null"));
        assert!(json.contains("\"remote_addr\":null"));
    }

    #[test]
    fn project_with_framework_serializes() {
        let proj = Project {
            name: "myapp".to_string(),
            root: PathBuf::from("/tmp/myapp"),
            framework: Some(Framework::Next),
        };
        let json = serde_json::to_string(&proj).unwrap();
        assert!(json.contains("\"myapp\""));
        assert!(json.contains("\"Next\""));
    }
}