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 #[serde(default = "default_true")]
92 pub cache_enabled: bool,
93 #[serde(default = "default_cache_ttl")]
95 pub cache_ttl_secs: u64,
96 #[serde(default = "default_cache_max_entries")]
98 pub cache_max_entries: usize,
99}
100
101fn default_true() -> bool {
102 true
103}
104
105fn default_max_recall() -> usize {
106 10
107}
108
109fn default_cache_ttl() -> u64 {
110 3600 }
112
113fn default_cache_max_entries() -> usize {
114 10000
115}
116
117impl Default for MemoryConfig {
118 fn default() -> Self {
119 Self {
120 enabled: true,
121 max_recall: 10,
122 auto_summarize: true,
123 capture_compaction: true,
124 retention_days: 0,
125 cache_enabled: true,
126 cache_ttl_secs: 3600,
127 cache_max_entries: 10000,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct ChannelsConfig {
135 #[serde(default = "default_channels_enabled")]
138 pub enabled: Vec<String>,
139
140 #[serde(default)]
142 pub telegram: TelegramChannelConfig,
143}
144
145fn default_channels_enabled() -> Vec<String> {
146 vec!["web".to_string()]
147}
148
149impl Default for ChannelsConfig {
150 fn default() -> Self {
151 Self {
152 enabled: default_channels_enabled(),
153 telegram: TelegramChannelConfig::default(),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Deserialize, Serialize)]
160pub struct TelegramChannelConfig {
161 #[serde(default = "default_telegram_token_env")]
163 pub bot_token_env: String,
164 #[serde(default)]
166 pub allowed_users: Vec<i64>,
167}
168
169fn default_telegram_token_env() -> String {
170 "TELEGRAM_BOT_TOKEN".to_string()
171}
172
173impl Default for TelegramChannelConfig {
174 fn default() -> Self {
175 Self {
176 bot_token_env: default_telegram_token_env(),
177 allowed_users: Vec::new(),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Deserialize, Serialize)]
184#[allow(clippy::derivable_impls)]
185pub struct EngineConfig {
186 #[serde(default)]
189 pub default_model: String,
190 #[serde(default, skip_serializing)]
194 pub api_key: Option<String>,
195}
196
197#[allow(clippy::derivable_impls)]
198impl Default for EngineConfig {
199 fn default() -> Self {
200 Self {
201 default_model: String::new(),
202 api_key: None,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Deserialize, Serialize)]
209pub struct DaemonConfig {
210 #[serde(default = "default_pid_file")]
212 pub pid_file: String,
213 #[serde(default = "default_daemon_log_dir")]
215 pub log_dir: String,
216}
217
218fn default_pid_file() -> String {
219 dirs::home_dir()
220 .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
221 .unwrap_or_else(|| "./oxios.pid".into())
222}
223
224fn default_daemon_log_dir() -> String {
225 dirs::home_dir()
226 .map(|h| format!("{}/.oxios/logs", h.display()))
227 .unwrap_or_else(|| "./logs".into())
228}
229
230impl Default for DaemonConfig {
231 fn default() -> Self {
232 Self {
233 pid_file: default_pid_file(),
234 log_dir: default_daemon_log_dir(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize, Default)]
241pub struct OxiosConfig {
242 pub kernel: KernelConfig,
244 #[serde(default)]
246 pub engine: EngineConfig,
247 #[serde(default)]
249 pub daemon: DaemonConfig,
250 #[serde(default)]
252 pub gateway: GatewayConfig,
253 #[serde(default)]
255 pub scheduler: SchedulerConfig,
256 #[serde(default)]
258 pub orchestrator: OrchestratorConfig,
259 #[serde(default)]
261 pub context: ContextConfig,
262 #[serde(default)]
264 pub security: SecurityConfig,
265 #[serde(default)]
267 pub persona: PersonaConfig,
268 #[serde(default)]
270 pub memory: MemoryConfig,
271 #[serde(default)]
273 pub cron: CronConfig,
274 #[serde(default)]
276 pub mcp: McpConfig,
277 #[serde(default)]
279 pub git: GitConfig,
280 #[serde(default)]
282 pub audit: AuditConfig,
283 #[serde(default)]
285 pub budget: BudgetConfig,
286 #[serde(default)]
288 pub exec: ExecConfig,
289 #[serde(default)]
291 pub resource_monitor: ResourceMonitorConfig,
292 #[serde(default)]
294 pub otel: OtelConfig,
295 #[serde(default)]
297 pub logging: LoggingConfig,
298 #[serde(default)]
300 pub channels: ChannelsConfig,
301 #[serde(default)]
303 pub browser: BrowserConfig,
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize)]
308pub struct KernelConfig {
309 #[serde(default = "default_workspace")]
311 pub workspace: String,
312 #[serde(default = "default_event_bus_capacity")]
314 pub event_bus_capacity: usize,
315 #[serde(default = "default_max_agents")]
317 pub max_agents: usize,
318}
319
320fn default_workspace() -> String {
321 dirs_home().unwrap_or_else(|| ".".into())
322}
323
324fn dirs_home() -> Option<String> {
325 dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
326}
327
328fn default_event_bus_capacity() -> usize {
329 256
330}
331
332fn default_max_agents() -> usize {
333 16
334}
335
336impl Default for KernelConfig {
337 fn default() -> Self {
338 Self {
339 workspace: default_workspace(),
340 event_bus_capacity: default_event_bus_capacity(),
341 max_agents: default_max_agents(),
342 }
343 }
344}
345
346#[derive(Debug, Clone, Deserialize, Serialize)]
348pub struct GatewayConfig {
349 #[serde(default = "default_gateway_host")]
351 pub host: String,
352 #[serde(default = "default_gateway_port")]
354 pub port: u16,
355}
356
357fn default_gateway_host() -> String {
358 "127.0.0.1".into()
359}
360
361fn default_gateway_port() -> u16 {
362 4200
363}
364
365impl Default for GatewayConfig {
366 fn default() -> Self {
367 Self {
368 host: default_gateway_host(),
369 port: default_gateway_port(),
370 }
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
379#[serde(rename_all = "lowercase")]
380pub enum ExecMode {
381 #[default]
383 Structured,
384 Shell,
386}
387
388#[derive(Debug, Clone, Deserialize, Serialize)]
392pub struct ExecConfig {
393 #[serde(default)]
395 pub default_mode: ExecMode,
396 #[serde(default = "default_false")]
398 pub allow_shell_mode: bool,
399 #[serde(default)]
402 pub allowed_commands: Vec<String>,
403 #[serde(default = "default_exec_timeout")]
405 pub default_timeout_secs: u64,
406 #[serde(default = "default_exec_max_timeout")]
408 pub max_timeout_secs: u64,
409 #[serde(default)]
411 pub required_host_tools: Vec<String>,
412 #[serde(default)]
414 pub optional_host_tools: Vec<String>,
415}
416
417fn default_false() -> bool {
418 false
419}
420
421fn default_exec_timeout() -> u64 {
422 120
423}
424
425fn default_exec_max_timeout() -> u64 {
426 600
427}
428
429impl ExecConfig {
430 pub fn is_binary_allowed(&self, name: &str) -> bool {
435 self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
436 }
437}
438
439impl Default for ExecConfig {
440 fn default() -> Self {
441 Self {
442 default_mode: ExecMode::default(),
443 allow_shell_mode: default_false(),
444 allowed_commands: Vec::new(),
445 default_timeout_secs: default_exec_timeout(),
446 max_timeout_secs: default_exec_max_timeout(),
447 required_host_tools: Vec::new(),
448 optional_host_tools: Vec::new(),
449 }
450 }
451}
452
453#[derive(Debug, Clone, Deserialize, Serialize)]
455pub struct SchedulerConfig {
456 #[serde(default = "default_max_concurrent")]
458 pub max_concurrent: usize,
459 #[serde(default = "default_rate_limit")]
461 pub rate_limit_per_minute: u32,
462 #[serde(default = "default_zombie_timeout")]
464 pub zombie_timeout_secs: u64,
465}
466
467fn default_max_concurrent() -> usize {
468 5
469}
470
471fn default_rate_limit() -> u32 {
472 60
473}
474
475fn default_zombie_timeout() -> u64 {
476 300
477}
478
479impl Default for SchedulerConfig {
480 fn default() -> Self {
481 Self {
482 max_concurrent: default_max_concurrent(),
483 rate_limit_per_minute: default_rate_limit(),
484 zombie_timeout_secs: default_zombie_timeout(),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Deserialize, Serialize)]
491pub struct OrchestratorConfig {}
492
493impl Default for OrchestratorConfig {
494 fn default() -> Self {
495 Self {}
496 }
497}
498
499#[derive(Debug, Clone, Deserialize, Serialize)]
501pub struct ContextConfig {
502 #[serde(default = "default_active_limit")]
504 pub active_limit_tokens: usize,
505 #[serde(default = "default_cache_limit")]
507 pub cache_limit_entries: usize,
508}
509
510fn default_active_limit() -> usize {
511 100_000
512}
513
514fn default_cache_limit() -> usize {
515 50
516}
517
518impl Default for ContextConfig {
519 fn default() -> Self {
520 Self {
521 active_limit_tokens: default_active_limit(),
522 cache_limit_entries: default_cache_limit(),
523 }
524 }
525}
526
527#[derive(Debug, Clone, Deserialize, Serialize)]
529pub struct SecurityConfig {
530 #[serde(default = "default_allowed_tools")]
532 pub allowed_tools: Vec<String>,
533 #[serde(default)]
535 pub network_access: bool,
536 #[serde(default = "default_max_exec_time")]
538 pub max_execution_time_secs: u64,
539 #[serde(default = "default_max_memory")]
541 pub max_memory_mb: u64,
542 #[serde(default)]
544 pub can_fork: bool,
545 #[serde(default = "default_max_audit")]
547 pub max_audit_entries: usize,
548 #[serde(default)]
550 pub auth_enabled: bool,
551 #[serde(default = "default_cors_origins")]
553 pub cors_origins: Vec<String>,
554 #[serde(default)]
556 pub audit_log_path: Option<String>,
557 #[serde(default = "default_rate_limit_per_minute")]
559 pub rate_limit_per_minute: u32,
560}
561
562fn default_allowed_tools() -> Vec<String> {
563 vec![
564 "read".to_string(),
565 "write".to_string(),
566 "edit".to_string(),
567 "bash".to_string(),
568 "grep".to_string(),
569 "find".to_string(),
570 ]
571}
572
573fn default_max_exec_time() -> u64 {
574 300
575}
576
577fn default_max_memory() -> u64 {
578 512
579}
580
581fn default_max_audit() -> usize {
582 10_000
583}
584
585fn default_rate_limit_per_minute() -> u32 {
586 120
587}
588
589fn default_cors_origins() -> Vec<String> {
590 vec!["http://localhost:4200".to_string()]
591}
592
593impl Default for SecurityConfig {
594 fn default() -> Self {
595 Self {
596 allowed_tools: default_allowed_tools(),
597 network_access: false,
598 max_execution_time_secs: default_max_exec_time(),
599 max_memory_mb: default_max_memory(),
600 can_fork: false,
601 max_audit_entries: default_max_audit(),
602 auth_enabled: false,
603 cors_origins: default_cors_origins(),
604 audit_log_path: None,
605 rate_limit_per_minute: default_rate_limit_per_minute(),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Deserialize, Serialize)]
612pub struct PersonaConfig {
613 #[serde(default)]
615 pub default_persona_id: Option<String>,
616 #[serde(default = "default_max_concurrent_personas")]
618 pub max_concurrent_personas: usize,
619}
620
621fn default_max_concurrent_personas() -> usize {
622 5
623}
624
625impl Default for PersonaConfig {
626 fn default() -> Self {
627 Self {
628 default_persona_id: Some("dev".to_string()),
629 max_concurrent_personas: default_max_concurrent_personas(),
630 }
631 }
632}
633
634#[derive(Debug, Clone, Deserialize, Serialize, Default)]
642pub struct McpConfig {
643 #[serde(default)]
645 pub servers: std::collections::HashMap<String, McpServerDef>,
646}
647
648#[derive(Debug, Clone, Deserialize, Serialize)]
650pub struct McpServerDef {
651 pub command: String,
653 #[serde(default)]
655 pub args: Vec<String>,
656 #[serde(default)]
658 pub env: std::collections::HashMap<String, String>,
659 #[serde(default = "default_mcp_enabled")]
661 pub enabled: bool,
662}
663
664fn default_mcp_enabled() -> bool {
665 true
666}
667
668#[derive(Debug, Clone, Deserialize, Serialize)]
670pub struct GitConfig {
671 #[serde(default = "default_true")]
673 pub auto_commit: bool,
674}
675
676impl Default for GitConfig {
677 fn default() -> Self {
678 Self { auto_commit: true }
679 }
680}
681
682#[derive(Debug, Clone, Deserialize, Serialize)]
684pub struct AuditConfig {
685 #[serde(default = "default_audit_max_entries")]
687 pub max_entries: usize,
688 #[serde(default = "default_true")]
690 pub enabled: bool,
691}
692
693fn default_audit_max_entries() -> usize {
694 100_000
695}
696
697impl Default for AuditConfig {
698 fn default() -> Self {
699 Self {
700 max_entries: default_audit_max_entries(),
701 enabled: true,
702 }
703 }
704}
705
706#[derive(Debug, Clone, Deserialize, Serialize)]
708pub struct BudgetConfig {
709 #[serde(default)]
711 pub default_token_budget: u64,
712 #[serde(default)]
714 pub default_calls_budget: u64,
715 #[serde(default = "default_budget_window")]
717 pub default_window_secs: u64,
718 #[serde(default = "default_true")]
720 pub enabled: bool,
721}
722
723fn default_budget_window() -> u64 {
724 3600
725}
726
727impl Default for BudgetConfig {
728 fn default() -> Self {
729 Self {
730 default_token_budget: 0,
731 default_calls_budget: 0,
732 default_window_secs: default_budget_window(),
733 enabled: true,
734 }
735 }
736}
737
738#[derive(Debug, Clone, Deserialize, Serialize)]
740pub struct ResourceMonitorConfig {
741 #[serde(default = "default_rm_interval")]
743 pub interval_secs: u64,
744 #[serde(default = "default_rm_history_max")]
746 pub history_max: usize,
747 #[serde(default = "default_rm_cpu_threshold")]
749 pub cpu_threshold: f32,
750 #[serde(default = "default_rm_mem_threshold")]
752 pub memory_threshold: f32,
753 #[serde(default = "default_rm_load_threshold")]
755 pub load_threshold: f32,
756}
757
758fn default_rm_interval() -> u64 {
759 60
760}
761
762fn default_rm_history_max() -> usize {
763 60
764}
765
766fn default_rm_cpu_threshold() -> f32 {
767 90.0
768}
769
770fn default_rm_mem_threshold() -> f32 {
771 90.0
772}
773
774fn default_rm_load_threshold() -> f32 {
775 8.0
776}
777
778impl Default for ResourceMonitorConfig {
779 fn default() -> Self {
780 Self {
781 interval_secs: default_rm_interval(),
782 history_max: default_rm_history_max(),
783 cpu_threshold: default_rm_cpu_threshold(),
784 memory_threshold: default_rm_mem_threshold(),
785 load_threshold: default_rm_load_threshold(),
786 }
787 }
788}
789
790#[derive(Debug, Clone, Deserialize, Serialize)]
792pub struct OtelConfig {
793 #[serde(default)]
795 pub enabled: bool,
796 #[serde(default = "default_otel_endpoint")]
798 pub endpoint: String,
799 #[serde(default = "default_otel_service_name")]
801 pub service_name: String,
802 #[serde(default = "default_otel_sampling_ratio")]
804 pub sampling_ratio: f64,
805}
806
807fn default_otel_endpoint() -> String {
808 "http://localhost:4317".into()
809}
810
811fn default_otel_service_name() -> String {
812 "oxios".into()
813}
814
815fn default_otel_sampling_ratio() -> f64 {
816 1.0
817}
818
819impl Default for OtelConfig {
820 fn default() -> Self {
821 Self {
822 enabled: false,
823 endpoint: default_otel_endpoint(),
824 service_name: default_otel_service_name(),
825 sampling_ratio: default_otel_sampling_ratio(),
826 }
827 }
828}
829
830#[derive(Debug, Clone, Deserialize, Serialize)]
832pub struct LoggingConfig {
833 #[serde(default = "default_log_format")]
835 pub format: String,
836 #[serde(default)]
838 pub level: Option<String>,
839}
840
841fn default_log_format() -> String {
842 "pretty".into()
843}
844
845impl Default for LoggingConfig {
846 fn default() -> Self {
847 Self {
848 format: default_log_format(),
849 level: None,
850 }
851 }
852}
853
854#[derive(Debug, Clone, Deserialize, Serialize)]
860pub struct BrowserConfig {
861 #[serde(default = "default_browser_enabled")]
863 pub enabled: bool,
864
865 #[serde(default)]
876 pub engine: oxibrowser_core::BrowserConfig,
877}
878
879fn default_browser_enabled() -> bool {
880 true
881}
882
883impl Default for BrowserConfig {
884 fn default() -> Self {
885 Self {
886 enabled: true,
887 engine: oxibrowser_core::BrowserConfig::headless(),
888 }
889 }
890}
891
892pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
894 let content = std::fs::read_to_string(path)?;
895 let config: OxiosConfig = toml::from_str(&content)?;
896 let (errors, warnings) = config.validate();
897 for w in warnings {
898 tracing::warn!("config: {}", w);
899 }
900 if !errors.is_empty() {
901 let msg = errors.join("; ");
902 anyhow::bail!("Configuration validation failed: {}", msg);
903 }
904 Ok(config)
905}
906
907impl OxiosConfig {
908 pub fn api_key(&self) -> Option<String> {
910 self.engine.api_key.clone().filter(|k| !k.is_empty())
911 }
912
913 pub fn validate(&self) -> (Vec<String>, Vec<String>) {
916 let mut errors = Vec::new();
917 let mut warnings = Vec::new();
918
919 if self.kernel.max_agents == 0 {
921 errors.push("kernel.max_agents must be > 0".into());
922 }
923 if self.kernel.workspace.is_empty() {
924 errors.push("kernel.workspace must not be empty".into());
925 }
926
927 if self.gateway.port == 0 {
929 errors.push("gateway.port must be > 0".into());
930 }
931 if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
932 warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
933 }
934
935 if self.scheduler.max_concurrent == 0 {
937 warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
938 }
939 if self.scheduler.zombie_timeout_secs == 0 {
940 errors.push("scheduler.zombie_timeout_secs must be > 0".into());
941 }
942
943 for (name, job) in &self.cron.jobs {
945 if job.schedule.is_empty() {
946 errors.push(format!("cron.jobs.{}: schedule is empty", name));
947 } else {
948 let normalized = {
950 let fields: Vec<&str> = job.schedule.split_whitespace().collect();
951 match fields.len() {
952 5 => format!("0 {}", job.schedule),
953 _ => job.schedule.clone(),
954 }
955 };
956 if Schedule::from_str(&normalized).is_err() {
957 errors.push(format!(
958 "cron.jobs.{}: invalid cron expression '{}'",
959 name, job.schedule
960 ));
961 }
962 }
963 if job.goal.is_empty() {
964 errors.push(format!("cron.jobs.{}: goal is empty", name));
965 }
966 }
967
968 if self.security.max_execution_time_secs == 0 {
970 warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
971 }
972
973 if self.audit.max_entries == 0 {
975 warnings.push("audit.max_entries is 0 — audit will never prune".into());
976 }
977
978 if self.budget.default_window_secs == 0 {
980 warnings.push("budget.default_window_secs is 0 — no time window".into());
981 }
982
983 if self.exec.default_timeout_secs == 0 {
985 errors.push("exec.default_timeout_secs must be > 0".into());
986 }
987 if self.exec.max_timeout_secs == 0 {
988 errors.push("exec.max_timeout_secs must be > 0".into());
989 }
990 if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
991 errors.push(format!(
992 "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
993 self.exec.default_timeout_secs, self.exec.max_timeout_secs
994 ));
995 }
996
997 if self.resource_monitor.cpu_threshold > 100.0 {
999 errors.push("resource_monitor.cpu_threshold must be <= 100".into());
1000 }
1001 if self.resource_monitor.memory_threshold > 100.0 {
1002 errors.push("resource_monitor.memory_threshold must be <= 100".into());
1003 }
1004
1005 for name in &self.channels.enabled {
1007 let valid = ["web", "cli", "telegram"];
1008 if !valid.contains(&name.as_str()) {
1009 warnings.push(format!("channels.enabled: unknown channel '{}'", name));
1010 }
1011 }
1012 if self.channels.enabled.iter().any(|c| c == "telegram")
1013 && std::env::var(&self.channels.telegram.bot_token_env).is_err()
1014 {
1015 warnings.push(format!(
1016 "channels.telegram: {} env var not set — telegram channel will fail",
1017 self.channels.telegram.bot_token_env
1018 ));
1019 }
1020
1021 (errors, warnings)
1022 }
1023}
1024
1025pub fn expand_home(path: &str) -> std::path::PathBuf {
1029 if let Some(rest) = path.strip_prefix("~/") {
1030 if let Ok(home) = std::env::var("HOME") {
1031 return std::path::PathBuf::from(format!("{home}/{rest}"));
1032 }
1033 }
1034 std::path::PathBuf::from(path)
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040
1041 #[test]
1042 fn test_default_config_validates() {
1043 let config = OxiosConfig::default();
1044 let (errors, _warnings) = config.validate();
1045 assert!(
1046 errors.is_empty(),
1047 "Default config should have no errors: {:?}",
1048 errors
1049 );
1050 }
1051
1052 #[test]
1053 fn test_exec_config_default_allowed_commands() {
1054 let config = ExecConfig::default();
1055 assert!(config.allowed_commands.is_empty());
1057 assert!(config.is_binary_allowed("anything"));
1058 assert!(config.is_binary_allowed("bash"));
1059 assert!(config.is_binary_allowed("rm"));
1060 }
1061
1062 #[test]
1063 fn test_is_binary_allowed_with_allowlist() {
1064 let config = ExecConfig {
1065 allowed_commands: vec!["git".into(), "echo".into()],
1066 ..Default::default()
1067 };
1068 assert!(config.is_binary_allowed("git"));
1069 assert!(config.is_binary_allowed("echo"));
1070 assert!(!config.is_binary_allowed("bash"));
1071 assert!(!config.is_binary_allowed("rm"));
1072 assert!(!config.is_binary_allowed("sudo"));
1073 }
1074
1075 #[test]
1076 fn test_expand_home() {
1077 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
1079 let expanded = expand_home("~/projects/test");
1080 assert_eq!(
1081 expanded.to_str().unwrap(),
1082 format!("{}/projects/test", home)
1083 );
1084
1085 let abs = expand_home("/absolute/path");
1087 assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
1088
1089 let bare = expand_home("~something");
1091 assert_eq!(bare, std::path::PathBuf::from("~something"));
1092 }
1093
1094 #[test]
1095 fn test_invalid_cron_expression() {
1096 let mut config = OxiosConfig::default();
1097 config.cron.enabled = true;
1098 config.cron.jobs.insert(
1099 "bad-job".to_string(),
1100 InlineCronJob {
1101 schedule: "not a valid cron".to_string(),
1102 goal: "Test goal".to_string(),
1103 constraints: vec![],
1104 acceptance_criteria: vec![],
1105 toolchain: "default".to_string(),
1106 priority: Priority::Normal,
1107 enabled: true,
1108 },
1109 );
1110
1111 let (errors, _warnings) = config.validate();
1112 assert!(
1113 !errors.is_empty(),
1114 "Expected validation error for invalid cron"
1115 );
1116 let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1117 assert!(
1118 has_cron_error,
1119 "Expected 'invalid cron expression' error, got: {:?}",
1120 errors
1121 );
1122 }
1123
1124 #[test]
1125 fn test_config_serialization_roundtrip() {
1126 let config = OxiosConfig::default();
1127
1128 let toml_str = toml::to_string(&config).expect("serialization should succeed");
1130
1131 let deserialized: OxiosConfig =
1133 toml::from_str(&toml_str).expect("deserialization should succeed");
1134
1135 assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
1137 assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
1138 assert_eq!(config.gateway.host, deserialized.gateway.host);
1139 assert_eq!(config.gateway.port, deserialized.gateway.port);
1140 assert_eq!(
1141 config.exec.default_timeout_secs,
1142 deserialized.exec.default_timeout_secs
1143 );
1144 assert_eq!(
1145 config.exec.max_timeout_secs,
1146 deserialized.exec.max_timeout_secs
1147 );
1148 }
1149
1150 #[test]
1151 fn test_exec_timeout_validation() {
1152 let mut config = OxiosConfig::default();
1153 config.exec.default_timeout_secs = 999;
1155 config.exec.max_timeout_secs = 100;
1156 let (errors, _warnings) = config.validate();
1157 let has_error = errors.iter().any(|e| e.contains("must not exceed"));
1158 assert!(
1159 has_error,
1160 "Expected timeout ordering error, got: {:?}",
1161 errors
1162 );
1163 }
1164
1165 #[test]
1166 fn test_zero_max_agents_error() {
1167 let mut config = OxiosConfig::default();
1168 config.kernel.max_agents = 0;
1169 let (errors, _warnings) = config.validate();
1170 assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
1171 }
1172}