New Tunnel trait + 5 implementations:
- NoneTunnel: local-only, no external exposure (default)
- CloudflareTunnel: wraps cloudflared binary, extracts public URL
- TailscaleTunnel: tailscale serve (tailnet) or funnel (public)
- NgrokTunnel: wraps ngrok binary, supports custom domains
- CustomTunnel: user-provided command with {port}/{host} placeholders
Config schema:
- [tunnel] section with provider selector
- Provider-specific sub-configs: cloudflare, tailscale, ngrok, custom
- Backward compatible (serde default = "none")
Gateway integration:
- Tunnel starts automatically on 'zeroclaw gateway'
- Prints public URL on success, falls back to local on failure
20 new tests (factory, constructors, NoneTunnel async start/health)
649 tests passing, 0 clippy warnings, cargo fmt clean
723 lines
24 KiB
Rust
723 lines
24 KiB
Rust
use crate::security::AutonomyLevel;
|
|
use anyhow::{Context, Result};
|
|
use directories::UserDirs;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
// ── Top-level config ──────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
pub workspace_dir: PathBuf,
|
|
pub config_path: PathBuf,
|
|
pub api_key: Option<String>,
|
|
pub default_provider: Option<String>,
|
|
pub default_model: Option<String>,
|
|
pub default_temperature: f64,
|
|
|
|
#[serde(default)]
|
|
pub observability: ObservabilityConfig,
|
|
|
|
#[serde(default)]
|
|
pub autonomy: AutonomyConfig,
|
|
|
|
#[serde(default)]
|
|
pub runtime: RuntimeConfig,
|
|
|
|
#[serde(default)]
|
|
pub heartbeat: HeartbeatConfig,
|
|
|
|
#[serde(default)]
|
|
pub channels_config: ChannelsConfig,
|
|
|
|
#[serde(default)]
|
|
pub memory: MemoryConfig,
|
|
|
|
#[serde(default)]
|
|
pub tunnel: TunnelConfig,
|
|
}
|
|
|
|
// ── Memory ───────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MemoryConfig {
|
|
/// "sqlite" | "markdown" | "none"
|
|
pub backend: String,
|
|
/// Auto-save conversation context to memory
|
|
pub auto_save: bool,
|
|
}
|
|
|
|
impl Default for MemoryConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
backend: "sqlite".into(),
|
|
auto_save: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Observability ─────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ObservabilityConfig {
|
|
/// "none" | "log" | "prometheus" | "otel"
|
|
pub backend: String,
|
|
}
|
|
|
|
impl Default for ObservabilityConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
backend: "none".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Autonomy / Security ──────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AutonomyConfig {
|
|
pub level: AutonomyLevel,
|
|
pub workspace_only: bool,
|
|
pub allowed_commands: Vec<String>,
|
|
pub forbidden_paths: Vec<String>,
|
|
pub max_actions_per_hour: u32,
|
|
pub max_cost_per_day_cents: u32,
|
|
}
|
|
|
|
impl Default for AutonomyConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
level: AutonomyLevel::Supervised,
|
|
workspace_only: true,
|
|
allowed_commands: vec![
|
|
"git".into(),
|
|
"npm".into(),
|
|
"cargo".into(),
|
|
"ls".into(),
|
|
"cat".into(),
|
|
"grep".into(),
|
|
"find".into(),
|
|
"echo".into(),
|
|
"pwd".into(),
|
|
"wc".into(),
|
|
"head".into(),
|
|
"tail".into(),
|
|
],
|
|
forbidden_paths: vec![
|
|
"/etc".into(),
|
|
"/root".into(),
|
|
"~/.ssh".into(),
|
|
"~/.gnupg".into(),
|
|
],
|
|
max_actions_per_hour: 20,
|
|
max_cost_per_day_cents: 500,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Runtime ──────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RuntimeConfig {
|
|
/// "native" | "docker" | "cloudflare"
|
|
pub kind: String,
|
|
}
|
|
|
|
impl Default for RuntimeConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
kind: "native".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Heartbeat ────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HeartbeatConfig {
|
|
pub enabled: bool,
|
|
pub interval_minutes: u32,
|
|
}
|
|
|
|
impl Default for HeartbeatConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
interval_minutes: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tunnel ──────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TunnelConfig {
|
|
/// "none", "cloudflare", "tailscale", "ngrok", "custom"
|
|
pub provider: String,
|
|
|
|
#[serde(default)]
|
|
pub cloudflare: Option<CloudflareTunnelConfig>,
|
|
|
|
#[serde(default)]
|
|
pub tailscale: Option<TailscaleTunnelConfig>,
|
|
|
|
#[serde(default)]
|
|
pub ngrok: Option<NgrokTunnelConfig>,
|
|
|
|
#[serde(default)]
|
|
pub custom: Option<CustomTunnelConfig>,
|
|
}
|
|
|
|
impl Default for TunnelConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
provider: "none".into(),
|
|
cloudflare: None,
|
|
tailscale: None,
|
|
ngrok: None,
|
|
custom: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudflareTunnelConfig {
|
|
/// Cloudflare Tunnel token (from Zero Trust dashboard)
|
|
pub token: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TailscaleTunnelConfig {
|
|
/// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
|
|
#[serde(default)]
|
|
pub funnel: bool,
|
|
/// Optional hostname override
|
|
pub hostname: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NgrokTunnelConfig {
|
|
/// ngrok auth token
|
|
pub auth_token: String,
|
|
/// Optional custom domain
|
|
pub domain: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CustomTunnelConfig {
|
|
/// Command template to start the tunnel. Use {port} and {host} placeholders.
|
|
/// Example: "bore local {port} --to bore.pub"
|
|
pub start_command: String,
|
|
/// Optional URL to check tunnel health
|
|
pub health_url: Option<String>,
|
|
/// Optional regex to extract public URL from command stdout
|
|
pub url_pattern: Option<String>,
|
|
}
|
|
|
|
// ── Channels ─────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChannelsConfig {
|
|
pub cli: bool,
|
|
pub telegram: Option<TelegramConfig>,
|
|
pub discord: Option<DiscordConfig>,
|
|
pub slack: Option<SlackConfig>,
|
|
pub webhook: Option<WebhookConfig>,
|
|
pub imessage: Option<IMessageConfig>,
|
|
pub matrix: Option<MatrixConfig>,
|
|
}
|
|
|
|
impl Default for ChannelsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
cli: true,
|
|
telegram: None,
|
|
discord: None,
|
|
slack: None,
|
|
webhook: None,
|
|
imessage: None,
|
|
matrix: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TelegramConfig {
|
|
pub bot_token: String,
|
|
pub allowed_users: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DiscordConfig {
|
|
pub bot_token: String,
|
|
pub guild_id: Option<String>,
|
|
#[serde(default)]
|
|
pub allowed_users: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SlackConfig {
|
|
pub bot_token: String,
|
|
pub app_token: Option<String>,
|
|
pub channel_id: Option<String>,
|
|
#[serde(default)]
|
|
pub allowed_users: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WebhookConfig {
|
|
pub port: u16,
|
|
pub secret: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IMessageConfig {
|
|
pub allowed_contacts: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MatrixConfig {
|
|
pub homeserver: String,
|
|
pub access_token: String,
|
|
pub room_id: String,
|
|
pub allowed_users: Vec<String>,
|
|
}
|
|
|
|
// ── Config impl ──────────────────────────────────────────────────
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let home =
|
|
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
|
|
let zeroclaw_dir = home.join(".zeroclaw");
|
|
|
|
Self {
|
|
workspace_dir: zeroclaw_dir.join("workspace"),
|
|
config_path: zeroclaw_dir.join("config.toml"),
|
|
api_key: None,
|
|
default_provider: Some("openrouter".to_string()),
|
|
default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
|
|
default_temperature: 0.7,
|
|
observability: ObservabilityConfig::default(),
|
|
autonomy: AutonomyConfig::default(),
|
|
runtime: RuntimeConfig::default(),
|
|
heartbeat: HeartbeatConfig::default(),
|
|
channels_config: ChannelsConfig::default(),
|
|
memory: MemoryConfig::default(),
|
|
tunnel: TunnelConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn load_or_init() -> Result<Self> {
|
|
let home = UserDirs::new()
|
|
.map(|u| u.home_dir().to_path_buf())
|
|
.context("Could not find home directory")?;
|
|
let zeroclaw_dir = home.join(".zeroclaw");
|
|
let config_path = zeroclaw_dir.join("config.toml");
|
|
|
|
if !zeroclaw_dir.exists() {
|
|
fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?;
|
|
fs::create_dir_all(zeroclaw_dir.join("workspace"))
|
|
.context("Failed to create workspace directory")?;
|
|
}
|
|
|
|
if config_path.exists() {
|
|
let contents =
|
|
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
|
let config: Config =
|
|
toml::from_str(&contents).context("Failed to parse config file")?;
|
|
Ok(config)
|
|
} else {
|
|
let config = Config::default();
|
|
config.save()?;
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
pub fn save(&self) -> Result<()> {
|
|
let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?;
|
|
fs::write(&self.config_path, toml_str).context("Failed to write config file")?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
// ── Defaults ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn config_default_has_sane_values() {
|
|
let c = Config::default();
|
|
assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
|
|
assert!(c.default_model.as_deref().unwrap().contains("claude"));
|
|
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
|
|
assert!(c.api_key.is_none());
|
|
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
|
|
assert!(c.config_path.to_string_lossy().contains("config.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn observability_config_default() {
|
|
let o = ObservabilityConfig::default();
|
|
assert_eq!(o.backend, "none");
|
|
}
|
|
|
|
#[test]
|
|
fn autonomy_config_default() {
|
|
let a = AutonomyConfig::default();
|
|
assert_eq!(a.level, AutonomyLevel::Supervised);
|
|
assert!(a.workspace_only);
|
|
assert!(a.allowed_commands.contains(&"git".to_string()));
|
|
assert!(a.allowed_commands.contains(&"cargo".to_string()));
|
|
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
|
|
assert_eq!(a.max_actions_per_hour, 20);
|
|
assert_eq!(a.max_cost_per_day_cents, 500);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_config_default() {
|
|
let r = RuntimeConfig::default();
|
|
assert_eq!(r.kind, "native");
|
|
}
|
|
|
|
#[test]
|
|
fn heartbeat_config_default() {
|
|
let h = HeartbeatConfig::default();
|
|
assert!(!h.enabled);
|
|
assert_eq!(h.interval_minutes, 30);
|
|
}
|
|
|
|
#[test]
|
|
fn channels_config_default() {
|
|
let c = ChannelsConfig::default();
|
|
assert!(c.cli);
|
|
assert!(c.telegram.is_none());
|
|
assert!(c.discord.is_none());
|
|
}
|
|
|
|
// ── Serde round-trip ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn config_toml_roundtrip() {
|
|
let config = Config {
|
|
workspace_dir: PathBuf::from("/tmp/test/workspace"),
|
|
config_path: PathBuf::from("/tmp/test/config.toml"),
|
|
api_key: Some("sk-test-key".into()),
|
|
default_provider: Some("openrouter".into()),
|
|
default_model: Some("gpt-4o".into()),
|
|
default_temperature: 0.5,
|
|
observability: ObservabilityConfig {
|
|
backend: "log".into(),
|
|
},
|
|
autonomy: AutonomyConfig {
|
|
level: AutonomyLevel::Full,
|
|
workspace_only: false,
|
|
allowed_commands: vec!["docker".into()],
|
|
forbidden_paths: vec!["/secret".into()],
|
|
max_actions_per_hour: 50,
|
|
max_cost_per_day_cents: 1000,
|
|
},
|
|
runtime: RuntimeConfig {
|
|
kind: "docker".into(),
|
|
},
|
|
heartbeat: HeartbeatConfig {
|
|
enabled: true,
|
|
interval_minutes: 15,
|
|
},
|
|
channels_config: ChannelsConfig {
|
|
cli: true,
|
|
telegram: Some(TelegramConfig {
|
|
bot_token: "123:ABC".into(),
|
|
allowed_users: vec!["user1".into()],
|
|
}),
|
|
discord: None,
|
|
slack: None,
|
|
webhook: None,
|
|
imessage: None,
|
|
matrix: None,
|
|
},
|
|
memory: MemoryConfig::default(),
|
|
tunnel: TunnelConfig::default(),
|
|
};
|
|
|
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
|
let parsed: Config = toml::from_str(&toml_str).unwrap();
|
|
|
|
assert_eq!(parsed.api_key, config.api_key);
|
|
assert_eq!(parsed.default_provider, config.default_provider);
|
|
assert_eq!(parsed.default_model, config.default_model);
|
|
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
|
|
assert_eq!(parsed.observability.backend, "log");
|
|
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
|
|
assert!(!parsed.autonomy.workspace_only);
|
|
assert_eq!(parsed.runtime.kind, "docker");
|
|
assert!(parsed.heartbeat.enabled);
|
|
assert_eq!(parsed.heartbeat.interval_minutes, 15);
|
|
assert!(parsed.channels_config.telegram.is_some());
|
|
assert_eq!(
|
|
parsed.channels_config.telegram.unwrap().bot_token,
|
|
"123:ABC"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_minimal_toml_uses_defaults() {
|
|
let minimal = r#"
|
|
workspace_dir = "/tmp/ws"
|
|
config_path = "/tmp/config.toml"
|
|
default_temperature = 0.7
|
|
"#;
|
|
let parsed: Config = toml::from_str(minimal).unwrap();
|
|
assert!(parsed.api_key.is_none());
|
|
assert!(parsed.default_provider.is_none());
|
|
assert_eq!(parsed.observability.backend, "none");
|
|
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
|
|
assert_eq!(parsed.runtime.kind, "native");
|
|
assert!(!parsed.heartbeat.enabled);
|
|
assert!(parsed.channels_config.cli);
|
|
}
|
|
|
|
#[test]
|
|
fn config_save_and_load_tmpdir() {
|
|
let dir = std::env::temp_dir().join("zeroclaw_test_config");
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).unwrap();
|
|
|
|
let config_path = dir.join("config.toml");
|
|
let config = Config {
|
|
workspace_dir: dir.join("workspace"),
|
|
config_path: config_path.clone(),
|
|
api_key: Some("sk-roundtrip".into()),
|
|
default_provider: Some("openrouter".into()),
|
|
default_model: Some("test-model".into()),
|
|
default_temperature: 0.9,
|
|
observability: ObservabilityConfig::default(),
|
|
autonomy: AutonomyConfig::default(),
|
|
runtime: RuntimeConfig::default(),
|
|
heartbeat: HeartbeatConfig::default(),
|
|
channels_config: ChannelsConfig::default(),
|
|
memory: MemoryConfig::default(),
|
|
tunnel: TunnelConfig::default(),
|
|
};
|
|
|
|
config.save().unwrap();
|
|
assert!(config_path.exists());
|
|
|
|
let contents = fs::read_to_string(&config_path).unwrap();
|
|
let loaded: Config = toml::from_str(&contents).unwrap();
|
|
assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip"));
|
|
assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
|
|
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
// ── Telegram / Discord config ────────────────────────────
|
|
|
|
#[test]
|
|
fn telegram_config_serde() {
|
|
let tc = TelegramConfig {
|
|
bot_token: "123:XYZ".into(),
|
|
allowed_users: vec!["alice".into(), "bob".into()],
|
|
};
|
|
let json = serde_json::to_string(&tc).unwrap();
|
|
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.bot_token, "123:XYZ");
|
|
assert_eq!(parsed.allowed_users.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn discord_config_serde() {
|
|
let dc = DiscordConfig {
|
|
bot_token: "discord-token".into(),
|
|
guild_id: Some("12345".into()),
|
|
allowed_users: vec![],
|
|
};
|
|
let json = serde_json::to_string(&dc).unwrap();
|
|
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.bot_token, "discord-token");
|
|
assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
|
|
}
|
|
|
|
#[test]
|
|
fn discord_config_optional_guild() {
|
|
let dc = DiscordConfig {
|
|
bot_token: "tok".into(),
|
|
guild_id: None,
|
|
allowed_users: vec![],
|
|
};
|
|
let json = serde_json::to_string(&dc).unwrap();
|
|
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
|
assert!(parsed.guild_id.is_none());
|
|
}
|
|
|
|
// ── iMessage / Matrix config ────────────────────────────
|
|
|
|
#[test]
|
|
fn imessage_config_serde() {
|
|
let ic = IMessageConfig {
|
|
allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
|
|
};
|
|
let json = serde_json::to_string(&ic).unwrap();
|
|
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.allowed_contacts.len(), 2);
|
|
assert_eq!(parsed.allowed_contacts[0], "+1234567890");
|
|
}
|
|
|
|
#[test]
|
|
fn imessage_config_empty_contacts() {
|
|
let ic = IMessageConfig {
|
|
allowed_contacts: vec![],
|
|
};
|
|
let json = serde_json::to_string(&ic).unwrap();
|
|
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
|
assert!(parsed.allowed_contacts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn imessage_config_wildcard() {
|
|
let ic = IMessageConfig {
|
|
allowed_contacts: vec!["*".into()],
|
|
};
|
|
let toml_str = toml::to_string(&ic).unwrap();
|
|
let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
|
|
assert_eq!(parsed.allowed_contacts, vec!["*"]);
|
|
}
|
|
|
|
#[test]
|
|
fn matrix_config_serde() {
|
|
let mc = MatrixConfig {
|
|
homeserver: "https://matrix.org".into(),
|
|
access_token: "syt_token_abc".into(),
|
|
room_id: "!room123:matrix.org".into(),
|
|
allowed_users: vec!["@user:matrix.org".into()],
|
|
};
|
|
let json = serde_json::to_string(&mc).unwrap();
|
|
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.homeserver, "https://matrix.org");
|
|
assert_eq!(parsed.access_token, "syt_token_abc");
|
|
assert_eq!(parsed.room_id, "!room123:matrix.org");
|
|
assert_eq!(parsed.allowed_users.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn matrix_config_toml_roundtrip() {
|
|
let mc = MatrixConfig {
|
|
homeserver: "https://synapse.local:8448".into(),
|
|
access_token: "tok".into(),
|
|
room_id: "!abc:synapse.local".into(),
|
|
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
|
|
};
|
|
let toml_str = toml::to_string(&mc).unwrap();
|
|
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
|
|
assert_eq!(parsed.homeserver, "https://synapse.local:8448");
|
|
assert_eq!(parsed.allowed_users.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn channels_config_with_imessage_and_matrix() {
|
|
let c = ChannelsConfig {
|
|
cli: true,
|
|
telegram: None,
|
|
discord: None,
|
|
slack: None,
|
|
webhook: None,
|
|
imessage: Some(IMessageConfig {
|
|
allowed_contacts: vec!["+1".into()],
|
|
}),
|
|
matrix: Some(MatrixConfig {
|
|
homeserver: "https://m.org".into(),
|
|
access_token: "tok".into(),
|
|
room_id: "!r:m".into(),
|
|
allowed_users: vec!["@u:m".into()],
|
|
}),
|
|
};
|
|
let toml_str = toml::to_string_pretty(&c).unwrap();
|
|
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
|
assert!(parsed.imessage.is_some());
|
|
assert!(parsed.matrix.is_some());
|
|
assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
|
|
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
|
|
}
|
|
|
|
#[test]
|
|
fn channels_config_default_has_no_imessage_matrix() {
|
|
let c = ChannelsConfig::default();
|
|
assert!(c.imessage.is_none());
|
|
assert!(c.matrix.is_none());
|
|
}
|
|
|
|
// ── Edge cases: serde(default) for allowed_users ─────────
|
|
|
|
#[test]
|
|
fn discord_config_deserializes_without_allowed_users() {
|
|
// Old configs won't have allowed_users — serde(default) should fill vec![]
|
|
let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
|
|
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
|
|
assert!(parsed.allowed_users.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn discord_config_deserializes_with_allowed_users() {
|
|
let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
|
|
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(parsed.allowed_users, vec!["111", "222"]);
|
|
}
|
|
|
|
#[test]
|
|
fn slack_config_deserializes_without_allowed_users() {
|
|
let json = r#"{"bot_token":"xoxb-tok"}"#;
|
|
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
|
|
assert!(parsed.allowed_users.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn slack_config_deserializes_with_allowed_users() {
|
|
let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
|
|
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(parsed.allowed_users, vec!["U111"]);
|
|
}
|
|
|
|
#[test]
|
|
fn discord_config_toml_backward_compat() {
|
|
let toml_str = r#"
|
|
bot_token = "tok"
|
|
guild_id = "123"
|
|
"#;
|
|
let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
|
|
assert!(parsed.allowed_users.is_empty());
|
|
assert_eq!(parsed.bot_token, "tok");
|
|
}
|
|
|
|
#[test]
|
|
fn slack_config_toml_backward_compat() {
|
|
let toml_str = r#"
|
|
bot_token = "xoxb-tok"
|
|
channel_id = "C123"
|
|
"#;
|
|
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
|
|
assert!(parsed.allowed_users.is_empty());
|
|
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_config_with_secret() {
|
|
let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
|
|
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
|
|
assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
|
|
}
|
|
|
|
#[test]
|
|
fn webhook_config_without_secret() {
|
|
let json = r#"{"port":8080}"#;
|
|
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
|
|
assert!(parsed.secret.is_none());
|
|
assert_eq!(parsed.port, 8080);
|
|
}
|
|
}
|