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 #[serde(default = "default_adapters_dir")]
28 pub adapters_dir: String,
29 #[serde(default = "default_adapter_port_range")]
31 pub adapter_port_range: (u16, u16),
32 #[serde(default = "default_backends_dir")]
34 pub backends_dir: String,
35 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum BackendProtocol {
68 Pipelit,
70 Opencode,
72 External,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(rename_all = "lowercase")]
79#[allow(dead_code)]
80pub enum GuardrailType {
81 #[default]
83 Cel,
84 Llm,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
90#[serde(rename_all = "lowercase")]
91#[allow(dead_code)]
92pub enum GuardrailAction {
93 #[default]
95 Block,
96 Log,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "lowercase")]
103#[allow(dead_code)]
104pub enum GuardrailDirection {
105 #[default]
107 Inbound,
108 Outbound,
110 Both,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
116#[serde(rename_all = "lowercase")]
117#[allow(dead_code)]
118pub enum GuardrailOnError {
119 #[default]
121 Allow,
122 Block,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[allow(dead_code)]
129pub struct GuardrailRule {
130 pub name: String,
132 #[serde(default)]
134 pub r#type: GuardrailType,
135 pub expression: String,
137 #[serde(default)]
139 pub action: GuardrailAction,
140 #[serde(default)]
142 pub direction: GuardrailDirection,
143 #[serde(default)]
145 pub on_error: GuardrailOnError,
146 #[serde(default)]
148 pub reject_message: Option<String>,
149 #[serde(default = "default_true")]
151 pub enabled: bool,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct BackendConfig {
157 pub protocol: BackendProtocol,
158 #[serde(default)]
160 pub inbound_url: Option<String>,
161 #[serde(default)]
163 pub base_url: Option<String>,
164 pub token: String,
166 #[serde(default)]
168 pub poll_interval_ms: Option<u64>,
169 #[serde(default)]
171 pub adapter_dir: Option<String>,
172 #[serde(default)]
174 pub port: Option<u16>,
175 #[serde(default = "default_true")]
177 pub active: bool,
178 #[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
224pub 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 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
279pub 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
308fn 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 #[test]
332 #[serial]
333 fn test_resolve_env_vars() {
334 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 #[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 #[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 #[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 #[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 #[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 #[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 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}