use crate::config::PortForgeConfig;
use crate::error::Result;
use crate::models::Protocol;
use std::collections::HashMap;
use std::net::TcpListener;
pub fn find_free_port(start_port: u16) -> Option<u16> {
for port in start_port..=65535 {
if is_port_free(port) {
return Some(port);
}
}
None
}
pub fn is_port_free(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_ok()
}
pub fn find_free_ports(start_port: u16, count: usize) -> Vec<u16> {
let mut free_ports = Vec::with_capacity(count);
let mut current_port = start_port;
while free_ports.len() < count {
if is_port_free(current_port) {
free_ports.push(current_port);
}
if current_port == 65535 {
break;
}
current_port += 1;
}
free_ports
}
pub async fn detect_conflicts(config: &PortForgeConfig) -> Result<Vec<PortConflict>> {
let _ = config;
let mut conflicts = Vec::new();
let listeners = listeners::get_all().map_err(|e| crate::error::PortForgeError::ScanError(e.to_string()))?;
let mut port_map: HashMap<(u16, Protocol), HashMap<u32, ProcessInfo>> = HashMap::new();
for listener in listeners {
let protocol = match listener.protocol {
listeners::Protocol::TCP => Protocol::Tcp,
listeners::Protocol::UDP => Protocol::Udp,
};
port_map
.entry((listener.socket.port(), protocol))
.or_default()
.entry(listener.process.pid)
.or_insert_with(|| ProcessInfo {
pid: listener.process.pid,
name: listener.process.name.clone(),
command: String::new(),
});
}
for ((port, protocol), processes) in port_map {
let processes: Vec<ProcessInfo> = processes.into_values().collect();
if processes.len() > 1 {
conflicts.push(PortConflict {
port,
protocol,
processes: processes.clone(),
suggestion: generate_conflict_suggestion(port, protocol, &processes),
});
}
}
Ok(conflicts)
}
#[derive(Debug, Clone)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
pub command: String,
}
#[derive(Debug, Clone)]
pub struct PortConflict {
pub port: u16,
pub protocol: Protocol,
pub processes: Vec<ProcessInfo>,
pub suggestion: String,
}
fn generate_conflict_suggestion(port: u16, protocol: Protocol, processes: &[ProcessInfo]) -> String {
if processes.is_empty() {
return "No processes found.".to_string();
}
if processes.len() == 1 {
return format!(
"Port {}/{} is used by PID {} ({})",
port,
protocol,
processes[0].pid,
processes[0].name
);
}
let mut suggestion = format!(
"Port {}/{} has {} conflicting processes:\n",
port,
protocol,
processes.len()
);
for (i, proc) in processes.iter().enumerate() {
if proc.command.is_empty() {
suggestion.push_str(&format!(" {}. PID {} - {}\n", i + 1, proc.pid, proc.name));
} else {
suggestion.push_str(&format!(
" {}. PID {} - {} ({})\n",
i + 1,
proc.pid,
proc.name,
proc.command
));
}
}
suggestion.push_str(&format!("\nSuggestion: Keep one process and kill the rest.\n"));
suggestion.push_str(&format!(" Run: portforge kill {} (to kill the first one)\n", port));
if let Some(free_port) = find_free_port(port + 1) {
suggestion.push_str(&format!(" Or use free port {} instead.", free_port));
}
suggestion
}
pub async fn check_port_conflict(port: u16, config: &PortForgeConfig) -> Result<Option<PortConflict>> {
let conflicts = detect_conflicts(config).await?;
Ok(conflicts.into_iter().find(|c| c.port == port))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{HealthResult, HealthStatus, Status};
use crate::models::PortEntry;
use std::path::PathBuf;
#[test]
fn test_find_free_port() {
let port = find_free_port(3000);
assert!(port.is_some());
let port = port.unwrap();
assert!(port >= 3000);
}
#[test]
fn test_find_free_ports() {
let ports = find_free_ports(8000, 5);
assert_eq!(ports.len(), 5);
let mut unique = ports.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), ports.len());
}
#[test]
fn test_is_port_free() {
assert!(is_port_free(0));
}
#[test]
fn test_generate_conflict_suggestion() {
let processes = vec![
ProcessInfo {
pid: 1234,
name: "node".to_string(),
command: "node server.js".to_string(),
},
ProcessInfo {
pid: 5678,
name: "python".to_string(),
command: "python app.py".to_string(),
},
];
let suggestion = generate_conflict_suggestion(3000, Protocol::Tcp, &processes);
assert!(suggestion.contains("2 conflicting processes"));
assert!(suggestion.contains("1234"));
assert!(suggestion.contains("5678"));
}
#[test]
fn test_check_port_conflict_matches_protocol_specific_entry() {
let conflict = PortConflict {
port: 3000,
protocol: Protocol::Tcp,
processes: vec![ProcessInfo {
pid: 1234,
name: "node".to_string(),
command: String::new(),
}],
suggestion: String::new(),
};
assert_eq!(conflict.protocol, Protocol::Tcp);
}
#[test]
fn test_port_entry_type_still_available_for_free_port_tests() {
let entry = PortEntry {
port: 3000,
protocol: Protocol::Tcp,
pid: 1,
process_name: "node".to_string(),
command: "node server.js".to_string(),
cwd: Some(PathBuf::from("/app")),
memory_mb: 10.0,
cpu_percent: 1.0,
uptime_secs: 1,
project: None,
docker: None,
git: None,
tunnel: None,
status: Status::Healthy,
health_check: Some(HealthResult {
status: HealthStatus::Healthy,
status_code: Some(200),
latency_ms: 1,
endpoint: "/health".to_string(),
}),
};
assert_eq!(entry.port, 3000);
}
}