use super::error::{Result, ToolError};
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolHints, ToolResult};
use crate::config::profile::{home_for_profile, list_profiles};
use crate::config::types::A2aConfig;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Default, Deserialize)]
struct ProfileConfigFile {
#[serde(default)]
a2a: A2aConfig,
}
pub(crate) struct ProfileA2aRow {
pub name: String,
pub description: Option<String>,
pub a2a: A2aConfig,
pub config_found: bool,
}
pub(crate) fn load_profile_a2a(name: &str) -> Result<(A2aConfig, bool)> {
let path = home_for_profile(Some(name)).join("config.toml");
if !path.exists() {
return Ok((A2aConfig::default(), false));
}
let content = std::fs::read_to_string(&path)
.map_err(|e| ToolError::Execution(format!("failed to read {}: {e}", path.display())))?;
let parsed: ProfileConfigFile = toml::from_str(&content)
.map_err(|e| ToolError::Execution(format!("failed to parse {}: {e}", path.display())))?;
let found = content.contains("[a2a]");
Ok((parsed.a2a, found))
}
pub(crate) fn effective_a2a_url(cfg: &A2aConfig) -> String {
match cfg.advertise_url.as_deref() {
Some(u) if !u.trim().is_empty() => u.trim().trim_end_matches('/').to_string(),
_ => format!("http://{}:{}", cfg.bind, cfg.port),
}
}
pub(crate) fn render_roster(rows: &[ProfileA2aRow]) -> String {
let mut lines = vec![format!("Profiles ({}):", rows.len())];
let mut enabled_by_port: std::collections::BTreeMap<u16, Vec<String>> =
std::collections::BTreeMap::new();
for row in rows {
let desc = row.description.as_deref().unwrap_or("").trim();
let mut line = if desc.is_empty() {
format!("- {}", row.name)
} else {
format!("- {} - {}", row.name, desc)
};
if row.a2a.enabled {
let mut a2a = format!(" a2a: enabled on {}:{}", row.a2a.bind, row.a2a.port);
if let Some(adv) = row.a2a.advertise_url.as_deref()
&& !adv.trim().is_empty()
{
a2a.push_str(&format!(" (advertise_url: {adv})"));
}
line.push('\n');
line.push_str(&a2a);
enabled_by_port
.entry(row.a2a.port)
.or_default()
.push(row.name.clone());
} else {
let note = if row.config_found {
""
} else {
" (no [a2a] in config.toml)"
};
line.push_str(&format!("\n a2a: disabled{note}"));
}
lines.push(line);
}
for (port, names) in &enabled_by_port {
if names.len() > 1 {
let joined = match names.as_slice() {
[a, b] => format!("{a} and {b}"),
_ => names.join(", "),
};
lines.push(format!(
"warning: {joined} both on {port} - the second to start will fail to bind"
));
}
}
lines.join("\n")
}
#[derive(Default)]
pub struct ProfileListTool;
impl ProfileListTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for ProfileListTool {
fn name(&self) -> &str {
"profile_list"
}
fn description(&self) -> &str {
"List OpenCrabs profiles and their A2A gateway endpoints (enabled \
state, bind address, port, advertise_url when set). Use a returned \
profile name as the 'profile' parameter of a2a_send instead of a raw \
URL. Flags enabled profiles that share a port."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {},
"required": []
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadFiles]
}
fn hints(&self) -> ToolHints {
ToolHints {
read_only: true,
destructive: false,
idempotent: true,
open_world: false,
}
}
async fn execute(&self, _input: Value, _context: &ToolExecutionContext) -> Result<ToolResult> {
let profiles = list_profiles()
.map_err(|e| ToolError::Execution(format!("failed to list profiles: {e}")))?;
let mut rows = Vec::with_capacity(profiles.len());
for entry in &profiles {
let (a2a, config_found) = load_profile_a2a(&entry.name)?;
rows.push(ProfileA2aRow {
name: entry.name.clone(),
description: entry.description.clone(),
a2a,
config_found,
});
}
Ok(ToolResult::success(render_roster(&rows)))
}
}