use serde::{Deserialize, Serialize};
use std::fmt;
pub const SCHEME: &str = "agentdns://";
pub const LOCAL_ORG: &str = "local";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceIdentifier {
pub organization: String,
pub category: Vec<String>,
pub name: String,
}
fn validate_segment(seg: &str, what: &str) -> Result<(), String> {
if seg.is_empty() {
return Err(format!("{what} segment is empty"));
}
if let Some(bad) = seg
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'))
{
return Err(format!("{what} segment '{seg}' has invalid character '{bad}'"));
}
Ok(())
}
impl ServiceIdentifier {
pub fn new(
organization: impl Into<String>,
category: impl IntoIterator<Item = String>,
name: impl Into<String>,
) -> Result<Self, String> {
let organization = organization.into();
let category: Vec<String> = category.into_iter().collect();
let name = name.into();
validate_segment(&organization, "organization")?;
for c in &category {
validate_segment(c, "category")?;
}
validate_segment(&name, "name")?;
Ok(Self { organization, category, name })
}
pub fn local(category: impl Into<String>, name: impl Into<String>) -> Result<Self, String> {
Self::new(LOCAL_ORG, std::iter::once(category.into()), name)
}
pub fn parse(s: &str) -> Result<Self, String> {
let rest = s
.strip_prefix(SCHEME)
.ok_or_else(|| format!("missing '{SCHEME}' scheme in '{s}'"))?;
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() < 2 {
return Err(format!(
"'{s}' needs at least organization and name (agentdns://org/name)"
));
}
let organization = segments[0];
let name = segments[segments.len() - 1];
let category: Vec<String> = segments[1..segments.len() - 1]
.iter()
.map(|s| s.to_string())
.collect();
Self::new(organization, category, name)
}
}
impl fmt::Display for ServiceIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{SCHEME}{}", self.organization)?;
for c in &self.category {
write!(f, "/{c}")?;
}
write!(f, "/{}", self.name)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteServiceRecord {
pub identifier: String,
pub name: String,
pub kind: String,
pub protocol: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RemoteResolveResponse {
#[serde(default)]
pub services: Vec<RemoteServiceRecord>,
}
pub struct RemoteRoot {
base_url: String,
auth_token: Option<String>,
http: reqwest::Client,
}
impl RemoteRoot {
pub fn new(base_url: impl Into<String>, auth_token: Option<String>) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(4))
.connect_timeout(std::time::Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
auth_token,
http,
}
}
pub fn resolve_endpoint(&self) -> String {
format!("{}/agentdns/resolve", self.base_url)
}
pub async fn resolve(&self, need: &str, limit: usize) -> Result<Vec<RemoteServiceRecord>, String> {
let mut req = self
.http
.post(self.resolve_endpoint())
.json(&resolve_request_body(need, limit));
if let Some(token) = &self.auth_token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| format!("agentdns root request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("agentdns root returned HTTP {status}"));
}
let parsed: RemoteResolveResponse = resp
.json()
.await
.map_err(|e| format!("agentdns root response: {e}"))?;
Ok(parsed.services)
}
}
fn resolve_request_body(need: &str, limit: usize) -> serde_json::Value {
serde_json::json!({ "need": need, "limit": limit })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrips_with_nested_category() {
let s = "agentdns://acme/academic/nlp/summarize";
let id = ServiceIdentifier::parse(s).unwrap();
assert_eq!(id.organization, "acme");
assert_eq!(id.category, vec!["academic", "nlp"]);
assert_eq!(id.name, "summarize");
assert_eq!(id.to_string(), s);
}
#[test]
fn roundtrips_without_category() {
let s = "agentdns://local/greeter";
let id = ServiceIdentifier::parse(s).unwrap();
assert!(id.category.is_empty());
assert_eq!(id.name, "greeter");
assert_eq!(id.to_string(), s);
}
#[test]
fn local_helper_builds_local_org() {
let id = ServiceIdentifier::local("agent", "greeter-bot").unwrap();
assert_eq!(id.to_string(), "agentdns://local/agent/greeter-bot");
}
#[test]
fn rejects_missing_scheme() {
assert!(ServiceIdentifier::parse("acme/agent/x").is_err());
assert!(ServiceIdentifier::parse("http://acme/agent/x").is_err());
}
#[test]
fn rejects_too_few_segments() {
assert!(ServiceIdentifier::parse("agentdns://onlyorg").is_err());
}
#[test]
fn rejects_empty_and_invalid_segments() {
assert!(ServiceIdentifier::parse("agentdns://acme//name").is_err()); assert!(ServiceIdentifier::parse("agentdns://acme/na me").is_err()); assert!(ServiceIdentifier::parse("agentdns://acme/na/me!").is_err()); assert!(ServiceIdentifier::new("", std::iter::empty(), "x").is_err());
}
#[test]
fn remote_root_endpoint_trims_base_slash() {
let root = RemoteRoot::new("https://api.parslee.ai/", None);
assert_eq!(root.resolve_endpoint(), "https://api.parslee.ai/agentdns/resolve");
}
#[test]
fn resolve_request_body_shape() {
let body = resolve_request_body("summarize a pdf", 5);
assert_eq!(body["need"], "summarize a pdf");
assert_eq!(body["limit"], 5);
}
#[test]
fn remote_response_deserializes_with_optional_fields() {
let json = r#"{
"services": [
{ "identifier": "agentdns://acme/tool/summarize", "name": "Summarizer",
"kind": "tool", "protocol": "mcp", "description": "Summarizes text",
"pricing": "$0.01/call", "endpoint": "https://acme.example/mcp" },
{ "identifier": "agentdns://acme/agent/writer", "name": "Writer",
"kind": "agent", "protocol": "a2a" }
]
}"#;
let parsed: RemoteResolveResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.services.len(), 2);
assert_eq!(parsed.services[0].pricing.as_deref(), Some("$0.01/call"));
assert_eq!(parsed.services[1].description, "");
assert_eq!(parsed.services[1].endpoint, None);
}
#[test]
fn empty_remote_response_is_default() {
let parsed: RemoteResolveResponse = serde_json::from_str("{}").unwrap();
assert!(parsed.services.is_empty());
}
#[test]
fn accepts_dotted_name_matching_registry_charset() {
let s = "agentdns://local/agent/my.bot.v2";
let id = ServiceIdentifier::parse(s).unwrap();
assert_eq!(id.name, "my.bot.v2");
assert_eq!(id.to_string(), s);
assert!(ServiceIdentifier::local("agent", "my.bot.v2").is_ok());
}
}