rust/bt-bap-unicast: Implement discovery logic in client

- Implement discover method in BapUnicastClient to read PACS and ASCS.
- Use AudioStreamControlServiceClient::create for ASCS discovery.
- Read PAC records from PACS service.
- Update unit tests in manager.rs to set up fake PACS and ASCS services with characteristics.

Test: unit tests passed
Bug: 433288675
Change-Id: I6be5a966bbb34378499ee5e3165bf2fd6a6a6964
Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3063
diff --git a/rust/bt-bap-unicast/DESIGN.md b/rust/bt-bap-unicast/DESIGN.md
index 0f7837a..cb01583 100644
--- a/rust/bt-bap-unicast/DESIGN.md
+++ b/rust/bt-bap-unicast/DESIGN.md
@@ -109,8 +109,8 @@
     InvalidState(String),
 
     /// The configuration requested is not valid according to the spec.
-    #[error("Invalid configuration: {0}")]
-    InvalidConfiguration(String),
+    #[error("Configuration issue: {0}")]
+    Configuration(String),
 
     /// The peer rejected the operation (e.g. QoS or Codec configuration rejected).
     #[error("Peer rejected operation: {0}")]
diff --git a/rust/bt-bap-unicast/src/client.rs b/rust/bt-bap-unicast/src/client.rs
index a89532d..eb44a1d 100644
--- a/rust/bt-bap-unicast/src/client.rs
+++ b/rust/bt-bap-unicast/src/client.rs
@@ -3,6 +3,10 @@
 // found in the LICENSE file.
 
 use bt_common::PeerId;
+use bt_gatt::client::FromCharacteristic;
+use bt_gatt::client::PeerService;
+use bt_gatt::client::PeerServiceHandle;
+use bt_gatt::Client;
 use bt_gatt::GattTypes;
 
 use crate::types::{Error, SupportedCapabilities};
@@ -25,17 +29,135 @@
     }
 
     /// Discovers PACS and ASCS and returns supported configurations.
-    pub async fn discover(&mut self) -> Result<SupportedCapabilities, Error> {
-        // TODO: Implement actual discovery.
-        // Using dummy data for now to trigger skeleton mapping logic.
-        let pac_records = vec![bt_pacs::PacRecord {
-            codec_id: bt_common::core::CodecId::Assigned(bt_common::core::CodingFormat::Lc3),
-            codec_specific_capabilities: vec![],
-            metadata: vec![],
-        }];
-        let sink_ase_count = 1;
-        let source_ase_count = 1;
+    pub async fn discover(&mut self) -> Result<SupportedCapabilities, Error>
+    where
+        <T as GattTypes>::NotificationStream: Send,
+    {
+        // 1. Discover PACS and read capabilities.
+        let pacs_handles = self.gatt_client.find_service(bt_pacs::PACS_UUID).await?;
+
+        if pacs_handles.len() != 1 {
+            return Err(Error::Configuration(format!(
+                "{} PACS service(s) - not unique",
+                pacs_handles.len()
+            )));
+        }
+
+        let handle = &pacs_handles[0];
+        let mut pac_records = vec![];
+
+        let service = handle.connect().await?;
+
+        let chrs = service.discover_characteristics(None).await?;
+
+        for chr in chrs {
+            if chr.uuid == bt_pacs::SourcePac::UUID {
+                let source_pac = bt_pacs::SourcePac::try_read::<T>(chr, &service).await?;
+                pac_records.extend(source_pac.capabilities);
+            } else if chr.uuid == bt_pacs::SinkPac::UUID {
+                let sink_pac = bt_pacs::SinkPac::try_read::<T>(chr, &service).await?;
+                pac_records.extend(sink_pac.capabilities);
+            }
+        }
+
+        // 2. Discover ASCS and count endpoints.
+        let ascs_handles = self.gatt_client.find_service(bt_ascs::server::ASCS_UUID).await?;
+
+        if ascs_handles.len() != 1 {
+            return Err(Error::Configuration(format!(
+                "{} ASCS service(s) - not unique",
+                ascs_handles.len()
+            )));
+        }
+
+        let handle = &ascs_handles[0];
+
+        let mut sink_ase_count = 0;
+        let mut source_ase_count = 0;
+
+        let service = handle.connect().await?;
+
+        let ascs_client =
+            bt_ascs::client::AudioStreamControlServiceClient::<T>::create(service).await?;
+
+        sink_ase_count += ascs_client.endpoints.sink_ases().len();
+        source_ase_count += ascs_client.endpoints.source_ases().len();
 
         Ok(crate::mapping::map_capabilities(&pac_records, sink_ase_count, source_ase_count))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::test_utils::{setup_ascs_service, setup_pacs_service};
+    use crate::Configuration;
+    use bt_common::PeerId;
+    use bt_gatt::test_utils::{FakeClient, FakeTypes};
+
+    #[test]
+    fn test_discover_success() {
+        let mut client = FakeClient::new();
+        let _pacs_service = setup_pacs_service(&mut client);
+        let _ascs_service = setup_ascs_service(&mut client, 1, 1);
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let mapping = futures::executor::block_on(unicast_client.discover())
+            .expect("discovery should succeed");
+        assert!(mapping.contains_key(&Configuration::MonoAudioSink));
+        assert!(mapping.contains_key(&Configuration::Conversational));
+    }
+
+    #[test]
+    fn test_discover_no_services() {
+        let client = FakeClient::new();
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let result = futures::executor::block_on(unicast_client.discover());
+        assert!(matches!(result, Err(Error::Configuration(_))));
+    }
+
+    #[test]
+    fn test_discover_no_pacs_service() {
+        let mut client = FakeClient::new();
+        let _ascs_service = setup_ascs_service(&mut client, 1, 1);
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let result = futures::executor::block_on(unicast_client.discover());
+        assert!(matches!(result, Err(Error::Configuration(_))));
+    }
+
+    #[test]
+    fn test_discover_multiple_pacs_services() {
+        let mut client = FakeClient::new();
+        let _pacs_service1 = setup_pacs_service(&mut client);
+        let _pacs_service2 = setup_pacs_service(&mut client);
+        let _ascs_service = setup_ascs_service(&mut client, 1, 1);
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let result = futures::executor::block_on(unicast_client.discover());
+        assert!(matches!(result, Err(Error::Configuration(_))));
+    }
+
+    #[test]
+    fn test_discover_no_ascs_service() {
+        let mut client = FakeClient::new();
+        let _pacs_service = setup_pacs_service(&mut client);
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let result = futures::executor::block_on(unicast_client.discover());
+        assert!(matches!(result, Err(Error::Configuration(_))));
+    }
+
+    #[test]
+    fn test_discover_multiple_ascs_services() {
+        let mut client = FakeClient::new();
+        let _pacs_service = setup_pacs_service(&mut client);
+        let _ascs_service1 = setup_ascs_service(&mut client, 1, 1);
+        let _ascs_service2 = setup_ascs_service(&mut client, 1, 1);
+
+        let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client);
+        let result = futures::executor::block_on(unicast_client.discover());
+        assert!(matches!(result, Err(Error::Configuration(_))));
+    }
+}
diff --git a/rust/bt-bap-unicast/src/lib.rs b/rust/bt-bap-unicast/src/lib.rs
index 12dc7b0..696aff4 100644
--- a/rust/bt-bap-unicast/src/lib.rs
+++ b/rust/bt-bap-unicast/src/lib.rs
@@ -5,6 +5,8 @@
 pub mod client;
 pub mod manager;
 pub mod mapping;
+#[cfg(test)]
+pub(crate) mod test_utils;
 pub mod types;
 
 pub use client::BapUnicastClient;
diff --git a/rust/bt-bap-unicast/src/manager.rs b/rust/bt-bap-unicast/src/manager.rs
index 58e770b..5cadb9f 100644
--- a/rust/bt-bap-unicast/src/manager.rs
+++ b/rust/bt-bap-unicast/src/manager.rs
@@ -26,7 +26,10 @@
         &mut self,
         peer_id: PeerId,
         gatt_client: T::Client,
-    ) -> Result<SupportedCapabilities, Error> {
+    ) -> Result<SupportedCapabilities, Error>
+    where
+        <T as GattTypes>::NotificationStream: Send,
+    {
         if self.peers.contains_key(&peer_id) {
             return Err(Error::PeerAlreadyExists(peer_id));
         }
@@ -49,6 +52,7 @@
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::test_utils::{setup_ascs_service, setup_pacs_service};
     use crate::Configuration;
     use bt_common::PeerId;
     use bt_gatt::test_utils::{FakeClient, FakeTypes};
@@ -57,7 +61,13 @@
     fn test_add_remove_peer() {
         let mut manager = BapUnicastManager::<FakeTypes>::new();
         let peer_id = PeerId(1);
-        let client = FakeClient::new();
+        let mut client = FakeClient::new();
+
+        // 1. Setup PACS
+        let _pacs_service = setup_pacs_service(&mut client);
+
+        // 2. Setup ASCS
+        let _ascs_service = setup_ascs_service(&mut client, 1, 1);
 
         let mapping =
             futures::executor::block_on(manager.add_peer(peer_id, client.clone())).unwrap();
@@ -69,4 +79,38 @@
         assert!(manager.remove_peer(peer_id).is_ok());
         assert!(manager.remove_peer(peer_id).is_err()); // Not found
     }
+
+    #[test]
+    fn test_add_peer_fails_non_unique_services() {
+        let mut manager = BapUnicastManager::<FakeTypes>::new();
+        let peer_id = PeerId(1);
+
+        // 0 PACS services: add_peer fails and peer is not stored.
+        let mut client = FakeClient::new();
+        let _ascs_service = setup_ascs_service(&mut client, 1, 1);
+        assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err());
+        assert!(manager.remove_peer(peer_id).is_err());
+
+        // Multiple PACS services: add_peer fails and peer is not stored.
+        let mut client = FakeClient::new();
+        let _pacs1 = setup_pacs_service(&mut client);
+        let _pacs2 = setup_pacs_service(&mut client);
+        let _ascs = setup_ascs_service(&mut client, 1, 1);
+        assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err());
+        assert!(manager.remove_peer(peer_id).is_err());
+
+        // 0 ASCS services: add_peer fails and peer is not stored.
+        let mut client = FakeClient::new();
+        let _pacs = setup_pacs_service(&mut client);
+        assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err());
+        assert!(manager.remove_peer(peer_id).is_err());
+
+        // Multiple ASCS services: add_peer fails and peer is not stored.
+        let mut client = FakeClient::new();
+        let _pacs = setup_pacs_service(&mut client);
+        let _ascs1 = setup_ascs_service(&mut client, 1, 1);
+        let _ascs2 = setup_ascs_service(&mut client, 1, 1);
+        assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err());
+        assert!(manager.remove_peer(peer_id).is_err());
+    }
 }
diff --git a/rust/bt-bap-unicast/src/test_utils.rs b/rust/bt-bap-unicast/src/test_utils.rs
new file mode 100644
index 0000000..1e779b1
--- /dev/null
+++ b/rust/bt-bap-unicast/src/test_utils.rs
@@ -0,0 +1,142 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#![allow(dead_code)]
+
+use bt_ascs::client::{ASE_CONTROL_POINT_UUID, SINK_ASE_UUID, SOURCE_ASE_UUID};
+use bt_ascs::server::ASCS_UUID;
+use bt_ascs::types::{
+    AseAdditionalParameters, AseId, AseState, AudioDirection, AudioStreamEndpoint,
+};
+use bt_common::core::{CodecId, CodingFormat};
+use bt_common::packet_encoding::Encodable;
+use bt_gatt::client::FromCharacteristic;
+use bt_gatt::test_utils::{FakeClient, FakePeerService};
+use bt_gatt::types::{
+    AttributePermissions, Characteristic, CharacteristicProperties, CharacteristicProperty, Handle,
+};
+use bt_pacs::{PacRecord, SourcePac, PACS_UUID};
+
+pub const SOURCE_PAC_HANDLE: Handle = Handle(1);
+pub const SINK_ASE_HANDLE: Handle = Handle(2);
+pub const CONTROL_POINT_HANDLE: Handle = Handle(3);
+pub const SOURCE_ASE_HANDLE: Handle = Handle(4);
+
+pub const DEFAULT_SINK_ASE_ID: AseId = AseId(1);
+pub const DEFAULT_SOURCE_ASE_ID: AseId = AseId(2);
+
+/// Creates a `FakePeerService` configured as a PACS service with a single
+/// Source PAC record for LC3.
+pub fn create_pacs_service() -> FakePeerService {
+    let mut pacs_service = FakePeerService::new();
+    let pac_record = PacRecord {
+        codec_id: CodecId::Assigned(CodingFormat::Lc3),
+        codec_specific_capabilities: vec![],
+        metadata: vec![],
+    };
+
+    let mut pac_buf = vec![0; 1 + pac_record.encoded_len()];
+    pac_buf[0] = 1; // 1 record
+    pac_record.encode(&mut pac_buf[1..]).unwrap();
+
+    let source_pac_chr = Characteristic {
+        handle: SOURCE_PAC_HANDLE,
+        uuid: SourcePac::UUID,
+        properties: CharacteristicProperty::Read.into(),
+        permissions: AttributePermissions::default(),
+        descriptors: vec![],
+    };
+    pacs_service.add_characteristic(source_pac_chr, pac_buf);
+    pacs_service
+}
+
+/// Sets up a PACS service in `client` with a single Source PAC record for LC3.
+/// Adds the service to `client` and returns the `FakePeerService`.
+pub fn setup_pacs_service(client: &mut FakeClient) -> FakePeerService {
+    let pacs_service = create_pacs_service();
+    client.add_service(PACS_UUID, true, pacs_service.clone());
+    pacs_service
+}
+
+/// Creates a `FakePeerService` configured as an ASCS service with
+/// `sink_ase_count` sink ASEs, `source_ase_count` source ASEs, and a Control
+/// Point initialized with `control_point_val`.
+pub fn create_ascs_service(
+    sink_ase_count: usize,
+    source_ase_count: usize,
+    control_point_val: Vec<u8>,
+) -> FakePeerService {
+    let mut ascs_service = FakePeerService::new();
+
+    let control_point_chr = Characteristic {
+        handle: CONTROL_POINT_HANDLE,
+        uuid: ASE_CONTROL_POINT_UUID,
+        properties: CharacteristicProperties::WRITE_NOTIFY,
+        permissions: AttributePermissions::default(),
+        descriptors: vec![],
+    };
+    ascs_service.add_characteristic(control_point_chr, control_point_val);
+
+    // ASE ids and handles are allocated from iterators shared by every endpoint
+    // characteristic, so sinks and sources can never collide with each other.
+    // The handle reserved for the control point characteristic is skipped.
+    let mut ase_ids = (1..).map(AseId);
+    let mut handles = (SINK_ASE_HANDLE.0..).map(Handle).filter(|h| *h != CONTROL_POINT_HANDLE);
+
+    let directions = std::iter::repeat_n(AudioDirection::Sink, sink_ase_count)
+        .chain(std::iter::repeat_n(AudioDirection::Source, source_ase_count));
+
+    for direction in directions {
+        let endpoint = AudioStreamEndpoint {
+            handle: Handle(0),
+            direction,
+            ase_id: ase_ids.next().expect("ase ids available"),
+            state: AseState::Idle,
+            additional: AseAdditionalParameters::None,
+        };
+        let mut ase_buf = vec![0; endpoint.encoded_len()];
+        endpoint.encode(&mut ase_buf).unwrap();
+
+        let uuid = match direction {
+            AudioDirection::Sink => SINK_ASE_UUID,
+            AudioDirection::Source => SOURCE_ASE_UUID,
+        };
+        let ase_chr = Characteristic {
+            handle: handles.next().expect("handles available"),
+            uuid,
+            properties: CharacteristicProperties::READ_NOTIFY,
+            permissions: AttributePermissions::default(),
+            descriptors: vec![],
+        };
+        ascs_service.add_characteristic(ase_chr, ase_buf);
+    }
+
+    ascs_service
+}
+
+/// Sets up an ASCS service in `client` with `sink_ase_count` sink ASEs,
+/// `source_ase_count` source ASEs, and an empty Control Point. Adds the service
+/// to `client` and returns the `FakePeerService`.
+pub fn setup_ascs_service(
+    client: &mut FakeClient,
+    sink_ase_count: usize,
+    source_ase_count: usize,
+) -> FakePeerService {
+    setup_ascs_service_with_cp_value(client, sink_ase_count, source_ase_count, vec![])
+}
+
+/// Sets up an ASCS service in `client` with `sink_ase_count` sink ASEs,
+/// `source_ase_count` source ASEs, and a Control Point initialized with
+/// `control_point_val`. Adds the service to `client` and returns the
+/// `FakePeerService`.
+pub fn setup_ascs_service_with_cp_value(
+    client: &mut FakeClient,
+    sink_ase_count: usize,
+    source_ase_count: usize,
+    control_point_val: Vec<u8>,
+) -> FakePeerService {
+    let ascs_service = create_ascs_service(sink_ase_count, source_ase_count, control_point_val);
+    client.add_service(ASCS_UUID, true, ascs_service.clone());
+    ascs_service
+}
diff --git a/rust/bt-bap-unicast/src/types.rs b/rust/bt-bap-unicast/src/types.rs
index eac9df9..22bb57a 100644
--- a/rust/bt-bap-unicast/src/types.rs
+++ b/rust/bt-bap-unicast/src/types.rs
@@ -63,8 +63,8 @@
     InvalidState(String),
 
     /// The configuration requested is not valid according to the spec.
-    #[error("Invalid configuration: {0}")]
-    InvalidConfiguration(String),
+    #[error("Configuration issue: {0}")]
+    Configuration(String),
 
     /// The peer rejected the operation (e.g. QoS or Codec configuration
     /// rejected).