use crate::base::errors::DiscoveryError;
use serialport::{SerialPortInfo, SerialPortType};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VidPid(pub u16, pub u16);
fn select_composite_port(
ports: Vec<SerialPortInfo>,
target: VidPid,
port_index: usize,
) -> Result<SerialPortInfo, DiscoveryError> {
let mut matched: Vec<SerialPortInfo> = ports
.into_iter()
.filter(|p| match &p.port_type {
SerialPortType::UsbPort(info) => info.vid == target.0 && info.pid == target.1,
_ => false,
})
.collect();
matched.sort_by_key(|p| match &p.port_type {
SerialPortType::UsbPort(info) => (info.interface.is_none(), info.interface),
_ => (true, None),
});
if matched.is_empty() {
return Err(DiscoveryError::NotFound {
message: format!(
"no device found with VID {:04x}:PID {:04x}. Is the device plugged in?",
target.0, target.1
),
});
}
if port_index >= matched.len() {
return Err(DiscoveryError::IndexOutOfRange {
requested: port_index,
found: matched.len(),
vid: target.0,
pid: target.1,
});
}
Ok(matched.remove(port_index))
}
fn filter_by_vid_pid(
ports: Vec<SerialPortInfo>,
target: VidPid,
) -> Result<SerialPortInfo, DiscoveryError> {
let mut matched: Vec<SerialPortInfo> = ports
.into_iter()
.filter(|p| match &p.port_type {
SerialPortType::UsbPort(info) => info.vid == target.0 && info.pid == target.1,
_ => false,
})
.collect();
match matched.len() {
0 => Err(DiscoveryError::NotFound {
message: format!(
"no device found with VID {:04x}:PID {:04x}. Is the device plugged in?",
target.0, target.1
),
}),
1 => Ok(matched.remove(0)),
n => Err(DiscoveryError::Ambiguous {
count: n,
hint: "use discover_composite_port_by_index(vid_pid, index) to select \
a specific port on a composite device, or --port to override"
.to_string(),
}),
}
}
fn filter_by_name_pattern(ports: Vec<SerialPortInfo>, pattern: &str) -> Vec<SerialPortInfo> {
let pattern_lower = pattern.to_ascii_lowercase();
ports
.into_iter()
.filter(|p| match &p.port_type {
SerialPortType::UsbPort(info) => info
.product
.as_deref()
.map(|prod| prod.to_ascii_lowercase().contains(&pattern_lower))
.unwrap_or(false),
_ => false,
})
.collect()
}
pub fn discover_by_vid_pid(target: VidPid) -> Result<SerialPortInfo, DiscoveryError> {
let ports = serialport::available_ports()?;
filter_by_vid_pid(ports, target)
}
pub fn discover_by_name_pattern(pattern: &str) -> Result<Vec<SerialPortInfo>, DiscoveryError> {
let ports = serialport::available_ports()?;
Ok(filter_by_name_pattern(ports, pattern))
}
pub fn discover_composite_port_by_index(
target: VidPid,
port_index: usize,
) -> Result<SerialPortInfo, DiscoveryError> {
let ports = serialport::available_ports()?;
select_composite_port(ports, target, port_index)
}
#[cfg(test)]
mod tests {
use super::*;
use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo};
fn make_usb_port(name: &str, vid: u16, pid: u16, interface: Option<u8>) -> SerialPortInfo {
SerialPortInfo {
port_name: name.to_string(),
port_type: SerialPortType::UsbPort(UsbPortInfo {
vid,
pid,
serial_number: None,
manufacturer: None,
product: None,
interface,
}),
}
}
fn make_usb_port_named(
name: &str,
vid: u16,
pid: u16,
product: &str,
interface: Option<u8>,
) -> SerialPortInfo {
SerialPortInfo {
port_name: name.to_string(),
port_type: SerialPortType::UsbPort(UsbPortInfo {
vid,
pid,
serial_number: None,
manufacturer: None,
product: Some(product.to_string()),
interface,
}),
}
}
fn make_non_usb_port(name: &str) -> SerialPortInfo {
SerialPortInfo {
port_name: name.to_string(),
port_type: SerialPortType::BluetoothPort,
}
}
#[test]
fn select_composite_port_picks_correct_index() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![
make_usb_port("COM5", 0x1d50, 0xacab, Some(0)), make_usb_port("COM6", 0x1d50, 0xacab, Some(2)), make_usb_port("COM7", 0x1d50, 0xacab, Some(4)), make_usb_port("COM8", 0x1d50, 0xacab, Some(6)), ];
let result = select_composite_port(ports, target, 2).unwrap();
assert_eq!(result.port_name, "COM7");
}
#[test]
fn select_composite_port_filters_by_vid_pid() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![
make_usb_port("COM1", 0xdead, 0xbeef, Some(0)), make_usb_port("COM2", 0x1d50, 0xacab, Some(0)), make_usb_port("COM3", 0x1d50, 0xacab, Some(2)), make_usb_port("COM4", 0x0403, 0x6001, Some(0)), ];
let result = select_composite_port(ports, target, 1).unwrap();
assert_eq!(result.port_name, "COM3");
}
#[test]
fn select_composite_port_not_found_empty_match() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![
make_usb_port("COM1", 0xdead, 0xbeef, Some(0)),
make_usb_port("COM2", 0x0403, 0x6001, Some(0)),
];
let err = select_composite_port(ports, target, 0).unwrap_err();
assert!(
matches!(err, DiscoveryError::NotFound { .. }),
"no-match should be NotFound, got: {err:?}"
);
let msg = err.to_string();
assert!(msg.contains("1d50"), "message should mention VID: {msg}");
assert!(msg.contains("acab"), "message should mention PID: {msg}");
}
#[test]
fn select_composite_port_index_out_of_range() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![
make_usb_port("COM2", 0x1d50, 0xacab, Some(0)),
make_usb_port("COM3", 0x1d50, 0xacab, Some(2)),
];
let err = select_composite_port(ports, target, 5).unwrap_err();
assert!(
matches!(
err,
DiscoveryError::IndexOutOfRange {
requested: 5,
found: 2,
..
}
),
"index-out-of-range should be IndexOutOfRange{{requested:5, found:2}}, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("port_index 5"),
"message should mention requested index: {msg}"
);
assert!(
msg.contains("found 2"),
"message should mention found count: {msg}"
);
assert!(msg.contains("1d50"), "message should mention VID: {msg}");
assert!(msg.contains("acab"), "message should mention PID: {msg}");
}
#[test]
fn select_composite_port_empty_match_is_not_index_out_of_range() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![];
let err = select_composite_port(ports, target, 0).unwrap_err();
assert!(
!matches!(err, DiscoveryError::IndexOutOfRange { .. }),
"empty match must be NotFound, not IndexOutOfRange"
);
}
#[test]
fn select_composite_port_handles_missing_interface_field() {
let target = VidPid(0x1d50, 0xacab);
let ports = vec![
make_usb_port("GHOST", 0x1d50, 0xacab, None), make_usb_port("COM5", 0x1d50, 0xacab, Some(0)), make_usb_port("COM7", 0x1d50, 0xacab, Some(4)), ];
let idx0 = select_composite_port(ports.clone(), target, 0).unwrap();
assert_eq!(idx0.port_name, "COM5");
let idx1 = select_composite_port(ports.clone(), target, 1).unwrap();
assert_eq!(idx1.port_name, "COM7");
let idx2 = select_composite_port(ports, target, 2).unwrap();
assert_eq!(idx2.port_name, "GHOST");
}
#[test]
fn filter_by_vid_pid_no_match_returns_not_found() {
let ports = vec![
make_usb_port("COM1", 0xdead, 0xbeef, Some(0)),
make_non_usb_port("COM2"),
];
let err = filter_by_vid_pid(ports, VidPid(0x1d50, 0xacab)).unwrap_err();
assert!(matches!(err, DiscoveryError::NotFound { .. }));
}
#[test]
fn filter_by_vid_pid_single_match_returns_ok() {
let ports = vec![
make_usb_port("COM1", 0xdead, 0xbeef, Some(0)),
make_usb_port("COM2", 0x1d50, 0xacab, Some(0)),
];
let result = filter_by_vid_pid(ports, VidPid(0x1d50, 0xacab)).unwrap();
assert_eq!(result.port_name, "COM2");
}
#[test]
fn filter_by_vid_pid_multiple_matches_returns_ambiguous() {
let ports = vec![
make_usb_port("COM2", 0x1d50, 0xacab, Some(0)),
make_usb_port("COM3", 0x1d50, 0xacab, Some(2)),
];
let err = filter_by_vid_pid(ports, VidPid(0x1d50, 0xacab)).unwrap_err();
assert!(matches!(err, DiscoveryError::Ambiguous { count: 2, .. }));
}
#[test]
fn name_pattern_case_insensitive_match() {
let ports = vec![
make_usb_port_named("COM1", 0x1d50, 0xacab, "JLV5PORT", Some(0)),
make_usb_port_named("COM2", 0x1d50, 0xacab, "JLV5PORT", Some(2)),
make_usb_port("COM3", 0x0403, 0x6001, None), ];
let matched = filter_by_name_pattern(ports, "jlv5port");
assert_eq!(matched.len(), 2);
assert_eq!(matched[0].port_name, "COM1");
assert_eq!(matched[1].port_name, "COM2");
}
#[test]
fn name_pattern_skips_non_usb_ports() {
let ports = vec![make_non_usb_port("COM1")];
let matched = filter_by_name_pattern(ports, "jlv5port");
assert!(matched.is_empty());
}
}