use std::sync::Arc;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
#[async_trait]
pub trait DeviceProvider: Send + Sync {
async fn devices(&self) -> Result<Value, String>;
async fn notify_device(
&self,
device_id: Option<String>,
title: String,
body: String,
) -> Result<Value, String>;
}
pub struct DeviceTools {
provider: Arc<dyn DeviceProvider>,
}
impl DeviceTools {
pub fn new(provider: Arc<dyn DeviceProvider>) -> Self {
Self { provider }
}
pub fn tool_defs() -> Vec<Value> {
vec![
json!({
"name": "linked_devices",
"description": "List the user's linked CAR host devices and their advertised consumer capabilities, such as iPhone chat, approvals, notifications, and push-to-talk. Read-only; does not access contacts, location, photos, microphone, or other private phone data.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}),
json!({
"name": "notify_linked_device",
"description": "Send a short CAR notification to a linked consumer device that advertises notifications.deliver, such as the user's iPhone. This only delivers title/body notification text through the CAR host event channel; it does not access contacts, location, photos, microphone, files, or arbitrary phone actions.",
"tier": "full_access",
"parameters": {
"type": "object",
"properties": {
"device_id": {
"type": "string",
"description": "Optional linked device id from linked_devices. If omitted, CAR chooses the first online device that can deliver notifications."
},
"title": {
"type": "string",
"description": "Short notification title."
},
"body": {
"type": "string",
"description": "Notification body shown on the linked device."
}
},
"required": ["title", "body"],
"additionalProperties": false
}
}),
]
}
}
#[async_trait]
impl ToolExecutor for DeviceTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"linked_devices" => self.provider.devices().await,
"notify_linked_device" => self.notify_linked_device(params).await,
_ => Err(format!("unknown tool: {tool}")),
}
}
}
impl DeviceTools {
async fn notify_linked_device(&self, params: &Value) -> Result<Value, String> {
let title = required_string(params, "title")?;
let body = required_string(params, "body")?;
let device_id = params
.get("device_id")
.and_then(Value::as_str)
.map(str::to_string);
let devices = self.provider.devices().await?;
let chosen = choose_notification_device(&devices, device_id.as_deref())?;
let chosen_id = chosen
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or(device_id);
self.provider
.notify_device(chosen_id.clone(), title.clone(), body.clone())
.await?;
Ok(json!({
"delivered": true,
"device_id": chosen_id,
"title": title,
"body": body
}))
}
}
fn required_string(params: &Value, key: &str) -> Result<String, String> {
let value = params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("{key} is required"))?;
Ok(value.to_string())
}
fn choose_notification_device<'a>(
devices: &'a Value,
requested_id: Option<&str>,
) -> Result<&'a Value, String> {
let devices = devices
.as_array()
.ok_or_else(|| "linked_devices returned a non-array response".to_string())?;
if let Some(id) = requested_id {
let device = devices
.iter()
.find(|device| device.get("id").and_then(Value::as_str) == Some(id))
.ok_or_else(|| format!("linked device '{id}' was not found"))?;
if !device_can_notify(device) {
return Err(format!(
"linked device '{id}' does not advertise notifications.deliver"
));
}
return Ok(device);
}
devices
.iter()
.find(|device| device_can_notify(device))
.ok_or_else(|| "no linked device advertises notifications.deliver".to_string())
}
fn device_can_notify(device: &Value) -> bool {
let is_online = device
.get("status")
.and_then(Value::as_str)
.map(|status| status == "online")
.unwrap_or(true);
let can_deliver = device
.get("capabilities")
.and_then(Value::as_array)
.map(|caps| {
caps.iter()
.any(|cap| cap.as_str() == Some("notifications.deliver"))
})
.unwrap_or(false);
is_online && can_deliver
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::Mutex;
struct StaticProvider {
devices: Value,
notifications: Mutex<Vec<Value>>,
}
impl StaticProvider {
fn new(devices: Value) -> Self {
Self {
devices,
notifications: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl DeviceProvider for StaticProvider {
async fn devices(&self) -> Result<Value, String> {
Ok(self.devices.clone())
}
async fn notify_device(
&self,
device_id: Option<String>,
title: String,
body: String,
) -> Result<Value, String> {
let event = json!({
"device_id": device_id,
"title": title,
"body": body
});
self.notifications.lock().await.push(event.clone());
Ok(event)
}
}
#[tokio::test]
async fn linked_devices_returns_provider_snapshot() {
let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
{"name": "iPhone", "platform": "ios", "status": "online"}
]))));
let out = tools.execute("linked_devices", &json!({})).await.unwrap();
assert_eq!(out[0]["platform"], "ios");
}
#[test]
fn linked_devices_def_is_read_only_phone_context() {
let defs = DeviceTools::tool_defs();
let desc = defs[0]["description"].as_str().unwrap();
assert!(desc.contains("Read-only"));
assert!(desc.contains("does not access contacts"));
}
#[tokio::test]
async fn notify_linked_device_requires_notification_capability() {
let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
{
"id": "phone-1",
"name": "Mia's iPhone",
"platform": "ios",
"status": "online",
"capabilities": ["notifications.deliver"]
}
]))));
let out = tools
.execute(
"notify_linked_device",
&json!({"title": "CAR", "body": "Done"}),
)
.await
.unwrap();
assert_eq!(out["delivered"], true);
assert_eq!(out["device_id"], "phone-1");
}
#[tokio::test]
async fn notify_linked_device_rejects_devices_without_notification_capability() {
let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
{
"id": "phone-1",
"name": "Mia's iPhone",
"platform": "ios",
"status": "online",
"capabilities": ["assistant.chat"]
}
]))));
let err = tools
.execute(
"notify_linked_device",
&json!({"device_id": "phone-1", "title": "CAR", "body": "Done"}),
)
.await
.unwrap_err();
assert!(err.contains("does not advertise notifications.deliver"));
}
#[test]
fn notify_linked_device_def_describes_limited_phone_access_and_tier() {
let defs = DeviceTools::tool_defs();
let notify = defs
.iter()
.find(|def| def["name"].as_str() == Some("notify_linked_device"))
.unwrap();
assert_eq!(notify["tier"], "full_access");
let desc = notify["description"].as_str().unwrap();
assert!(desc.contains("title/body notification text"));
assert!(desc.contains("does not access contacts"));
}
}