From 5702c8a46adc2ae2c96425441a737f025ba366ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:10:48 +0000 Subject: [PATCH 1/2] Extract typed transport model Co-authored-by: phieri <12006381+phieri@users.noreply.github.com> --- src/main.rs | 1 + src/pipewire.rs | 9 +- src/transport.rs | 256 ++++------------------------------------ src/transport_model.rs | 261 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 240 deletions(-) create mode 100644 src/transport_model.rs diff --git a/src/main.rs b/src/main.rs index b4c623b..178e841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod session; mod terminal_style; mod transmit; mod transport; +mod transport_model; mod worker; mod tui; diff --git a/src/pipewire.rs b/src/pipewire.rs index d574b46..90a11d5 100644 --- a/src/pipewire.rs +++ b/src/pipewire.rs @@ -7,10 +7,9 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; use crate::bluez::Address; -use crate::transport::{Device, Transport, profile_matches}; +use crate::transport_model::{Device, ProfileFamily, Transport}; pub(crate) const OWNER_PROPERTY: &str = "bt-intercom.owner"; -pub(crate) const HEADSET_PROFILE: &str = "headset-head-unit"; pub(crate) const MONO_CHANNEL: &str = "MONO"; /// A directed link, represented as `(output_port_id, input_port_id)`. pub(crate) type Link = (u64, u64); @@ -218,7 +217,7 @@ impl Snapshot { && node .profile .as_deref() - .is_some_and(|p| profile_matches(p, HEADSET_PROFILE)) + .is_some_and(|p| ProfileFamily::Hfp.matches(p)) }) } @@ -246,10 +245,10 @@ impl Snapshot { let Some(profile) = node.profile.as_deref() else { continue; }; - if !profile_matches(profile, HEADSET_PROFILE) + if !ProfileFamily::Hfp.matches(profile) && !(transport == Transport::ScoA2dp && node.class.as_deref() == Some("Audio/Sink") - && profile_matches(profile, "a2dp-sink")) + && ProfileFamily::A2dp.matches(profile)) { continue; } diff --git a/src/transport.rs b/src/transport.rs index be182f5..c93ddc5 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -8,18 +8,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::time::{Duration, Instant}; -use serde_json::Value; - use crate::bluez::Address; -use crate::pipewire::{HEADSET_PROFILE, Snapshot}; +use crate::pipewire::Snapshot; use crate::transmit::{Mode, Transmit}; +use crate::transport_model::ProfileFamily; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] -pub enum Transport { - #[default] - Hfp, - ScoA2dp, -} +pub use crate::transport_model::{Device, Transport}; pub(crate) fn validate_controller_count( mode: Mode, @@ -80,68 +74,6 @@ pub(crate) fn refresh_transmit_requests( before != *transmit.sources() } -#[derive(Clone, Debug)] -pub struct Device { - pub controller: String, - pub hfp_available: bool, - pub a2dp_available: bool, - pub(crate) id: u64, - pub(crate) serial: String, - pub(crate) path: String, - pub(crate) profiles: BTreeMap, - a2dp_sbc: Option, - a2dp_codec_info: bool, - pub(crate) current: Option, -} - -pub(crate) fn profile_matches(name: &str, family: &str) -> bool { - name == family - || name - .strip_prefix(family) - .is_some_and(|suffix| suffix.starts_with('-')) -} - -impl Device { - pub(crate) fn index(&self, family: &str) -> Option { - if family == "a2dp-sink" { - return self.a2dp_sbc.or_else(|| { - (!self.a2dp_codec_info) - .then(|| self.generic_index(family)) - .flatten() - }); - } - self.generic_index(family) - } - - fn generic_index(&self, family: &str) -> Option { - self.current - .filter(|index| { - self.profiles - .get(index) - .is_some_and(|name| profile_matches(name, family)) - }) - .or_else(|| { - self.profiles - .iter() - .find(|(_, name)| profile_matches(name, family)) - .map(|(index, _)| *index) - }) - } - - pub(crate) fn is_profile(&self, family: &str) -> bool { - if family == "a2dp-sink" && self.a2dp_codec_info { - return self.a2dp_sbc.is_some() && self.a2dp_sbc == self.current; - } - self.current - .and_then(|index| self.profiles.get(&index)) - .is_some_and(|name| profile_matches(name, family)) - } - - pub(crate) fn same_identity(&self, other: &Self) -> bool { - self.id == other.id && self.serial == other.serial && self.path == other.path - } -} - fn addresses_by_controller<'a>( devices: impl IntoIterator, ) -> BTreeMap<&'a str, Vec<&'a Address>> { @@ -155,13 +87,6 @@ fn addresses_by_controller<'a>( controllers } -fn identifier(value: &Value) -> Option { - value - .as_str() - .map(str::to_owned) - .or_else(|| value.as_u64().map(|n| n.to_string())) -} - struct PendingProfile { device: Device, index: u64, @@ -453,8 +378,8 @@ impl Coordinator { let mut demoting = false; for (address, device) in devices { if !sources.contains(address) { - if !device.is_profile("a2dp-sink") { - self.change_profile(address, device, "a2dp-sink", sources, io)?; + if !device.is_profile(ProfileFamily::A2dp) { + self.change_profile(address, device, ProfileFamily::A2dp, sources, io)?; demoting = true; } else if snapshot.has_hfp_node(device.id) { demoting = true; @@ -476,8 +401,8 @@ impl Coordinator { { for address in sources { let device = &devices[address]; - if !device.is_profile(HEADSET_PROFILE) { - self.change_profile(address, device, HEADSET_PROFILE, sources, io)?; + if !device.is_profile(ProfileFamily::Hfp) { + self.change_profile(address, device, ProfileFamily::Hfp, sources, io)?; } } Ok(()) @@ -492,9 +417,9 @@ impl Coordinator { devices.iter().all(|(address, device)| { headsets.get(address).is_some_and(|headset| { if sources.contains(address) { - device.is_profile(HEADSET_PROFILE) && headset.has_duplex_audio() + device.is_profile(ProfileFamily::Hfp) && headset.has_duplex_audio() } else { - device.is_profile("a2dp-sink") && !headset.sinks.is_empty() + device.is_profile(ProfileFamily::A2dp) && !headset.sinks.is_empty() } }) }) @@ -504,7 +429,7 @@ impl Coordinator { &mut self, address: &Address, expected: &Device, - family: &str, + family: ProfileFamily, sources: &BTreeSet
, io: &mut TransitionIo<'_, F, C>, ) -> Result<(), String> @@ -540,21 +465,21 @@ impl Coordinator { let index = current .index(family) .ok_or_else(|| { - if family == "a2dp-sink" { + if family == ProfileFamily::A2dp { format!( "{address}: no advertised A2DP SBC profile; enable standard SBC in WirePlumber's Bluetooth codec settings" ) } else { - format!("{address}: {family} profile is no longer available") + format!("{address}: {} profile is no longer available", family.name()) } })?; if current.is_profile(family) { return Ok(()); } - if family == HEADSET_PROFILE + if family == ProfileFamily::Hfp && devices.iter().any(|(address, device)| { !sources.contains(address) - && (!device.is_profile("a2dp-sink") || snapshot.has_hfp_node(device.id)) + && (!device.is_profile(ProfileFamily::A2dp) || snapshot.has_hfp_node(device.id)) }) { return Ok(()); } @@ -564,7 +489,8 @@ impl Coordinator { { if pending.requested_at.elapsed() >= Duration::from_secs(15) { return Err(format!( - "{address}: timed out waiting for {family}; inspect pw-dump and Bluetooth connection" + "{address}: timed out waiting for {}; inspect pw-dump and Bluetooth connection", + family.name() )); } return Ok(()); @@ -577,7 +503,7 @@ impl Coordinator { "Profile", &format!("{{ index: {index}, save: false }}"), ]) - .map_err(|error| format!("{address}: could not select {family}: {error}"))?; + .map_err(|error| format!("{address}: could not select {}: {error}", family.name()))?; self.transition .record_profile(address.clone(), current.clone(), index); Ok(()) @@ -611,15 +537,16 @@ fn validate_source_capacity( fn validate_mixed_devices(devices: &BTreeMap) -> Result<(), String> { for (address, device) in devices { - for family in [HEADSET_PROFILE, "a2dp-sink"] { + for family in [ProfileFamily::Hfp, ProfileFamily::A2dp] { if device.index(family).is_none() { - return Err(if family == "a2dp-sink" { + return Err(if family == ProfileFamily::A2dp { format!( "{address}: no available advertised A2DP SBC profile; SCO/A2DP requires standard SBC on every connected headset; enable it in WirePlumber's Bluetooth codec settings" ) } else { format!( - "{address}: no available advertised {family} profile; SCO/A2DP requires both HFP and A2DP on every connected headset; check headset support and PipeWire Bluetooth configuration" + "{address}: no available advertised {} profile; SCO/A2DP requires both HFP and A2DP on every connected headset; check headset support and PipeWire Bluetooth configuration", + family.name() ) }); } @@ -628,83 +555,6 @@ fn validate_mixed_devices(devices: &BTreeMap) -> Result<(), Str Ok(()) } -impl Device { - pub(crate) fn parse(id: u64, address: &Address, info: &Value) -> Option { - let props = &info["props"]; - let path = props["api.bluez5.path"].as_str()?; - let (controller, device_path) = path.rsplit_once('/')?; - let hci = controller.strip_prefix("/org/bluez/hci")?; - if hci.is_empty() - || !hci.bytes().all(|byte| byte.is_ascii_digit()) - || device_path != format!("dev_{}", address.as_str().replace(':', "_")) - { - return None; - } - let serial = identifier(&props["object.serial"])?; - let params = &info["params"]; - let profile_entries = params["EnumProfile"] - .as_array() - .into_iter() - .flatten() - .filter(|profile| { - !matches!(profile["available"].as_str(), Some("no" | "unavailable")) - && profile["available"].as_u64() != Some(1) - && profile["available"].as_bool() != Some(false) - }) - .collect::>(); - let profiles: BTreeMap<_, _> = profile_entries - .iter() - .filter_map(|profile| { - Some(( - profile["index"].as_u64()?, - profile["name"].as_str()?.to_owned(), - )) - }) - .collect(); - let a2dp_sbc = profile_entries.iter().find_map(|profile| { - let name = profile["name"].as_str()?; - let is_sbc = name == "a2dp-sink-sbc" - || (name == "a2dp-sink" - && profile["description"].as_str().is_some_and(|description| { - description.to_ascii_lowercase().contains("codec sbc)") - })); - is_sbc.then(|| profile["index"].as_u64()).flatten() - }); - let a2dp_codec_info = profile_entries.iter().any(|profile| { - let Some(name) = profile["name"].as_str() else { - return false; - }; - profile_matches(name, "a2dp-sink") - && (name != "a2dp-sink" - || profile["description"].as_str().is_some_and(|description| { - description.to_ascii_lowercase().contains("codec ") - })) - }); - let hfp_available = profiles - .values() - .any(|name| profile_matches(name, "headset-head-unit")); - let a2dp_available = profiles - .values() - .any(|name| profile_matches(name, "a2dp-sink")); - let current = params["Profile"] - .as_array() - .and_then(|profiles| profiles.first()) - .and_then(|profile| profile["index"].as_u64()); - Some(Self { - controller: controller.into(), - hfp_available, - a2dp_available, - id, - serial, - path: path.into(), - profiles, - a2dp_sbc, - a2dp_codec_info, - current, - }) - } -} - #[cfg(test)] mod tests { use super::*; @@ -716,24 +566,6 @@ mod tests { Address::parse(value).unwrap() } - fn device(profiles: Value, current: u64) -> Device { - Device::parse( - 10, - &address(ADDRESS), - &json!({ - "props": { - "api.bluez5.path": format!("/org/bluez/hci0/dev_{}", ADDRESS.replace(':', "_")), - "object.serial": 1000 - }, - "params": { - "EnumProfile": profiles, - "Profile": [{"index": current}] - } - }), - ) - .unwrap() - } - fn device_on_controller(id: u64, address: &Address, controller: &str) -> Device { Device::parse( id, @@ -781,50 +613,4 @@ mod tests { assert!(validate_source_capacity(&three, &devices, 2).is_err()); } - #[test] - fn a2dp_prefers_standard_sbc_over_other_codecs() { - let device = device( - json!([ - {"index": 20, "name": "a2dp-sink-aac", "description": "codec AAC", "available": "yes"}, - {"index": 21, "name": "a2dp-sink-sbc_xq", "description": "codec SBC XQ", "available": "yes"}, - {"index": 22, "name": "a2dp-sink-sbc", "description": "codec SBC", "available": "yes"} - ]), - 20, - ); - - assert_eq!(device.index("a2dp-sink"), Some(22)); - assert!(!device.is_profile("a2dp-sink")); - } - - #[test] - fn a2dp_recognizes_sbc_on_pipewire_base_profile() { - let device = device( - json!([{ - "index": 41, - "name": "a2dp-sink", - "description": "High Fidelity Playback (A2DP Sink, codec SBC)", - "available": "yes" - }]), - 41, - ); - - assert_eq!(device.index("a2dp-sink"), Some(41)); - assert!(device.is_profile("a2dp-sink")); - } - - #[test] - fn a2dp_does_not_choose_a_known_non_sbc_codec() { - let device = device( - json!([{ - "index": 40, - "name": "a2dp-sink", - "description": "High Fidelity Playback (A2DP Sink, codec AAC)", - "available": "yes" - }]), - 40, - ); - - assert_eq!(device.index("a2dp-sink"), None); - assert!(!device.is_profile("a2dp-sink")); - } } diff --git a/src/transport_model.rs b/src/transport_model.rs new file mode 100644 index 0000000..6679e02 --- /dev/null +++ b/src/transport_model.rs @@ -0,0 +1,261 @@ +// Copyright (C) 2026 Philip Eriksson. All rights reserved. + +//! Typed transport and PipeWire profile/device models. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::bluez::Address; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ProfileFamily { + Hfp, + A2dp, +} + +impl ProfileFamily { + pub(crate) fn name(self) -> &'static str { + match self { + Self::Hfp => "headset-head-unit", + Self::A2dp => "a2dp-sink", + } + } + + pub(crate) fn matches(self, profile: &str) -> bool { + let family = self.name(); + profile == family + || profile + .strip_prefix(family) + .is_some_and(|suffix| suffix.starts_with('-')) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + #[default] + Hfp, + ScoA2dp, +} + +#[derive(Clone, Debug)] +pub struct Device { + pub controller: String, + pub hfp_available: bool, + pub a2dp_available: bool, + pub(crate) id: u64, + pub(crate) serial: String, + pub(crate) path: String, + profiles: BTreeMap, + a2dp_sbc: Option, + a2dp_codec_info: bool, + pub(crate) current: Option, +} + +impl Device { + pub(crate) fn parse(id: u64, address: &Address, info: &Value) -> Option { + let props = &info["props"]; + let path = props["api.bluez5.path"].as_str()?; + let (controller, device_path) = path.rsplit_once('/')?; + let hci = controller.strip_prefix("/org/bluez/hci")?; + if hci.is_empty() + || !hci.bytes().all(|byte| byte.is_ascii_digit()) + || device_path != format!("dev_{}", address.as_str().replace(':', "_")) + { + return None; + } + let serial = identifier(&props["object.serial"])?; + let params = &info["params"]; + let profile_entries = params["EnumProfile"] + .as_array() + .into_iter() + .flatten() + .filter(|profile| { + !matches!(profile["available"].as_str(), Some("no" | "unavailable")) + && profile["available"].as_u64() != Some(1) + && profile["available"].as_bool() != Some(false) + }) + .collect::>(); + let profiles: BTreeMap<_, _> = profile_entries + .iter() + .filter_map(|profile| { + Some(( + profile["index"].as_u64()?, + profile["name"].as_str()?.to_owned(), + )) + }) + .collect(); + let a2dp_sbc = profile_entries.iter().find_map(|profile| { + let name = profile["name"].as_str()?; + let is_sbc = name == "a2dp-sink-sbc" + || (name == "a2dp-sink" + && profile["description"].as_str().is_some_and(|description| { + description.to_ascii_lowercase().contains("codec sbc)") + })); + is_sbc.then(|| profile["index"].as_u64()).flatten() + }); + let a2dp_codec_info = profile_entries.iter().any(|profile| { + let Some(name) = profile["name"].as_str() else { + return false; + }; + ProfileFamily::A2dp.matches(name) + && (name != "a2dp-sink" + || profile["description"].as_str().is_some_and(|description| { + description.to_ascii_lowercase().contains("codec ") + })) + }); + let hfp_available = profiles + .values() + .any(|name| ProfileFamily::Hfp.matches(name)); + let a2dp_available = profiles + .values() + .any(|name| ProfileFamily::A2dp.matches(name)); + let current = params["Profile"] + .as_array() + .and_then(|profiles| profiles.first()) + .and_then(|profile| profile["index"].as_u64()); + Some(Self { + controller: controller.into(), + hfp_available, + a2dp_available, + id, + serial, + path: path.into(), + profiles, + a2dp_sbc, + a2dp_codec_info, + current, + }) + } + + pub(crate) fn index(&self, family: ProfileFamily) -> Option { + if family == ProfileFamily::A2dp { + return self.a2dp_sbc.or_else(|| { + (!self.a2dp_codec_info) + .then(|| self.generic_index(family)) + .flatten() + }); + } + self.generic_index(family) + } + + fn generic_index(&self, family: ProfileFamily) -> Option { + self.current + .filter(|index| { + self.profiles + .get(index) + .is_some_and(|name| family.matches(name)) + }) + .or_else(|| { + self.profiles + .iter() + .find(|(_, name)| family.matches(name)) + .map(|(index, _)| *index) + }) + } + + pub(crate) fn is_profile(&self, family: ProfileFamily) -> bool { + if family == ProfileFamily::A2dp && self.a2dp_codec_info { + return self.a2dp_sbc.is_some() && self.a2dp_sbc == self.current; + } + self.current + .and_then(|index| self.profiles.get(&index)) + .is_some_and(|name| family.matches(name)) + } + + pub(crate) fn same_identity(&self, other: &Self) -> bool { + self.id == other.id && self.serial == other.serial && self.path == other.path + } +} + +fn identifier(value: &Value) -> Option { + value + .as_str() + .map(str::to_owned) + .or_else(|| value.as_u64().map(|n| n.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const ADDRESS: &str = "AA:BB:CC:DD:EE:01"; + + fn address() -> Address { + Address::parse(ADDRESS).unwrap() + } + + fn device(profiles: Value, current: u64) -> Device { + Device::parse( + 10, + &address(), + &json!({ + "props": { + "api.bluez5.path": format!("/org/bluez/hci0/dev_{}", ADDRESS.replace(':', "_")), + "object.serial": 1000 + }, + "params": { + "EnumProfile": profiles, + "Profile": [{"index": current}] + } + }), + ) + .unwrap() + } + + #[test] + fn profile_families_match_variants_without_matching_neighbor_names() { + assert!(ProfileFamily::Hfp.matches("headset-head-unit-msbc")); + assert!(ProfileFamily::A2dp.matches("a2dp-sink-sbc_xq")); + assert!(!ProfileFamily::Hfp.matches("headset-head-unitary")); + assert!(!ProfileFamily::A2dp.matches("a2dp-sinkish")); + } + + #[test] + fn a2dp_prefers_standard_sbc_over_other_codecs() { + let device = device( + json!([ + {"index": 20, "name": "a2dp-sink-aac", "description": "codec AAC", "available": "yes"}, + {"index": 21, "name": "a2dp-sink-sbc_xq", "description": "codec SBC XQ", "available": "yes"}, + {"index": 22, "name": "a2dp-sink-sbc", "description": "codec SBC", "available": "yes"} + ]), + 20, + ); + + assert_eq!(device.index(ProfileFamily::A2dp), Some(22)); + assert!(!device.is_profile(ProfileFamily::A2dp)); + } + + #[test] + fn a2dp_recognizes_sbc_on_pipewire_base_profile() { + let device = device( + json!([{ + "index": 41, + "name": "a2dp-sink", + "description": "High Fidelity Playback (A2DP Sink, codec SBC)", + "available": "yes" + }]), + 41, + ); + + assert_eq!(device.index(ProfileFamily::A2dp), Some(41)); + assert!(device.is_profile(ProfileFamily::A2dp)); + } + + #[test] + fn a2dp_does_not_choose_a_known_non_sbc_codec() { + let device = device( + json!([{ + "index": 40, + "name": "a2dp-sink", + "description": "High Fidelity Playback (A2DP Sink, codec AAC)", + "available": "yes" + }]), + 40, + ); + + assert_eq!(device.index(ProfileFamily::A2dp), None); + assert!(!device.is_profile(ProfileFamily::A2dp)); + } +} From 256507c035457cdfa7462916ad678bc2f97acb50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:15:10 +0000 Subject: [PATCH 2/2] Relocate tests to implementation modules Co-authored-by: phieri <12006381+phieri@users.noreply.github.com> --- src/cli.rs | 142 ++++++++++++++++++++- src/main.rs | 286 +----------------------------------------- src/network.rs | 137 ++++++++++++++++++++ src/runtime_config.rs | 5 + src/transport.rs | 3 - 5 files changed, 284 insertions(+), 289 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0816286..e42328c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -150,7 +150,13 @@ fn parse_ptt_binding(value: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use clap::FromArgMatches; + use crate::bluez::Address; + use crate::transmit::Mode; + use clap::{FromArgMatches, Parser}; + + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } #[test] fn validates_ptt_bindings() { @@ -182,4 +188,138 @@ mod tests { .unwrap(); assert!(Cli::from_arg_matches(&matches).is_ok()); } + + #[test] + fn clap_cli_validates_arguments_and_accepts_completion_shells() { + for args in [ + vec!["bt-intercom", "run", "invalid"], + vec!["bt-intercom", "run", "--interval", "NaN"], + vec!["bt-intercom", "run", "--interval", "0.00000000001"], + vec!["bt-intercom", "run", "--sco-limit", "0"], + vec!["bt-intercom", "scan", "--seconds", "301"], + vec!["bt-intercom", "status"], + vec!["bt-intercom", "run", "--unknown"], + vec!["bt-intercom", "run", "--ptt"], + vec!["bt-intercom", "run", "--ptt", "AA:BB:CC:DD:EE:01="], + vec!["bt-intercom", "remove"], + vec!["bt-intercom", "remove", "invalid"], + ] { + assert_eq!(Cli::try_parse_from(args).err().unwrap().exit_code(), 2); + } + + for shell in ["bash", "zsh", "fish", "elvish"] { + assert!(Cli::try_parse_from(["bt-intercom", "completions", shell]).is_ok()); + } + } + + #[test] + fn terminal_control_panel_uses_panel_subcommand() { + assert!(matches!( + Cli::try_parse_from(["bt-intercom", "panel"]) + .unwrap() + .command, + CliCommand::Panel + )); + assert!(Cli::try_parse_from(["bt-intercom", "tui"]).is_err()); + } + + #[test] + fn verbose_is_global_and_version_short_alias_is_preserved() { + for args in [ + ["bt-intercom", "--verbose", "run"], + ["bt-intercom", "run", "--verbose"], + ] { + assert!(Cli::try_parse_from(args).unwrap().verbose); + } + assert!(!Cli::try_parse_from(["bt-intercom", "run"]).unwrap().verbose); + assert_eq!( + cli_command() + .try_get_matches_from(["bt-intercom", "-v"]) + .unwrap_err() + .kind(), + clap::error::ErrorKind::DisplayVersion + ); + } + + #[test] + fn version_flags_print_the_long_version_with_build_datetime() { + let command = cli_command(); + let long_version = command.get_long_version().unwrap().to_string(); + assert_eq!(command.get_version(), Some(long_version.as_str())); + + let short = command + .clone() + .try_get_matches_from(["bt-intercom", "-v"]) + .err() + .unwrap() + .to_string(); + let long = command + .clone() + .try_get_matches_from(["bt-intercom", "--version"]) + .err() + .unwrap() + .to_string(); + let legacy_short = command + .try_get_matches_from(["bt-intercom", "-V"]) + .err() + .unwrap() + .to_string(); + + assert_eq!(short, long); + assert_eq!(short, legacy_short); + assert!(short.contains(env!("CARGO_PKG_VERSION"))); + assert!(short.contains(env!("BT_INTERCOM_BUILD_DATETIME"))); + } + + #[test] + fn top_level_help_mentions_dashboard_and_control_panel() { + let help = cli_command().render_help().to_string(); + assert!(help.contains("dashboard")); + assert!(help.contains("control panel")); + } + + #[test] + fn parses_ptt_without_changing_default_mode() { + let raw_address = "AA:BB:CC:DD:EE:01"; + let default = Cli::try_parse_from(["bt-intercom", "run", raw_address]).unwrap(); + let CliCommand::Run { + ptt, + mode, + dashboard, + .. + } = default.command + else { + panic!("expected run command"); + }; + assert!(ptt.is_empty()); + assert_eq!(mode, Mode::FullDuplex); + assert!(!dashboard); + + let binding = format!("{raw_address}=/dev/input/event4"); + let configured = Cli::try_parse_from([ + "bt-intercom", + "run", + "--ptt", + binding.as_str(), + raw_address, + "--connect", + "--dashboard", + ]) + .unwrap(); + let CliCommand::Run { + addresses, + connect, + ptt, + dashboard, + .. + } = configured.command + else { + panic!("expected run command"); + }; + assert_eq!(addresses, [address(raw_address)]); + assert!(connect && dashboard); + assert_eq!(ptt.len(), 1); + assert_eq!(ptt[0].path, "/dev/input/event4"); + assert_eq!(ptt[0].address, Some(address(raw_address))); + } } diff --git a/src/main.rs b/src/main.rs index 178e841..c6417ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,13 +32,7 @@ mod worker; mod tui; use std::collections::BTreeSet; -#[cfg(test)] -use std::env; -#[cfg(test)] -use std::fs; use std::io::IsTerminal; -#[cfg(test)] -use std::path::PathBuf; use std::process::{Command, Stdio}; #[cfg(test)] use std::sync::atomic::{AtomicBool, Ordering}; @@ -61,8 +55,6 @@ use groups::config_path as talk_groups_path; use network::{ headset_path as headset_network_path, load as load_headsets, remove as remove_headset, }; -#[cfg(test)] -use network::{load_for_run as load_run_headsets, save as save_headsets}; use process::{COMMAND_TIMEOUT, command}; #[cfg(test)] use ptt::KEY_PLAYPAUSE; @@ -250,14 +242,13 @@ mod tests { use super::*; use crate::ptt::PttButton; use crate::ptt::{BEEP_WAV, confirm_transmissions, drain_events, read_input_events}; - use crate::runtime_config::{parse_interval, validate_mode, validate_ptt}; + use crate::runtime_config::{validate_mode, validate_ptt}; use crate::session::{connect_disconnected, guard_half_update, reconnect_worker}; use crate::transport::{ refresh_transmit_requests as refresh_transport_requests, validate_controller_count, validate_transport, }; use clap::{CommandFactory, Parser}; - use std::ffi::OsStr; fn address(value: &str) -> Address { Address::parse(value).unwrap() @@ -479,133 +470,6 @@ mod tests { assert!(parse_address("invalid").is_err()); } - fn temporary_network_path() -> PathBuf { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - env::temp_dir().join(format!( - "bt-intercom-headsets-{}-{nonce}/headsets", - std::process::id() - )) - } - - #[test] - fn uses_absolute_xdg_runtime_dir_for_ptt_beeps() { - assert_eq!( - network::runtime_directory(Some(OsStr::new("/run/user/1000"))), - PathBuf::from("/run/user/1000") - ); - assert_eq!( - network::runtime_directory(Some(OsStr::new("relative"))), - env::temp_dir() - ); - assert_eq!(network::runtime_directory(None), env::temp_dir()); - } - - #[test] - fn saves_and_restores_headset_network() { - let path = temporary_network_path(); - let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:02"), address("AA:BB:CC:DD:EE:01")]); - save_headsets(&path, &allowed).unwrap(); - assert_eq!(load_headsets(&path).unwrap(), allowed); - assert_eq!( - fs::read_to_string(&path).unwrap(), - "AA:BB:CC:DD:EE:01\nAA:BB:CC:DD:EE:02\n" - ); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - - #[test] - fn removes_headsets_from_saved_network() { - let path = temporary_network_path(); - let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01"), address("AA:BB:CC:DD:EE:02")]); - save_headsets(&path, &allowed).unwrap(); - - remove_headset(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); - - assert_eq!( - load_headsets(&path).unwrap(), - BTreeSet::from([address("AA:BB:CC:DD:EE:02")]) - ); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - - #[test] - fn removes_last_headset_and_rejects_unknown_address_without_changes() { - let path = temporary_network_path(); - let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01")]); - save_headsets(&path, &allowed).unwrap(); - - assert!(remove_headset(&path, &address("AA:BB:CC:DD:EE:02")).is_err()); - assert_eq!(load_headsets(&path).unwrap(), allowed); - remove_headset(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); - assert!(!path.exists()); - assert!(load_headsets(&path).is_err()); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - - #[test] - fn loads_normalized_addresses_and_rejects_invalid_network_files() { - let path = temporary_network_path(); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "\naa:bb:cc:dd:ee:01\nAA:BB:CC:DD:EE:01\n \n").unwrap(); - assert_eq!( - load_headsets(&path).unwrap(), - BTreeSet::from([address("AA:BB:CC:DD:EE:01")]) - ); - fs::write(&path, "not-a-bluetooth-address\n").unwrap(); - assert!(load_headsets(&path).is_err()); - fs::write(&path, "\n \n").unwrap(); - assert!(load_headsets(&path).is_err()); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - - #[test] - fn missing_saved_network_explains_how_to_create_it() { - let error = load_headsets(&temporary_network_path()).unwrap_err(); - assert!(error.contains("run with headset addresses first")); - } - - #[test] - fn only_button_mode_can_start_without_a_saved_network() { - let path = temporary_network_path(); - assert!(load_run_headsets(&path, false).is_err()); - assert!(load_run_headsets(&path, true).unwrap().is_empty()); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "invalid\n").unwrap(); - assert!(load_run_headsets(&path, true).is_err()); - fs::write(&path, "AA:BB:CC:DD:EE:01\n").unwrap(); - assert_eq!( - load_run_headsets(&path, true).unwrap(), - BTreeSet::from(["AA:BB:CC:DD:EE:01".into()]) - ); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - - #[test] - fn enrollment_preserves_network_and_only_allows_saved_devices() { - let path = temporary_network_path(); - let mut allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".into()]); - network::enroll(&path, &mut allowed, "AA:BB:CC:DD:EE:02".into()).unwrap(); - assert_eq!(load_headsets(&path).unwrap(), allowed); - assert_eq!(allowed.len(), 2); - network::enroll(&path, &mut allowed, "AA:BB:CC:DD:EE:02".into()).unwrap(); - assert_eq!(allowed.len(), 2); - let before = allowed.clone(); - assert!( - network::enroll( - &path.join("not-a-directory"), - &mut allowed, - "AA:BB:CC:DD:EE:03".into(), - ) - .is_err() - ); - assert_eq!(allowed, before); - assert_eq!(load_headsets(&path).unwrap(), before); - fs::remove_dir_all(path.parent().unwrap()).unwrap(); - } - #[test] fn pairing_button_is_opt_in_and_incompatible_with_ptt() { let CliCommand::Run { pair_button, .. } = @@ -635,15 +499,6 @@ mod tests { assert!(validate_mode(Mode::HalfDuplex, &[], &BTreeSet::new()).is_err()); } - #[test] - fn confirms_bluetooth_device_flags() { - assert!(!device_flag(" Paired: no\n", "Paired")); - assert!(device_flag(" Paired: yes\n", "Paired")); - assert!(!device_flag("Not Paired: yes\n", "Paired")); - assert!(device_flag(" Connected: yes\n", "Connected")); - assert!(!device_flag(" Paired: yes\n", "Connected")); - } - #[test] fn connects_only_when_disconnected_or_info_fails() { let device = "AA:BB:CC:DD:EE:01"; @@ -689,140 +544,6 @@ mod tests { } } - #[test] - fn clap_cli_validates_arguments_and_accepts_completion_shells() { - for args in [ - vec!["bt-intercom", "run", "invalid"], - vec!["bt-intercom", "run", "--interval", "NaN"], - vec!["bt-intercom", "run", "--interval", "0.00000000001"], - vec!["bt-intercom", "run", "--sco-limit", "0"], - vec!["bt-intercom", "scan", "--seconds", "301"], - vec!["bt-intercom", "status"], - vec!["bt-intercom", "run", "--unknown"], - vec!["bt-intercom", "run", "--ptt"], - vec!["bt-intercom", "run", "--ptt", "AA:BB:CC:DD:EE:01="], - vec!["bt-intercom", "remove"], - vec!["bt-intercom", "remove", "invalid"], - ] { - assert_eq!(Cli::try_parse_from(args).err().unwrap().exit_code(), 2); - } - - for shell in ["bash", "zsh", "fish", "elvish"] { - assert!(Cli::try_parse_from(["bt-intercom", "completions", shell]).is_ok()); - } - } - - #[test] - fn terminal_control_panel_uses_panel_subcommand() { - assert!(matches!( - Cli::try_parse_from(["bt-intercom", "panel"]) - .unwrap() - .command, - CliCommand::Panel - )); - assert!(Cli::try_parse_from(["bt-intercom", "tui"]).is_err()); - } - - #[test] - fn verbose_is_global_and_version_short_alias_is_preserved() { - for args in [ - ["bt-intercom", "--verbose", "run"], - ["bt-intercom", "run", "--verbose"], - ] { - assert!(Cli::try_parse_from(args).unwrap().verbose); - } - assert!(!Cli::try_parse_from(["bt-intercom", "run"]).unwrap().verbose); - assert_eq!( - cli_command() - .try_get_matches_from(["bt-intercom", "-v"]) - .unwrap_err() - .kind(), - clap::error::ErrorKind::DisplayVersion - ); - } - - #[test] - fn version_flags_print_the_long_version_with_build_datetime() { - let command = cli_command(); - let long_version = command.get_long_version().unwrap().to_string(); - assert_eq!(command.get_version(), Some(long_version.as_str())); - - let short = command - .clone() - .try_get_matches_from(["bt-intercom", "-v"]) - .err() - .unwrap() - .to_string(); - let long = command - .clone() - .try_get_matches_from(["bt-intercom", "--version"]) - .err() - .unwrap() - .to_string(); - let legacy_short = command - .try_get_matches_from(["bt-intercom", "-V"]) - .err() - .unwrap() - .to_string(); - - assert_eq!(short, long); - assert_eq!(short, legacy_short); - assert!(short.contains(env!("CARGO_PKG_VERSION"))); - assert!(short.contains(env!("BT_INTERCOM_BUILD_DATETIME"))); - } - - #[test] - fn top_level_help_mentions_dashboard_and_control_panel() { - let help = cli_command().render_help().to_string(); - assert!(help.contains("dashboard")); - assert!(help.contains("control panel")); - } - - #[test] - fn parses_ptt_without_changing_default_mode() { - let raw_address = "AA:BB:CC:DD:EE:01"; - let default = Cli::try_parse_from(["bt-intercom", "run", raw_address]).unwrap(); - let CliCommand::Run { - ptt, - mode, - dashboard, - .. - } = default.command - else { - panic!("expected run command"); - }; - assert!(ptt.is_empty()); - assert_eq!(mode, Mode::FullDuplex); - assert!(!dashboard); - - let binding = format!("{raw_address}=/dev/input/event4"); - let configured = Cli::try_parse_from([ - "bt-intercom", - "run", - "--ptt", - binding.as_str(), - raw_address, - "--connect", - "--dashboard", - ]) - .unwrap(); - let CliCommand::Run { - addresses, - connect, - ptt, - dashboard, - .. - } = configured.command - else { - panic!("expected run command"); - }; - assert_eq!(addresses, [address(raw_address)]); - assert!(connect && dashboard); - assert_eq!(ptt.len(), 1); - assert_eq!(ptt[0].path, "/dev/input/event4"); - assert_eq!(ptt[0].address, Some(address(raw_address))); - } - #[test] fn mode_parsing_validation_and_documented_window() { for (name, expected) in [ @@ -1106,11 +827,6 @@ mod tests { assert!(receiver.recv().unwrap().is_err()); } - #[test] - fn rejects_intervals_that_round_to_zero() { - assert!(parse_interval("0.00000000001").is_err()); - } - #[test] fn connection_cancellation_skips_remaining_devices() { let stopped = AtomicBool::new(false); diff --git a/src/network.rs b/src/network.rs index 15b6a9f..cbd8eec 100644 --- a/src/network.rs +++ b/src/network.rs @@ -126,3 +126,140 @@ pub(crate) fn enroll( *allowed = enrolled; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn address(value: &str) -> Address { + Address::parse(value).unwrap() + } + + fn temporary_network_path() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!( + "bt-intercom-headsets-{}-{nonce}/headsets", + std::process::id() + )) + } + + #[test] + fn uses_absolute_xdg_runtime_dir_for_ptt_beeps() { + assert_eq!( + runtime_directory(Some(OsStr::new("/run/user/1000"))), + PathBuf::from("/run/user/1000") + ); + assert_eq!( + runtime_directory(Some(OsStr::new("relative"))), + env::temp_dir() + ); + assert_eq!(runtime_directory(None), env::temp_dir()); + } + + #[test] + fn saves_and_restores_headset_network() { + let path = temporary_network_path(); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:02"), address("AA:BB:CC:DD:EE:01")]); + save(&path, &allowed).unwrap(); + assert_eq!(load(&path).unwrap(), allowed); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "AA:BB:CC:DD:EE:01\nAA:BB:CC:DD:EE:02\n" + ); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn removes_headsets_from_saved_network() { + let path = temporary_network_path(); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01"), address("AA:BB:CC:DD:EE:02")]); + save(&path, &allowed).unwrap(); + + remove(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); + + assert_eq!( + load(&path).unwrap(), + BTreeSet::from([address("AA:BB:CC:DD:EE:02")]) + ); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn removes_last_headset_and_rejects_unknown_address_without_changes() { + let path = temporary_network_path(); + let allowed = BTreeSet::from([address("AA:BB:CC:DD:EE:01")]); + save(&path, &allowed).unwrap(); + + assert!(remove(&path, &address("AA:BB:CC:DD:EE:02")).is_err()); + assert_eq!(load(&path).unwrap(), allowed); + remove(&path, &address("AA:BB:CC:DD:EE:01")).unwrap(); + assert!(!path.exists()); + assert!(load(&path).is_err()); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn loads_normalized_addresses_and_rejects_invalid_network_files() { + let path = temporary_network_path(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "\naa:bb:cc:dd:ee:01\nAA:BB:CC:DD:EE:01\n \n").unwrap(); + assert_eq!( + load(&path).unwrap(), + BTreeSet::from([address("AA:BB:CC:DD:EE:01")]) + ); + fs::write(&path, "not-a-bluetooth-address\n").unwrap(); + assert!(load(&path).is_err()); + fs::write(&path, "\n \n").unwrap(); + assert!(load(&path).is_err()); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn missing_saved_network_explains_how_to_create_it() { + let error = load(&temporary_network_path()).unwrap_err(); + assert!(error.contains("run with headset addresses first")); + } + + #[test] + fn only_button_mode_can_start_without_a_saved_network() { + let path = temporary_network_path(); + assert!(load_for_run(&path, false).is_err()); + assert!(load_for_run(&path, true).unwrap().is_empty()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "invalid\n").unwrap(); + assert!(load_for_run(&path, true).is_err()); + fs::write(&path, "AA:BB:CC:DD:EE:01\n").unwrap(); + assert_eq!( + load_for_run(&path, true).unwrap(), + BTreeSet::from(["AA:BB:CC:DD:EE:01".into()]) + ); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn enrollment_preserves_network_and_only_allows_saved_devices() { + let path = temporary_network_path(); + let mut allowed = BTreeSet::from(["AA:BB:CC:DD:EE:01".into()]); + enroll(&path, &mut allowed, "AA:BB:CC:DD:EE:02".into()).unwrap(); + assert_eq!(load(&path).unwrap(), allowed); + assert_eq!(allowed.len(), 2); + enroll(&path, &mut allowed, "AA:BB:CC:DD:EE:02".into()).unwrap(); + assert_eq!(allowed.len(), 2); + let before = allowed.clone(); + assert!( + enroll( + &path.join("not-a-directory"), + &mut allowed, + "AA:BB:CC:DD:EE:03".into(), + ) + .is_err() + ); + assert_eq!(allowed, before); + assert_eq!(load(&path).unwrap(), before); + fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } +} diff --git a/src/runtime_config.rs b/src/runtime_config.rs index bda7527..5375a86 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -367,4 +367,9 @@ mod tests { .all(|button| button.path.starts_with("input")) ); } + + #[test] + fn rejects_intervals_that_round_to_zero() { + assert!(parse_interval("0.00000000001").is_err()); + } } diff --git a/src/transport.rs b/src/transport.rs index c93ddc5..6b1f8f7 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -560,8 +560,6 @@ mod tests { use super::*; use serde_json::json; - const ADDRESS: &str = "AA:BB:CC:DD:EE:01"; - fn address(value: &str) -> Address { Address::parse(value).unwrap() } @@ -612,5 +610,4 @@ mod tests { assert!(validate_source_capacity(&two, &devices, 2).is_ok()); assert!(validate_source_capacity(&three, &devices, 2).is_err()); } - }