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)]
184pub struct EngineConfig {
185 #[serde(default)]
188 pub default_model: String,
189 #[serde(default, skip_serializing)]
193 pub api_key: Option<String>,
194}
195
196impl Default for EngineConfig {
197 fn default() -> Self {
198 Self {
199 default_model: String::new(),
200 api_key: None,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Deserialize, Serialize)]
207pub struct DaemonConfig {
208 #[serde(default = "default_pid_file")]
210 pub pid_file: String,
211 #[serde(default = "default_daemon_log_dir")]
213 pub log_dir: String,
214}
215
216fn default_pid_file() -> String {
217 dirs::home_dir()
218 .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
219 .unwrap_or_else(|| "./oxios.pid".into())
220}
221
222fn default_daemon_log_dir() -> String {
223 dirs::home_dir()
224 .map(|h| format!("{}/.oxios/logs", h.display()))
225 .unwrap_or_else(|| "./logs".into())
226}
227
228impl Default for DaemonConfig {
229 fn default() -> Self {
230 Self {
231 pid_file: default_pid_file(),
232 log_dir: default_daemon_log_dir(),
233 }
234 }
235}
236
237#[derive(Debug, Clone, Deserialize, Serialize, Default)]
239pub struct OxiosConfig {
240 pub kernel: KernelConfig,
242 #[serde(default)]
244 pub engine: EngineConfig,
245 #[serde(default)]
247 pub daemon: DaemonConfig,
248 #[serde(default)]
250 pub gateway: GatewayConfig,
251 #[serde(default)]
253 pub scheduler: SchedulerConfig,
254 #[serde(default)]
256 pub orchestrator: OrchestratorConfig,
257 #[serde(default)]
259 pub context: ContextConfig,
260 #[serde(default)]
262 pub security: SecurityConfig,
263 #[serde(default)]
265 pub persona: PersonaConfig,
266 #[serde(default)]
268 pub memory: MemoryConfig,
269 #[serde(default)]
271 pub cron: CronConfig,
272 #[serde(default)]
274 pub mcp: McpConfig,
275 #[serde(default)]
277 pub git: GitConfig,
278 #[serde(default)]
280 pub audit: AuditConfig,
281 #[serde(default)]
283 pub budget: BudgetConfig,
284 #[serde(default)]
286 pub exec: ExecConfig,
287 #[serde(default)]
289 pub resource_monitor: ResourceMonitorConfig,
290 #[serde(default)]
292 pub otel: OtelConfig,
293 #[serde(default)]
295 pub channels: ChannelsConfig,
296 #[serde(default)]
298 pub browser: BrowserConfig,
299}
300
301#[derive(Debug, Clone, Deserialize, Serialize)]
303pub struct KernelConfig {
304 #[serde(default = "default_workspace")]
306 pub workspace: String,
307 #[serde(default = "default_event_bus_capacity")]
309 pub event_bus_capacity: usize,
310 #[serde(default = "default_max_agents")]
312 pub max_agents: usize,
313}
314
315fn default_workspace() -> String {
316 dirs_home().unwrap_or_else(|| ".".into())
317}
318
319fn dirs_home() -> Option<String> {
320 dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
321}
322
323fn default_event_bus_capacity() -> usize {
324 256
325}
326
327fn default_max_agents() -> usize {
328 16
329}
330
331impl Default for KernelConfig {
332 fn default() -> Self {
333 Self {
334 workspace: default_workspace(),
335 event_bus_capacity: default_event_bus_capacity(),
336 max_agents: default_max_agents(),
337 }
338 }
339}
340
341#[derive(Debug, Clone, Deserialize, Serialize)]
343pub struct GatewayConfig {
344 #[serde(default = "default_gateway_host")]
346 pub host: String,
347 #[serde(default = "default_gateway_port")]
349 pub port: u16,
350}
351
352fn default_gateway_host() -> String {
353 "127.0.0.1".into()
354}
355
356fn default_gateway_port() -> u16 {
357 4200
358}
359
360impl Default for GatewayConfig {
361 fn default() -> Self {
362 Self {
363 host: default_gateway_host(),
364 port: default_gateway_port(),
365 }
366 }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "lowercase")]
375pub enum ExecMode {
376 Structured,
378 Shell,
380}
381
382impl Default for ExecMode {
383 fn default() -> Self {
384 Self::Structured
385 }
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)]
503pub struct OrchestratorConfig {
504 #[serde(default = "default_max_evolution_iterations")]
506 pub max_evolution_iterations: usize,
507 #[serde(default = "default_min_evaluation_score")]
510 pub min_evaluation_score: f64,
511}
512
513fn default_max_evolution_iterations() -> usize {
514 3
515}
516
517fn default_min_evaluation_score() -> f64 {
518 0.8
519}
520
521impl Default for OrchestratorConfig {
522 fn default() -> Self {
523 Self {
524 max_evolution_iterations: default_max_evolution_iterations(),
525 min_evaluation_score: default_min_evaluation_score(),
526 }
527 }
528}
529
530#[derive(Debug, Clone, Deserialize, Serialize)]
532pub struct ContextConfig {
533 #[serde(default = "default_active_limit")]
535 pub active_limit_tokens: usize,
536 #[serde(default = "default_cache_limit")]
538 pub cache_limit_entries: usize,
539}
540
541fn default_active_limit() -> usize {
542 100_000
543}
544
545fn default_cache_limit() -> usize {
546 50
547}
548
549impl Default for ContextConfig {
550 fn default() -> Self {
551 Self {
552 active_limit_tokens: default_active_limit(),
553 cache_limit_entries: default_cache_limit(),
554 }
555 }
556}
557
558#[derive(Debug, Clone, Deserialize, Serialize)]
560pub struct SecurityConfig {
561 #[serde(default = "default_allowed_tools")]
563 pub allowed_tools: Vec<String>,
564 #[serde(default)]
566 pub network_access: bool,
567 #[serde(default = "default_max_exec_time")]
569 pub max_execution_time_secs: u64,
570 #[serde(default = "default_max_memory")]
572 pub max_memory_mb: u64,
573 #[serde(default)]
575 pub can_fork: bool,
576 #[serde(default = "default_max_audit")]
578 pub max_audit_entries: usize,
579 #[serde(default)]
581 pub auth_enabled: bool,
582 #[serde(default = "default_cors_origins")]
584 pub cors_origins: Vec<String>,
585 #[serde(default)]
587 pub audit_log_path: Option<String>,
588 #[serde(default = "default_rate_limit_per_minute")]
590 pub rate_limit_per_minute: u32,
591}
592
593fn default_allowed_tools() -> Vec<String> {
594 vec![
595 "read".to_string(),
596 "write".to_string(),
597 "edit".to_string(),
598 "bash".to_string(),
599 "grep".to_string(),
600 "find".to_string(),
601 ]
602}
603
604fn default_max_exec_time() -> u64 {
605 300
606}
607
608fn default_max_memory() -> u64 {
609 512
610}
611
612fn default_max_audit() -> usize {
613 10_000
614}
615
616fn default_rate_limit_per_minute() -> u32 {
617 120
618}
619
620fn default_cors_origins() -> Vec<String> {
621 vec!["http://localhost:4200".to_string()]
622}
623
624impl Default for SecurityConfig {
625 fn default() -> Self {
626 Self {
627 allowed_tools: default_allowed_tools(),
628 network_access: false,
629 max_execution_time_secs: default_max_exec_time(),
630 max_memory_mb: default_max_memory(),
631 can_fork: false,
632 max_audit_entries: default_max_audit(),
633 auth_enabled: false,
634 cors_origins: default_cors_origins(),
635 audit_log_path: None,
636 rate_limit_per_minute: default_rate_limit_per_minute(),
637 }
638 }
639}
640
641#[derive(Debug, Clone, Deserialize, Serialize)]
643pub struct PersonaConfig {
644 #[serde(default)]
646 pub default_persona_id: Option<String>,
647 #[serde(default = "default_max_concurrent_personas")]
649 pub max_concurrent_personas: usize,
650}
651
652fn default_max_concurrent_personas() -> usize {
653 5
654}
655
656impl Default for PersonaConfig {
657 fn default() -> Self {
658 Self {
659 default_persona_id: Some("dev".to_string()),
660 max_concurrent_personas: default_max_concurrent_personas(),
661 }
662 }
663}
664
665#[derive(Debug, Clone, Deserialize, Serialize, Default)]
673pub struct McpConfig {
674 #[serde(default)]
676 pub servers: std::collections::HashMap<String, McpServerDef>,
677}
678
679#[derive(Debug, Clone, Deserialize, Serialize)]
681pub struct McpServerDef {
682 pub command: String,
684 #[serde(default)]
686 pub args: Vec<String>,
687 #[serde(default)]
689 pub env: std::collections::HashMap<String, String>,
690 #[serde(default = "default_mcp_enabled")]
692 pub enabled: bool,
693}
694
695fn default_mcp_enabled() -> bool {
696 true
697}
698
699#[derive(Debug, Clone, Deserialize, Serialize)]
701pub struct GitConfig {
702 #[serde(default = "default_true")]
704 pub auto_commit: bool,
705}
706
707impl Default for GitConfig {
708 fn default() -> Self {
709 Self { auto_commit: true }
710 }
711}
712
713#[derive(Debug, Clone, Deserialize, Serialize)]
715pub struct AuditConfig {
716 #[serde(default = "default_audit_max_entries")]
718 pub max_entries: usize,
719 #[serde(default = "default_true")]
721 pub enabled: bool,
722}
723
724fn default_audit_max_entries() -> usize {
725 100_000
726}
727
728impl Default for AuditConfig {
729 fn default() -> Self {
730 Self {
731 max_entries: default_audit_max_entries(),
732 enabled: true,
733 }
734 }
735}
736
737#[derive(Debug, Clone, Deserialize, Serialize)]
739pub struct BudgetConfig {
740 #[serde(default)]
742 pub default_token_budget: u64,
743 #[serde(default)]
745 pub default_calls_budget: u64,
746 #[serde(default = "default_budget_window")]
748 pub default_window_secs: u64,
749 #[serde(default = "default_true")]
751 pub enabled: bool,
752}
753
754fn default_budget_window() -> u64 {
755 3600
756}
757
758impl Default for BudgetConfig {
759 fn default() -> Self {
760 Self {
761 default_token_budget: 0,
762 default_calls_budget: 0,
763 default_window_secs: default_budget_window(),
764 enabled: true,
765 }
766 }
767}
768
769#[derive(Debug, Clone, Deserialize, Serialize)]
771pub struct ResourceMonitorConfig {
772 #[serde(default = "default_rm_interval")]
774 pub interval_secs: u64,
775 #[serde(default = "default_rm_history_max")]
777 pub history_max: usize,
778 #[serde(default = "default_rm_cpu_threshold")]
780 pub cpu_threshold: f32,
781 #[serde(default = "default_rm_mem_threshold")]
783 pub memory_threshold: f32,
784 #[serde(default = "default_rm_load_threshold")]
786 pub load_threshold: f32,
787}
788
789fn default_rm_interval() -> u64 {
790 60
791}
792
793fn default_rm_history_max() -> usize {
794 60
795}
796
797fn default_rm_cpu_threshold() -> f32 {
798 90.0
799}
800
801fn default_rm_mem_threshold() -> f32 {
802 90.0
803}
804
805fn default_rm_load_threshold() -> f32 {
806 8.0
807}
808
809impl Default for ResourceMonitorConfig {
810 fn default() -> Self {
811 Self {
812 interval_secs: default_rm_interval(),
813 history_max: default_rm_history_max(),
814 cpu_threshold: default_rm_cpu_threshold(),
815 memory_threshold: default_rm_mem_threshold(),
816 load_threshold: default_rm_load_threshold(),
817 }
818 }
819}
820
821#[derive(Debug, Clone, Deserialize, Serialize)]
823pub struct OtelConfig {
824 #[serde(default)]
826 pub enabled: bool,
827 #[serde(default = "default_otel_endpoint")]
829 pub endpoint: String,
830 #[serde(default = "default_otel_service_name")]
832 pub service_name: String,
833 #[serde(default = "default_otel_sampling_ratio")]
835 pub sampling_ratio: f64,
836}
837
838fn default_otel_endpoint() -> String {
839 "http://localhost:4317".into()
840}
841
842fn default_otel_service_name() -> String {
843 "oxios".into()
844}
845
846fn default_otel_sampling_ratio() -> f64 {
847 1.0
848}
849
850impl Default for OtelConfig {
851 fn default() -> Self {
852 Self {
853 enabled: false,
854 endpoint: default_otel_endpoint(),
855 service_name: default_otel_service_name(),
856 sampling_ratio: default_otel_sampling_ratio(),
857 }
858 }
859}
860
861#[derive(Debug, Clone, Deserialize, Serialize)]
867pub struct BrowserConfig {
868 #[serde(default = "default_browser_enabled")]
870 pub enabled: bool,
871
872 #[serde(default)]
883 pub engine: oxibrowser_core::BrowserConfig,
884}
885
886fn default_browser_enabled() -> bool {
887 true
888}
889
890impl Default for BrowserConfig {
891 fn default() -> Self {
892 Self {
893 enabled: true,
894 engine: oxibrowser_core::BrowserConfig::headless(),
895 }
896 }
897}
898
899pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
901 let content = std::fs::read_to_string(path)?;
902 let config: OxiosConfig = toml::from_str(&content)?;
903 let (errors, warnings) = config.validate();
904 for w in warnings {
905 tracing::warn!("config: {}", w);
906 }
907 if !errors.is_empty() {
908 let msg = errors.join("; ");
909 anyhow::bail!("Configuration validation failed: {}", msg);
910 }
911 Ok(config)
912}
913
914impl OxiosConfig {
915 pub fn api_key(&self) -> Option<String> {
917 self.engine.api_key.clone().filter(|k| !k.is_empty())
918 }
919
920 pub fn validate(&self) -> (Vec<String>, Vec<String>) {
923 let mut errors = Vec::new();
924 let mut warnings = Vec::new();
925
926 if self.kernel.max_agents == 0 {
928 errors.push("kernel.max_agents must be > 0".into());
929 }
930 if self.kernel.workspace.is_empty() {
931 errors.push("kernel.workspace must not be empty".into());
932 }
933
934 if self.gateway.port == 0 {
936 errors.push("gateway.port must be > 0".into());
937 }
938 if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
939 warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
940 }
941
942 if self.scheduler.max_concurrent == 0 {
944 warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
945 }
946 if self.scheduler.zombie_timeout_secs == 0 {
947 errors.push("scheduler.zombie_timeout_secs must be > 0".into());
948 }
949
950 for (name, job) in &self.cron.jobs {
952 if job.schedule.is_empty() {
953 errors.push(format!("cron.jobs.{}: schedule is empty", name));
954 } else {
955 let normalized = {
957 let fields: Vec<&str> = job.schedule.split_whitespace().collect();
958 match fields.len() {
959 5 => format!("0 {}", job.schedule),
960 _ => job.schedule.clone(),
961 }
962 };
963 if Schedule::from_str(&normalized).is_err() {
964 errors.push(format!(
965 "cron.jobs.{}: invalid cron expression '{}'",
966 name, job.schedule
967 ));
968 }
969 }
970 if job.goal.is_empty() {
971 errors.push(format!("cron.jobs.{}: goal is empty", name));
972 }
973 }
974
975 if self.security.max_execution_time_secs == 0 {
977 warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
978 }
979
980 if self.audit.max_entries == 0 {
982 warnings.push("audit.max_entries is 0 — audit will never prune".into());
983 }
984
985 if self.budget.default_window_secs == 0 {
987 warnings.push("budget.default_window_secs is 0 — no time window".into());
988 }
989
990 if self.exec.default_timeout_secs == 0 {
992 errors.push("exec.default_timeout_secs must be > 0".into());
993 }
994 if self.exec.max_timeout_secs == 0 {
995 errors.push("exec.max_timeout_secs must be > 0".into());
996 }
997 if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
998 errors.push(format!(
999 "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
1000 self.exec.default_timeout_secs, self.exec.max_timeout_secs
1001 ));
1002 }
1003
1004 if self.resource_monitor.cpu_threshold > 100.0 {
1006 errors.push("resource_monitor.cpu_threshold must be <= 100".into());
1007 }
1008 if self.resource_monitor.memory_threshold > 100.0 {
1009 errors.push("resource_monitor.memory_threshold must be <= 100".into());
1010 }
1011
1012 for name in &self.channels.enabled {
1014 let valid = ["web", "cli", "telegram"];
1015 if !valid.contains(&name.as_str()) {
1016 warnings.push(format!("channels.enabled: unknown channel '{}'", name));
1017 }
1018 }
1019 if self.channels.enabled.iter().any(|c| c == "telegram")
1020 && std::env::var(&self.channels.telegram.bot_token_env).is_err()
1021 {
1022 warnings.push(format!(
1023 "channels.telegram: {} env var not set — telegram channel will fail",
1024 self.channels.telegram.bot_token_env
1025 ));
1026 }
1027
1028 (errors, warnings)
1029 }
1030}
1031
1032pub fn expand_home(path: &str) -> std::path::PathBuf {
1036 if let Some(rest) = path.strip_prefix("~/") {
1037 if let Ok(home) = std::env::var("HOME") {
1038 return std::path::PathBuf::from(format!("{home}/{rest}"));
1039 }
1040 }
1041 std::path::PathBuf::from(path)
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn test_default_config_validates() {
1050 let config = OxiosConfig::default();
1051 let (errors, _warnings) = config.validate();
1052 assert!(
1053 errors.is_empty(),
1054 "Default config should have no errors: {:?}",
1055 errors
1056 );
1057 }
1058
1059 #[test]
1060 fn test_exec_config_default_allowed_commands() {
1061 let config = ExecConfig::default();
1062 assert!(config.allowed_commands.is_empty());
1064 assert!(config.is_binary_allowed("anything"));
1065 assert!(config.is_binary_allowed("bash"));
1066 assert!(config.is_binary_allowed("rm"));
1067 }
1068
1069 #[test]
1070 fn test_is_binary_allowed_with_allowlist() {
1071 let config = ExecConfig {
1072 allowed_commands: vec!["git".into(), "echo".into()],
1073 ..Default::default()
1074 };
1075 assert!(config.is_binary_allowed("git"));
1076 assert!(config.is_binary_allowed("echo"));
1077 assert!(!config.is_binary_allowed("bash"));
1078 assert!(!config.is_binary_allowed("rm"));
1079 assert!(!config.is_binary_allowed("sudo"));
1080 }
1081
1082 #[test]
1083 fn test_expand_home() {
1084 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
1086 let expanded = expand_home("~/projects/test");
1087 assert_eq!(
1088 expanded.to_str().unwrap(),
1089 format!("{}/projects/test", home)
1090 );
1091
1092 let abs = expand_home("/absolute/path");
1094 assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
1095
1096 let bare = expand_home("~something");
1098 assert_eq!(bare, std::path::PathBuf::from("~something"));
1099 }
1100
1101 #[test]
1102 fn test_invalid_cron_expression() {
1103 let mut config = OxiosConfig::default();
1104 config.cron.enabled = true;
1105 config.cron.jobs.insert(
1106 "bad-job".to_string(),
1107 InlineCronJob {
1108 schedule: "not a valid cron".to_string(),
1109 goal: "Test goal".to_string(),
1110 constraints: vec![],
1111 acceptance_criteria: vec![],
1112 toolchain: "default".to_string(),
1113 priority: Priority::Normal,
1114 enabled: true,
1115 },
1116 );
1117
1118 let (errors, _warnings) = config.validate();
1119 assert!(
1120 !errors.is_empty(),
1121 "Expected validation error for invalid cron"
1122 );
1123 let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1124 assert!(
1125 has_cron_error,
1126 "Expected 'invalid cron expression' error, got: {:?}",
1127 errors
1128 );
1129 }
1130
1131 #[test]
1132 fn test_config_serialization_roundtrip() {
1133 let config = OxiosConfig::default();
1134
1135 let toml_str = toml::to_string(&config).expect("serialization should succeed");
1137
1138 let deserialized: OxiosConfig =
1140 toml::from_str(&toml_str).expect("deserialization should succeed");
1141
1142 assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
1144 assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
1145 assert_eq!(config.gateway.host, deserialized.gateway.host);
1146 assert_eq!(config.gateway.port, deserialized.gateway.port);
1147 assert_eq!(
1148 config.exec.default_timeout_secs,
1149 deserialized.exec.default_timeout_secs
1150 );
1151 assert_eq!(
1152 config.exec.max_timeout_secs,
1153 deserialized.exec.max_timeout_secs
1154 );
1155 }
1156
1157 #[test]
1158 fn test_exec_timeout_validation() {
1159 let mut config = OxiosConfig::default();
1160 config.exec.default_timeout_secs = 999;
1162 config.exec.max_timeout_secs = 100;
1163 let (errors, _warnings) = config.validate();
1164 let has_error = errors.iter().any(|e| e.contains("must not exceed"));
1165 assert!(
1166 has_error,
1167 "Expected timeout ordering error, got: {:?}",
1168 errors
1169 );
1170 }
1171
1172 #[test]
1173 fn test_zero_max_agents_error() {
1174 let mut config = OxiosConfig::default();
1175 config.kernel.max_agents = 0;
1176 let (errors, _warnings) = config.validate();
1177 assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
1178 }
1179}