1use cron::Schedule;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10use crate::scheduler::Priority;
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct CronConfig {
15 #[serde(default)]
17 pub enabled: bool,
18 #[serde(default = "default_tick_interval")]
20 pub tick_interval_secs: u64,
21 #[serde(default)]
23 pub jobs: std::collections::HashMap<String, InlineCronJob>,
24}
25
26impl Default for CronConfig {
27 fn default() -> Self {
28 Self {
29 enabled: false,
30 tick_interval_secs: default_tick_interval(),
31 jobs: std::collections::HashMap::new(),
32 }
33 }
34}
35
36fn default_tick_interval() -> u64 {
37 60
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct InlineCronJob {
43 pub schedule: String,
45 pub goal: String,
47 #[serde(default)]
49 pub constraints: Vec<String>,
50 #[serde(default)]
52 pub acceptance_criteria: Vec<String>,
53 #[serde(default = "default_toolchain_inline")]
55 pub toolchain: String,
56 #[serde(default)]
58 pub priority: Priority,
59 #[serde(default = "default_true_inline")]
61 pub enabled: bool,
62}
63
64fn default_toolchain_inline() -> String {
65 "default".into()
66}
67
68fn default_true_inline() -> bool {
69 true
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MemoryConfig {
75 #[serde(default = "default_true")]
77 pub enabled: bool,
78 #[serde(default = "default_max_recall")]
80 pub max_recall: usize,
81 #[serde(default = "default_true")]
83 pub auto_summarize: bool,
84 #[serde(default = "default_true")]
86 pub capture_compaction: bool,
87 #[serde(default)]
89 pub retention_days: u32,
90}
91
92fn default_true() -> bool {
93 true
94}
95
96fn default_max_recall() -> usize {
97 10
98}
99
100impl Default for MemoryConfig {
101 fn default() -> Self {
102 Self {
103 enabled: true,
104 max_recall: 10,
105 auto_summarize: true,
106 capture_compaction: true,
107 retention_days: 0,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Deserialize, Serialize)]
114pub struct ChannelsConfig {
115 #[serde(default = "default_channels_enabled")]
118 pub enabled: Vec<String>,
119
120 #[serde(default)]
122 pub telegram: TelegramChannelConfig,
123}
124
125fn default_channels_enabled() -> Vec<String> {
126 vec!["web".to_string()]
127}
128
129impl Default for ChannelsConfig {
130 fn default() -> Self {
131 Self {
132 enabled: default_channels_enabled(),
133 telegram: TelegramChannelConfig::default(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Deserialize, Serialize)]
140pub struct TelegramChannelConfig {
141 #[serde(default = "default_telegram_token_env")]
143 pub bot_token_env: String,
144 #[serde(default)]
146 pub allowed_users: Vec<i64>,
147}
148
149fn default_telegram_token_env() -> String {
150 "TELEGRAM_BOT_TOKEN".to_string()
151}
152
153impl Default for TelegramChannelConfig {
154 fn default() -> Self {
155 Self {
156 bot_token_env: default_telegram_token_env(),
157 allowed_users: Vec::new(),
158 }
159 }
160}
161
162#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct EngineConfig {
165 #[serde(default)]
168 pub default_model: String,
169 #[serde(default)]
172 pub api_key: Option<String>,
173}
174
175impl Default for EngineConfig {
176 fn default() -> Self {
177 Self {
178 default_model: String::new(),
179 api_key: None,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize)]
186pub struct DaemonConfig {
187 #[serde(default = "default_pid_file")]
189 pub pid_file: String,
190 #[serde(default = "default_daemon_log_dir")]
192 pub log_dir: String,
193}
194
195fn default_pid_file() -> String {
196 dirs::home_dir()
197 .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
198 .unwrap_or_else(|| "./oxios.pid".into())
199}
200
201fn default_daemon_log_dir() -> String {
202 dirs::home_dir()
203 .map(|h| format!("{}/.oxios/logs", h.display()))
204 .unwrap_or_else(|| "./logs".into())
205}
206
207impl Default for DaemonConfig {
208 fn default() -> Self {
209 Self {
210 pid_file: default_pid_file(),
211 log_dir: default_daemon_log_dir(),
212 }
213 }
214}
215
216#[derive(Debug, Clone, Deserialize, Serialize, Default)]
218pub struct OxiosConfig {
219 pub kernel: KernelConfig,
221 #[serde(default)]
223 pub engine: EngineConfig,
224 #[serde(default)]
226 pub daemon: DaemonConfig,
227 #[serde(default)]
229 pub gateway: GatewayConfig,
230 #[serde(default)]
232 pub scheduler: SchedulerConfig,
233 #[serde(default)]
235 pub context: ContextConfig,
236 #[serde(default)]
238 pub security: SecurityConfig,
239 #[serde(default)]
241 pub persona: PersonaConfig,
242 #[serde(default)]
244 pub memory: MemoryConfig,
245 #[serde(default)]
247 pub cron: CronConfig,
248 #[serde(default)]
250 pub mcp: McpConfig,
251 #[serde(default)]
253 pub git: GitConfig,
254 #[serde(default)]
256 pub audit: AuditConfig,
257 #[serde(default)]
259 pub budget: BudgetConfig,
260 #[serde(default)]
262 pub exec: ExecConfig,
263 #[serde(default)]
265 pub resource_monitor: ResourceMonitorConfig,
266 #[serde(default)]
268 pub otel: OtelConfig,
269 #[serde(default)]
271 pub channels: ChannelsConfig,
272 #[serde(default)]
274 pub browser: BrowserConfig,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize)]
279pub struct KernelConfig {
280 #[serde(default = "default_workspace")]
282 pub workspace: String,
283 #[serde(default = "default_event_bus_capacity")]
285 pub event_bus_capacity: usize,
286 #[serde(default = "default_max_agents")]
288 pub max_agents: usize,
289}
290
291fn default_workspace() -> String {
292 dirs_home().unwrap_or_else(|| ".".into())
293}
294
295fn dirs_home() -> Option<String> {
296 dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
297}
298
299fn default_event_bus_capacity() -> usize {
300 256
301}
302
303fn default_max_agents() -> usize {
304 16
305}
306
307impl Default for KernelConfig {
308 fn default() -> Self {
309 Self {
310 workspace: default_workspace(),
311 event_bus_capacity: default_event_bus_capacity(),
312 max_agents: default_max_agents(),
313 }
314 }
315}
316
317#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct GatewayConfig {
320 #[serde(default = "default_gateway_host")]
322 pub host: String,
323 #[serde(default = "default_gateway_port")]
325 pub port: u16,
326}
327
328fn default_gateway_host() -> String {
329 "127.0.0.1".into()
330}
331
332fn default_gateway_port() -> u16 {
333 4200
334}
335
336impl Default for GatewayConfig {
337 fn default() -> Self {
338 Self {
339 host: default_gateway_host(),
340 port: default_gateway_port(),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Deserialize, Serialize)]
349pub struct ExecConfig {
350 #[serde(default)]
353 pub allowed_commands: Vec<String>,
354 #[serde(default = "default_exec_timeout")]
356 pub default_timeout_secs: u64,
357 #[serde(default = "default_exec_max_timeout")]
359 pub max_timeout_secs: u64,
360 #[serde(default)]
362 pub required_host_tools: Vec<String>,
363 #[serde(default)]
365 pub optional_host_tools: Vec<String>,
366}
367
368fn default_exec_timeout() -> u64 {
369 120
370}
371
372fn default_exec_max_timeout() -> u64 {
373 600
374}
375
376impl ExecConfig {
377 pub fn is_binary_allowed(&self, name: &str) -> bool {
382 self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
383 }
384}
385
386impl Default for ExecConfig {
387 fn default() -> Self {
388 Self {
389 allowed_commands: Vec::new(),
390 default_timeout_secs: default_exec_timeout(),
391 max_timeout_secs: default_exec_max_timeout(),
392 required_host_tools: Vec::new(),
393 optional_host_tools: Vec::new(),
394 }
395 }
396}
397
398#[derive(Debug, Clone, Deserialize, Serialize)]
400pub struct SchedulerConfig {
401 #[serde(default = "default_max_concurrent")]
403 pub max_concurrent: usize,
404 #[serde(default = "default_rate_limit")]
406 pub rate_limit_per_minute: u32,
407 #[serde(default = "default_zombie_timeout")]
409 pub zombie_timeout_secs: u64,
410}
411
412fn default_max_concurrent() -> usize {
413 5
414}
415
416fn default_rate_limit() -> u32 {
417 60
418}
419
420fn default_zombie_timeout() -> u64 {
421 300
422}
423
424impl Default for SchedulerConfig {
425 fn default() -> Self {
426 Self {
427 max_concurrent: default_max_concurrent(),
428 rate_limit_per_minute: default_rate_limit(),
429 zombie_timeout_secs: default_zombie_timeout(),
430 }
431 }
432}
433
434#[derive(Debug, Clone, Deserialize, Serialize)]
436pub struct ContextConfig {
437 #[serde(default = "default_active_limit")]
439 pub active_limit_tokens: usize,
440 #[serde(default = "default_cache_limit")]
442 pub cache_limit_entries: usize,
443}
444
445fn default_active_limit() -> usize {
446 100_000
447}
448
449fn default_cache_limit() -> usize {
450 50
451}
452
453impl Default for ContextConfig {
454 fn default() -> Self {
455 Self {
456 active_limit_tokens: default_active_limit(),
457 cache_limit_entries: default_cache_limit(),
458 }
459 }
460}
461
462#[derive(Debug, Clone, Deserialize, Serialize)]
464pub struct SecurityConfig {
465 #[serde(default = "default_allowed_tools")]
467 pub allowed_tools: Vec<String>,
468 #[serde(default)]
470 pub network_access: bool,
471 #[serde(default = "default_max_exec_time")]
473 pub max_execution_time_secs: u64,
474 #[serde(default = "default_max_memory")]
476 pub max_memory_mb: u64,
477 #[serde(default)]
479 pub can_fork: bool,
480 #[serde(default = "default_max_audit")]
482 pub max_audit_entries: usize,
483 #[serde(default)]
485 pub auth_enabled: bool,
486 #[serde(default = "default_cors_origins")]
488 pub cors_origins: Vec<String>,
489 #[serde(default)]
491 pub audit_log_path: Option<String>,
492 #[serde(default = "default_rate_limit_per_minute")]
494 pub rate_limit_per_minute: u32,
495}
496
497fn default_allowed_tools() -> Vec<String> {
498 vec![
499 "read".to_string(),
500 "write".to_string(),
501 "edit".to_string(),
502 "bash".to_string(),
503 "grep".to_string(),
504 "find".to_string(),
505 ]
506}
507
508fn default_max_exec_time() -> u64 {
509 300
510}
511
512fn default_max_memory() -> u64 {
513 512
514}
515
516fn default_max_audit() -> usize {
517 10_000
518}
519
520fn default_rate_limit_per_minute() -> u32 {
521 120
522}
523
524fn default_cors_origins() -> Vec<String> {
525 vec!["http://localhost:4200".to_string()]
526}
527
528impl Default for SecurityConfig {
529 fn default() -> Self {
530 Self {
531 allowed_tools: default_allowed_tools(),
532 network_access: false,
533 max_execution_time_secs: default_max_exec_time(),
534 max_memory_mb: default_max_memory(),
535 can_fork: false,
536 max_audit_entries: default_max_audit(),
537 auth_enabled: false,
538 cors_origins: default_cors_origins(),
539 audit_log_path: None,
540 rate_limit_per_minute: default_rate_limit_per_minute(),
541 }
542 }
543}
544
545#[derive(Debug, Clone, Deserialize, Serialize)]
547pub struct PersonaConfig {
548 #[serde(default)]
550 pub default_persona_id: Option<String>,
551 #[serde(default = "default_max_concurrent_personas")]
553 pub max_concurrent_personas: usize,
554}
555
556fn default_max_concurrent_personas() -> usize {
557 5
558}
559
560impl Default for PersonaConfig {
561 fn default() -> Self {
562 Self {
563 default_persona_id: Some("dev".to_string()),
564 max_concurrent_personas: default_max_concurrent_personas(),
565 }
566 }
567}
568
569#[derive(Debug, Clone, Deserialize, Serialize, Default)]
577pub struct McpConfig {
578 #[serde(default)]
580 pub servers: std::collections::HashMap<String, McpServerDef>,
581}
582
583#[derive(Debug, Clone, Deserialize, Serialize)]
585pub struct McpServerDef {
586 pub command: String,
588 #[serde(default)]
590 pub args: Vec<String>,
591 #[serde(default)]
593 pub env: std::collections::HashMap<String, String>,
594 #[serde(default = "default_mcp_enabled")]
596 pub enabled: bool,
597}
598
599fn default_mcp_enabled() -> bool {
600 true
601}
602
603#[derive(Debug, Clone, Deserialize, Serialize)]
605pub struct GitConfig {
606 #[serde(default = "default_true")]
608 pub auto_commit: bool,
609}
610
611impl Default for GitConfig {
612 fn default() -> Self {
613 Self { auto_commit: true }
614 }
615}
616
617#[derive(Debug, Clone, Deserialize, Serialize)]
619pub struct AuditConfig {
620 #[serde(default = "default_audit_max_entries")]
622 pub max_entries: usize,
623 #[serde(default = "default_true")]
625 pub enabled: bool,
626}
627
628fn default_audit_max_entries() -> usize {
629 100_000
630}
631
632impl Default for AuditConfig {
633 fn default() -> Self {
634 Self {
635 max_entries: default_audit_max_entries(),
636 enabled: true,
637 }
638 }
639}
640
641#[derive(Debug, Clone, Deserialize, Serialize)]
643pub struct BudgetConfig {
644 #[serde(default)]
646 pub default_token_budget: u64,
647 #[serde(default)]
649 pub default_calls_budget: u64,
650 #[serde(default = "default_budget_window")]
652 pub default_window_secs: u64,
653 #[serde(default = "default_true")]
655 pub enabled: bool,
656}
657
658fn default_budget_window() -> u64 {
659 3600
660}
661
662impl Default for BudgetConfig {
663 fn default() -> Self {
664 Self {
665 default_token_budget: 0,
666 default_calls_budget: 0,
667 default_window_secs: default_budget_window(),
668 enabled: true,
669 }
670 }
671}
672
673#[derive(Debug, Clone, Deserialize, Serialize)]
675pub struct ResourceMonitorConfig {
676 #[serde(default = "default_rm_interval")]
678 pub interval_secs: u64,
679 #[serde(default = "default_rm_history_max")]
681 pub history_max: usize,
682 #[serde(default = "default_rm_cpu_threshold")]
684 pub cpu_threshold: f32,
685 #[serde(default = "default_rm_mem_threshold")]
687 pub memory_threshold: f32,
688 #[serde(default = "default_rm_load_threshold")]
690 pub load_threshold: f32,
691}
692
693fn default_rm_interval() -> u64 {
694 60
695}
696
697fn default_rm_history_max() -> usize {
698 60
699}
700
701fn default_rm_cpu_threshold() -> f32 {
702 90.0
703}
704
705fn default_rm_mem_threshold() -> f32 {
706 90.0
707}
708
709fn default_rm_load_threshold() -> f32 {
710 8.0
711}
712
713impl Default for ResourceMonitorConfig {
714 fn default() -> Self {
715 Self {
716 interval_secs: default_rm_interval(),
717 history_max: default_rm_history_max(),
718 cpu_threshold: default_rm_cpu_threshold(),
719 memory_threshold: default_rm_mem_threshold(),
720 load_threshold: default_rm_load_threshold(),
721 }
722 }
723}
724
725#[derive(Debug, Clone, Deserialize, Serialize)]
727pub struct OtelConfig {
728 #[serde(default)]
730 pub enabled: bool,
731 #[serde(default = "default_otel_endpoint")]
733 pub endpoint: String,
734 #[serde(default = "default_otel_service_name")]
736 pub service_name: String,
737 #[serde(default = "default_otel_sampling_ratio")]
739 pub sampling_ratio: f64,
740}
741
742fn default_otel_endpoint() -> String {
743 "http://localhost:4317".into()
744}
745
746fn default_otel_service_name() -> String {
747 "oxios".into()
748}
749
750fn default_otel_sampling_ratio() -> f64 {
751 1.0
752}
753
754impl Default for OtelConfig {
755 fn default() -> Self {
756 Self {
757 enabled: false,
758 endpoint: default_otel_endpoint(),
759 service_name: default_otel_service_name(),
760 sampling_ratio: default_otel_sampling_ratio(),
761 }
762 }
763}
764
765#[derive(Debug, Clone, Deserialize, Serialize)]
771pub struct BrowserConfig {
772 #[serde(default = "default_browser_enabled")]
774 pub enabled: bool,
775
776 #[serde(default)]
787 pub engine: oxibrowser_core::BrowserConfig,
788}
789
790fn default_browser_enabled() -> bool {
791 true
792}
793
794impl Default for BrowserConfig {
795 fn default() -> Self {
796 Self {
797 enabled: true,
798 engine: oxibrowser_core::BrowserConfig::headless(),
799 }
800 }
801}
802
803pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
805 let content = std::fs::read_to_string(path)?;
806 let config: OxiosConfig = toml::from_str(&content)?;
807 let (errors, warnings) = config.validate();
808 for w in warnings {
809 tracing::warn!("config: {}", w);
810 }
811 if !errors.is_empty() {
812 let msg = errors.join("; ");
813 anyhow::bail!("Configuration validation failed: {}", msg);
814 }
815 Ok(config)
816}
817
818impl OxiosConfig {
819 pub fn api_key(&self) -> Option<String> {
821 self.engine.api_key.clone().filter(|k| !k.is_empty())
822 }
823
824 pub fn validate(&self) -> (Vec<String>, Vec<String>) {
827 let mut errors = Vec::new();
828 let mut warnings = Vec::new();
829
830 if self.kernel.max_agents == 0 {
832 errors.push("kernel.max_agents must be > 0".into());
833 }
834 if self.kernel.workspace.is_empty() {
835 errors.push("kernel.workspace must not be empty".into());
836 }
837
838 if self.gateway.port == 0 {
840 errors.push("gateway.port must be > 0".into());
841 }
842 if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
843 warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
844 }
845
846 if self.scheduler.max_concurrent == 0 {
848 warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
849 }
850 if self.scheduler.zombie_timeout_secs == 0 {
851 errors.push("scheduler.zombie_timeout_secs must be > 0".into());
852 }
853
854 for (name, job) in &self.cron.jobs {
856 if job.schedule.is_empty() {
857 errors.push(format!("cron.jobs.{}: schedule is empty", name));
858 } else {
859 let normalized = {
861 let fields: Vec<&str> = job.schedule.split_whitespace().collect();
862 match fields.len() {
863 5 => format!("0 {}", job.schedule),
864 _ => job.schedule.clone(),
865 }
866 };
867 if Schedule::from_str(&normalized).is_err() {
868 errors.push(format!(
869 "cron.jobs.{}: invalid cron expression '{}'",
870 name, job.schedule
871 ));
872 }
873 }
874 if job.goal.is_empty() {
875 errors.push(format!("cron.jobs.{}: goal is empty", name));
876 }
877 }
878
879 if self.security.max_execution_time_secs == 0 {
881 warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
882 }
883
884 if self.audit.max_entries == 0 {
886 warnings.push("audit.max_entries is 0 — audit will never prune".into());
887 }
888
889 if self.budget.default_window_secs == 0 {
891 warnings.push("budget.default_window_secs is 0 — no time window".into());
892 }
893
894 if self.exec.default_timeout_secs == 0 {
896 errors.push("exec.default_timeout_secs must be > 0".into());
897 }
898 if self.exec.max_timeout_secs == 0 {
899 errors.push("exec.max_timeout_secs must be > 0".into());
900 }
901 if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
902 errors.push(format!(
903 "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
904 self.exec.default_timeout_secs, self.exec.max_timeout_secs
905 ));
906 }
907
908 if self.resource_monitor.cpu_threshold > 100.0 {
910 errors.push("resource_monitor.cpu_threshold must be <= 100".into());
911 }
912 if self.resource_monitor.memory_threshold > 100.0 {
913 errors.push("resource_monitor.memory_threshold must be <= 100".into());
914 }
915
916 for name in &self.channels.enabled {
918 let valid = ["web", "cli", "telegram"];
919 if !valid.contains(&name.as_str()) {
920 warnings.push(format!("channels.enabled: unknown channel '{}'", name));
921 }
922 }
923 if self.channels.enabled.iter().any(|c| c == "telegram")
924 && std::env::var(&self.channels.telegram.bot_token_env).is_err()
925 {
926 warnings.push(format!(
927 "channels.telegram: {} env var not set — telegram channel will fail",
928 self.channels.telegram.bot_token_env
929 ));
930 }
931
932 (errors, warnings)
933 }
934}
935
936pub fn expand_home(path: &str) -> std::path::PathBuf {
940 if let Some(rest) = path.strip_prefix("~/") {
941 if let Ok(home) = std::env::var("HOME") {
942 return std::path::PathBuf::from(format!("{home}/{rest}"));
943 }
944 }
945 std::path::PathBuf::from(path)
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 #[test]
953 fn test_default_config_validates() {
954 let config = OxiosConfig::default();
955 let (errors, _warnings) = config.validate();
956 assert!(
957 errors.is_empty(),
958 "Default config should have no errors: {:?}",
959 errors
960 );
961 }
962
963 #[test]
964 fn test_exec_config_default_allowed_commands() {
965 let config = ExecConfig::default();
966 assert!(config.allowed_commands.is_empty());
968 assert!(config.is_binary_allowed("anything"));
969 assert!(config.is_binary_allowed("bash"));
970 assert!(config.is_binary_allowed("rm"));
971 }
972
973 #[test]
974 fn test_is_binary_allowed_with_allowlist() {
975 let config = ExecConfig {
976 allowed_commands: vec!["git".into(), "echo".into()],
977 ..Default::default()
978 };
979 assert!(config.is_binary_allowed("git"));
980 assert!(config.is_binary_allowed("echo"));
981 assert!(!config.is_binary_allowed("bash"));
982 assert!(!config.is_binary_allowed("rm"));
983 assert!(!config.is_binary_allowed("sudo"));
984 }
985
986 #[test]
987 fn test_expand_home() {
988 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
990 let expanded = expand_home("~/projects/test");
991 assert_eq!(
992 expanded.to_str().unwrap(),
993 format!("{}/projects/test", home)
994 );
995
996 let abs = expand_home("/absolute/path");
998 assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
999
1000 let bare = expand_home("~something");
1002 assert_eq!(bare, std::path::PathBuf::from("~something"));
1003 }
1004
1005 #[test]
1006 fn test_invalid_cron_expression() {
1007 let mut config = OxiosConfig::default();
1008 config.cron.enabled = true;
1009 config.cron.jobs.insert(
1010 "bad-job".to_string(),
1011 InlineCronJob {
1012 schedule: "not a valid cron".to_string(),
1013 goal: "Test goal".to_string(),
1014 constraints: vec![],
1015 acceptance_criteria: vec![],
1016 toolchain: "default".to_string(),
1017 priority: Priority::Normal,
1018 enabled: true,
1019 },
1020 );
1021
1022 let (errors, _warnings) = config.validate();
1023 assert!(
1024 !errors.is_empty(),
1025 "Expected validation error for invalid cron"
1026 );
1027 let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1028 assert!(
1029 has_cron_error,
1030 "Expected 'invalid cron expression' error, got: {:?}",
1031 errors
1032 );
1033 }
1034
1035 #[test]
1036 fn test_config_serialization_roundtrip() {
1037 let config = OxiosConfig::default();
1038
1039 let toml_str = toml::to_string(&config).expect("serialization should succeed");
1041
1042 let deserialized: OxiosConfig =
1044 toml::from_str(&toml_str).expect("deserialization should succeed");
1045
1046 assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
1048 assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
1049 assert_eq!(config.gateway.host, deserialized.gateway.host);
1050 assert_eq!(config.gateway.port, deserialized.gateway.port);
1051 assert_eq!(
1052 config.exec.default_timeout_secs,
1053 deserialized.exec.default_timeout_secs
1054 );
1055 assert_eq!(
1056 config.exec.max_timeout_secs,
1057 deserialized.exec.max_timeout_secs
1058 );
1059 }
1060
1061 #[test]
1062 fn test_exec_timeout_validation() {
1063 let mut config = OxiosConfig::default();
1064 config.exec.default_timeout_secs = 999;
1066 config.exec.max_timeout_secs = 100;
1067 let (errors, _warnings) = config.validate();
1068 let has_error = errors.iter().any(|e| e.contains("must not exceed"));
1069 assert!(
1070 has_error,
1071 "Expected timeout ordering error, got: {:?}",
1072 errors
1073 );
1074 }
1075
1076 #[test]
1077 fn test_zero_max_agents_error() {
1078 let mut config = OxiosConfig::default();
1079 config.kernel.max_agents = 0;
1080 let (errors, _warnings) = config.validate();
1081 assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
1082 }
1083}