use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use tracing::{debug, info};
const CONFIG_FILE_NAME: &str = "config.toml";
const CONFIG_DIR_NAME: &str = "devboy-tools";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitlab: Option<GitLabConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clickup: Option<ClickUpConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jira: Option<JiraConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fireflies: Option<FirefliesConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confluence: Option<ConfluenceConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackConfig>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub contexts: BTreeMap<String, ContextConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_context: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub proxy_mcp_servers: Vec<ProxyMcpServerConfig>,
#[serde(default, skip_serializing_if = "BuiltinToolsConfig::is_empty")]
pub builtin_tools: BuiltinToolsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_pipeline: Option<FormatPipelineConfig>,
#[serde(default, skip_serializing_if = "ProxyConfig::is_default")]
pub proxy: ProxyConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sentry: Option<SentryConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_config: Option<RemoteConfigSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyMcpServerConfig {
pub name: String,
pub url: String,
#[serde(default = "default_auth_none")]
pub auth_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_prefix: Option<String>,
#[serde(default = "default_transport_sse")]
pub transport: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub routing: Option<ProxyRoutingOverride>,
}
fn default_transport_sse() -> String {
"sse".to_string()
}
fn default_auth_none() -> String {
"none".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ContextConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitlab: Option<GitLabConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clickup: Option<ClickUpConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jira: Option<JiraConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fireflies: Option<FirefliesConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confluence: Option<ConfluenceConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubConfig {
pub owner: String,
pub repo: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLabConfig {
#[serde(default = "default_gitlab_url")]
pub url: String,
pub project_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClickUpConfig {
pub list_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JiraConfig {
pub url: String,
pub project_key: String,
pub email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FirefliesConfig {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfluenceConfig {
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub space_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect_uri: Option<String>,
#[serde(
default = "default_slack_required_scopes",
skip_serializing_if = "is_default_slack_required_scopes"
)]
pub required_scopes: Vec<String>,
}
impl Default for SlackConfig {
fn default() -> Self {
Self {
team_id: None,
workspace: None,
base_url: None,
client_id: None,
redirect_uri: None,
required_scopes: default_slack_required_scopes(),
}
}
}
pub fn default_slack_required_scopes() -> Vec<String> {
vec![
"channels:read".to_string(),
"channels:history".to_string(),
"groups:read".to_string(),
"groups:history".to_string(),
"im:read".to_string(),
"im:history".to_string(),
"mpim:read".to_string(),
"mpim:history".to_string(),
"chat:write".to_string(),
"users:read".to_string(),
]
}
fn is_default_slack_required_scopes(scopes: &[String]) -> bool {
scopes == default_slack_required_scopes().as_slice()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BuiltinToolsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled: Vec<String>,
}
impl BuiltinToolsConfig {
pub fn is_empty(&self) -> bool {
self.disabled.is_empty() && self.enabled.is_empty()
}
pub fn validate(&self) -> Result<()> {
if !self.disabled.is_empty() && !self.enabled.is_empty() {
return Err(Error::Config(
"builtin_tools: 'disabled' and 'enabled' are mutually exclusive, use only one"
.to_string(),
));
}
Ok(())
}
pub fn is_tool_allowed(&self, name: &str) -> bool {
if !self.enabled.is_empty() {
return self.enabled.iter().any(|n| n == name);
}
if !self.disabled.is_empty() {
return !self.disabled.iter().any(|n| n == name);
}
true
}
pub fn warn_unknown_tools(&self, known: &[&str]) {
for name in self.disabled.iter().chain(self.enabled.iter()) {
if !known.iter().any(|k| k == name) {
tracing::warn!(
"builtin_tools: unknown tool name '{}', it will have no effect",
name
);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatPipelineConfig {
#[serde(default = "default_budget_tokens")]
pub budget_tokens: usize,
#[serde(default = "default_margin")]
pub margin: f64,
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
#[serde(default = "default_format_toon")]
pub default_format: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub strategies: HashMap<String, String>,
#[serde(default)]
pub proxy_matching: ProxyMatchingConfig,
}
impl Default for FormatPipelineConfig {
fn default() -> Self {
Self {
budget_tokens: default_budget_tokens(),
margin: default_margin(),
max_iterations: default_max_iterations(),
default_format: default_format_toon(),
strategies: HashMap::new(),
proxy_matching: ProxyMatchingConfig::default(),
}
}
}
fn default_budget_tokens() -> usize {
8000
}
fn default_margin() -> f64 {
0.20
}
fn default_max_iterations() -> usize {
3
}
fn default_format_toon() -> String {
"toon".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyMatchingConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for ProxyMatchingConfig {
fn default() -> Self {
Self {
enabled: default_true(),
}
}
}
fn default_true() -> bool {
true
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct SentryConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dsn: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample_rate: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub traces_sample_rate: Option<f32>,
}
impl std::fmt::Debug for SentryConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SentryConfig")
.field("dsn", &self.dsn.as_ref().map(|_| "<redacted>"))
.field("environment", &self.environment)
.field("sample_rate", &self.sample_rate)
.field("traces_sample_rate", &self.traces_sample_rate)
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RemoteConfigSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_key: Option<String>,
}
fn default_gitlab_url() -> String {
"https://gitlab.com".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RoutingStrategy {
#[default]
Remote,
Local,
#[serde(rename = "local-first")]
LocalFirst,
#[serde(rename = "remote-first")]
RemoteFirst,
}
impl RoutingStrategy {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"remote" => Some(Self::Remote),
"local" => Some(Self::Local),
"local-first" | "local_first" | "localfirst" => Some(Self::LocalFirst),
"remote-first" | "remote_first" | "remotefirst" => Some(Self::RemoteFirst),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyToolRule {
pub pattern: String,
pub strategy: RoutingStrategy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyRoutingConfig {
#[serde(default)]
pub strategy: RoutingStrategy,
#[serde(default = "default_true")]
pub fallback_on_error: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_overrides: Vec<ProxyToolRule>,
}
impl Default for ProxyRoutingConfig {
fn default() -> Self {
Self {
strategy: RoutingStrategy::default(),
fallback_on_error: true,
tool_overrides: Vec::new(),
}
}
}
impl ProxyRoutingConfig {
pub fn strategy_for(&self, tool_name: &str) -> RoutingStrategy {
for rule in &self.tool_overrides {
if matches_glob(&rule.pattern, tool_name) {
return rule.strategy;
}
}
self.strategy
}
pub fn merged_with(&self, override_cfg: Option<&ProxyRoutingOverride>) -> ProxyRoutingConfig {
let Some(o) = override_cfg else {
return self.clone();
};
let mut merged = self.clone();
if let Some(strategy) = o.strategy {
merged.strategy = strategy;
}
if let Some(fallback_on_error) = o.fallback_on_error {
merged.fallback_on_error = fallback_on_error;
}
if let Some(extra) = &o.tool_overrides
&& !extra.is_empty()
{
let mut combined = extra.clone();
combined.extend(self.tool_overrides.iter().cloned());
merged.tool_overrides = combined;
}
merged
}
pub fn is_default(&self) -> bool {
self.strategy == RoutingStrategy::default()
&& self.fallback_on_error
&& self.tool_overrides.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyRoutingOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<RoutingStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_on_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_overrides: Option<Vec<ProxyToolRule>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxySecretsConfig {
#[serde(default = "default_secrets_cache_ttl")]
pub cache_ttl_secs: u64,
}
impl Default for ProxySecretsConfig {
fn default() -> Self {
Self {
cache_ttl_secs: default_secrets_cache_ttl(),
}
}
}
impl ProxySecretsConfig {
pub fn is_default(&self) -> bool {
self.cache_ttl_secs == default_secrets_cache_ttl()
}
}
fn default_secrets_cache_ttl() -> u64 {
300
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyTelemetryConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_batch_interval_secs")]
pub batch_interval_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_key: Option<String>,
#[serde(default = "default_offline_queue_max")]
pub offline_queue_max: usize,
}
impl Default for ProxyTelemetryConfig {
fn default() -> Self {
Self {
enabled: true,
batch_size: default_batch_size(),
batch_interval_secs: default_batch_interval_secs(),
endpoint: None,
token_key: None,
offline_queue_max: default_offline_queue_max(),
}
}
}
impl ProxyTelemetryConfig {
pub fn is_default(&self) -> bool {
self.enabled
&& self.batch_size == default_batch_size()
&& self.batch_interval_secs == default_batch_interval_secs()
&& self.endpoint.is_none()
&& self.token_key.is_none()
&& self.offline_queue_max == default_offline_queue_max()
}
}
fn default_batch_size() -> usize {
100
}
fn default_batch_interval_secs() -> u64 {
30
}
fn default_offline_queue_max() -> usize {
10_000
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyConfig {
#[serde(default, skip_serializing_if = "ProxyRoutingConfig::is_default")]
pub routing: ProxyRoutingConfig,
#[serde(default, skip_serializing_if = "ProxySecretsConfig::is_default")]
pub secrets: ProxySecretsConfig,
#[serde(default, skip_serializing_if = "ProxyTelemetryConfig::is_default")]
pub telemetry: ProxyTelemetryConfig,
}
impl ProxyConfig {
pub fn is_default(&self) -> bool {
self.routing.is_default() && self.secrets.is_default() && self.telemetry.is_default()
}
}
pub fn matches_glob(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == name;
}
let segments: Vec<&str> = pattern.split('*').collect();
let mut cursor = 0usize;
let last_idx = segments.len() - 1;
if !segments[0].is_empty() {
if !name.starts_with(segments[0]) {
return false;
}
cursor = segments[0].len();
}
for seg in &segments[1..last_idx] {
if seg.is_empty() {
continue; }
match name[cursor..].find(seg) {
Some(pos) => cursor += pos + seg.len(),
None => return false,
}
}
let last = segments[last_idx];
if last.is_empty() {
return true;
}
if cursor > name.len() {
return false;
}
name[cursor..].ends_with(last)
}
impl Config {
pub const DEFAULT_CONTEXT_NAME: &'static str = "default";
pub fn config_dir() -> Result<PathBuf> {
dirs::config_dir()
.map(|p| p.join(CONFIG_DIR_NAME))
.ok_or_else(|| Error::Config("Could not determine config directory".to_string()))
}
pub fn config_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join(CONFIG_FILE_NAME))
}
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &PathBuf) -> Result<Self> {
if !path.exists() {
debug!(path = ?path, "Config file does not exist, using defaults");
return Ok(Self::default());
}
debug!(path = ?path, "Loading config");
let contents = std::fs::read_to_string(path)
.map_err(|e| Error::Config(format!("Failed to read config file: {}", e)))?;
let mut config: Config = toml::from_str(&contents)
.map_err(|e| Error::Config(format!("Failed to parse config file: {}", e)))?;
config.sanitize();
config.validate()?;
info!(path = ?path, "Config loaded successfully");
Ok(config)
}
pub fn sanitize(&mut self) {
if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref()
&& endpoint.is_empty()
{
self.proxy.telemetry.endpoint = None;
}
}
pub fn validate(&self) -> Result<()> {
if let Some(endpoint) = self.proxy.telemetry.endpoint.as_deref() {
validate_http_url(endpoint, "proxy.telemetry.endpoint")?;
}
Ok(())
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| Error::Config(format!("Failed to create config directory: {}", e)))?;
}
debug!(path = ?path, "Saving config");
let contents = toml::to_string_pretty(self)
.map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
std::fs::write(path, contents)
.map_err(|e| Error::Config(format!("Failed to write config file: {}", e)))?;
info!(path = ?path, "Config saved successfully");
Ok(())
}
pub fn has_any_provider(&self) -> bool {
self.github.is_some()
|| self.gitlab.is_some()
|| self.clickup.is_some()
|| self.jira.is_some()
|| self.fireflies.is_some()
|| self.confluence.is_some()
|| self.slack.is_some()
|| self.contexts.values().any(ContextConfig::has_any_provider)
}
pub fn configured_providers(&self) -> Vec<&'static str> {
let mut providers = Vec::new();
if self.github.is_some() {
providers.push("github");
}
if self.gitlab.is_some() {
providers.push("gitlab");
}
if self.clickup.is_some() {
providers.push("clickup");
}
if self.jira.is_some() {
providers.push("jira");
}
if self.confluence.is_some() {
providers.push("confluence");
}
if self.slack.is_some() {
providers.push("slack");
}
providers
}
pub fn context_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.contexts.keys().cloned().collect();
if self.legacy_default_context().is_some()
&& !names.iter().any(|n| n == Self::DEFAULT_CONTEXT_NAME)
{
names.push(Self::DEFAULT_CONTEXT_NAME.to_string());
}
names.sort();
names
}
pub fn get_context(&self, name: &str) -> Option<ContextConfig> {
if name == Self::DEFAULT_CONTEXT_NAME {
return self
.contexts
.get(name)
.cloned()
.or_else(|| self.legacy_default_context());
}
self.contexts.get(name).cloned()
}
pub fn resolve_active_context_name(&self) -> Option<String> {
if let Some(active) = &self.active_context
&& self.get_context(active).is_some()
{
return Some(active.clone());
}
if self.get_context(Self::DEFAULT_CONTEXT_NAME).is_some() {
return Some(Self::DEFAULT_CONTEXT_NAME.to_string());
}
self.context_names().into_iter().next()
}
pub fn set_active_context(&mut self, name: &str) -> Result<()> {
if self.get_context(name).is_none() {
return Err(Error::Config(format!("Unknown context: {}", name)));
}
self.active_context = Some(name.to_string());
Ok(())
}
pub fn legacy_default_context(&self) -> Option<ContextConfig> {
let ctx = ContextConfig {
github: self.github.clone(),
gitlab: self.gitlab.clone(),
clickup: self.clickup.clone(),
jira: self.jira.clone(),
fireflies: self.fireflies.clone(),
confluence: self.confluence.clone(),
slack: self.slack.clone(),
};
if ctx.has_any_provider() {
Some(ctx)
} else {
None
}
}
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 3 && parts[0] == "proxy" {
return self.set_proxy_field(parts[1], parts[2], value);
}
if parts.len() != 2 {
return Err(Error::Config(format!(
"Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
key
)));
}
let (provider, field) = (parts[0], parts[1]);
match provider {
"github" => {
let config = self.github.get_or_insert_with(|| GitHubConfig {
owner: String::new(),
repo: String::new(),
base_url: None,
});
match field {
"owner" => config.owner = value.to_string(),
"repo" => config.repo = value.to_string(),
"base_url" | "url" => config.base_url = Some(value.to_string()),
_ => {
return Err(Error::Config(format!(
"Unknown GitHub config field: {}",
field
)));
}
}
}
"gitlab" => {
let config = self.gitlab.get_or_insert_with(|| GitLabConfig {
url: default_gitlab_url(),
project_id: String::new(),
});
match field {
"url" => config.url = value.to_string(),
"project_id" | "project" => config.project_id = value.to_string(),
_ => {
return Err(Error::Config(format!(
"Unknown GitLab config field: {}",
field
)));
}
}
}
"clickup" => {
let config = self.clickup.get_or_insert_with(|| ClickUpConfig {
list_id: String::new(),
team_id: None,
});
match field {
"list_id" | "list" => config.list_id = value.to_string(),
"team_id" | "team" => config.team_id = Some(value.to_string()),
_ => {
return Err(Error::Config(format!(
"Unknown ClickUp config field: {}",
field
)));
}
}
}
"jira" => {
let config = self.jira.get_or_insert_with(|| JiraConfig {
url: String::new(),
project_key: String::new(),
email: String::new(),
});
match field {
"url" => config.url = value.to_string(),
"project_key" | "project" => config.project_key = value.to_string(),
"email" => config.email = value.to_string(),
_ => {
return Err(Error::Config(format!(
"Unknown Jira config field: {}",
field
)));
}
}
}
"confluence" => {
let config = self.confluence.get_or_insert_with(|| ConfluenceConfig {
base_url: String::new(),
api_version: None,
username: None,
space_key: None,
});
match field {
"base_url" | "url" => config.base_url = value.to_string(),
"api_version" | "api" | "version" => {
config.api_version = Some(value.to_string())
}
"username" | "email" | "user" => config.username = Some(value.to_string()),
"space_key" | "space" => config.space_key = Some(value.to_string()),
_ => {
return Err(Error::Config(format!(
"Unknown Confluence config field: {}",
field
)));
}
}
}
"slack" => {
let config = self.slack.get_or_insert_with(SlackConfig::default);
match field {
"team_id" | "team" => config.team_id = Some(value.to_string()),
"workspace" => config.workspace = Some(value.to_string()),
"base_url" | "url" => config.base_url = Some(value.to_string()),
"client_id" => config.client_id = Some(value.to_string()),
"redirect_uri" => config.redirect_uri = Some(value.to_string()),
_ => {
return Err(Error::Config(format!(
"Unknown Slack config field: {}",
field
)));
}
}
}
_ => {
return Err(Error::Config(format!("Unknown provider: {}", provider)));
}
}
Ok(())
}
pub fn get(&self, key: &str) -> Result<Option<String>> {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 3 && parts[0] == "proxy" {
return self.get_proxy_field(parts[1], parts[2]);
}
if parts.len() != 2 {
return Err(Error::Config(format!(
"Invalid config key '{}'. Expected formats: provider.field or proxy.section.field",
key
)));
}
let (provider, field) = (parts[0], parts[1]);
match provider {
"github" => {
let Some(config) = &self.github else {
return Ok(None);
};
match field {
"owner" => Ok(Some(config.owner.clone())),
"repo" => Ok(Some(config.repo.clone())),
"base_url" | "url" => Ok(config.base_url.clone()),
_ => Err(Error::Config(format!(
"Unknown GitHub config field: {}",
field
))),
}
}
"gitlab" => {
let Some(config) = &self.gitlab else {
return Ok(None);
};
match field {
"url" => Ok(Some(config.url.clone())),
"project_id" | "project" => Ok(Some(config.project_id.clone())),
_ => Err(Error::Config(format!(
"Unknown GitLab config field: {}",
field
))),
}
}
"clickup" => {
let Some(config) = &self.clickup else {
return Ok(None);
};
match field {
"list_id" | "list" => Ok(Some(config.list_id.clone())),
"team_id" | "team" => Ok(config.team_id.clone()),
_ => Err(Error::Config(format!(
"Unknown ClickUp config field: {}",
field
))),
}
}
"jira" => {
let Some(config) = &self.jira else {
return Ok(None);
};
match field {
"url" => Ok(Some(config.url.clone())),
"project_key" | "project" => Ok(Some(config.project_key.clone())),
"email" => Ok(Some(config.email.clone())),
_ => Err(Error::Config(format!(
"Unknown Jira config field: {}",
field
))),
}
}
"confluence" => {
let Some(config) = &self.confluence else {
return Ok(None);
};
match field {
"base_url" | "url" => Ok(Some(config.base_url.clone())),
"api_version" | "api" | "version" => Ok(config.api_version.clone()),
"username" | "email" | "user" => Ok(config.username.clone()),
"space_key" | "space" => Ok(config.space_key.clone()),
_ => Err(Error::Config(format!(
"Unknown Confluence config field: {}",
field
))),
}
}
"slack" => {
let Some(config) = &self.slack else {
return Ok(None);
};
match field {
"team_id" | "team" => Ok(config.team_id.clone()),
"workspace" => Ok(config.workspace.clone()),
"base_url" | "url" => Ok(config.base_url.clone()),
"client_id" => Ok(config.client_id.clone()),
"redirect_uri" => Ok(config.redirect_uri.clone()),
_ => Err(Error::Config(format!(
"Unknown Slack config field: {}",
field
))),
}
}
_ => Err(Error::Config(format!("Unknown provider: {}", provider))),
}
}
fn set_proxy_field(&mut self, section: &str, field: &str, value: &str) -> Result<()> {
match section {
"routing" => match field {
"strategy" => {
let strat = RoutingStrategy::parse(value).ok_or_else(|| {
Error::Config(format!(
"Invalid routing strategy '{}'. Allowed (case-insensitive): \
remote, local, local-first, remote-first",
value
))
})?;
self.proxy.routing.strategy = strat;
Ok(())
}
"fallback_on_error" => {
self.proxy.routing.fallback_on_error = parse_bool(value)?;
Ok(())
}
_ => Err(Error::Config(format!(
"Unknown proxy.routing field: {}",
field
))),
},
"secrets" => match field {
"cache_ttl_secs" => {
self.proxy.secrets.cache_ttl_secs = parse_u64(value, field)?;
Ok(())
}
_ => Err(Error::Config(format!(
"Unknown proxy.secrets field: {}",
field
))),
},
"telemetry" => match field {
"enabled" => {
self.proxy.telemetry.enabled = parse_bool(value)?;
Ok(())
}
"endpoint" => {
self.proxy.telemetry.endpoint = if value.is_empty() {
None
} else {
validate_http_url(value, "proxy.telemetry.endpoint")?;
Some(value.to_string())
};
Ok(())
}
"token_key" => {
self.proxy.telemetry.token_key = if value.is_empty() {
None
} else {
Some(value.to_string())
};
Ok(())
}
"batch_size" => {
self.proxy.telemetry.batch_size = parse_usize(value, field)?;
Ok(())
}
"batch_interval_secs" => {
self.proxy.telemetry.batch_interval_secs = parse_u64(value, field)?;
Ok(())
}
"offline_queue_max" => {
self.proxy.telemetry.offline_queue_max = parse_usize(value, field)?;
Ok(())
}
_ => Err(Error::Config(format!(
"Unknown proxy.telemetry field: {}",
field
))),
},
_ => Err(Error::Config(format!(
"Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
section
))),
}
}
fn get_proxy_field(&self, section: &str, field: &str) -> Result<Option<String>> {
match section {
"routing" => match field {
"strategy" => Ok(Some(routing_strategy_slug(self.proxy.routing.strategy))),
"fallback_on_error" => Ok(Some(self.proxy.routing.fallback_on_error.to_string())),
_ => Err(Error::Config(format!(
"Unknown proxy.routing field: {}",
field
))),
},
"secrets" => match field {
"cache_ttl_secs" => Ok(Some(self.proxy.secrets.cache_ttl_secs.to_string())),
_ => Err(Error::Config(format!(
"Unknown proxy.secrets field: {}",
field
))),
},
"telemetry" => match field {
"enabled" => Ok(Some(self.proxy.telemetry.enabled.to_string())),
"endpoint" => Ok(self.proxy.telemetry.endpoint.clone()),
"token_key" => Ok(self.proxy.telemetry.token_key.clone()),
"batch_size" => Ok(Some(self.proxy.telemetry.batch_size.to_string())),
"batch_interval_secs" => {
Ok(Some(self.proxy.telemetry.batch_interval_secs.to_string()))
}
"offline_queue_max" => Ok(Some(self.proxy.telemetry.offline_queue_max.to_string())),
_ => Err(Error::Config(format!(
"Unknown proxy.telemetry field: {}",
field
))),
},
_ => Err(Error::Config(format!(
"Unknown proxy section: {}. Allowed: routing, secrets, telemetry",
section
))),
}
}
}
fn parse_bool(value: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(Error::Config(format!(
"Invalid boolean '{}'. Allowed: true/false, 1/0, yes/no, on/off",
value
))),
}
}
fn parse_u64(value: &str, field: &str) -> Result<u64> {
value.trim().parse::<u64>().map_err(|_| {
Error::Config(format!(
"Invalid value for {}: '{}'. Expected non-negative integer",
field, value
))
})
}
fn parse_usize(value: &str, field: &str) -> Result<usize> {
value.trim().parse::<usize>().map_err(|_| {
Error::Config(format!(
"Invalid value for {}: '{}'. Expected non-negative integer",
field, value
))
})
}
fn validate_http_url(value: &str, field: &str) -> Result<()> {
if value.contains(|c: char| c.is_whitespace()) {
return Err(Error::Config(format!(
"Invalid URL for {}: '{}'. Must not contain whitespace",
field, value
)));
}
let rest = if let Some(r) = value.strip_prefix("https://") {
r
} else if let Some(r) = value.strip_prefix("http://") {
r
} else {
return Err(Error::Config(format!(
"Invalid URL for {}: '{}'. Must start with http:// or https://",
field, value
)));
};
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let host = &rest[..host_end];
if host.is_empty() {
return Err(Error::Config(format!(
"Invalid URL for {}: '{}'. Missing host",
field, value
)));
}
Ok(())
}
pub fn routing_strategy_slug(s: RoutingStrategy) -> String {
match s {
RoutingStrategy::Remote => "remote",
RoutingStrategy::Local => "local",
RoutingStrategy::LocalFirst => "local-first",
RoutingStrategy::RemoteFirst => "remote-first",
}
.to_string()
}
impl ContextConfig {
pub fn has_any_provider(&self) -> bool {
self.github.is_some()
|| self.gitlab.is_some()
|| self.clickup.is_some()
|| self.jira.is_some()
|| self.fireflies.is_some()
|| self.confluence.is_some()
|| self.slack.is_some()
}
pub fn configured_providers(&self) -> Vec<&'static str> {
let mut providers = Vec::new();
if self.github.is_some() {
providers.push("github");
}
if self.gitlab.is_some() {
providers.push("gitlab");
}
if self.clickup.is_some() {
providers.push("clickup");
}
if self.jira.is_some() {
providers.push("jira");
}
if self.confluence.is_some() {
providers.push("confluence");
}
if self.slack.is_some() {
providers.push("slack");
}
providers
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.github.is_none());
assert!(config.gitlab.is_none());
assert!(config.contexts.is_empty());
assert!(!config.has_any_provider());
assert!(config.configured_providers().is_empty());
}
#[test]
fn test_set_and_get() {
let mut config = Config::default();
config.set("github.owner", "test-owner").unwrap();
config.set("github.repo", "test-repo").unwrap();
assert_eq!(
config.get("github.owner").unwrap(),
Some("test-owner".to_string())
);
assert_eq!(
config.get("github.repo").unwrap(),
Some("test-repo".to_string())
);
config
.set("gitlab.url", "https://gitlab.example.com")
.unwrap();
config.set("gitlab.project_id", "123").unwrap();
assert_eq!(
config.get("gitlab.url").unwrap(),
Some("https://gitlab.example.com".to_string())
);
assert!(config.has_any_provider());
let providers = config.configured_providers();
assert!(providers.contains(&"github"));
assert!(providers.contains(&"gitlab"));
}
#[test]
fn test_default_slack_required_scopes_cover_default_conversation_types() {
let scopes = default_slack_required_scopes();
assert!(scopes.contains(&"channels:read".to_string()));
assert!(scopes.contains(&"channels:history".to_string()));
assert!(scopes.contains(&"groups:read".to_string()));
assert!(scopes.contains(&"groups:history".to_string()));
assert!(scopes.contains(&"im:read".to_string()));
assert!(scopes.contains(&"im:history".to_string()));
assert!(scopes.contains(&"mpim:read".to_string()));
assert!(scopes.contains(&"mpim:history".to_string()));
}
#[test]
fn test_invalid_key() {
let mut config = Config::default();
assert!(config.set("invalid", "value").is_err());
assert!(config.set("too.many.parts", "value").is_err());
assert!(config.set("unknown.field", "value").is_err());
assert_eq!(config.get("github.owner").unwrap(), None);
config.set("github.owner", "test").unwrap();
assert!(config.get("github.unknown_field").is_err());
}
#[test]
fn test_save_and_load() {
let config = Config {
github: Some(GitHubConfig {
owner: "test-owner".to_string(),
repo: "test-repo".to_string(),
base_url: None,
}),
..Default::default()
};
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
config.save_to(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("owner = \"test-owner\""));
assert!(contents.contains("repo = \"test-repo\""));
let loaded = Config::load_from(&path).unwrap();
assert!(loaded.github.is_some());
let gh = loaded.github.unwrap();
assert_eq!(gh.owner, "test-owner");
assert_eq!(gh.repo, "test-repo");
}
#[test]
fn test_load_nonexistent() {
let path = PathBuf::from("/nonexistent/path/config.toml");
let config = Config::load_from(&path).unwrap();
assert!(config.github.is_none());
}
#[test]
fn test_set_and_get_gitlab() {
let mut config = Config::default();
config
.set("gitlab.url", "https://gitlab.example.com")
.unwrap();
config.set("gitlab.project_id", "456").unwrap();
assert_eq!(
config.get("gitlab.url").unwrap(),
Some("https://gitlab.example.com".to_string())
);
assert_eq!(
config.get("gitlab.project_id").unwrap(),
Some("456".to_string())
);
assert_eq!(
config.get("gitlab.project").unwrap(),
Some("456".to_string())
);
}
#[test]
fn test_set_and_get_gitlab_alias() {
let mut config = Config::default();
config.set("gitlab.project", "789").unwrap();
assert_eq!(
config.get("gitlab.project_id").unwrap(),
Some("789".to_string())
);
}
#[test]
fn test_set_and_get_clickup() {
let mut config = Config::default();
config.set("clickup.list_id", "list123").unwrap();
assert_eq!(
config.get("clickup.list_id").unwrap(),
Some("list123".to_string())
);
assert_eq!(
config.get("clickup.list").unwrap(),
Some("list123".to_string())
);
}
#[test]
fn test_set_and_get_clickup_alias() {
let mut config = Config::default();
config.set("clickup.list", "list456").unwrap();
assert_eq!(
config.get("clickup.list_id").unwrap(),
Some("list456".to_string())
);
}
#[test]
fn test_set_and_get_jira() {
let mut config = Config::default();
config.set("jira.url", "https://jira.example.com").unwrap();
config.set("jira.project_key", "PROJ").unwrap();
config.set("jira.email", "user@example.com").unwrap();
assert_eq!(
config.get("jira.url").unwrap(),
Some("https://jira.example.com".to_string())
);
assert_eq!(
config.get("jira.project_key").unwrap(),
Some("PROJ".to_string())
);
assert_eq!(
config.get("jira.email").unwrap(),
Some("user@example.com".to_string())
);
assert_eq!(
config.get("jira.project").unwrap(),
Some("PROJ".to_string())
);
}
#[test]
fn test_set_and_get_jira_alias() {
let mut config = Config::default();
config.set("jira.project", "KEY").unwrap();
assert_eq!(
config.get("jira.project_key").unwrap(),
Some("KEY".to_string())
);
}
#[test]
fn test_set_and_get_confluence() {
let mut config = Config::default();
config
.set("confluence.base_url", "https://wiki.example.com")
.unwrap();
config.set("confluence.api_version", "v1").unwrap();
config
.set("confluence.username", "dev@example.com")
.unwrap();
config.set("confluence.space_key", "ENG").unwrap();
assert_eq!(
config.get("confluence.base_url").unwrap(),
Some("https://wiki.example.com".to_string())
);
assert_eq!(
config.get("confluence.url").unwrap(),
Some("https://wiki.example.com".to_string())
);
assert_eq!(
config.get("confluence.api").unwrap(),
Some("v1".to_string())
);
assert_eq!(
config.get("confluence.username").unwrap(),
Some("dev@example.com".to_string())
);
assert_eq!(
config.get("confluence.space").unwrap(),
Some("ENG".to_string())
);
}
#[test]
fn test_set_github_base_url() {
let mut config = Config::default();
config
.set("github.base_url", "https://github.example.com/api/v3")
.unwrap();
assert_eq!(
config.get("github.base_url").unwrap(),
Some("https://github.example.com/api/v3".to_string())
);
assert_eq!(
config.get("github.url").unwrap(),
Some("https://github.example.com/api/v3".to_string())
);
}
#[test]
fn test_set_github_url_alias() {
let mut config = Config::default();
config
.set("github.url", "https://github.example.com/api/v3")
.unwrap();
assert_eq!(
config.get("github.base_url").unwrap(),
Some("https://github.example.com/api/v3".to_string())
);
}
#[test]
fn test_unknown_field_errors() {
let mut config = Config::default();
assert!(config.set("github.unknown", "value").is_err());
config.set("github.owner", "test").unwrap();
assert!(config.get("github.unknown").is_err());
assert!(config.set("gitlab.unknown", "value").is_err());
config.set("gitlab.url", "https://gitlab.com").unwrap();
assert!(config.get("gitlab.unknown").is_err());
assert!(config.set("clickup.unknown", "value").is_err());
config.set("clickup.list_id", "123").unwrap();
assert!(config.get("clickup.unknown").is_err());
assert!(config.set("jira.unknown", "value").is_err());
config.set("jira.url", "https://jira.com").unwrap();
assert!(config.get("jira.unknown").is_err());
}
#[test]
fn test_get_unconfigured_providers() {
let config = Config::default();
assert_eq!(config.get("github.owner").unwrap(), None);
assert_eq!(config.get("gitlab.url").unwrap(), None);
assert_eq!(config.get("clickup.list_id").unwrap(), None);
assert_eq!(config.get("jira.url").unwrap(), None);
assert_eq!(config.get("confluence.base_url").unwrap(), None);
}
#[test]
fn test_unknown_provider_set() {
let mut config = Config::default();
let result = config.set("unknown.field", "value");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown provider: unknown"));
}
#[test]
fn test_unknown_provider_get() {
let config = Config::default();
let result = config.get("unknown.field");
assert!(result.is_err());
}
#[test]
fn test_malformed_toml() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
std::fs::write(&path, "invalid toml content [[[").unwrap();
let result = Config::load_from(&path);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Failed to parse config file"));
}
#[test]
fn test_configured_providers_all() {
let config = Config {
github: Some(GitHubConfig {
owner: "o".to_string(),
repo: "r".to_string(),
base_url: None,
}),
gitlab: Some(GitLabConfig {
url: "u".to_string(),
project_id: "p".to_string(),
}),
clickup: Some(ClickUpConfig {
list_id: "l".to_string(),
team_id: None,
}),
jira: Some(JiraConfig {
url: "u".to_string(),
project_key: "k".to_string(),
email: "e".to_string(),
}),
fireflies: None,
confluence: None,
slack: None,
contexts: BTreeMap::new(),
active_context: None,
proxy_mcp_servers: Vec::new(),
builtin_tools: BuiltinToolsConfig::default(),
format_pipeline: None,
proxy: ProxyConfig::default(),
sentry: None,
remote_config: None,
};
let providers = config.configured_providers();
assert_eq!(providers.len(), 4);
assert!(providers.contains(&"github"));
assert!(providers.contains(&"gitlab"));
assert!(providers.contains(&"clickup"));
assert!(providers.contains(&"jira"));
assert!(config.has_any_provider());
}
#[test]
fn test_config_dir() {
let dir = Config::config_dir().unwrap();
assert!(dir.ends_with("devboy-tools"));
}
#[test]
fn test_config_path() {
let path = Config::config_path().unwrap();
assert!(path.ends_with("config.toml"));
assert!(path.parent().unwrap().ends_with("devboy-tools"));
}
#[test]
fn test_load_default_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = Config::load_from(&path).unwrap();
assert!(!config.has_any_provider());
}
#[test]
fn test_save_default_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = Config {
github: Some(GitHubConfig {
owner: "test".to_string(),
repo: "repo".to_string(),
base_url: None,
}),
..Default::default()
};
config.save_to(&path).unwrap();
assert!(path.exists());
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.github.unwrap().owner, "test");
}
#[test]
fn test_toml_serialization() {
let config = Config {
github: Some(GitHubConfig {
owner: "owner".to_string(),
repo: "repo".to_string(),
base_url: Some("https://github.example.com".to_string()),
}),
gitlab: Some(GitLabConfig {
url: "https://gitlab.example.com".to_string(),
project_id: "123".to_string(),
}),
clickup: None,
jira: None,
fireflies: None,
confluence: None,
slack: None,
contexts: BTreeMap::new(),
active_context: None,
proxy_mcp_servers: Vec::new(),
builtin_tools: BuiltinToolsConfig::default(),
format_pipeline: None,
proxy: ProxyConfig::default(),
sentry: None,
remote_config: None,
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[github]"));
assert!(toml_str.contains("[gitlab]"));
assert!(!toml_str.contains("[clickup]"));
assert!(!toml_str.contains("[jira]"));
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert!(parsed.github.is_some());
assert!(parsed.gitlab.is_some());
}
#[test]
fn test_contexts_and_active_context() {
let mut config = Config::default();
config.contexts.insert(
"dashboard".to_string(),
ContextConfig {
github: Some(GitHubConfig {
owner: "meteora-pro".to_string(),
repo: "my-project".to_string(),
base_url: None,
}),
clickup: Some(ClickUpConfig {
list_id: "abc123".to_string(),
team_id: None,
}),
..Default::default()
},
);
let names = config.context_names();
assert_eq!(names, vec!["dashboard".to_string()]);
config.set_active_context("dashboard").unwrap();
assert_eq!(
config.resolve_active_context_name(),
Some("dashboard".to_string())
);
}
#[test]
fn test_context_names_include_legacy_default() {
let mut config = Config {
github: Some(GitHubConfig {
owner: "legacy-owner".to_string(),
repo: "legacy-repo".to_string(),
base_url: None,
}),
..Default::default()
};
config
.contexts
.insert("workspace".to_string(), ContextConfig::default());
assert_eq!(
config.context_names(),
vec!["default".to_string(), "workspace".to_string()]
);
}
#[test]
fn test_get_context_prefers_explicit_default_over_legacy() {
let mut config = Config {
github: Some(GitHubConfig {
owner: "legacy-owner".to_string(),
repo: "legacy-repo".to_string(),
base_url: None,
}),
..Default::default()
};
config.contexts.insert(
Config::DEFAULT_CONTEXT_NAME.to_string(),
ContextConfig {
github: Some(GitHubConfig {
owner: "explicit-owner".to_string(),
repo: "explicit-repo".to_string(),
base_url: None,
}),
..Default::default()
},
);
let default_ctx = config.get_context(Config::DEFAULT_CONTEXT_NAME).unwrap();
let gh = default_ctx.github.unwrap();
assert_eq!(gh.owner, "explicit-owner");
assert_eq!(gh.repo, "explicit-repo");
}
#[test]
fn test_resolve_active_context_fallbacks() {
let mut config = Config {
active_context: Some("missing".to_string()),
github: Some(GitHubConfig {
owner: "legacy-owner".to_string(),
repo: "legacy-repo".to_string(),
base_url: None,
}),
..Default::default()
};
config
.contexts
.insert("beta".to_string(), ContextConfig::default());
config
.contexts
.insert("alpha".to_string(), ContextConfig::default());
assert_eq!(
config.resolve_active_context_name(),
Some("default".to_string())
);
config.github = None;
assert_eq!(
config.resolve_active_context_name(),
Some("alpha".to_string())
);
}
#[test]
fn test_set_active_context_unknown_context_errors() {
let mut config = Config::default();
let result = config.set_active_context("missing");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown context"));
}
#[test]
fn test_context_config_configured_providers() {
let context = ContextConfig {
github: Some(GitHubConfig {
owner: "owner".to_string(),
repo: "repo".to_string(),
base_url: None,
}),
jira: Some(JiraConfig {
url: "https://jira.example.com".to_string(),
project_key: "DEV".to_string(),
email: "dev@example.com".to_string(),
}),
..Default::default()
};
let providers = context.configured_providers();
assert_eq!(providers, vec!["github", "jira"]);
assert!(context.has_any_provider());
}
#[test]
fn test_proxy_mcp_server_config_defaults() {
let toml_str = r#"
[[proxy_mcp_servers]]
name = "my-server"
url = "https://example.com/mcp"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.proxy_mcp_servers.len(), 1);
let proxy = &config.proxy_mcp_servers[0];
assert_eq!(proxy.name, "my-server");
assert_eq!(proxy.url, "https://example.com/mcp");
assert_eq!(proxy.auth_type, "none");
assert_eq!(proxy.transport, "sse");
assert!(proxy.token_key.is_none());
assert!(proxy.tool_prefix.is_none());
}
#[test]
fn test_proxy_mcp_server_config_full() {
let toml_str = r#"
[[proxy_mcp_servers]]
name = "devboy-cloud"
url = "https://app.devboy.pro/api/mcp"
auth_type = "bearer"
token_key = "devboy-cloud.token"
tool_prefix = "cloud"
transport = "streamable-http"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let proxy = &config.proxy_mcp_servers[0];
assert_eq!(proxy.name, "devboy-cloud");
assert_eq!(proxy.auth_type, "bearer");
assert_eq!(proxy.token_key.as_deref(), Some("devboy-cloud.token"));
assert_eq!(proxy.tool_prefix.as_deref(), Some("cloud"));
assert_eq!(proxy.transport, "streamable-http");
}
#[test]
fn test_proxy_mcp_server_config_multiple() {
let toml_str = r#"
[[proxy_mcp_servers]]
name = "server1"
url = "https://s1.example.com/mcp"
[[proxy_mcp_servers]]
name = "server2"
url = "https://s2.example.com/mcp"
auth_type = "api_key"
token_key = "s2.token"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.proxy_mcp_servers.len(), 2);
assert_eq!(config.proxy_mcp_servers[0].name, "server1");
assert_eq!(config.proxy_mcp_servers[1].name, "server2");
assert_eq!(config.proxy_mcp_servers[1].auth_type, "api_key");
}
#[test]
fn test_proxy_mcp_server_config_serialization_roundtrip() {
let config = Config {
proxy_mcp_servers: vec![ProxyMcpServerConfig {
name: "test".to_string(),
url: "https://test.com/mcp".to_string(),
auth_type: "bearer".to_string(),
token_key: Some("test.token".to_string()),
tool_prefix: Some("tst".to_string()),
transport: "streamable-http".to_string(),
routing: None,
}],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[[proxy_mcp_servers]]"));
assert!(toml_str.contains("name = \"test\""));
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.proxy_mcp_servers.len(), 1);
assert_eq!(parsed.proxy_mcp_servers[0].name, "test");
assert_eq!(parsed.proxy_mcp_servers[0].transport, "streamable-http");
}
#[test]
fn test_proxy_mcp_server_config_skips_none_fields_in_serialization() {
let config = Config {
proxy_mcp_servers: vec![ProxyMcpServerConfig {
name: "minimal".to_string(),
url: "https://test.com/mcp".to_string(),
auth_type: "none".to_string(),
token_key: None,
tool_prefix: None,
transport: "sse".to_string(),
routing: None,
}],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(!toml_str.contains("token_key"));
assert!(!toml_str.contains("tool_prefix"));
}
#[test]
fn test_empty_proxy_mcp_servers_not_serialized() {
let config = Config::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(!toml_str.contains("proxy_mcp_servers"));
}
#[test]
fn test_proxy_config_default_is_default() {
let cfg = ProxyConfig::default();
assert!(cfg.is_default());
}
#[test]
fn test_default_proxy_section_not_serialized() {
let config = Config::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(!toml_str.contains("[proxy]"));
assert!(!toml_str.contains("[proxy.routing]"));
}
#[test]
fn test_routing_strategy_default_is_remote() {
let strategy = RoutingStrategy::default();
assert_eq!(strategy, RoutingStrategy::Remote);
}
#[test]
fn test_routing_strategy_parse_tolerates_formats() {
assert_eq!(
RoutingStrategy::parse("remote"),
Some(RoutingStrategy::Remote)
);
assert_eq!(
RoutingStrategy::parse(" REMOTE "),
Some(RoutingStrategy::Remote)
);
assert_eq!(
RoutingStrategy::parse("local"),
Some(RoutingStrategy::Local)
);
assert_eq!(
RoutingStrategy::parse("local-first"),
Some(RoutingStrategy::LocalFirst)
);
assert_eq!(
RoutingStrategy::parse("local_first"),
Some(RoutingStrategy::LocalFirst)
);
assert_eq!(
RoutingStrategy::parse("remote-first"),
Some(RoutingStrategy::RemoteFirst)
);
assert_eq!(RoutingStrategy::parse("unknown"), None);
}
#[test]
fn test_routing_strategy_serde_kebab_case() {
let toml_str = r#"
[proxy.routing]
strategy = "local-first"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(serialized.contains("strategy = \"local-first\""));
}
#[test]
fn test_proxy_routing_strategy_for_picks_first_matching_override() {
let routing = ProxyRoutingConfig {
strategy: RoutingStrategy::Remote,
fallback_on_error: true,
tool_overrides: vec![
ProxyToolRule {
pattern: "create_*".to_string(),
strategy: RoutingStrategy::Remote,
},
ProxyToolRule {
pattern: "get_*".to_string(),
strategy: RoutingStrategy::LocalFirst,
},
ProxyToolRule {
pattern: "*".to_string(),
strategy: RoutingStrategy::Local,
},
],
};
assert_eq!(
routing.strategy_for("create_issue"),
RoutingStrategy::Remote
);
assert_eq!(
routing.strategy_for("get_issues"),
RoutingStrategy::LocalFirst
);
assert_eq!(
routing.strategy_for("anything_else"),
RoutingStrategy::Local
);
}
#[test]
fn test_proxy_routing_strategy_for_falls_back_to_global() {
let routing = ProxyRoutingConfig {
strategy: RoutingStrategy::Remote,
fallback_on_error: true,
tool_overrides: vec![ProxyToolRule {
pattern: "get_*".to_string(),
strategy: RoutingStrategy::LocalFirst,
}],
};
assert_eq!(
routing.strategy_for("unrelated_tool"),
RoutingStrategy::Remote
);
}
#[test]
fn test_proxy_routing_merged_with_override_wins() {
let global = ProxyRoutingConfig {
strategy: RoutingStrategy::Remote,
fallback_on_error: true,
tool_overrides: vec![ProxyToolRule {
pattern: "get_*".to_string(),
strategy: RoutingStrategy::LocalFirst,
}],
};
let override_cfg = ProxyRoutingOverride {
strategy: Some(RoutingStrategy::Local),
fallback_on_error: Some(false),
tool_overrides: Some(vec![ProxyToolRule {
pattern: "create_*".to_string(),
strategy: RoutingStrategy::Remote,
}]),
};
let merged = global.merged_with(Some(&override_cfg));
assert_eq!(merged.strategy, RoutingStrategy::Local);
assert!(!merged.fallback_on_error);
assert_eq!(merged.tool_overrides.len(), 2);
assert_eq!(merged.tool_overrides[0].pattern, "create_*");
assert_eq!(merged.tool_overrides[1].pattern, "get_*");
}
#[test]
fn test_proxy_routing_merged_with_partial_override_preserves_unset_fields() {
let global = ProxyRoutingConfig {
strategy: RoutingStrategy::Remote,
fallback_on_error: false, tool_overrides: vec![ProxyToolRule {
pattern: "get_*".to_string(),
strategy: RoutingStrategy::LocalFirst,
}],
};
let override_cfg = ProxyRoutingOverride {
strategy: Some(RoutingStrategy::Local),
fallback_on_error: None,
tool_overrides: None,
};
let merged = global.merged_with(Some(&override_cfg));
assert_eq!(merged.strategy, RoutingStrategy::Local);
assert!(
!merged.fallback_on_error,
"fallback_on_error must inherit from global, not snap to default"
);
assert_eq!(
merged.tool_overrides.len(),
1,
"tool_overrides must inherit from global when override omits them"
);
assert_eq!(merged.tool_overrides[0].pattern, "get_*");
}
#[test]
fn test_proxy_routing_merged_with_none_returns_clone() {
let global = ProxyRoutingConfig {
strategy: RoutingStrategy::LocalFirst,
..Default::default()
};
let merged = global.merged_with(None);
assert_eq!(merged.strategy, RoutingStrategy::LocalFirst);
}
#[test]
fn test_proxy_secrets_default_cache_ttl() {
let s = ProxySecretsConfig::default();
assert_eq!(s.cache_ttl_secs, 300);
assert!(s.is_default());
}
#[test]
fn test_proxy_telemetry_defaults() {
let t = ProxyTelemetryConfig::default();
assert!(t.enabled);
assert_eq!(t.batch_size, 100);
assert_eq!(t.batch_interval_secs, 30);
assert!(t.endpoint.is_none());
assert!(t.is_default());
}
#[test]
fn test_proxy_toml_parse_full() {
let toml_str = r#"
[proxy.routing]
strategy = "local-first"
fallback_on_error = false
[[proxy.routing.tool_overrides]]
pattern = "create_*"
strategy = "remote"
[proxy.secrets]
cache_ttl_secs = 120
[proxy.telemetry]
enabled = true
batch_size = 50
batch_interval_secs = 10
endpoint = "https://telemetry.example.com/api/events"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.proxy.routing.strategy, RoutingStrategy::LocalFirst);
assert!(!config.proxy.routing.fallback_on_error);
assert_eq!(config.proxy.routing.tool_overrides.len(), 1);
assert_eq!(config.proxy.secrets.cache_ttl_secs, 120);
assert_eq!(config.proxy.telemetry.batch_size, 50);
assert_eq!(
config.proxy.telemetry.endpoint.as_deref(),
Some("https://telemetry.example.com/api/events")
);
}
#[test]
fn test_proxy_mcp_server_per_server_routing_override() {
let toml_str = r#"
[[proxy_mcp_servers]]
name = "cloud"
url = "https://api.example.com/mcp"
[proxy_mcp_servers.routing]
strategy = "local-first"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let server = &config.proxy_mcp_servers[0];
let override_cfg = server.routing.as_ref().expect("override present");
assert_eq!(override_cfg.strategy, Some(RoutingStrategy::LocalFirst));
assert!(override_cfg.fallback_on_error.is_none());
assert!(override_cfg.tool_overrides.is_none());
}
#[test]
fn test_set_get_proxy_routing_strategy_roundtrip() {
let mut cfg = Config::default();
cfg.set("proxy.routing.strategy", "local-first").unwrap();
assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
assert_eq!(
cfg.get("proxy.routing.strategy").unwrap().as_deref(),
Some("local-first")
);
cfg.set("proxy.routing.strategy", "remote").unwrap();
assert_eq!(
cfg.get("proxy.routing.strategy").unwrap().as_deref(),
Some("remote")
);
}
#[test]
fn test_set_proxy_routing_strategy_rejects_garbage() {
let mut cfg = Config::default();
let err = cfg
.set("proxy.routing.strategy", "teleport")
.unwrap_err()
.to_string();
assert!(err.contains("Invalid routing strategy"));
}
#[test]
fn test_set_proxy_routing_booleans_accept_many_forms() {
let mut cfg = Config::default();
for truthy in ["true", "TRUE", "1", "yes", "on"] {
cfg.set("proxy.routing.fallback_on_error", truthy).unwrap();
assert!(cfg.proxy.routing.fallback_on_error);
}
for falsy in ["false", "0", "no", "off"] {
cfg.set("proxy.routing.fallback_on_error", falsy).unwrap();
assert!(!cfg.proxy.routing.fallback_on_error);
}
}
#[test]
fn test_set_proxy_secrets_cache_ttl() {
let mut cfg = Config::default();
cfg.set("proxy.secrets.cache_ttl_secs", "120").unwrap();
assert_eq!(cfg.proxy.secrets.cache_ttl_secs, 120);
assert_eq!(
cfg.get("proxy.secrets.cache_ttl_secs").unwrap().as_deref(),
Some("120")
);
assert!(cfg.set("proxy.secrets.cache_ttl_secs", "-5").is_err());
}
#[test]
fn test_set_proxy_telemetry_endpoint_and_clear() {
let mut cfg = Config::default();
cfg.set("proxy.telemetry.endpoint", "https://example.com/t")
.unwrap();
assert_eq!(
cfg.proxy.telemetry.endpoint.as_deref(),
Some("https://example.com/t")
);
cfg.set("proxy.telemetry.endpoint", "").unwrap();
assert!(cfg.proxy.telemetry.endpoint.is_none());
}
#[test]
fn test_set_proxy_telemetry_endpoint_rejects_garbage() {
let mut cfg = Config::default();
for bad in [
"not-a-url",
"ftp://host.example.com",
"//example.com",
"https://",
"http:// space.example.com",
"https://example.com/a b",
"https://example.com/path?key=a b",
"https://example.com/\tpath",
"https://example.com/ ",
] {
match cfg.set("proxy.telemetry.endpoint", bad) {
Ok(()) => panic!("expected reject for {}", bad),
Err(e) => assert!(
e.to_string().contains("Invalid URL"),
"bad={}, err={}",
bad,
e
),
}
}
}
#[test]
fn test_set_proxy_telemetry_endpoint_accepts_common_forms() {
let mut cfg = Config::default();
for good in [
"https://app.example.com/api/telemetry/tool-invocations",
"http://localhost:4335/api/telemetry/tool-invocations",
"https://example.com",
"http://10.0.0.1:8080/",
] {
cfg.set("proxy.telemetry.endpoint", good)
.unwrap_or_else(|e| panic!("expected accept for {}: {}", good, e));
}
}
#[test]
fn test_validate_rejects_bad_endpoint_from_toml() {
let toml_str = r#"
[proxy.telemetry]
endpoint = "not-a-url"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let err = config
.validate()
.expect_err("expected validation to fail for 'not-a-url'");
assert!(
err.to_string().contains("Invalid URL"),
"unexpected error: {}",
err
);
}
#[test]
fn test_validate_accepts_empty_endpoint_as_absent() {
let config = Config::default();
config.validate().expect("default config validates");
}
#[test]
fn test_sanitize_normalizes_empty_endpoint_to_none() {
let mut config: Config = toml::from_str(
r#"
[proxy.telemetry]
endpoint = ""
"#,
)
.unwrap();
assert_eq!(config.proxy.telemetry.endpoint.as_deref(), Some(""));
config.sanitize();
assert!(config.proxy.telemetry.endpoint.is_none());
config.validate().expect("sanitized config must validate");
}
#[test]
fn test_load_from_sanitizes_empty_endpoint() {
use std::fs::write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(
&path,
r#"
[proxy.telemetry]
endpoint = ""
"#,
)
.unwrap();
let cfg = Config::load_from(&path).expect("empty endpoint must be normalised on load");
assert!(
cfg.proxy.telemetry.endpoint.is_none(),
"empty string must load as None, not Some(\"\")"
);
}
#[test]
fn test_validate_rejects_naked_empty_string_endpoint() {
let mut config = Config::default();
config.proxy.telemetry.endpoint = Some(String::new());
let err = config
.validate()
.expect_err("empty string must be rejected if caller skipped sanitize");
assert!(
err.to_string().contains("Invalid URL"),
"unexpected error: {}",
err
);
}
#[test]
fn test_load_from_runs_validation() {
use std::fs::write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(
&path,
r#"
[proxy.telemetry]
endpoint = "ftp://wrong-scheme.example.com"
"#,
)
.unwrap();
let err = Config::load_from(&path).expect_err("must reject bad URL from file");
assert!(
err.to_string().contains("Invalid URL"),
"unexpected error: {}",
err
);
}
#[test]
fn test_unknown_field_in_proxy_routing_rejected() {
let toml_str = r#"
[proxy.routing]
strategy = "local-first"
startegy = "typo"
"#;
let err = toml::from_str::<Config>(toml_str)
.expect_err("expected parse error for typo 'startegy'");
let msg = err.to_string();
assert!(
msg.contains("startegy") || msg.contains("unknown field"),
"unexpected error: {}",
msg
);
}
#[test]
fn test_unknown_field_in_proxy_secrets_rejected() {
let toml_str = r#"
[proxy.secrets]
cache_ttl_secs = 60
chache_ttl_secs = 120
"#;
let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
assert!(
err.to_string().contains("chache_ttl_secs")
|| err.to_string().contains("unknown field")
);
}
#[test]
fn test_unknown_field_in_proxy_telemetry_rejected() {
let toml_str = r#"
[proxy.telemetry]
enabled = true
endpooint = "https://example.com"
"#;
let err = toml::from_str::<Config>(toml_str).expect_err("typo must fail");
assert!(err.to_string().contains("endpooint") || err.to_string().contains("unknown field"));
}
#[test]
fn test_unknown_field_in_tool_override_rejected() {
let toml_str = r#"
[[proxy.routing.tool_overrides]]
pattern = "get_*"
strategy = "local"
unknown = 1
"#;
let err = toml::from_str::<Config>(toml_str).expect_err("typo in rule must fail");
assert!(err.to_string().contains("unknown"));
}
#[test]
fn test_unknown_top_level_proxy_section_rejected() {
let toml_str = r#"
[proxy.typo]
foo = 1
"#;
let err = toml::from_str::<Config>(toml_str).expect_err("unknown section must fail");
let msg = err.to_string();
assert!(msg.contains("typo") || msg.contains("unknown field"));
}
#[test]
fn test_load_from_accepts_valid_proxy_config() {
use std::fs::write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write(
&path,
r#"
[proxy.routing]
strategy = "local-first"
[proxy.telemetry]
endpoint = "https://app.example.com/api/telemetry/tool-invocations"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).expect("valid config must load");
assert_eq!(cfg.proxy.routing.strategy, RoutingStrategy::LocalFirst);
assert_eq!(
cfg.proxy.telemetry.endpoint.as_deref(),
Some("https://app.example.com/api/telemetry/tool-invocations")
);
}
#[test]
fn test_set_proxy_telemetry_batch_fields() {
let mut cfg = Config::default();
cfg.set("proxy.telemetry.batch_size", "50").unwrap();
cfg.set("proxy.telemetry.batch_interval_secs", "15")
.unwrap();
cfg.set("proxy.telemetry.offline_queue_max", "2000")
.unwrap();
assert_eq!(cfg.proxy.telemetry.batch_size, 50);
assert_eq!(cfg.proxy.telemetry.batch_interval_secs, 15);
assert_eq!(cfg.proxy.telemetry.offline_queue_max, 2000);
}
#[test]
fn test_unknown_proxy_section_or_field_errors() {
let mut cfg = Config::default();
assert!(cfg.set("proxy.unknown.foo", "1").is_err());
assert!(cfg.set("proxy.routing.unknown", "1").is_err());
assert!(cfg.get("proxy.unknown.foo").is_err());
assert!(cfg.get("proxy.routing.unknown").is_err());
}
#[test]
fn test_four_part_key_rejected() {
let mut cfg = Config::default();
assert!(cfg.set("proxy.routing.strategy.extra", "local").is_err());
}
#[test]
fn test_legacy_config_without_proxy_section_still_parses() {
let toml_str = r#"
[github]
owner = "me"
repo = "repo"
[[proxy_mcp_servers]]
name = "cloud"
url = "https://api.example.com/mcp"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.github.unwrap().owner, "me");
assert_eq!(config.proxy_mcp_servers.len(), 1);
assert!(config.proxy.is_default());
}
#[test]
fn test_matches_glob_exact() {
assert!(matches_glob("get_issues", "get_issues"));
assert!(!matches_glob("get_issues", "get_issue"));
assert!(!matches_glob("get_issues", "gets_issues"));
}
#[test]
fn test_matches_glob_star_alone() {
assert!(matches_glob("*", ""));
assert!(matches_glob("*", "anything"));
assert!(matches_glob("*", "create_merge_request"));
}
#[test]
fn test_matches_glob_prefix() {
assert!(matches_glob("get_*", "get_issues"));
assert!(matches_glob("get_*", "get_"));
assert!(!matches_glob("get_*", "create_issues"));
}
#[test]
fn test_matches_glob_suffix() {
assert!(matches_glob("*_issue", "create_issue"));
assert!(matches_glob("*_issue", "_issue"));
assert!(!matches_glob("*_issue", "create_issues"));
}
#[test]
fn test_matches_glob_contains() {
assert!(matches_glob("*issue*", "get_issues"));
assert!(matches_glob("*issue*", "issue"));
assert!(!matches_glob("*issue*", "merge_request"));
}
#[test]
fn test_matches_glob_multiple_wildcards() {
assert!(matches_glob("get_*_by_*", "get_issue_by_id"));
assert!(matches_glob("get_*_by_*", "get_user_by_email"));
assert!(!matches_glob("get_*_by_*", "get_issue"));
assert!(!matches_glob("get_*_by_*", "create_issue_by_id"));
}
#[test]
fn test_matches_glob_collapses_double_star() {
assert!(matches_glob("get_**_issue", "get_new_issue"));
}
#[test]
fn test_builtin_tools_config_default_is_empty() {
let config = BuiltinToolsConfig::default();
assert!(config.is_empty());
assert!(config.validate().is_ok());
assert!(config.is_tool_allowed("get_issues"));
}
#[test]
fn test_builtin_tools_disabled_mode() {
let config = BuiltinToolsConfig {
disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
enabled: vec![],
};
assert!(!config.is_empty());
assert!(config.validate().is_ok());
assert!(!config.is_tool_allowed("get_issues"));
assert!(!config.is_tool_allowed("create_issue"));
assert!(config.is_tool_allowed("get_merge_requests"));
assert!(config.is_tool_allowed("list_contexts"));
}
#[test]
fn test_builtin_tools_enabled_mode() {
let config = BuiltinToolsConfig {
disabled: vec![],
enabled: vec![
"list_contexts".to_string(),
"use_context".to_string(),
"get_current_context".to_string(),
],
};
assert!(!config.is_empty());
assert!(config.validate().is_ok());
assert!(config.is_tool_allowed("list_contexts"));
assert!(config.is_tool_allowed("use_context"));
assert!(!config.is_tool_allowed("get_issues"));
assert!(!config.is_tool_allowed("create_issue"));
}
#[test]
fn test_builtin_tools_mutually_exclusive_error() {
let config = BuiltinToolsConfig {
disabled: vec!["get_issues".to_string()],
enabled: vec!["list_contexts".to_string()],
};
assert!(config.validate().is_err());
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("mutually exclusive"));
}
#[test]
fn test_builtin_tools_toml_parsing_disabled() {
let toml_str = r#"
[builtin_tools]
disabled = ["get_issues", "create_issue"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.builtin_tools.is_empty());
assert_eq!(config.builtin_tools.disabled.len(), 2);
assert!(config.builtin_tools.enabled.is_empty());
}
#[test]
fn test_builtin_tools_toml_parsing_enabled() {
let toml_str = r#"
[builtin_tools]
enabled = ["list_contexts", "use_context", "get_current_context"]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.builtin_tools.enabled.len(), 3);
assert!(config.builtin_tools.disabled.is_empty());
}
#[test]
fn test_builtin_tools_not_serialized_when_empty() {
let config = Config::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(!toml_str.contains("builtin_tools"));
}
#[test]
fn test_builtin_tools_serialization_roundtrip() {
let config = Config {
builtin_tools: BuiltinToolsConfig {
disabled: vec!["get_issues".to_string(), "create_issue".to_string()],
enabled: vec![],
},
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[builtin_tools]"));
assert!(toml_str.contains("get_issues"));
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.builtin_tools.disabled.len(), 2);
}
#[test]
fn test_builtin_tools_warn_unknown_with_unknown_names() {
let known = &["get_issues", "create_issue"];
let config = BuiltinToolsConfig {
disabled: vec!["get_issues".to_string(), "nonexistent_tool".to_string()],
enabled: vec![],
};
config.warn_unknown_tools(known);
}
#[test]
fn test_builtin_tools_warn_unknown_all_known() {
let known = &["get_issues", "create_issue"];
let config = BuiltinToolsConfig {
disabled: vec!["get_issues".to_string()],
enabled: vec![],
};
config.warn_unknown_tools(known);
}
#[test]
fn test_builtin_tools_warn_unknown_in_enabled_list() {
let known = &["get_issues", "create_issue"];
let config = BuiltinToolsConfig {
disabled: vec![],
enabled: vec!["get_issues".to_string(), "unknown_tool".to_string()],
};
config.warn_unknown_tools(known);
}
#[test]
fn test_builtin_tools_warn_unknown_empty_config() {
let known = &["get_issues"];
let config = BuiltinToolsConfig::default();
config.warn_unknown_tools(known);
}
}