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 = "default_model")]
167 pub default_model: String,
168 #[serde(default)]
171 pub api_key: Option<String>,
172}
173
174fn default_model() -> String {
175 "anthropic/claude-sonnet-4-20250514".into()
176}
177
178impl Default for EngineConfig {
179 fn default() -> Self {
180 Self {
181 default_model: default_model(),
182 api_key: None,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Deserialize, Serialize)]
189pub struct DaemonConfig {
190 #[serde(default = "default_pid_file")]
192 pub pid_file: String,
193 #[serde(default = "default_daemon_log_dir")]
195 pub log_dir: String,
196}
197
198fn default_pid_file() -> String {
199 dirs::home_dir()
200 .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
201 .unwrap_or_else(|| "./oxios.pid".into())
202}
203
204fn default_daemon_log_dir() -> String {
205 dirs::home_dir()
206 .map(|h| format!("{}/.oxios/logs", h.display()))
207 .unwrap_or_else(|| "./logs".into())
208}
209
210impl Default for DaemonConfig {
211 fn default() -> Self {
212 Self {
213 pid_file: default_pid_file(),
214 log_dir: default_daemon_log_dir(),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize, Default)]
221pub struct OxiosConfig {
222 pub kernel: KernelConfig,
224 #[serde(default)]
226 pub engine: EngineConfig,
227 #[serde(default)]
229 pub daemon: DaemonConfig,
230 #[serde(default)]
232 pub gateway: GatewayConfig,
233 #[serde(default)]
235 pub scheduler: SchedulerConfig,
236 #[serde(default)]
238 pub context: ContextConfig,
239 #[serde(default)]
241 pub security: SecurityConfig,
242 #[serde(default)]
244 pub persona: PersonaConfig,
245 #[serde(default)]
247 pub memory: MemoryConfig,
248 #[serde(default)]
250 pub cron: CronConfig,
251 #[serde(default)]
253 pub mcp: McpConfig,
254 #[serde(default)]
256 pub git: GitConfig,
257 #[serde(default)]
259 pub audit: AuditConfig,
260 #[serde(default)]
262 pub budget: BudgetConfig,
263 #[serde(default)]
265 pub exec: ExecConfig,
266 #[serde(default)]
268 pub resource_monitor: ResourceMonitorConfig,
269 #[serde(default)]
271 pub otel: OtelConfig,
272 #[serde(default)]
274 pub channels: ChannelsConfig,
275 #[serde(default)]
277 pub browser: BrowserConfig,
278}
279
280#[derive(Debug, Clone, Deserialize, Serialize)]
282pub struct KernelConfig {
283 #[serde(default = "default_workspace")]
285 pub workspace: String,
286 #[serde(default = "default_event_bus_capacity")]
288 pub event_bus_capacity: usize,
289 #[serde(default = "default_max_agents")]
291 pub max_agents: usize,
292}
293
294fn default_workspace() -> String {
295 dirs_home().unwrap_or_else(|| ".".into())
296}
297
298fn dirs_home() -> Option<String> {
299 dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
300}
301
302fn default_event_bus_capacity() -> usize {
303 256
304}
305
306fn default_max_agents() -> usize {
307 16
308}
309
310impl Default for KernelConfig {
311 fn default() -> Self {
312 Self {
313 workspace: default_workspace(),
314 event_bus_capacity: default_event_bus_capacity(),
315 max_agents: default_max_agents(),
316 }
317 }
318}
319
320#[derive(Debug, Clone, Deserialize, Serialize)]
322pub struct GatewayConfig {
323 #[serde(default = "default_gateway_host")]
325 pub host: String,
326 #[serde(default = "default_gateway_port")]
328 pub port: u16,
329}
330
331fn default_gateway_host() -> String {
332 "127.0.0.1".into()
333}
334
335fn default_gateway_port() -> u16 {
336 4200
337}
338
339impl Default for GatewayConfig {
340 fn default() -> Self {
341 Self {
342 host: default_gateway_host(),
343 port: default_gateway_port(),
344 }
345 }
346}
347
348#[derive(Debug, Clone, Deserialize, Serialize)]
352pub struct ExecConfig {
353 #[serde(default)]
356 pub allowed_commands: Vec<String>,
357 #[serde(default = "default_exec_timeout")]
359 pub default_timeout_secs: u64,
360 #[serde(default = "default_exec_max_timeout")]
362 pub max_timeout_secs: u64,
363 #[serde(default)]
365 pub required_host_tools: Vec<String>,
366 #[serde(default)]
368 pub optional_host_tools: Vec<String>,
369}
370
371fn default_exec_timeout() -> u64 {
372 120
373}
374
375fn default_exec_max_timeout() -> u64 {
376 600
377}
378
379impl ExecConfig {
380 pub fn is_binary_allowed(&self, name: &str) -> bool {
385 self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
386 }
387}
388
389impl Default for ExecConfig {
390 fn default() -> Self {
391 Self {
392 allowed_commands: Vec::new(),
393 default_timeout_secs: default_exec_timeout(),
394 max_timeout_secs: default_exec_max_timeout(),
395 required_host_tools: Vec::new(),
396 optional_host_tools: Vec::new(),
397 }
398 }
399}
400
401#[derive(Debug, Clone, Deserialize, Serialize)]
403pub struct SchedulerConfig {
404 #[serde(default = "default_max_concurrent")]
406 pub max_concurrent: usize,
407 #[serde(default = "default_rate_limit")]
409 pub rate_limit_per_minute: u32,
410 #[serde(default = "default_zombie_timeout")]
412 pub zombie_timeout_secs: u64,
413}
414
415fn default_max_concurrent() -> usize {
416 5
417}
418
419fn default_rate_limit() -> u32 {
420 60
421}
422
423fn default_zombie_timeout() -> u64 {
424 300
425}
426
427impl Default for SchedulerConfig {
428 fn default() -> Self {
429 Self {
430 max_concurrent: default_max_concurrent(),
431 rate_limit_per_minute: default_rate_limit(),
432 zombie_timeout_secs: default_zombie_timeout(),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Deserialize, Serialize)]
439pub struct ContextConfig {
440 #[serde(default = "default_active_limit")]
442 pub active_limit_tokens: usize,
443 #[serde(default = "default_cache_limit")]
445 pub cache_limit_entries: usize,
446}
447
448fn default_active_limit() -> usize {
449 100_000
450}
451
452fn default_cache_limit() -> usize {
453 50
454}
455
456impl Default for ContextConfig {
457 fn default() -> Self {
458 Self {
459 active_limit_tokens: default_active_limit(),
460 cache_limit_entries: default_cache_limit(),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize)]
467pub struct SecurityConfig {
468 #[serde(default = "default_allowed_tools")]
470 pub allowed_tools: Vec<String>,
471 #[serde(default)]
473 pub network_access: bool,
474 #[serde(default = "default_max_exec_time")]
476 pub max_execution_time_secs: u64,
477 #[serde(default = "default_max_memory")]
479 pub max_memory_mb: u64,
480 #[serde(default)]
482 pub can_fork: bool,
483 #[serde(default = "default_max_audit")]
485 pub max_audit_entries: usize,
486 #[serde(default)]
488 pub auth_enabled: bool,
489 #[serde(default = "default_cors_origins")]
491 pub cors_origins: Vec<String>,
492 #[serde(default)]
494 pub audit_log_path: Option<String>,
495 #[serde(default = "default_rate_limit_per_minute")]
497 pub rate_limit_per_minute: u32,
498}
499
500fn default_allowed_tools() -> Vec<String> {
501 vec![
502 "read".to_string(),
503 "write".to_string(),
504 "edit".to_string(),
505 "bash".to_string(),
506 "grep".to_string(),
507 "find".to_string(),
508 ]
509}
510
511fn default_max_exec_time() -> u64 {
512 300
513}
514
515fn default_max_memory() -> u64 {
516 512
517}
518
519fn default_max_audit() -> usize {
520 10_000
521}
522
523fn default_rate_limit_per_minute() -> u32 {
524 120
525}
526
527fn default_cors_origins() -> Vec<String> {
528 vec!["http://localhost:4200".to_string()]
529}
530
531impl Default for SecurityConfig {
532 fn default() -> Self {
533 Self {
534 allowed_tools: default_allowed_tools(),
535 network_access: false,
536 max_execution_time_secs: default_max_exec_time(),
537 max_memory_mb: default_max_memory(),
538 can_fork: false,
539 max_audit_entries: default_max_audit(),
540 auth_enabled: false,
541 cors_origins: default_cors_origins(),
542 audit_log_path: None,
543 rate_limit_per_minute: default_rate_limit_per_minute(),
544 }
545 }
546}
547
548#[derive(Debug, Clone, Deserialize, Serialize)]
550pub struct PersonaConfig {
551 #[serde(default)]
553 pub default_persona_id: Option<String>,
554 #[serde(default = "default_max_concurrent_personas")]
556 pub max_concurrent_personas: usize,
557}
558
559fn default_max_concurrent_personas() -> usize {
560 5
561}
562
563impl Default for PersonaConfig {
564 fn default() -> Self {
565 Self {
566 default_persona_id: Some("dev".to_string()),
567 max_concurrent_personas: default_max_concurrent_personas(),
568 }
569 }
570}
571
572#[derive(Debug, Clone, Deserialize, Serialize, Default)]
580pub struct McpConfig {
581 #[serde(default)]
583 pub servers: std::collections::HashMap<String, McpServerDef>,
584}
585
586#[derive(Debug, Clone, Deserialize, Serialize)]
588pub struct McpServerDef {
589 pub command: String,
591 #[serde(default)]
593 pub args: Vec<String>,
594 #[serde(default)]
596 pub env: std::collections::HashMap<String, String>,
597 #[serde(default = "default_mcp_enabled")]
599 pub enabled: bool,
600}
601
602fn default_mcp_enabled() -> bool {
603 true
604}
605
606#[derive(Debug, Clone, Deserialize, Serialize)]
608pub struct GitConfig {
609 #[serde(default = "default_true")]
611 pub auto_commit: bool,
612}
613
614impl Default for GitConfig {
615 fn default() -> Self {
616 Self { auto_commit: true }
617 }
618}
619
620#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct AuditConfig {
623 #[serde(default = "default_audit_max_entries")]
625 pub max_entries: usize,
626 #[serde(default = "default_true")]
628 pub enabled: bool,
629}
630
631fn default_audit_max_entries() -> usize {
632 100_000
633}
634
635impl Default for AuditConfig {
636 fn default() -> Self {
637 Self {
638 max_entries: default_audit_max_entries(),
639 enabled: true,
640 }
641 }
642}
643
644#[derive(Debug, Clone, Deserialize, Serialize)]
646pub struct BudgetConfig {
647 #[serde(default)]
649 pub default_token_budget: u64,
650 #[serde(default)]
652 pub default_calls_budget: u64,
653 #[serde(default = "default_budget_window")]
655 pub default_window_secs: u64,
656 #[serde(default = "default_true")]
658 pub enabled: bool,
659}
660
661fn default_budget_window() -> u64 {
662 3600
663}
664
665impl Default for BudgetConfig {
666 fn default() -> Self {
667 Self {
668 default_token_budget: 0,
669 default_calls_budget: 0,
670 default_window_secs: default_budget_window(),
671 enabled: true,
672 }
673 }
674}
675
676#[derive(Debug, Clone, Deserialize, Serialize)]
678pub struct ResourceMonitorConfig {
679 #[serde(default = "default_rm_interval")]
681 pub interval_secs: u64,
682 #[serde(default = "default_rm_history_max")]
684 pub history_max: usize,
685 #[serde(default = "default_rm_cpu_threshold")]
687 pub cpu_threshold: f32,
688 #[serde(default = "default_rm_mem_threshold")]
690 pub memory_threshold: f32,
691 #[serde(default = "default_rm_load_threshold")]
693 pub load_threshold: f32,
694}
695
696fn default_rm_interval() -> u64 {
697 60
698}
699
700fn default_rm_history_max() -> usize {
701 60
702}
703
704fn default_rm_cpu_threshold() -> f32 {
705 90.0
706}
707
708fn default_rm_mem_threshold() -> f32 {
709 90.0
710}
711
712fn default_rm_load_threshold() -> f32 {
713 8.0
714}
715
716impl Default for ResourceMonitorConfig {
717 fn default() -> Self {
718 Self {
719 interval_secs: default_rm_interval(),
720 history_max: default_rm_history_max(),
721 cpu_threshold: default_rm_cpu_threshold(),
722 memory_threshold: default_rm_mem_threshold(),
723 load_threshold: default_rm_load_threshold(),
724 }
725 }
726}
727
728#[derive(Debug, Clone, Deserialize, Serialize)]
730pub struct OtelConfig {
731 #[serde(default)]
733 pub enabled: bool,
734 #[serde(default = "default_otel_endpoint")]
736 pub endpoint: String,
737 #[serde(default = "default_otel_service_name")]
739 pub service_name: String,
740 #[serde(default = "default_otel_sampling_ratio")]
742 pub sampling_ratio: f64,
743}
744
745fn default_otel_endpoint() -> String {
746 "http://localhost:4317".into()
747}
748
749fn default_otel_service_name() -> String {
750 "oxios".into()
751}
752
753fn default_otel_sampling_ratio() -> f64 {
754 1.0
755}
756
757impl Default for OtelConfig {
758 fn default() -> Self {
759 Self {
760 enabled: false,
761 endpoint: default_otel_endpoint(),
762 service_name: default_otel_service_name(),
763 sampling_ratio: default_otel_sampling_ratio(),
764 }
765 }
766}
767
768#[derive(Debug, Clone, Deserialize, Serialize)]
774pub struct BrowserConfig {
775 #[serde(default = "default_browser_enabled")]
777 pub enabled: bool,
778
779 #[serde(default)]
790 pub engine: oxibrowser_core::BrowserConfig,
791}
792
793fn default_browser_enabled() -> bool {
794 true
795}
796
797impl Default for BrowserConfig {
798 fn default() -> Self {
799 Self {
800 enabled: true,
801 engine: oxibrowser_core::BrowserConfig::headless(),
802 }
803 }
804}
805
806pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
808 let content = std::fs::read_to_string(path)?;
809 let config: OxiosConfig = toml::from_str(&content)?;
810 let (errors, warnings) = config.validate();
811 for w in warnings {
812 tracing::warn!("config: {}", w);
813 }
814 if !errors.is_empty() {
815 let msg = errors.join("; ");
816 anyhow::bail!("Configuration validation failed: {}", msg);
817 }
818 Ok(config)
819}
820
821impl OxiosConfig {
822 pub fn api_key(&self) -> Option<String> {
824 self.engine.api_key.clone().filter(|k| !k.is_empty())
825 }
826
827 pub fn validate(&self) -> (Vec<String>, Vec<String>) {
830 let mut errors = Vec::new();
831 let mut warnings = Vec::new();
832
833 if self.kernel.max_agents == 0 {
835 errors.push("kernel.max_agents must be > 0".into());
836 }
837 if self.kernel.workspace.is_empty() {
838 errors.push("kernel.workspace must not be empty".into());
839 }
840
841 if self.gateway.port == 0 {
843 errors.push("gateway.port must be > 0".into());
844 }
845 if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
846 warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
847 }
848
849 if self.scheduler.max_concurrent == 0 {
851 warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
852 }
853 if self.scheduler.zombie_timeout_secs == 0 {
854 errors.push("scheduler.zombie_timeout_secs must be > 0".into());
855 }
856
857 for (name, job) in &self.cron.jobs {
859 if job.schedule.is_empty() {
860 errors.push(format!("cron.jobs.{}: schedule is empty", name));
861 } else {
862 let normalized = {
864 let fields: Vec<&str> = job.schedule.split_whitespace().collect();
865 match fields.len() {
866 5 => format!("0 {}", job.schedule),
867 _ => job.schedule.clone(),
868 }
869 };
870 if Schedule::from_str(&normalized).is_err() {
871 errors.push(format!(
872 "cron.jobs.{}: invalid cron expression '{}'",
873 name, job.schedule
874 ));
875 }
876 }
877 if job.goal.is_empty() {
878 errors.push(format!("cron.jobs.{}: goal is empty", name));
879 }
880 }
881
882 if self.security.max_execution_time_secs == 0 {
884 warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
885 }
886
887 if self.audit.max_entries == 0 {
889 warnings.push("audit.max_entries is 0 — audit will never prune".into());
890 }
891
892 if self.budget.default_window_secs == 0 {
894 warnings.push("budget.default_window_secs is 0 — no time window".into());
895 }
896
897 if self.exec.default_timeout_secs == 0 {
899 errors.push("exec.default_timeout_secs must be > 0".into());
900 }
901 if self.exec.max_timeout_secs == 0 {
902 errors.push("exec.max_timeout_secs must be > 0".into());
903 }
904 if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
905 errors.push(format!(
906 "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
907 self.exec.default_timeout_secs, self.exec.max_timeout_secs
908 ));
909 }
910
911 if self.resource_monitor.cpu_threshold > 100.0 {
913 errors.push("resource_monitor.cpu_threshold must be <= 100".into());
914 }
915 if self.resource_monitor.memory_threshold > 100.0 {
916 errors.push("resource_monitor.memory_threshold must be <= 100".into());
917 }
918
919 for name in &self.channels.enabled {
921 let valid = ["web", "cli", "telegram"];
922 if !valid.contains(&name.as_str()) {
923 warnings.push(format!("channels.enabled: unknown channel '{}'", name));
924 }
925 }
926 if self.channels.enabled.iter().any(|c| c == "telegram")
927 && std::env::var(&self.channels.telegram.bot_token_env).is_err()
928 {
929 warnings.push(format!(
930 "channels.telegram: {} env var not set — telegram channel will fail",
931 self.channels.telegram.bot_token_env
932 ));
933 }
934
935 (errors, warnings)
936 }
937}
938
939pub fn expand_home(path: &str) -> std::path::PathBuf {
943 if let Some(rest) = path.strip_prefix("~/") {
944 if let Ok(home) = std::env::var("HOME") {
945 return std::path::PathBuf::from(format!("{home}/{rest}"));
946 }
947 }
948 std::path::PathBuf::from(path)
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 #[test]
956 fn test_default_config_validates() {
957 let config = OxiosConfig::default();
958 let (errors, _warnings) = config.validate();
959 assert!(
960 errors.is_empty(),
961 "Default config should have no errors: {:?}",
962 errors
963 );
964 }
965
966 #[test]
967 fn test_exec_config_default_allowed_commands() {
968 let config = ExecConfig::default();
969 assert!(config.allowed_commands.is_empty());
971 assert!(config.is_binary_allowed("anything"));
972 assert!(config.is_binary_allowed("bash"));
973 assert!(config.is_binary_allowed("rm"));
974 }
975
976 #[test]
977 fn test_is_binary_allowed_with_allowlist() {
978 let config = ExecConfig {
979 allowed_commands: vec!["git".into(), "echo".into()],
980 ..Default::default()
981 };
982 assert!(config.is_binary_allowed("git"));
983 assert!(config.is_binary_allowed("echo"));
984 assert!(!config.is_binary_allowed("bash"));
985 assert!(!config.is_binary_allowed("rm"));
986 assert!(!config.is_binary_allowed("sudo"));
987 }
988
989 #[test]
990 fn test_expand_home() {
991 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
993 let expanded = expand_home("~/projects/test");
994 assert_eq!(
995 expanded.to_str().unwrap(),
996 format!("{}/projects/test", home)
997 );
998
999 let abs = expand_home("/absolute/path");
1001 assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
1002
1003 let bare = expand_home("~something");
1005 assert_eq!(bare, std::path::PathBuf::from("~something"));
1006 }
1007
1008 #[test]
1009 fn test_invalid_cron_expression() {
1010 let mut config = OxiosConfig::default();
1011 config.cron.enabled = true;
1012 config.cron.jobs.insert(
1013 "bad-job".to_string(),
1014 InlineCronJob {
1015 schedule: "not a valid cron".to_string(),
1016 goal: "Test goal".to_string(),
1017 constraints: vec![],
1018 acceptance_criteria: vec![],
1019 toolchain: "default".to_string(),
1020 priority: Priority::Normal,
1021 enabled: true,
1022 },
1023 );
1024
1025 let (errors, _warnings) = config.validate();
1026 assert!(
1027 !errors.is_empty(),
1028 "Expected validation error for invalid cron"
1029 );
1030 let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1031 assert!(
1032 has_cron_error,
1033 "Expected 'invalid cron expression' error, got: {:?}",
1034 errors
1035 );
1036 }
1037
1038 #[test]
1039 fn test_config_serialization_roundtrip() {
1040 let config = OxiosConfig::default();
1041
1042 let toml_str = toml::to_string(&config).expect("serialization should succeed");
1044
1045 let deserialized: OxiosConfig =
1047 toml::from_str(&toml_str).expect("deserialization should succeed");
1048
1049 assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
1051 assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
1052 assert_eq!(config.gateway.host, deserialized.gateway.host);
1053 assert_eq!(config.gateway.port, deserialized.gateway.port);
1054 assert_eq!(
1055 config.exec.default_timeout_secs,
1056 deserialized.exec.default_timeout_secs
1057 );
1058 assert_eq!(
1059 config.exec.max_timeout_secs,
1060 deserialized.exec.max_timeout_secs
1061 );
1062 }
1063
1064 #[test]
1065 fn test_exec_timeout_validation() {
1066 let mut config = OxiosConfig::default();
1067 config.exec.default_timeout_secs = 999;
1069 config.exec.max_timeout_secs = 100;
1070 let (errors, _warnings) = config.validate();
1071 let has_error = errors.iter().any(|e| e.contains("must not exceed"));
1072 assert!(
1073 has_error,
1074 "Expected timeout ordering error, got: {:?}",
1075 errors
1076 );
1077 }
1078
1079 #[test]
1080 fn test_zero_max_agents_error() {
1081 let mut config = OxiosConfig::default();
1082 config.kernel.max_agents = 0;
1083 let (errors, _warnings) = config.validate();
1084 assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
1085 }
1086}