Skip to main content

msg_gateway/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::error::AppError;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Config {
10    pub gateway: GatewayConfig,
11    pub auth: AuthConfig,
12    #[serde(default)]
13    pub health_checks: HashMap<String, HealthCheckConfig>,
14    #[serde(default)]
15    pub credentials: HashMap<String, CredentialConfig>,
16    #[serde(default)]
17    pub backends: HashMap<String, BackendConfig>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GatewayConfig {
22    pub listen: String,
23    pub admin_token: String,
24    #[serde(default)]
25    pub default_backend: Option<String>,
26    /// Directory containing adapter definitions
27    #[serde(default = "default_adapters_dir")]
28    pub adapters_dir: String,
29    /// Port range for adapter processes [start, end]
30    #[serde(default = "default_adapter_port_range")]
31    pub adapter_port_range: (u16, u16),
32    /// Directory containing backend adapter definitions
33    #[serde(default = "default_backends_dir")]
34    pub backends_dir: String,
35    /// Port range for backend adapter processes [start, end]
36    #[serde(default = "default_backend_port_range")]
37    pub backend_port_range: (u16, u16),
38    #[serde(default)]
39    pub file_cache: Option<FileCacheConfig>,
40    #[serde(default)]
41    pub guardrails_dir: Option<String>,
42}
43
44fn default_adapters_dir() -> String {
45    "./adapters".to_string()
46}
47
48fn default_adapter_port_range() -> (u16, u16) {
49    (9000, 9100)
50}
51
52fn default_backends_dir() -> String {
53    "./backends".to_string()
54}
55
56fn default_backend_port_range() -> (u16, u16) {
57    (9200, 9300)
58}
59
60fn default_true() -> bool {
61    true
62}
63
64/// Backend protocol type
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum BackendProtocol {
68    /// Pipelit: POST webhook, callback via /api/v1/send
69    Pipelit,
70    /// OpenCode: REST + SSE polling
71    Opencode,
72    /// External: subprocess-managed backend adapter (any language)
73    External,
74}
75
76/// Guardrail evaluation type
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(rename_all = "lowercase")]
79#[allow(dead_code)]
80pub enum GuardrailType {
81    /// CEL (Common Expression Language) evaluation
82    #[default]
83    Cel,
84    /// LLM-based evaluation (placeholder for future)
85    Llm,
86}
87
88/// Action to take when guardrail rule matches
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[serde(rename_all = "lowercase")]
91#[allow(dead_code)]
92pub enum GuardrailAction {
93    /// Block the message
94    #[default]
95    Block,
96    /// Log the violation
97    Log,
98}
99
100/// Direction of message flow to apply guardrail
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "lowercase")]
103#[allow(dead_code)]
104pub enum GuardrailDirection {
105    /// Inbound messages only (adapter → gateway)
106    #[default]
107    Inbound,
108    /// Outbound messages only (gateway → adapter)
109    Outbound,
110    /// Both inbound and outbound
111    Both,
112}
113
114/// Behavior when guardrail evaluation errors
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
116#[serde(rename_all = "lowercase")]
117#[allow(dead_code)]
118pub enum GuardrailOnError {
119    /// Allow the message (fail-open)
120    #[default]
121    Allow,
122    /// Block the message (fail-closed)
123    Block,
124}
125
126/// Guardrail rule configuration
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[allow(dead_code)]
129pub struct GuardrailRule {
130    /// Rule name
131    pub name: String,
132    /// Evaluation type
133    #[serde(default)]
134    pub r#type: GuardrailType,
135    /// CEL expression to evaluate
136    pub expression: String,
137    /// Action when rule matches
138    #[serde(default)]
139    pub action: GuardrailAction,
140    /// Message direction to apply rule
141    #[serde(default)]
142    pub direction: GuardrailDirection,
143    /// Behavior on evaluation error
144    #[serde(default)]
145    pub on_error: GuardrailOnError,
146    /// Message to return if blocked
147    #[serde(default)]
148    pub reject_message: Option<String>,
149    /// Whether rule is enabled
150    #[serde(default = "default_true")]
151    pub enabled: bool,
152}
153
154/// Backend configuration
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct BackendConfig {
157    pub protocol: BackendProtocol,
158    /// Inbound URL for Pipelit (POST destination)
159    #[serde(default)]
160    pub inbound_url: Option<String>,
161    /// Base URL for OpenCode
162    #[serde(default)]
163    pub base_url: Option<String>,
164    /// Auth token for the backend
165    pub token: String,
166    /// Poll interval for OpenCode (milliseconds)
167    #[serde(default)]
168    pub poll_interval_ms: Option<u64>,
169    /// Directory containing the external backend adapter (for External protocol)
170    #[serde(default)]
171    pub adapter_dir: Option<String>,
172    /// Port for pre-spawned external backend adapter
173    #[serde(default)]
174    pub port: Option<u16>,
175    /// Whether this backend is active (auto-spawned at startup for External protocol)
176    #[serde(default = "default_true")]
177    pub active: bool,
178    /// Opaque config blob passed as BACKEND_CONFIG env var to external subprocess
179    #[serde(default)]
180    pub config: Option<serde_json::Value>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct FileCacheConfig {
185    pub directory: String,
186    pub ttl_hours: u32,
187    pub max_cache_size_mb: u32,
188    pub cleanup_interval_minutes: u32,
189    pub max_file_size_mb: u32,
190    #[serde(default)]
191    pub allowed_mime_types: Vec<String>,
192    #[serde(default)]
193    pub blocked_mime_types: Vec<String>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct AuthConfig {
198    pub send_token: String,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct HealthCheckConfig {
203    pub url: String,
204    pub interval_seconds: u32,
205    pub alert_after_failures: u32,
206    #[serde(default)]
207    pub notify_credentials: Vec<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct CredentialConfig {
212    pub adapter: String,
213    pub token: String,
214    pub active: bool,
215    #[serde(default)]
216    pub emergency: bool,
217    #[serde(default)]
218    pub config: Option<serde_json::Value>,
219    #[serde(default)]
220    pub backend: Option<String>,
221    pub route: serde_json::Value,
222}
223
224/// Load config from file, resolving environment variables
225pub fn load_config<P: AsRef<Path>>(path: P) -> Result<Config, AppError> {
226    let path = path.as_ref();
227    let content = fs::read_to_string(path)
228        .map_err(|e| AppError::Config(format!("Failed to read config file: {}", e)))?;
229
230    let resolved = resolve_env_vars(&content)?;
231
232    let mut config: Config = serde_json::from_str(&resolved)
233        .map_err(|e| AppError::Config(format!("Failed to parse config: {}", e)))?;
234
235    // Validate backend name references
236    if let Some(ref default_backend) = config.gateway.default_backend
237        && !config.backends.contains_key(default_backend)
238    {
239        return Err(AppError::Config(format!(
240            "default_backend '{}' not found in backends map",
241            default_backend
242        )));
243    }
244
245    for (cred_id, cred) in &config.credentials {
246        if let Some(ref backend) = cred.backend
247            && !config.backends.contains_key(backend)
248        {
249            return Err(AppError::Config(format!(
250                "Credential '{}' references unknown backend '{}'",
251                cred_id, backend
252            )));
253        }
254    }
255
256    let config_dir = path.parent().unwrap_or(Path::new("."));
257    resolve_guardrails_dir(&mut config.gateway, config_dir);
258
259    Ok(config)
260}
261
262fn resolve_guardrails_dir(gateway: &mut GatewayConfig, config_dir: &Path) {
263    match &gateway.guardrails_dir {
264        Some(dir) => {
265            let p = Path::new(dir);
266            if p.is_relative() {
267                gateway.guardrails_dir = Some(config_dir.join(p).to_string_lossy().into_owned());
268            }
269        }
270        None => {
271            let auto = config_dir.join("guardrails");
272            if auto.exists() {
273                gateway.guardrails_dir = Some(auto.to_string_lossy().into_owned());
274            }
275        }
276    }
277}
278
279/// Resolve config file path using XDG conventions.
280///
281/// Resolution order:
282/// 1. `GATEWAY_CONFIG` env var — returned as-is (backward compat, no existence check)
283/// 2. `$XDG_CONFIG_HOME/msg-gateway/config.json` — if file exists
284/// 3. `$HOME/.config/msg-gateway/config.json` — if file exists
285/// 4. `./config.json` — CWD fallback
286pub fn resolve_config_path() -> PathBuf {
287    if let Ok(path) = std::env::var("GATEWAY_CONFIG") {
288        return PathBuf::from(path);
289    }
290    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
291        let p = PathBuf::from(xdg).join("msg-gateway").join("config.json");
292        if p.exists() {
293            return p;
294        }
295    }
296    if let Ok(home) = std::env::var("HOME") {
297        let p = PathBuf::from(home)
298            .join(".config")
299            .join("msg-gateway")
300            .join("config.json");
301        if p.exists() {
302            return p;
303        }
304    }
305    PathBuf::from("config.json")
306}
307
308/// Resolve ${VAR} patterns to environment variable values
309fn resolve_env_vars(content: &str) -> Result<String, AppError> {
310    let mut result = content.to_string();
311    let re = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
312
313    for cap in re.captures_iter(content) {
314        let var_name = &cap[1];
315        let var_value = std::env::var(var_name)
316            .map_err(|_| AppError::Config(format!("Environment variable not set: {}", var_name)))?;
317        result = result.replace(&cap[0], &var_value);
318    }
319
320    Ok(result)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serial_test::serial;
327    use tempfile::TempDir;
328
329    // ==================== resolve_env_vars Tests ====================
330
331    #[test]
332    #[serial]
333    fn test_resolve_env_vars() {
334        // SAFETY: This is a single-threaded test
335        unsafe {
336            std::env::set_var("TEST_VAR", "test_value");
337        }
338        let input = r#"{"token": "${TEST_VAR}"}"#;
339        let result = resolve_env_vars(input).unwrap();
340        assert_eq!(result, r#"{"token": "test_value"}"#);
341    }
342
343    #[test]
344    #[serial]
345    fn test_resolve_env_vars_multiple() {
346        unsafe {
347            std::env::set_var("VAR1", "value1");
348            std::env::set_var("VAR2", "value2");
349        }
350        let input = r#"{"a": "${VAR1}", "b": "${VAR2}"}"#;
351        let result = resolve_env_vars(input).unwrap();
352        assert_eq!(result, r#"{"a": "value1", "b": "value2"}"#);
353    }
354
355    #[test]
356    fn test_resolve_env_vars_no_vars() {
357        let input = r#"{"token": "literal_value"}"#;
358        let result = resolve_env_vars(input).unwrap();
359        assert_eq!(result, r#"{"token": "literal_value"}"#);
360    }
361
362    #[test]
363    fn test_resolve_env_vars_missing_var() {
364        let input = r#"{"token": "${NONEXISTENT_VAR_12345}"}"#;
365        let result = resolve_env_vars(input);
366        assert!(result.is_err());
367        let err = result.unwrap_err();
368        assert!(matches!(err, AppError::Config(_)));
369    }
370
371    // ==================== load_config Tests ====================
372
373    #[test]
374    #[serial]
375    fn test_load_config_success() {
376        let temp_dir = TempDir::new().unwrap();
377        let config_path = temp_dir.path().join("config.json");
378
379        unsafe {
380            std::env::set_var("TEST_ADMIN_TOKEN", "admin123");
381            std::env::set_var("TEST_SEND_TOKEN", "send456");
382            std::env::set_var("TEST_BACKEND_TOKEN", "backend789");
383        }
384
385        let config_content = r#"{
386            "gateway": {
387                "listen": "127.0.0.1:8080",
388                "admin_token": "${TEST_ADMIN_TOKEN}",
389                "default_backend": "pipelit"
390            },
391            "backends": {
392                "pipelit": {
393                    "protocol": "pipelit",
394                    "inbound_url": "http://localhost:9000/inbound",
395                    "token": "${TEST_BACKEND_TOKEN}"
396                }
397            },
398            "auth": {
399                "send_token": "${TEST_SEND_TOKEN}"
400            }
401        }"#;
402
403        std::fs::write(&config_path, config_content).unwrap();
404
405        let config = load_config(&config_path).unwrap();
406        assert_eq!(config.gateway.listen, "127.0.0.1:8080");
407        assert_eq!(config.gateway.admin_token, "admin123");
408        assert_eq!(config.auth.send_token, "send456");
409        assert_eq!(config.gateway.default_backend, Some("pipelit".to_string()));
410        assert_eq!(config.backends["pipelit"].token, "backend789");
411    }
412
413    #[test]
414    fn test_load_config_file_not_found() {
415        let result = load_config("/nonexistent/config.json");
416        assert!(result.is_err());
417        let err = result.unwrap_err();
418        assert!(matches!(err, AppError::Config(_)));
419    }
420
421    #[test]
422    fn test_load_config_invalid_json() {
423        let temp_dir = TempDir::new().unwrap();
424        let config_path = temp_dir.path().join("invalid.json");
425        std::fs::write(&config_path, "{ invalid json }").unwrap();
426
427        let result = load_config(&config_path);
428        assert!(result.is_err());
429        let err = result.unwrap_err();
430        assert!(matches!(err, AppError::Config(_)));
431    }
432
433    #[test]
434    fn test_load_config_invalid_default_backend() {
435        let temp_dir = TempDir::new().unwrap();
436        let config_path = temp_dir.path().join("config.json");
437        let content = r#"{
438            "gateway": {"listen": "127.0.0.1:8080", "admin_token": "a", "default_backend": "nonexistent"},
439            "auth": {"send_token": "s"}
440        }"#;
441        std::fs::write(&config_path, content).unwrap();
442        let result = load_config(&config_path);
443        assert!(result.is_err());
444        assert!(matches!(result.unwrap_err(), AppError::Config(_)));
445    }
446
447    #[test]
448    fn test_load_config_invalid_credential_backend() {
449        let temp_dir = TempDir::new().unwrap();
450        let config_path = temp_dir.path().join("config.json");
451        let content = r#"{
452            "gateway": {"listen": "127.0.0.1:8080", "admin_token": "a"},
453            "auth": {"send_token": "s"},
454            "credentials": {
455                "test_cred": {
456                    "adapter": "generic",
457                    "token": "token123",
458                    "active": true,
459                    "backend": "nonexistent",
460                    "route": {"channel": "test"}
461                }
462            }
463        }"#;
464        std::fs::write(&config_path, content).unwrap();
465        let result = load_config(&config_path);
466        assert!(result.is_err());
467        assert!(matches!(result.unwrap_err(), AppError::Config(_)));
468    }
469
470    #[test]
471    #[serial]
472    fn test_load_config_with_defaults() {
473        let temp_dir = TempDir::new().unwrap();
474        let config_path = temp_dir.path().join("config.json");
475
476        unsafe {
477            std::env::set_var("TEST_TOKEN_DEFAULT", "token123");
478        }
479
480        let config_content = r#"{
481            "gateway": {
482                "listen": "127.0.0.1:8080",
483                "admin_token": "${TEST_TOKEN_DEFAULT}"
484            },
485            "auth": {
486                "send_token": "${TEST_TOKEN_DEFAULT}"
487            }
488        }"#;
489
490        std::fs::write(&config_path, config_content).unwrap();
491
492        let config = load_config(&config_path).unwrap();
493        assert_eq!(config.gateway.adapters_dir, "./adapters");
494        assert_eq!(config.gateway.adapter_port_range, (9000, 9100));
495        assert!(config.gateway.file_cache.is_none());
496        assert!(config.gateway.default_backend.is_none());
497        assert!(config.credentials.is_empty());
498        assert!(config.health_checks.is_empty());
499        assert!(config.backends.is_empty());
500    }
501
502    // ==================== Config Struct Tests ====================
503
504    #[test]
505    fn test_backend_protocol_serialize() {
506        let pipelit = BackendProtocol::Pipelit;
507        let json = serde_json::to_string(&pipelit).unwrap();
508        assert_eq!(json, "\"pipelit\"");
509
510        let opencode = BackendProtocol::Opencode;
511        let json = serde_json::to_string(&opencode).unwrap();
512        assert_eq!(json, "\"opencode\"");
513
514        let external = BackendProtocol::External;
515        let json = serde_json::to_string(&external).unwrap();
516        assert_eq!(json, "\"external\"");
517    }
518
519    #[test]
520    fn test_backend_protocol_deserialize() {
521        let pipelit: BackendProtocol = serde_json::from_str("\"pipelit\"").unwrap();
522        assert_eq!(pipelit, BackendProtocol::Pipelit);
523
524        let opencode: BackendProtocol = serde_json::from_str("\"opencode\"").unwrap();
525        assert_eq!(opencode, BackendProtocol::Opencode);
526
527        let external: BackendProtocol = serde_json::from_str("\"external\"").unwrap();
528        assert_eq!(external, BackendProtocol::External);
529    }
530
531    #[test]
532    fn test_backend_config_serialize() {
533        let backend = BackendConfig {
534            protocol: BackendProtocol::Pipelit,
535            inbound_url: Some("http://localhost:9000".to_string()),
536            base_url: None,
537            token: "test_token".to_string(),
538            poll_interval_ms: None,
539            adapter_dir: None,
540            port: None,
541            active: true,
542            config: None,
543        };
544
545        let json = serde_json::to_string(&backend).unwrap();
546        assert!(json.contains("\"protocol\":\"pipelit\""));
547        assert!(json.contains("\"token\":\"test_token\""));
548        assert!(json.contains("\"active\":true"));
549    }
550
551    #[test]
552    fn test_backend_config_opencode() {
553        let json = r#"{
554            "protocol": "opencode",
555            "base_url": "http://localhost:8000",
556            "token": "api_key",
557            "poll_interval_ms": 1000
558        }"#;
559
560        let backend: BackendConfig = serde_json::from_str(json).unwrap();
561        assert_eq!(backend.protocol, BackendProtocol::Opencode);
562        assert_eq!(backend.base_url, Some("http://localhost:8000".to_string()));
563        assert_eq!(backend.poll_interval_ms, Some(1000));
564        assert!(backend.active);
565        assert!(backend.config.is_none());
566    }
567
568    #[test]
569    fn test_file_cache_config_serialize() {
570        let cache = FileCacheConfig {
571            directory: "/tmp/cache".to_string(),
572            ttl_hours: 24,
573            max_cache_size_mb: 100,
574            cleanup_interval_minutes: 60,
575            max_file_size_mb: 10,
576            allowed_mime_types: vec!["image/*".to_string()],
577            blocked_mime_types: vec![],
578        };
579
580        let json = serde_json::to_string(&cache).unwrap();
581        assert!(json.contains("\"directory\":\"/tmp/cache\""));
582        assert!(json.contains("\"ttl_hours\":24"));
583    }
584
585    #[test]
586    fn test_health_check_config_serialize() {
587        let check = HealthCheckConfig {
588            url: "http://localhost:8080/health".to_string(),
589            interval_seconds: 30,
590            alert_after_failures: 3,
591            notify_credentials: vec!["cred1".to_string()],
592        };
593
594        let json = serde_json::to_string(&check).unwrap();
595        assert!(json.contains("\"interval_seconds\":30"));
596    }
597
598    #[test]
599    fn test_credential_config_minimal() {
600        let json = r#"{
601            "adapter": "generic",
602            "token": "token123",
603            "active": true,
604            "route": {"channel": "test"}
605        }"#;
606
607        let cred: CredentialConfig = serde_json::from_str(json).unwrap();
608        assert_eq!(cred.adapter, "generic");
609        assert!(cred.active);
610        assert!(!cred.emergency);
611        assert!(cred.config.is_none());
612        assert!(cred.backend.is_none());
613    }
614
615    #[test]
616    fn test_credential_config_full() {
617        let json = r#"{
618            "adapter": "telegram",
619            "token": "bot_token",
620            "active": true,
621            "emergency": true,
622            "config": {"webhook_url": "https://example.com"},
623            "backend": "opencode",
624            "route": {"user_id": 123}
625        }"#;
626
627        let cred: CredentialConfig = serde_json::from_str(json).unwrap();
628        assert_eq!(cred.adapter, "telegram");
629        assert!(cred.emergency);
630        assert!(cred.config.is_some());
631        assert_eq!(cred.backend, Some("opencode".to_string()));
632    }
633
634    #[test]
635    fn test_auth_config_serialize() {
636        let auth = AuthConfig {
637            send_token: "secret_token".to_string(),
638        };
639
640        let json = serde_json::to_string(&auth).unwrap();
641        assert!(json.contains("\"send_token\":\"secret_token\""));
642    }
643
644    #[test]
645    fn test_gateway_config_serialize() {
646        let gateway = GatewayConfig {
647            listen: "0.0.0.0:8080".to_string(),
648            admin_token: "admin123".to_string(),
649            default_backend: Some("opencode".to_string()),
650            adapters_dir: "./adapters".to_string(),
651            adapter_port_range: (9000, 9100),
652            backends_dir: "./backends".to_string(),
653            backend_port_range: (9200, 9300),
654            file_cache: None,
655            guardrails_dir: None,
656        };
657
658        let json = serde_json::to_string(&gateway).unwrap();
659        assert!(json.contains("\"listen\":\"0.0.0.0:8080\""));
660        assert!(json.contains("\"default_backend\":\"opencode\""));
661        assert!(json.contains("\"adapter_port_range\":[9000,9100]"));
662        assert!(json.contains("\"backend_port_range\":[9200,9300]"));
663    }
664
665    #[test]
666    fn test_config_full_roundtrip() {
667        let mut backends = HashMap::new();
668        backends.insert(
669            "pipelit".to_string(),
670            BackendConfig {
671                protocol: BackendProtocol::Pipelit,
672                inbound_url: Some("http://localhost:9000".to_string()),
673                base_url: None,
674                token: "token".to_string(),
675                poll_interval_ms: None,
676                adapter_dir: None,
677                port: None,
678                active: true,
679                config: None,
680            },
681        );
682
683        let config = Config {
684            gateway: GatewayConfig {
685                listen: "127.0.0.1:8080".to_string(),
686                admin_token: "admin".to_string(),
687                default_backend: Some("pipelit".to_string()),
688                adapters_dir: "./adapters".to_string(),
689                adapter_port_range: (9000, 9100),
690                backends_dir: "./backends".to_string(),
691                backend_port_range: (9200, 9300),
692                file_cache: None,
693                guardrails_dir: None,
694            },
695            auth: AuthConfig {
696                send_token: "send".to_string(),
697            },
698            health_checks: HashMap::new(),
699            credentials: HashMap::new(),
700            backends,
701        };
702
703        let json = serde_json::to_string(&config).unwrap();
704        let parsed: Config = serde_json::from_str(&json).unwrap();
705
706        assert_eq!(parsed.gateway.listen, config.gateway.listen);
707        assert_eq!(parsed.auth.send_token, config.auth.send_token);
708        assert_eq!(
709            parsed.gateway.default_backend,
710            config.gateway.default_backend
711        );
712        assert_eq!(parsed.backends.len(), 1);
713        assert!(parsed.backends.contains_key("pipelit"));
714    }
715
716    // ==================== Default Function Tests ====================
717
718    #[test]
719    fn test_default_adapters_dir() {
720        assert_eq!(default_adapters_dir(), "./adapters");
721    }
722
723    #[test]
724    fn test_default_adapter_port_range() {
725        assert_eq!(default_adapter_port_range(), (9000, 9100));
726    }
727
728    #[test]
729    fn test_default_backends_dir() {
730        assert_eq!(default_backends_dir(), "./backends");
731    }
732
733    #[test]
734    fn test_default_backend_port_range() {
735        assert_eq!(default_backend_port_range(), (9200, 9300));
736    }
737
738    // ==================== New Named-Backends Tests ====================
739
740    #[test]
741    fn test_backends_deserialization() {
742        let json = r#"{
743            "gateway": {
744                "listen": "127.0.0.1:8080",
745                "admin_token": "admin",
746                "default_backend": "opencode"
747            },
748            "backends": {
749                "opencode": {
750                    "protocol": "external",
751                    "adapter_dir": "./backends/opencode",
752                    "active": true,
753                    "token": "",
754                    "config": {"base_url": "http://127.0.0.1:4096"}
755                },
756                "pipelit": {
757                    "protocol": "pipelit",
758                    "inbound_url": "http://localhost:8000/api/v1/inbound",
759                    "token": "pipelit-token",
760                    "active": true
761                }
762            },
763            "auth": {
764                "send_token": "send-token"
765            }
766        }"#;
767
768        let config: Config = serde_json::from_str(json).unwrap();
769        assert_eq!(config.backends.len(), 2);
770
771        let opencode = &config.backends["opencode"];
772        assert_eq!(opencode.protocol, BackendProtocol::External);
773        assert_eq!(
774            opencode.adapter_dir,
775            Some("./backends/opencode".to_string())
776        );
777        assert!(opencode.active);
778        assert_eq!(opencode.token, "");
779        assert!(opencode.config.is_some());
780
781        let pipelit = &config.backends["pipelit"];
782        assert_eq!(pipelit.protocol, BackendProtocol::Pipelit);
783        assert_eq!(
784            pipelit.inbound_url,
785            Some("http://localhost:8000/api/v1/inbound".to_string())
786        );
787        assert!(pipelit.active);
788        assert_eq!(pipelit.token, "pipelit-token");
789        assert!(pipelit.config.is_none());
790    }
791
792    #[test]
793    fn test_credential_backend_field() {
794        let json = r#"{
795            "adapter": "telegram",
796            "token": "bot_token",
797            "active": true,
798            "backend": "opencode",
799            "route": {"channel": "telegram"}
800        }"#;
801
802        let cred: CredentialConfig = serde_json::from_str(json).unwrap();
803        assert_eq!(cred.backend, Some("opencode".to_string()));
804        assert_eq!(cred.adapter, "telegram");
805    }
806
807    #[test]
808    fn test_default_backend_field_serde() {
809        let json = r#"{
810            "gateway": {
811                "listen": "127.0.0.1:8080",
812                "admin_token": "admin",
813                "default_backend": "opencode"
814            },
815            "auth": {
816                "send_token": "token"
817            }
818        }"#;
819
820        let config: Config = serde_json::from_str(json).unwrap();
821        assert_eq!(config.gateway.default_backend, Some("opencode".to_string()));
822        assert!(config.backends.is_empty());
823    }
824
825    // ==================== GuardrailRule Tests ====================
826
827    #[test]
828    fn test_guardrail_rule_minimal_json() {
829        let json = r#"{"name":"test","expression":"true"}"#;
830        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
831
832        assert_eq!(rule.name, "test");
833        assert_eq!(rule.expression, "true");
834        assert_eq!(rule.r#type, GuardrailType::Cel);
835        assert_eq!(rule.action, GuardrailAction::Block);
836        assert_eq!(rule.direction, GuardrailDirection::Inbound);
837        assert_eq!(rule.on_error, GuardrailOnError::Allow);
838        assert_eq!(rule.reject_message, None);
839        assert!(rule.enabled);
840    }
841
842    #[test]
843    fn test_guardrail_rule_full_json() {
844        let json = r#"{
845            "name":"test_rule",
846            "type":"cel",
847            "expression":"message.text.size() > 100",
848            "action":"log",
849            "direction":"both",
850            "on_error":"block",
851            "reject_message":"Message too long",
852            "enabled":false
853        }"#;
854        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
855
856        assert_eq!(rule.name, "test_rule");
857        assert_eq!(rule.r#type, GuardrailType::Cel);
858        assert_eq!(rule.expression, "message.text.size() > 100");
859        assert_eq!(rule.action, GuardrailAction::Log);
860        assert_eq!(rule.direction, GuardrailDirection::Both);
861        assert_eq!(rule.on_error, GuardrailOnError::Block);
862        assert_eq!(rule.reject_message, Some("Message too long".to_string()));
863        assert!(!rule.enabled);
864    }
865
866    #[test]
867    fn test_guardrail_rule_enabled_default() {
868        let json = r#"{"name":"test","expression":"true"}"#;
869        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
870        assert!(rule.enabled);
871    }
872
873    #[test]
874    fn test_guardrail_rule_enabled_false() {
875        let json = r#"{"name":"test","expression":"true","enabled":false}"#;
876        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
877        assert!(!rule.enabled);
878    }
879
880    #[test]
881    fn test_guardrail_rule_roundtrip() {
882        let rule = GuardrailRule {
883            name: "test".to_string(),
884            r#type: GuardrailType::Cel,
885            expression: "true".to_string(),
886            action: GuardrailAction::Block,
887            direction: GuardrailDirection::Inbound,
888            on_error: GuardrailOnError::Allow,
889            reject_message: Some("rejected".to_string()),
890            enabled: true,
891        };
892
893        let json = serde_json::to_string(&rule).unwrap();
894        let parsed: GuardrailRule = serde_json::from_str(&json).unwrap();
895
896        assert_eq!(parsed.name, rule.name);
897        assert_eq!(parsed.r#type, rule.r#type);
898        assert_eq!(parsed.expression, rule.expression);
899        assert_eq!(parsed.action, rule.action);
900        assert_eq!(parsed.direction, rule.direction);
901        assert_eq!(parsed.on_error, rule.on_error);
902        assert_eq!(parsed.reject_message, rule.reject_message);
903        assert_eq!(parsed.enabled, rule.enabled);
904    }
905
906    #[test]
907    fn test_guardrail_type_default() {
908        let json = r#"{"name":"test","expression":"true"}"#;
909        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
910        assert_eq!(rule.r#type, GuardrailType::Cel);
911    }
912
913    #[test]
914    fn test_guardrail_action_default() {
915        let json = r#"{"name":"test","expression":"true"}"#;
916        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
917        assert_eq!(rule.action, GuardrailAction::Block);
918    }
919
920    #[test]
921    fn test_guardrail_direction_default() {
922        let json = r#"{"name":"test","expression":"true"}"#;
923        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
924        assert_eq!(rule.direction, GuardrailDirection::Inbound);
925    }
926
927    #[test]
928    fn test_guardrail_on_error_default() {
929        let json = r#"{"name":"test","expression":"true"}"#;
930        let rule: GuardrailRule = serde_json::from_str(json).unwrap();
931        assert_eq!(rule.on_error, GuardrailOnError::Allow);
932    }
933
934    #[test]
935    fn test_guardrail_invalid_action_error() {
936        let json = r#"{"name":"test","expression":"true","action":"invalid"}"#;
937        let result: Result<GuardrailRule, _> = serde_json::from_str(json);
938        assert!(result.is_err());
939    }
940
941    #[test]
942    fn test_guardrail_invalid_type_error() {
943        let json = r#"{"name":"test","expression":"true","type":"invalid"}"#;
944        let result: Result<GuardrailRule, _> = serde_json::from_str(json);
945        assert!(result.is_err());
946    }
947
948    #[test]
949    fn test_guardrail_invalid_direction_error() {
950        let json = r#"{"name":"test","expression":"true","direction":"invalid"}"#;
951        let result: Result<GuardrailRule, _> = serde_json::from_str(json);
952        assert!(result.is_err());
953    }
954
955    #[test]
956    fn test_guardrail_invalid_on_error_error() {
957        let json = r#"{"name":"test","expression":"true","on_error":"invalid"}"#;
958        let result: Result<GuardrailRule, _> = serde_json::from_str(json);
959        assert!(result.is_err());
960    }
961
962    // ==================== resolve_config_path Tests ====================
963
964    #[test]
965    #[serial]
966    fn test_resolve_config_path_env_override() {
967        unsafe {
968            std::env::set_var("GATEWAY_CONFIG", "/tmp/custom.json");
969            std::env::remove_var("XDG_CONFIG_HOME");
970            std::env::remove_var("HOME");
971        }
972        let result = resolve_config_path();
973        unsafe {
974            std::env::remove_var("GATEWAY_CONFIG");
975        }
976        assert_eq!(result, PathBuf::from("/tmp/custom.json"));
977    }
978
979    #[test]
980    #[serial]
981    fn test_resolve_config_path_xdg_config_home() {
982        let temp_dir = TempDir::new().unwrap();
983        let xdg_config = temp_dir.path().join("msg-gateway");
984        std::fs::create_dir_all(&xdg_config).unwrap();
985        let config_file = xdg_config.join("config.json");
986        std::fs::write(&config_file, "{}").unwrap();
987
988        unsafe {
989            std::env::remove_var("GATEWAY_CONFIG");
990            std::env::set_var("XDG_CONFIG_HOME", temp_dir.path());
991            std::env::remove_var("HOME");
992        }
993        let result = resolve_config_path();
994        unsafe {
995            std::env::remove_var("XDG_CONFIG_HOME");
996        }
997        assert_eq!(result, config_file);
998    }
999
1000    #[test]
1001    #[serial]
1002    fn test_resolve_config_path_home_config() {
1003        let temp_dir = TempDir::new().unwrap();
1004        let home_config = temp_dir.path().join(".config").join("msg-gateway");
1005        std::fs::create_dir_all(&home_config).unwrap();
1006        let config_file = home_config.join("config.json");
1007        std::fs::write(&config_file, "{}").unwrap();
1008
1009        unsafe {
1010            std::env::remove_var("GATEWAY_CONFIG");
1011            std::env::remove_var("XDG_CONFIG_HOME");
1012            std::env::set_var("HOME", temp_dir.path());
1013        }
1014        let result = resolve_config_path();
1015        unsafe {
1016            std::env::remove_var("HOME");
1017        }
1018        assert_eq!(result, config_file);
1019    }
1020
1021    #[test]
1022    #[serial]
1023    fn test_resolve_config_path_cwd_fallback() {
1024        unsafe {
1025            std::env::remove_var("GATEWAY_CONFIG");
1026            std::env::remove_var("XDG_CONFIG_HOME");
1027            std::env::remove_var("HOME");
1028        }
1029        let result = resolve_config_path();
1030        assert_eq!(result, PathBuf::from("config.json"));
1031    }
1032
1033    #[test]
1034    #[serial]
1035    fn test_resolve_config_path_xdg_takes_precedence_over_home() {
1036        let temp_dir = TempDir::new().unwrap();
1037
1038        let xdg_config = temp_dir.path().join("xdg").join("msg-gateway");
1039        std::fs::create_dir_all(&xdg_config).unwrap();
1040        let xdg_file = xdg_config.join("config.json");
1041        std::fs::write(&xdg_file, "{}").unwrap();
1042
1043        let home_config = temp_dir
1044            .path()
1045            .join("home")
1046            .join(".config")
1047            .join("msg-gateway");
1048        std::fs::create_dir_all(&home_config).unwrap();
1049        let home_file = home_config.join("config.json");
1050        std::fs::write(&home_file, "{}").unwrap();
1051
1052        unsafe {
1053            std::env::remove_var("GATEWAY_CONFIG");
1054            std::env::set_var("XDG_CONFIG_HOME", temp_dir.path().join("xdg"));
1055            std::env::set_var("HOME", temp_dir.path().join("home"));
1056        }
1057        let result = resolve_config_path();
1058        unsafe {
1059            std::env::remove_var("XDG_CONFIG_HOME");
1060            std::env::remove_var("HOME");
1061        }
1062        assert_eq!(result, xdg_file);
1063    }
1064
1065    // ==================== guardrails_dir Tests ====================
1066
1067    fn make_minimal_gateway(guardrails_dir: Option<String>) -> GatewayConfig {
1068        GatewayConfig {
1069            listen: "127.0.0.1:8080".to_string(),
1070            admin_token: "token".to_string(),
1071            default_backend: None,
1072            adapters_dir: "./adapters".to_string(),
1073            adapter_port_range: (9000, 9100),
1074            backends_dir: "./backends".to_string(),
1075            backend_port_range: (9200, 9300),
1076            file_cache: None,
1077            guardrails_dir,
1078        }
1079    }
1080
1081    #[test]
1082    fn test_guardrails_dir_auto_discovery() {
1083        let temp_dir = TempDir::new().unwrap();
1084        let guardrails_path = temp_dir.path().join("guardrails");
1085        std::fs::create_dir_all(&guardrails_path).unwrap();
1086
1087        let mut gateway = make_minimal_gateway(None);
1088        resolve_guardrails_dir(&mut gateway, temp_dir.path());
1089
1090        assert_eq!(
1091            gateway.guardrails_dir,
1092            Some(guardrails_path.to_string_lossy().into_owned())
1093        );
1094    }
1095
1096    #[test]
1097    fn test_guardrails_dir_none_no_dir() {
1098        let temp_dir = TempDir::new().unwrap();
1099        let mut gateway = make_minimal_gateway(None);
1100        resolve_guardrails_dir(&mut gateway, temp_dir.path());
1101        assert_eq!(gateway.guardrails_dir, None);
1102    }
1103
1104    #[test]
1105    fn test_guardrails_dir_relative_resolved() {
1106        let temp_dir = TempDir::new().unwrap();
1107        let mut gateway = make_minimal_gateway(Some("./my_rules".to_string()));
1108        resolve_guardrails_dir(&mut gateway, temp_dir.path());
1109        let result = gateway.guardrails_dir.unwrap();
1110        assert!(
1111            result.contains("my_rules"),
1112            "Expected path to contain 'my_rules', got: {}",
1113            result
1114        );
1115        assert!(
1116            result.starts_with(temp_dir.path().to_str().unwrap()),
1117            "Expected path to start with temp dir"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_guardrails_dir_absolute_unchanged() {
1123        let temp_dir = TempDir::new().unwrap();
1124        let abs_path = "/absolute/path/to/rules".to_string();
1125        let mut gateway = make_minimal_gateway(Some(abs_path.clone()));
1126        resolve_guardrails_dir(&mut gateway, temp_dir.path());
1127        assert_eq!(gateway.guardrails_dir, Some(abs_path));
1128    }
1129
1130    #[test]
1131    fn test_guardrails_dir_serde_absent() {
1132        let json = r#"{
1133            "listen": "127.0.0.1:8080",
1134            "admin_token": "tok"
1135        }"#;
1136        let gw: GatewayConfig = serde_json::from_str(json).unwrap();
1137        assert_eq!(gw.guardrails_dir, None);
1138    }
1139
1140    #[test]
1141    fn test_guardrails_dir_field_in_gateway_config() {
1142        let json = r#"{
1143            "listen": "127.0.0.1:8080",
1144            "admin_token": "tok",
1145            "guardrails_dir": "/my/rules"
1146        }"#;
1147        let gw: GatewayConfig = serde_json::from_str(json).unwrap();
1148        assert_eq!(gw.guardrails_dir, Some("/my/rules".to_string()));
1149    }
1150
1151    #[test]
1152    fn test_guardrails_dir_absent_defaults_none() {
1153        let json = r#"{
1154            "listen": "127.0.0.1:8080",
1155            "admin_token": "tok"
1156        }"#;
1157        let gw: GatewayConfig = serde_json::from_str(json).unwrap();
1158        assert_eq!(gw.guardrails_dir, None);
1159    }
1160}