use std::{collections::BTreeMap, path::Path};
use mentra::a2a::{A2aAgentConfig, A2aConfigError};
use serde::Deserialize;
use crate::expand::expand;
use super::{A2aAgent, A2aError};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct A2aFile {
agents: Option<BTreeMap<String, RawAgent>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAgent {
url: Option<String>,
#[serde(default)]
headers: BTreeMap<String, String>,
}
pub(super) fn parse(path: &Path, text: &str) -> Result<Vec<A2aAgent>, A2aError> {
parse_with(path, text, &|name| std::env::var(name).ok())
}
fn parse_with(
path: &Path,
text: &str,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Vec<A2aAgent>, A2aError> {
let file: A2aFile = serde_json::from_str(text).map_err(|source| A2aError::Parse {
path: path.to_path_buf(),
problem: match source.classify() {
serde_json::error::Category::Syntax => "a syntax error",
serde_json::error::Category::Data => "an unknown key or a value of the wrong type",
serde_json::error::Category::Eof => "an unexpected end of input",
serde_json::error::Category::Io => "a read error",
},
line: source.line(),
column: source.column(),
})?;
let Some(entries) = file.agents else {
return Err(A2aError::NoAgents {
path: path.to_path_buf(),
});
};
let origin = path.display().to_string();
entries
.into_iter()
.map(|(name, raw)| raw.into_agent(&origin, name, lookup))
.collect()
}
pub(super) fn refused(error: &A2aConfigError) -> String {
match error {
A2aConfigError::PlaintextCredentials { .. } => "has headers that would travel over \
plaintext `http` to a non-loopback host; use `https` or a loopback address"
.to_string(),
other => format!("is not a valid A2A agent configuration: {other}"),
}
}
impl RawAgent {
fn into_agent(
self,
origin: &str,
name: String,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<A2aAgent, A2aError> {
let invalid = |reason: String| A2aError::Invalid {
origin: origin.to_string(),
name: name.clone(),
reason,
};
let expanded = |field: &str, raw: &str| -> Result<String, A2aError> {
expand(raw, lookup)
.map_err(|reason| invalid(format!("has a `{field}` value that {reason}")))
};
let url = self
.url
.as_deref()
.filter(|url| !url.trim().is_empty())
.ok_or_else(|| invalid("has no `url` to reach".to_string()))?;
let url = expanded("url", url)?;
let config = self.headers.iter().try_fold(
A2aAgentConfig::new(name.clone(), url),
|config, (key, value)| {
Ok::<_, A2aError>(
config.with_header(key, expanded(&format!("headers.{key}"), value)?),
)
},
)?;
config
.validate()
.map_err(|error| invalid(refused(&error)))?;
Ok(A2aAgent(config))
}
}
#[cfg(test)]
mod tests;