adk-ui 0.8.0

Dynamic UI generation for ADK-Rust agents - render forms, cards, tables, charts and more
Documentation
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub enum CatalogSource {
    Embedded,
    Local(PathBuf),
    Remote(String),
}

#[derive(Debug, Clone)]
pub struct CatalogArtifact {
    pub catalog_id: String,
    pub catalog: Value,
    pub metadata: Option<Value>,
    pub source: CatalogSource,
}

#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
    #[error("catalog not found: {0}")]
    NotFound(String),
    #[error("catalog IO error: {0}")]
    Io(String),
    #[error("catalog JSON error: {0}")]
    Json(String),
    #[error("remote catalogs disabled")]
    RemoteDisabled,
    #[error("remote catalog error: {0}")]
    Remote(String),
}

#[derive(Debug, Clone)]
enum CatalogEntry {
    Embedded {
        catalog: &'static str,
        metadata: Option<&'static str>,
    },
    File {
        catalog_path: PathBuf,
        metadata_path: Option<PathBuf>,
    },
}

#[derive(Debug, Clone)]
pub struct CatalogRegistry {
    default_catalog_id: String,
    entries: HashMap<String, CatalogEntry>,
    remote_base_url: Option<String>,
    allow_absolute_remote_urls: bool,
}

impl Default for CatalogRegistry {
    fn default() -> Self {
        let mut registry = Self {
            default_catalog_id: DEFAULT_CATALOG_ID.to_string(),
            entries: HashMap::new(),
            remote_base_url: None,
            allow_absolute_remote_urls: false,
        };

        registry.register_embedded(
            DEFAULT_CATALOG_ID,
            include_str!("../catalog/extended_catalog.json"),
            Some(include_str!("../catalog/metadata.json")),
        );

        registry
    }
}

impl CatalogRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_remote_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.remote_base_url = Some(base_url.into());
        self
    }

    pub fn with_allow_absolute_remote_urls(mut self, allow: bool) -> Self {
        self.allow_absolute_remote_urls = allow;
        self
    }

    pub fn register_local(
        &mut self,
        catalog_id: impl Into<String>,
        catalog_path: impl Into<PathBuf>,
        metadata_path: Option<PathBuf>,
    ) -> &mut Self {
        self.entries.insert(
            catalog_id.into(),
            CatalogEntry::File {
                catalog_path: catalog_path.into(),
                metadata_path,
            },
        );
        self
    }

    pub fn register_embedded(
        &mut self,
        catalog_id: impl Into<String>,
        catalog: &'static str,
        metadata: Option<&'static str>,
    ) -> &mut Self {
        self.entries.insert(
            catalog_id.into(),
            CatalogEntry::Embedded { catalog, metadata },
        );
        self
    }

    pub fn default_catalog_id(&self) -> &str {
        &self.default_catalog_id
    }

    pub fn load_local_catalog(&self, catalog_id: &str) -> Result<CatalogArtifact, CatalogError> {
        let entry = self
            .entries
            .get(catalog_id)
            .ok_or_else(|| CatalogError::NotFound(catalog_id.to_string()))?;

        match entry {
            CatalogEntry::Embedded { catalog, metadata } => {
                let catalog_value: Value =
                    serde_json::from_str(catalog).map_err(|e| CatalogError::Json(e.to_string()))?;
                let metadata_value = metadata.and_then(|raw| serde_json::from_str(raw).ok());
                Ok(CatalogArtifact {
                    catalog_id: catalog_id.to_string(),
                    catalog: catalog_value,
                    metadata: metadata_value,
                    source: CatalogSource::Embedded,
                })
            }
            CatalogEntry::File {
                catalog_path,
                metadata_path,
            } => {
                let raw = std::fs::read_to_string(catalog_path)
                    .map_err(|e| CatalogError::Io(e.to_string()))?;
                let catalog_value: Value =
                    serde_json::from_str(&raw).map_err(|e| CatalogError::Json(e.to_string()))?;

                let metadata_value = match metadata_path {
                    Some(path) => std::fs::read_to_string(path)
                        .ok()
                        .and_then(|s| serde_json::from_str(&s).ok()),
                    None => None,
                };

                Ok(CatalogArtifact {
                    catalog_id: catalog_id.to_string(),
                    catalog: catalog_value,
                    metadata: metadata_value,
                    source: CatalogSource::Local(catalog_path.clone()),
                })
            }
        }
    }

    pub async fn resolve_catalog(&self, catalog_id: &str) -> Result<CatalogArtifact, CatalogError> {
        if let Ok(local) = self.load_local_catalog(catalog_id) {
            return Ok(local);
        }

        let url = if is_absolute_remote_catalog_id(catalog_id) {
            if !self.allow_absolute_remote_urls {
                return Err(CatalogError::Remote(
                    "absolute remote catalog URLs are disabled; use with_remote_base_url(...) or explicitly opt in with with_allow_absolute_remote_urls(true)"
                        .to_string(),
                ));
            }
            Some(catalog_id.to_string())
        } else {
            self.remote_base_url.as_ref().map(|base| {
                format!(
                    "{}/{}",
                    base.trim_end_matches('/'),
                    catalog_id.trim_start_matches('/')
                )
            })
        };

        if let Some(url) = url {
            return self.fetch_remote_catalog(&url).await;
        }

        Err(CatalogError::NotFound(catalog_id.to_string()))
    }

    async fn fetch_remote_catalog(&self, url: &str) -> Result<CatalogArtifact, CatalogError> {
        fetch_remote_catalog(url).await
    }
}

const DEFAULT_CATALOG_ID: &str = "zavora.ai:adk-ui/extended@0.2.0";

fn is_absolute_remote_catalog_id(catalog_id: &str) -> bool {
    catalog_id.starts_with("http://") || catalog_id.starts_with("https://")
}

#[cfg(feature = "remote-catalogs")]
async fn fetch_remote_catalog(url: &str) -> Result<CatalogArtifact, CatalogError> {
    let response = reqwest::get(url)
        .await
        .map_err(|e| CatalogError::Remote(e.to_string()))?;
    let status = response.status();
    if !status.is_success() {
        return Err(CatalogError::Remote(format!("HTTP {}", status)));
    }
    let text = response
        .text()
        .await
        .map_err(|e| CatalogError::Remote(e.to_string()))?;
    let catalog: Value =
        serde_json::from_str(&text).map_err(|e| CatalogError::Json(e.to_string()))?;

    Ok(CatalogArtifact {
        catalog_id: url.to_string(),
        catalog,
        metadata: None,
        source: CatalogSource::Remote(url.to_string()),
    })
}

#[cfg(not(feature = "remote-catalogs"))]
async fn fetch_remote_catalog(_url: &str) -> Result<CatalogArtifact, CatalogError> {
    Err(CatalogError::RemoteDisabled)
}