pub mod discovery;
pub mod manager;
pub mod registry;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
pub use registry::ExtensionRegistry;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtensionKind {
McpServer,
WasmTool,
WasmChannel,
ChannelRelay,
}
impl std::fmt::Display for ExtensionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtensionKind::McpServer => write!(f, "mcp_server"),
ExtensionKind::WasmTool => write!(f, "wasm_tool"),
ExtensionKind::WasmChannel => write!(f, "wasm_channel"),
ExtensionKind::ChannelRelay => write!(f, "channel_relay"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryEntry {
pub name: String,
pub display_name: String,
pub kind: ExtensionKind,
pub description: String,
#[serde(default)]
pub keywords: Vec<String>,
pub source: ExtensionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_source: Option<Box<ExtensionSource>>,
pub auth_hint: AuthHint,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExtensionSource {
McpUrl { url: String },
WasmDownload {
wasm_url: String,
#[serde(default)]
capabilities_url: Option<String>,
},
WasmBuildable {
#[serde(alias = "repo_url")]
source_dir: String,
#[serde(default)]
build_dir: Option<String>,
#[serde(default)]
crate_name: Option<String>,
},
Discovered { url: String },
ChannelRelay { relay_url: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuthHint {
Dcr,
OAuthPreConfigured {
setup_url: String,
},
CapabilitiesAuth,
None,
ChannelRelayOAuth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResultSource {
Registry,
Discovered,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
#[serde(flatten)]
pub entry: RegistryEntry,
pub source: ResultSource,
#[serde(default)]
pub validated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallResult {
pub name: String,
pub kind: ExtensionKind,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpgradeResult {
pub results: Vec<UpgradeOutcome>,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpgradeOutcome {
pub name: String,
pub kind: ExtensionKind,
pub status: String,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolAuthState {
Ready,
NeedsAuth,
NeedsSetup,
NoAuth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthStatus {
Authenticated,
NoAuthRequired,
AwaitingAuthorization {
auth_url: String,
callback_type: String,
},
AwaitingToken {
instructions: String,
setup_url: Option<String>,
},
NeedsSetup {
instructions: String,
setup_url: Option<String>,
},
}
impl AuthStatus {
pub fn as_str(&self) -> &'static str {
match self {
AuthStatus::Authenticated => "authenticated",
AuthStatus::NoAuthRequired => "no_auth_required",
AuthStatus::AwaitingAuthorization { .. } => "awaiting_authorization",
AuthStatus::AwaitingToken { .. } => "awaiting_token",
AuthStatus::NeedsSetup { .. } => "needs_setup",
}
}
}
#[derive(Debug, Clone)]
pub struct AuthResult {
pub name: String,
pub kind: ExtensionKind,
pub status: AuthStatus,
}
impl AuthResult {
pub fn authenticated(name: impl Into<String>, kind: ExtensionKind) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::Authenticated,
}
}
pub fn no_auth_required(name: impl Into<String>, kind: ExtensionKind) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::NoAuthRequired,
}
}
pub fn awaiting_authorization(
name: impl Into<String>,
kind: ExtensionKind,
auth_url: String,
callback_type: String,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingAuthorization {
auth_url,
callback_type,
},
}
}
pub fn awaiting_token(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingToken {
instructions,
setup_url,
},
}
}
pub fn needs_setup(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::NeedsSetup {
instructions,
setup_url,
},
}
}
pub fn is_authenticated(&self) -> bool {
matches!(self.status, AuthStatus::Authenticated)
}
pub fn auth_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { auth_url, .. } => Some(auth_url),
_ => None,
}
}
pub fn callback_type(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { callback_type, .. } => Some(callback_type),
_ => None,
}
}
pub fn instructions(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { instructions, .. }
| AuthStatus::NeedsSetup { instructions, .. } => Some(instructions),
_ => None,
}
}
pub fn setup_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { setup_url, .. }
| AuthStatus::NeedsSetup { setup_url, .. } => setup_url.as_deref(),
_ => None,
}
}
pub fn is_awaiting_token(&self) -> bool {
matches!(self.status, AuthStatus::AwaitingToken { .. })
}
pub fn status_str(&self) -> &'static str {
self.status.as_str()
}
}
impl Serialize for AuthResult {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let optional_count = self.auth_url().is_some() as usize
+ self.callback_type().is_some() as usize
+ self.instructions().is_some() as usize
+ self.setup_url().is_some() as usize;
let mut map = serializer.serialize_map(Some(4 + optional_count))?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("kind", &self.kind)?;
if let Some(url) = self.auth_url() {
map.serialize_entry("auth_url", url)?;
}
if let Some(cb) = self.callback_type() {
map.serialize_entry("callback_type", cb)?;
}
if let Some(inst) = self.instructions() {
map.serialize_entry("instructions", inst)?;
}
if let Some(url) = self.setup_url() {
map.serialize_entry("setup_url", url)?;
}
map.serialize_entry("awaiting_token", &self.is_awaiting_token())?;
map.serialize_entry("status", self.status_str())?;
map.end()
}
}
impl<'de> Deserialize<'de> for AuthResult {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[allow(dead_code)]
struct Raw {
name: String,
kind: ExtensionKind,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
callback_type: Option<String>,
#[serde(default)]
instructions: Option<String>,
#[serde(default)]
setup_url: Option<String>,
#[serde(default)]
awaiting_token: bool,
status: String,
}
let raw = Raw::deserialize(deserializer)?;
let status = match raw.status.as_str() {
"authenticated" => AuthStatus::Authenticated,
"no_auth_required" => AuthStatus::NoAuthRequired,
"awaiting_authorization" => AuthStatus::AwaitingAuthorization {
auth_url: raw.auth_url.unwrap_or_default(),
callback_type: raw.callback_type.unwrap_or_default(),
},
"awaiting_token" => AuthStatus::AwaitingToken {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
"needs_setup" => AuthStatus::NeedsSetup {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
other => {
return Err(serde::de::Error::unknown_variant(
other,
&[
"authenticated",
"no_auth_required",
"awaiting_authorization",
"awaiting_token",
"needs_setup",
],
));
}
};
Ok(AuthResult {
name: raw.name,
kind: raw.kind,
status,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
pub name: String,
pub kind: ExtensionKind,
pub tools_loaded: Vec<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationChallenge {
pub code: String,
pub instructions: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub deep_link: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigureResult {
pub message: String,
pub activated: bool,
pub restart_required: bool,
pub auth_url: Option<String>,
pub verification: Option<VerificationChallenge>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledExtension {
pub name: String,
pub kind: ExtensionKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub authenticated: bool,
pub active: bool,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub needs_setup: bool,
#[serde(default)]
pub has_auth: bool,
#[serde(default = "default_true")]
pub installed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub activation_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExtensionError {
#[error("Extension not found: {0}")]
NotFound(String),
#[error("Extension already installed: {0}")]
AlreadyInstalled(String),
#[error("Extension not installed: {0}")]
NotInstalled(String),
#[error("Authentication failed: {0}")]
AuthFailed(String),
#[error("Server does not support OAuth: {0}")]
AuthNotSupported(String),
#[error("Activation failed: {0}")]
ActivationFailed(String),
#[error("Authentication required")]
AuthRequired,
#[error("Installation failed: {0}")]
InstallFailed(String),
#[error("Discovery failed: {0}")]
DiscoveryFailed(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("Config error: {0}")]
Config(String),
#[error("Primary install failed: {primary}; fallback install also failed: {fallback}")]
FallbackFailed {
primary: Box<ExtensionError>,
fallback: Box<ExtensionError>,
},
#[error("Token validation failed: {0}")]
ValidationFailed(String),
#[error("{0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_result_authenticated_round_trip() {
let result = AuthResult::authenticated("gmail", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "authenticated");
assert_eq!(json["name"], "gmail");
assert_eq!(json["kind"], "wasm_tool");
assert_eq!(json["awaiting_token"], false);
assert!(json.get("auth_url").is_none());
assert!(json.get("instructions").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_authenticated());
assert!(back.auth_url().is_none());
}
#[test]
fn auth_result_awaiting_authorization_round_trip() {
let result = AuthResult::awaiting_authorization(
"google-drive",
ExtensionKind::WasmTool,
"https://accounts.google.com/o/oauth2/v2/auth?state=abc".to_string(),
"local".to_string(),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_authorization");
assert_eq!(
json["auth_url"],
"https://accounts.google.com/o/oauth2/v2/auth?state=abc"
);
assert_eq!(json["callback_type"], "local");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert_eq!(
back.auth_url(),
Some("https://accounts.google.com/o/oauth2/v2/auth?state=abc")
);
assert_eq!(back.callback_type(), Some("local"));
assert!(!back.is_authenticated());
}
#[test]
fn auth_result_awaiting_token_round_trip() {
let result = AuthResult::awaiting_token(
"telegram",
ExtensionKind::WasmChannel,
"Enter your bot token".to_string(),
None,
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_token");
assert_eq!(json["instructions"], "Enter your bot token");
assert_eq!(json["awaiting_token"], true);
assert!(json.get("auth_url").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_awaiting_token());
assert_eq!(back.instructions(), Some("Enter your bot token"));
}
#[test]
fn auth_result_needs_setup_round_trip() {
let result = AuthResult::needs_setup(
"custom-tool",
ExtensionKind::WasmTool,
"Configure OAuth credentials in the Setup tab.".to_string(),
Some("https://console.cloud.google.com".to_string()),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "needs_setup");
assert_eq!(json["setup_url"], "https://console.cloud.google.com");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert!(!back.is_awaiting_token());
assert_eq!(back.setup_url(), Some("https://console.cloud.google.com"));
}
#[test]
fn auth_result_no_auth_required_round_trip() {
let result = AuthResult::no_auth_required("echo", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "no_auth_required");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert_eq!(back.status, AuthStatus::NoAuthRequired);
}
#[test]
fn auth_status_type_safety() {
let result = AuthResult::awaiting_authorization(
"test",
ExtensionKind::WasmTool,
"https://example.com".to_string(),
"local".to_string(),
);
assert!(result.auth_url().is_some());
assert!(!result.is_awaiting_token());
let result = AuthResult::authenticated("test", ExtensionKind::WasmTool);
assert!(result.auth_url().is_none());
assert!(result.instructions().is_none());
assert!(result.setup_url().is_none());
}
#[test]
fn extension_kind_display() {
assert_eq!(ExtensionKind::McpServer.to_string(), "mcp_server");
assert_eq!(ExtensionKind::WasmTool.to_string(), "wasm_tool");
assert_eq!(ExtensionKind::WasmChannel.to_string(), "wasm_channel");
}
#[test]
fn extension_kind_serde_roundtrip() {
for kind in [
ExtensionKind::McpServer,
ExtensionKind::WasmTool,
ExtensionKind::WasmChannel,
] {
let json = serde_json::to_value(kind).unwrap();
let back: ExtensionKind = serde_json::from_value(json).unwrap();
assert_eq!(back, kind);
}
assert_eq!(
serde_json::to_value(ExtensionKind::McpServer).unwrap(),
"mcp_server"
);
assert_eq!(
serde_json::to_value(ExtensionKind::WasmTool).unwrap(),
"wasm_tool"
);
assert_eq!(
serde_json::to_value(ExtensionKind::WasmChannel).unwrap(),
"wasm_channel"
);
}
#[test]
fn extension_source_serde_mcp_url() {
let src = ExtensionSource::McpUrl {
url: "https://mcp.example.com".to_string(),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "mcp_url");
assert_eq!(json["url"], "https://mcp.example.com");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::McpUrl { url } if url == "https://mcp.example.com")
);
}
#[test]
fn extension_source_serde_wasm_download() {
let src = ExtensionSource::WasmDownload {
wasm_url: "https://cdn.example.com/tool.wasm".to_string(),
capabilities_url: Some("https://cdn.example.com/caps.json".to_string()),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "wasm_download");
assert_eq!(json["wasm_url"], "https://cdn.example.com/tool.wasm");
assert_eq!(
json["capabilities_url"],
"https://cdn.example.com/caps.json"
);
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::WasmDownload { capabilities_url: Some(c), .. } if c.contains("caps.json"))
);
}
#[test]
fn extension_source_serde_wasm_buildable() {
let src = ExtensionSource::WasmBuildable {
source_dir: "/home/user/tools/my-tool".to_string(),
build_dir: Some("target/wasm32-wasip2/release".to_string()),
crate_name: Some("my_tool".to_string()),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "wasm_buildable");
assert_eq!(json["source_dir"], "/home/user/tools/my-tool");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(
matches!(back, ExtensionSource::WasmBuildable { source_dir, .. } if source_dir.contains("my-tool"))
);
}
#[test]
fn extension_source_serde_discovered() {
let src = ExtensionSource::Discovered {
url: "https://discovered.example.com".to_string(),
};
let json = serde_json::to_value(&src).unwrap();
assert_eq!(json["type"], "discovered");
let back: ExtensionSource = serde_json::from_value(json).unwrap();
assert!(matches!(back, ExtensionSource::Discovered { url } if url.contains("discovered")));
}
#[test]
fn auth_hint_serde_all_variants() {
let json = serde_json::to_value(&AuthHint::Dcr).unwrap();
assert_eq!(json["type"], "dcr");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::Dcr));
let hint = AuthHint::OAuthPreConfigured {
setup_url: "https://dev.example.com/apps".to_string(),
};
let json = serde_json::to_value(&hint).unwrap();
assert_eq!(json["type"], "o_auth_pre_configured");
assert_eq!(json["setup_url"], "https://dev.example.com/apps");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(
matches!(back, AuthHint::OAuthPreConfigured { setup_url } if setup_url.contains("dev.example"))
);
let json = serde_json::to_value(&AuthHint::CapabilitiesAuth).unwrap();
assert_eq!(json["type"], "capabilities_auth");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::CapabilitiesAuth));
let json = serde_json::to_value(&AuthHint::None).unwrap();
assert_eq!(json["type"], "none");
let back: AuthHint = serde_json::from_value(json).unwrap();
assert!(matches!(back, AuthHint::None));
}
#[test]
fn search_result_serde_registry_source() {
let entry = RegistryEntry {
name: "notion".to_string(),
display_name: "Notion".to_string(),
kind: ExtensionKind::McpServer,
description: "Notion integration".to_string(),
keywords: vec!["notes".to_string(), "wiki".to_string()],
source: ExtensionSource::McpUrl {
url: "https://mcp.notion.so".to_string(),
},
fallback_source: None,
auth_hint: AuthHint::Dcr,
version: None,
};
let sr = SearchResult {
entry,
source: ResultSource::Registry,
validated: false,
};
let json = serde_json::to_value(&sr).unwrap();
assert_eq!(json["name"], "notion");
assert_eq!(json["kind"], "mcp_server");
assert_eq!(json["description"], "Notion integration");
assert_eq!(json["validated"], false);
assert!(json.get("auth_hint").is_some());
assert_eq!(json["keywords"].as_array().unwrap().len(), 2);
}
#[test]
fn search_result_serde_discovered_source() {
let entry = RegistryEntry {
name: "custom-api".to_string(),
display_name: "Custom API".to_string(),
kind: ExtensionKind::McpServer,
description: "Discovered MCP server".to_string(),
keywords: vec![],
source: ExtensionSource::Discovered {
url: "https://custom.example.com/.well-known/mcp".to_string(),
},
fallback_source: None,
auth_hint: AuthHint::None,
version: None,
};
let sr = SearchResult {
entry,
source: ResultSource::Discovered,
validated: true,
};
let json = serde_json::to_value(&sr).unwrap();
assert_eq!(json["name"], "custom-api");
assert_eq!(json["display_name"], "Custom API");
assert_eq!(json["validated"], true);
assert!(json.get("keywords").is_some());
}
#[test]
fn install_result_serde_roundtrip() {
let ir = InstallResult {
name: "weather".to_string(),
kind: ExtensionKind::WasmTool,
message: "Installed successfully".to_string(),
};
let json = serde_json::to_value(&ir).unwrap();
assert_eq!(json["name"], "weather");
assert_eq!(json["kind"], "wasm_tool");
assert_eq!(json["message"], "Installed successfully");
let back: InstallResult = serde_json::from_value(json).unwrap();
assert_eq!(back.name, "weather");
assert_eq!(back.kind, ExtensionKind::WasmTool);
}
#[test]
fn activate_result_serde_roundtrip() {
let ar = ActivateResult {
name: "slack".to_string(),
kind: ExtensionKind::WasmChannel,
tools_loaded: vec!["send_message".to_string(), "read_channel".to_string()],
message: "Activated with 2 tools".to_string(),
};
let json = serde_json::to_value(&ar).unwrap();
assert_eq!(json["name"], "slack");
assert_eq!(json["kind"], "wasm_channel");
assert_eq!(json["tools_loaded"].as_array().unwrap().len(), 2);
let back: ActivateResult = serde_json::from_value(json).unwrap();
assert_eq!(back.tools_loaded, vec!["send_message", "read_channel"]);
}
#[test]
fn installed_extension_serde_defaults() {
let json = serde_json::json!({
"name": "echo",
"kind": "wasm_tool",
"authenticated": false,
"active": false,
});
let ext: InstalledExtension = serde_json::from_value(json).unwrap();
assert_eq!(ext.name, "echo");
assert!(ext.installed, "installed should default to true");
assert!(!ext.needs_setup, "needs_setup should default to false");
assert!(!ext.has_auth);
assert!(ext.tools.is_empty());
assert!(ext.display_name.is_none());
assert!(ext.description.is_none());
assert!(ext.url.is_none());
assert!(ext.activation_error.is_none());
}
#[test]
fn installed_extension_serde_all_fields() {
let ext = InstalledExtension {
name: "gmail".to_string(),
kind: ExtensionKind::WasmTool,
display_name: Some("Gmail Tool".to_string()),
description: Some("Read and send emails".to_string()),
url: Some("https://gmail.example.com".to_string()),
authenticated: true,
active: true,
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
needs_setup: true,
has_auth: true,
installed: false,
activation_error: Some("token expired".to_string()),
version: None,
};
let json = serde_json::to_value(&ext).unwrap();
assert_eq!(json["display_name"], "Gmail Tool");
assert_eq!(json["description"], "Read and send emails");
assert_eq!(json["url"], "https://gmail.example.com");
assert_eq!(json["needs_setup"], true);
assert_eq!(json["installed"], false);
assert_eq!(json["activation_error"], "token expired");
let back: InstalledExtension = serde_json::from_value(json).unwrap();
assert_eq!(back.name, "gmail");
assert_eq!(back.tools.len(), 2);
assert!(back.needs_setup);
assert!(!back.installed);
assert_eq!(back.activation_error.as_deref(), Some("token expired"));
}
#[test]
fn extension_error_display_all_variants() {
let cases: Vec<(ExtensionError, &str)> = vec![
(
ExtensionError::NotFound("foo".into()),
"Extension not found: foo",
),
(
ExtensionError::AlreadyInstalled("bar".into()),
"Extension already installed: bar",
),
(
ExtensionError::NotInstalled("baz".into()),
"Extension not installed: baz",
),
(
ExtensionError::AuthFailed("bad token".into()),
"Authentication failed: bad token",
),
(
ExtensionError::ActivationFailed("crash".into()),
"Activation failed: crash",
),
(
ExtensionError::InstallFailed("disk full".into()),
"Installation failed: disk full",
),
(
ExtensionError::DiscoveryFailed("timeout".into()),
"Discovery failed: timeout",
),
(
ExtensionError::InvalidUrl("not a url".into()),
"Invalid URL: not a url",
),
(
ExtensionError::DownloadFailed("404".into()),
"Download failed: 404",
),
(
ExtensionError::Config("missing key".into()),
"Config error: missing key",
),
(ExtensionError::AuthRequired, "Authentication required"),
(
ExtensionError::Other("something broke".into()),
"something broke",
),
(
ExtensionError::FallbackFailed {
primary: Box::new(ExtensionError::DownloadFailed("404".into())),
fallback: Box::new(ExtensionError::InstallFailed("no cargo".into())),
},
"Primary install failed: Download failed: 404; fallback install also failed: Installation failed: no cargo",
),
];
for (err, expected) in cases {
assert_eq!(err.to_string(), expected);
}
}
#[test]
fn tool_auth_state_equality() {
assert_eq!(ToolAuthState::Ready, ToolAuthState::Ready);
assert_eq!(ToolAuthState::NeedsAuth, ToolAuthState::NeedsAuth);
assert_eq!(ToolAuthState::NeedsSetup, ToolAuthState::NeedsSetup);
assert_eq!(ToolAuthState::NoAuth, ToolAuthState::NoAuth);
assert_ne!(ToolAuthState::Ready, ToolAuthState::NeedsAuth);
assert_ne!(ToolAuthState::NeedsSetup, ToolAuthState::NoAuth);
assert_ne!(ToolAuthState::Ready, ToolAuthState::NoAuth);
}
#[test]
fn result_source_serde() {
let json = serde_json::to_value(ResultSource::Registry).unwrap();
assert_eq!(json, "registry");
let back: ResultSource = serde_json::from_value(json).unwrap();
assert_eq!(back, ResultSource::Registry);
let json = serde_json::to_value(ResultSource::Discovered).unwrap();
assert_eq!(json, "discovered");
let back: ResultSource = serde_json::from_value(json).unwrap();
assert_eq!(back, ResultSource::Discovered);
}
#[test]
fn auth_result_status_str_all_variants() {
assert_eq!(
AuthResult::authenticated("a", ExtensionKind::McpServer).status_str(),
"authenticated"
);
assert_eq!(
AuthResult::no_auth_required("b", ExtensionKind::WasmTool).status_str(),
"no_auth_required"
);
assert_eq!(
AuthResult::awaiting_authorization(
"c",
ExtensionKind::WasmChannel,
"https://x.com".into(),
"local".into(),
)
.status_str(),
"awaiting_authorization"
);
assert_eq!(
AuthResult::awaiting_token("d", ExtensionKind::WasmTool, "paste token".into(), None)
.status_str(),
"awaiting_token"
);
assert_eq!(
AuthResult::needs_setup(
"e",
ExtensionKind::McpServer,
"configure oauth".into(),
Some("https://setup.example.com".into()),
)
.status_str(),
"needs_setup"
);
}
}