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