1use figment::{
8 Figment,
9 providers::{Env, Format, Serialized, Toml},
10};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::channels::discord::DiscordConfig;
16use crate::channels::email::EmailConfig;
17use crate::channels::imessage::IMessageConfig;
18use crate::channels::irc::IrcConfig;
19use crate::channels::matrix::MatrixConfig;
20use crate::channels::signal::SignalConfig;
21use crate::channels::slack::SlackConfig;
22use crate::channels::sms::SmsConfig;
23use crate::channels::teams::TeamsConfig;
24use crate::channels::telegram::TelegramConfig;
25use crate::channels::webchat::WebChatConfig;
26use crate::channels::webhook::WebhookConfig;
27use crate::channels::whatsapp::WhatsAppConfig;
28use crate::gateway::GatewayConfig;
29use crate::memory::FlushConfig;
30use crate::search::SearchConfig;
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct AgentConfig {
35 pub llm: LlmConfig,
36 pub safety: SafetyConfig,
37 pub memory: MemoryConfig,
38 pub ui: UiConfig,
39 pub tools: ToolsConfig,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub gateway: Option<GatewayConfig>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub search: Option<SearchConfig>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub flush: Option<FlushConfig>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub channels: Option<ChannelsConfig>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub multi_agent: Option<MultiAgentConfig>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub workflow: Option<WorkflowConfig>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub browser: Option<BrowserConfig>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub scheduler: Option<SchedulerConfig>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub voice: Option<VoiceConfig>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub budget: Option<BudgetConfig>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub knowledge: Option<KnowledgeConfig>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub intelligence: Option<IntelligenceConfig>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub meeting: Option<MeetingConfig>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub council: Option<CouncilConfig>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub plan: Option<crate::plan::PlanConfig>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub cdc: Option<crate::channels::cdc::CdcConfig>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub mcp_servers: Vec<ExternalMcpServerConfig>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub mcp_safety: Option<McpSafetyConfig>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MeetingConfig {
99 pub enabled: bool,
101 pub notes_folder: String,
103 pub audio_format: String,
105 pub sample_rate: u32,
107 pub max_duration_mins: u64,
109 pub auto_detect_virtual_audio: bool,
111 pub auto_transcribe: bool,
113 pub auto_summarize: bool,
115 pub silence_timeout_secs: u64,
117}
118
119impl Default for MeetingConfig {
120 fn default() -> Self {
121 Self {
122 enabled: true,
123 notes_folder: "Meeting Transcripts".to_string(),
124 audio_format: "wav".to_string(),
125 sample_rate: 16000,
126 max_duration_mins: 180,
127 auto_detect_virtual_audio: true,
128 auto_transcribe: true,
129 auto_summarize: true,
130 silence_timeout_secs: 60,
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ExternalMcpServerConfig {
147 pub name: String,
149 pub command: String,
151 #[serde(default)]
153 pub args: Vec<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub working_dir: Option<PathBuf>,
157 #[serde(default)]
159 pub env: HashMap<String, String>,
160 #[serde(default = "default_true")]
162 pub auto_connect: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct McpSafetyConfig {
172 pub enabled: bool,
174 pub max_risk_level: String,
178 #[serde(default)]
180 pub allowed_tools: Vec<String>,
181 #[serde(default)]
183 pub denied_tools: Vec<String>,
184 pub scan_inputs: bool,
186 pub scan_outputs: bool,
188 pub audit_enabled: bool,
190 pub max_calls_per_minute: usize,
192}
193
194impl McpSafetyConfig {
195 pub fn parsed_max_risk_level(&self) -> crate::types::RiskLevel {
199 use crate::types::RiskLevel;
200 match self.max_risk_level.to_lowercase().as_str() {
201 "read_only" | "readonly" => RiskLevel::ReadOnly,
202 "write" => RiskLevel::Write,
203 "execute" => RiskLevel::Execute,
204 "network" => RiskLevel::Network,
205 "destructive" => RiskLevel::Destructive,
206 _ => RiskLevel::Write,
207 }
208 }
209}
210
211impl Default for McpSafetyConfig {
212 fn default() -> Self {
213 Self {
214 enabled: true,
215 max_risk_level: "write".to_string(),
216 allowed_tools: Vec::new(),
217 denied_tools: vec!["shell_exec".to_string(), "macos_gui_scripting".to_string()],
218 scan_inputs: true,
219 scan_outputs: true,
220 audit_enabled: true,
221 max_calls_per_minute: 60,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct WorkflowConfig {
229 pub enabled: bool,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub workflow_dir: Option<PathBuf>,
234 pub max_concurrent_runs: usize,
236 pub default_step_timeout_secs: u64,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub state_persistence_path: Option<PathBuf>,
241}
242
243impl Default for WorkflowConfig {
244 fn default() -> Self {
245 Self {
246 enabled: true,
247 workflow_dir: None,
248 max_concurrent_runs: 4,
249 default_step_timeout_secs: 300,
250 state_persistence_path: None,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct BrowserConfig {
258 pub enabled: bool,
260 pub connection_mode: BrowserConnectionMode,
262 pub debug_port: u16,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub ws_url: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub chrome_path: Option<String>,
270 pub headless: bool,
272 pub default_viewport_width: u32,
274 pub default_viewport_height: u32,
276 pub default_timeout_secs: u64,
278 #[serde(default)]
280 pub allowed_domains: Vec<String>,
281 #[serde(default)]
283 pub blocked_domains: Vec<String>,
284 pub isolate_profile: bool,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub user_data_dir: Option<PathBuf>,
290 pub max_pages: usize,
292}
293
294#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "lowercase")]
297pub enum BrowserConnectionMode {
298 #[default]
300 Auto,
301 Connect,
303 Launch,
305}
306
307impl std::fmt::Display for BrowserConnectionMode {
308 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309 match self {
310 BrowserConnectionMode::Auto => write!(f, "auto"),
311 BrowserConnectionMode::Connect => write!(f, "connect"),
312 BrowserConnectionMode::Launch => write!(f, "launch"),
313 }
314 }
315}
316
317impl Default for BrowserConfig {
318 fn default() -> Self {
319 Self {
320 enabled: false,
321 connection_mode: BrowserConnectionMode::default(),
322 debug_port: 9222,
323 ws_url: None,
324 chrome_path: None,
325 headless: true,
326 default_viewport_width: 1280,
327 default_viewport_height: 720,
328 default_timeout_secs: 30,
329 allowed_domains: Vec::new(),
330 blocked_domains: Vec::new(),
331 isolate_profile: true,
332 user_data_dir: None,
333 max_pages: 5,
334 }
335 }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SchedulerConfig {
341 pub enabled: bool,
343 #[serde(default)]
345 pub cron_jobs: Vec<crate::scheduler::CronJobConfig>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub heartbeat: Option<crate::scheduler::HeartbeatConfig>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub webhook_port: Option<u16>,
352 pub max_background_jobs: usize,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub state_path: Option<PathBuf>,
357}
358
359impl Default for SchedulerConfig {
360 fn default() -> Self {
361 Self {
362 enabled: false,
363 cron_jobs: Vec::new(),
364 heartbeat: None,
365 webhook_port: None,
366 max_background_jobs: 10,
367 state_path: None,
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct VoiceConfig {
375 pub enabled: bool,
377 pub stt_provider: String,
379 pub stt_model: String,
381 pub stt_language: String,
383 pub tts_provider: String,
385 pub tts_voice: String,
387 pub tts_speed: f32,
389 pub vad_enabled: bool,
391 pub vad_threshold: f32,
393 #[serde(default)]
395 pub wake_words: Vec<String>,
396 pub wake_sensitivity: f32,
398 pub auto_speak: bool,
400 pub max_listen_secs: u64,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub input_device: Option<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub output_device: Option<String>,
408}
409
410impl Default for VoiceConfig {
411 fn default() -> Self {
412 Self {
413 enabled: false,
414 stt_provider: "openai".to_string(),
415 stt_model: "base".to_string(),
416 stt_language: "en".to_string(),
417 tts_provider: "openai".to_string(),
418 tts_voice: "alloy".to_string(),
419 tts_speed: 1.0,
420 vad_enabled: true,
421 vad_threshold: 0.01,
422 wake_words: vec!["hey rustant".to_string()],
423 wake_sensitivity: 0.5,
424 auto_speak: false,
425 max_listen_secs: 30,
426 input_device: None,
427 output_device: None,
428 }
429 }
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct MultiAgentConfig {
435 pub enabled: bool,
437 pub max_agents: usize,
439 pub max_mailbox_size: usize,
441 #[serde(default)]
443 pub default_resource_limits: crate::multi::ResourceLimits,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub default_workspace_base: Option<String>,
447}
448
449impl Default for MultiAgentConfig {
450 fn default() -> Self {
451 Self {
452 enabled: false,
453 max_agents: 8,
454 max_mailbox_size: 1000,
455 default_resource_limits: crate::multi::ResourceLimits::default(),
456 default_workspace_base: None,
457 }
458 }
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
465#[serde(rename_all = "snake_case")]
466pub enum AutoReplyMode {
467 Disabled,
469 DraftOnly,
471 AutoWithApproval,
473 #[default]
475 FullAuto,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
480#[serde(rename_all = "snake_case")]
481pub enum DigestFrequency {
482 #[default]
484 Off,
485 Hourly,
487 Daily,
489 Weekly,
491}
492
493#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
495#[serde(rename_all = "snake_case")]
496pub enum MessagePriority {
497 Low = 0,
499 #[default]
501 Normal = 1,
502 High = 2,
504 Urgent = 3,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct ChannelIntelligenceConfig {
513 #[serde(default)]
515 pub auto_reply: AutoReplyMode,
516 #[serde(default)]
518 pub digest: DigestFrequency,
519 #[serde(default = "default_true")]
521 pub smart_scheduling: bool,
522 #[serde(default)]
524 pub escalation_threshold: MessagePriority,
525 #[serde(default = "default_followup_minutes")]
527 pub default_followup_minutes: u32,
528}
529
530impl Default for ChannelIntelligenceConfig {
531 fn default() -> Self {
532 Self {
533 auto_reply: AutoReplyMode::default(),
534 digest: DigestFrequency::default(),
535 smart_scheduling: true,
536 escalation_threshold: MessagePriority::High,
537 default_followup_minutes: default_followup_minutes(),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct IntelligenceConfig {
547 #[serde(default = "default_true")]
549 pub enabled: bool,
550 #[serde(default)]
552 pub defaults: ChannelIntelligenceConfig,
553 #[serde(default)]
555 pub channels: HashMap<String, ChannelIntelligenceConfig>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub quiet_hours: Option<crate::scheduler::QuietHours>,
559 #[serde(default = "default_digest_dir")]
561 pub digest_dir: PathBuf,
562 #[serde(default = "default_reminders_dir")]
564 pub reminders_dir: PathBuf,
565 #[serde(default = "default_max_reply_tokens")]
567 pub max_reply_tokens: usize,
568}
569
570fn default_true() -> bool {
571 true
572}
573
574fn default_followup_minutes() -> u32 {
575 60
576}
577
578fn default_digest_dir() -> PathBuf {
579 PathBuf::from(".rustant/digests")
580}
581
582fn default_reminders_dir() -> PathBuf {
583 PathBuf::from(".rustant/reminders")
584}
585
586fn default_max_reply_tokens() -> usize {
587 500
588}
589
590impl Default for IntelligenceConfig {
591 fn default() -> Self {
592 Self {
593 enabled: true,
594 defaults: ChannelIntelligenceConfig::default(),
595 channels: HashMap::new(),
596 quiet_hours: None,
597 digest_dir: default_digest_dir(),
598 reminders_dir: default_reminders_dir(),
599 max_reply_tokens: 500,
600 }
601 }
602}
603
604impl ChannelIntelligenceConfig {
605 pub fn validate(&self) -> Vec<String> {
610 let mut warnings = Vec::new();
611
612 if self.default_followup_minutes == 0 {
614 warnings.push(
615 "default_followup_minutes is 0 — follow-ups will trigger immediately".to_string(),
616 );
617 }
618
619 if self.default_followup_minutes > 43_200 {
621 warnings.push(format!(
622 "default_followup_minutes is {} (>{} days) — this is unusually large",
623 self.default_followup_minutes,
624 self.default_followup_minutes / 1440
625 ));
626 }
627
628 if self.escalation_threshold == MessagePriority::Low {
630 warnings
631 .push("escalation_threshold is Low — all messages will be escalated".to_string());
632 }
633
634 warnings
635 }
636}
637
638impl IntelligenceConfig {
639 pub fn for_channel(&self, channel_name: &str) -> &ChannelIntelligenceConfig {
641 self.channels.get(channel_name).unwrap_or(&self.defaults)
642 }
643
644 pub fn is_quiet_hours_now(&self) -> bool {
646 if let Some(ref quiet) = self.quiet_hours {
647 quiet.is_active(&chrono::Utc::now())
648 } else {
649 false
650 }
651 }
652
653 pub fn validate(&self) -> Vec<String> {
657 let mut warnings = Vec::new();
658
659 for w in self.defaults.validate() {
661 warnings.push(format!("[defaults] {}", w));
662 }
663
664 for (name, cfg) in &self.channels {
666 for w in cfg.validate() {
667 warnings.push(format!("[channel:{}] {}", name, w));
668 }
669 }
670
671 if let Some(ref quiet) = self.quiet_hours {
673 if !is_valid_time_format(&quiet.start) {
674 warnings.push(format!(
675 "quiet_hours.start '{}' is not in HH:MM format",
676 quiet.start
677 ));
678 }
679 if !is_valid_time_format(&quiet.end) {
680 warnings.push(format!(
681 "quiet_hours.end '{}' is not in HH:MM format",
682 quiet.end
683 ));
684 }
685 }
686
687 if self.max_reply_tokens == 0 {
689 warnings.push("max_reply_tokens is 0 — auto-replies will be empty".to_string());
690 }
691
692 warnings
693 }
694}
695
696fn is_valid_time_format(s: &str) -> bool {
698 if s.len() != 5 {
699 return false;
700 }
701 let parts: Vec<&str> = s.split(':').collect();
702 if parts.len() != 2 {
703 return false;
704 }
705 match (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
706 (Ok(h), Ok(m)) => h < 24 && m < 60,
707 _ => false,
708 }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct RetryConfig {
714 pub max_retries: u32,
716 pub initial_backoff_ms: u64,
718 pub max_backoff_ms: u64,
720 pub backoff_multiplier: f64,
722 pub jitter: bool,
724}
725
726impl Default for RetryConfig {
727 fn default() -> Self {
728 Self {
729 max_retries: 3,
730 initial_backoff_ms: 1000,
731 max_backoff_ms: 60000,
732 backoff_multiplier: 2.0,
733 jitter: true,
734 }
735 }
736}
737
738#[derive(Debug, Clone, Default, Serialize, Deserialize)]
740pub struct ChannelsConfig {
741 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub telegram: Option<TelegramConfig>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub discord: Option<DiscordConfig>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub slack: Option<SlackConfig>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub webchat: Option<WebChatConfig>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub matrix: Option<MatrixConfig>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub signal: Option<SignalConfig>,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub whatsapp: Option<WhatsAppConfig>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub email: Option<EmailConfig>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub imessage: Option<IMessageConfig>,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub teams: Option<TeamsConfig>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub sms: Option<SmsConfig>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub irc: Option<IrcConfig>,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub webhook: Option<WebhookConfig>,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct LlmConfig {
772 pub provider: String,
774 pub model: String,
776 pub api_key_env: String,
778 pub base_url: Option<String>,
780 pub max_tokens: usize,
782 pub temperature: f32,
784 pub context_window: usize,
786 pub input_cost_per_million: f64,
788 pub output_cost_per_million: f64,
790 pub use_streaming: bool,
792 #[serde(default)]
794 pub fallback_providers: Vec<FallbackProviderConfig>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub credential_store_key: Option<String>,
799 #[serde(default)]
803 pub auth_method: String,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub api_key: Option<String>,
809 #[serde(default)]
811 pub retry: RetryConfig,
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize)]
816pub struct FallbackProviderConfig {
817 pub provider: String,
819 pub model: String,
821 pub api_key_env: String,
823 #[serde(default)]
825 pub base_url: Option<String>,
826}
827
828impl Default for LlmConfig {
829 fn default() -> Self {
830 Self {
831 provider: "openai".to_string(),
832 model: "gpt-4o".to_string(),
833 api_key_env: "OPENAI_API_KEY".to_string(),
834 base_url: None,
835 max_tokens: 4096,
836 temperature: 0.7,
837 context_window: 128_000,
838 input_cost_per_million: 2.50,
839 output_cost_per_million: 10.00,
840 use_streaming: true,
841 fallback_providers: Vec::new(),
842 credential_store_key: None,
843 auth_method: String::new(),
844 api_key: None,
845 retry: RetryConfig::default(),
846 }
847 }
848}
849
850impl LlmConfig {
851 pub fn validate(&self) -> Vec<String> {
856 let mut warnings = Vec::new();
857 if self.max_tokens >= self.context_window {
858 warnings.push(format!(
859 "max_tokens ({}) >= context_window ({}); responses may be truncated or fail",
860 self.max_tokens, self.context_window
861 ));
862 }
863 if self.temperature < 0.0 || self.temperature > 2.0 {
864 warnings.push(format!(
865 "temperature ({}) is outside the typical range 0.0–2.0",
866 self.temperature
867 ));
868 }
869 warnings
870 }
871}
872
873#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(rename_all = "lowercase")]
876pub enum ApprovalMode {
877 #[default]
879 Safe,
880 Cautious,
882 Paranoid,
884 Yolo,
886}
887
888impl std::fmt::Display for ApprovalMode {
889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890 match self {
891 ApprovalMode::Safe => write!(f, "safe"),
892 ApprovalMode::Cautious => write!(f, "cautious"),
893 ApprovalMode::Paranoid => write!(f, "paranoid"),
894 ApprovalMode::Yolo => write!(f, "yolo"),
895 }
896 }
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize)]
901pub struct SafetyConfig {
902 pub approval_mode: ApprovalMode,
903 pub allowed_paths: Vec<String>,
905 pub denied_paths: Vec<String>,
907 pub allowed_commands: Vec<String>,
909 pub ask_commands: Vec<String>,
911 pub denied_commands: Vec<String>,
913 pub allowed_hosts: Vec<String>,
915 pub max_iterations: usize,
917 #[serde(default)]
919 pub injection_detection: InjectionDetectionConfig,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub adaptive_trust: Option<AdaptiveTrustConfig>,
923 #[serde(default)]
925 pub max_tool_calls_per_minute: usize,
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct InjectionDetectionConfig {
931 pub enabled: bool,
933 pub threshold: f32,
935 pub scan_tool_outputs: bool,
937}
938
939impl Default for InjectionDetectionConfig {
940 fn default() -> Self {
941 Self {
942 enabled: true,
943 threshold: 0.5,
944 scan_tool_outputs: true,
945 }
946 }
947}
948
949#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct AdaptiveTrustConfig {
952 pub enabled: bool,
954 pub trust_escalation_threshold: usize,
956 pub anomaly_threshold: f64,
958}
959
960impl Default for AdaptiveTrustConfig {
961 fn default() -> Self {
962 Self {
963 enabled: true,
964 trust_escalation_threshold: 5,
965 anomaly_threshold: 0.7,
966 }
967 }
968}
969
970impl Default for SafetyConfig {
971 fn default() -> Self {
972 Self {
973 approval_mode: ApprovalMode::Safe,
974 allowed_paths: vec![
975 "src/**".to_string(),
976 "tests/**".to_string(),
977 "docs/**".to_string(),
978 ],
979 denied_paths: vec![
980 ".env*".to_string(),
981 "**/*.key".to_string(),
982 "**/secrets/**".to_string(),
983 "**/*.pem".to_string(),
984 "**/credentials*".to_string(),
985 ".ssh/**".to_string(),
986 ".aws/**".to_string(),
987 ".docker/config.json".to_string(),
988 "**/*id_rsa*".to_string(),
989 "**/*id_ed25519*".to_string(),
990 ],
991 allowed_commands: vec![
992 "cargo".to_string(),
993 "git".to_string(),
994 "npm".to_string(),
995 "pnpm".to_string(),
996 "yarn".to_string(),
997 "python -m pytest".to_string(),
998 "open".to_string(),
1000 "osascript".to_string(),
1001 "mdfind".to_string(),
1002 "screencapture".to_string(),
1003 "pbcopy".to_string(),
1004 "pbpaste".to_string(),
1005 "pmset".to_string(),
1006 "sw_vers".to_string(),
1007 "brew".to_string(),
1008 ],
1009 ask_commands: vec![
1010 "rm".to_string(),
1011 "mv".to_string(),
1012 "cp".to_string(),
1013 "chmod".to_string(),
1014 ],
1015 denied_commands: vec![
1016 "sudo".to_string(),
1017 "curl | sh".to_string(),
1018 "wget | bash".to_string(),
1019 ],
1020 allowed_hosts: vec![
1021 "api.github.com".to_string(),
1022 "crates.io".to_string(),
1023 "registry.npmjs.org".to_string(),
1024 ],
1025 max_iterations: 50,
1026 injection_detection: InjectionDetectionConfig::default(),
1027 adaptive_trust: None,
1028 max_tool_calls_per_minute: 0,
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone, Serialize, Deserialize)]
1035pub struct MemoryConfig {
1036 pub window_size: usize,
1038 pub compression_threshold: f32,
1040 pub persist_path: Option<PathBuf>,
1042 pub enable_persistence: bool,
1044}
1045
1046impl Default for MemoryConfig {
1047 fn default() -> Self {
1048 Self {
1049 window_size: 20,
1050 compression_threshold: 0.7,
1051 persist_path: None,
1052 enable_persistence: true,
1053 }
1054 }
1055}
1056
1057#[derive(Debug, Clone, Serialize, Deserialize)]
1059pub struct UiConfig {
1060 pub theme: String,
1062 pub vim_mode: bool,
1064 pub show_cost: bool,
1066 pub use_tui: bool,
1068 #[serde(default)]
1070 pub verbose: bool,
1071}
1072
1073impl Default for UiConfig {
1074 fn default() -> Self {
1075 Self {
1076 theme: "dark".to_string(),
1077 vim_mode: false,
1078 show_cost: true,
1079 use_tui: false,
1080 verbose: false,
1081 }
1082 }
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct ToolsConfig {
1088 pub enable_builtins: bool,
1090 pub default_timeout_secs: u64,
1092 pub max_output_bytes: usize,
1094}
1095
1096impl Default for ToolsConfig {
1097 fn default() -> Self {
1098 Self {
1099 enable_builtins: true,
1100 default_timeout_secs: 60,
1101 max_output_bytes: 1_048_576, }
1103 }
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1108pub struct BudgetConfig {
1109 pub session_limit_usd: f64,
1111 pub task_limit_usd: f64,
1113 pub session_token_limit: usize,
1115 pub halt_on_exceed: bool,
1117}
1118
1119impl Default for BudgetConfig {
1120 fn default() -> Self {
1121 Self {
1122 session_limit_usd: 0.0,
1123 task_limit_usd: 0.0,
1124 session_token_limit: 0,
1125 halt_on_exceed: false,
1126 }
1127 }
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1132pub struct KnowledgeConfig {
1133 pub enabled: bool,
1135 pub max_rules: usize,
1137 pub min_entries_for_distillation: usize,
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub knowledge_path: Option<PathBuf>,
1142}
1143
1144impl Default for KnowledgeConfig {
1145 fn default() -> Self {
1146 Self {
1147 enabled: true,
1148 max_rules: 20,
1149 min_entries_for_distillation: 3,
1150 knowledge_path: None,
1151 }
1152 }
1153}
1154
1155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1159#[serde(rename_all = "snake_case")]
1160pub enum VotingStrategy {
1161 #[default]
1163 ChairmanSynthesis,
1164 HighestScore,
1166 MajorityConsensus,
1168}
1169
1170impl std::fmt::Display for VotingStrategy {
1171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1172 match self {
1173 VotingStrategy::ChairmanSynthesis => write!(f, "chairman_synthesis"),
1174 VotingStrategy::HighestScore => write!(f, "highest_score"),
1175 VotingStrategy::MajorityConsensus => write!(f, "majority_consensus"),
1176 }
1177 }
1178}
1179
1180#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct CouncilMemberConfig {
1183 pub provider: String,
1185 pub model: String,
1187 #[serde(default)]
1189 pub api_key_env: String,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1192 pub base_url: Option<String>,
1193 #[serde(default = "default_weight")]
1195 pub weight: f64,
1196}
1197
1198fn default_weight() -> f64 {
1199 1.0
1200}
1201
1202impl Default for CouncilMemberConfig {
1203 fn default() -> Self {
1204 Self {
1205 provider: "openai".to_string(),
1206 model: "gpt-4o".to_string(),
1207 api_key_env: "OPENAI_API_KEY".to_string(),
1208 base_url: None,
1209 weight: 1.0,
1210 }
1211 }
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1220pub struct CouncilConfig {
1221 #[serde(default)]
1223 pub enabled: bool,
1224 #[serde(default)]
1226 pub members: Vec<CouncilMemberConfig>,
1227 #[serde(default)]
1229 pub voting_strategy: VotingStrategy,
1230 #[serde(default = "default_true")]
1232 pub enable_peer_review: bool,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub chairman_model: Option<String>,
1236 #[serde(default = "default_max_member_tokens")]
1238 pub max_member_tokens: usize,
1239 #[serde(default = "default_true")]
1241 pub auto_detect: bool,
1242}
1243
1244fn default_max_member_tokens() -> usize {
1245 2048
1246}
1247
1248impl Default for CouncilConfig {
1249 fn default() -> Self {
1250 Self {
1251 enabled: false,
1252 members: Vec::new(),
1253 voting_strategy: VotingStrategy::default(),
1254 enable_peer_review: true,
1255 chairman_model: None,
1256 max_member_tokens: 2048,
1257 auto_detect: true,
1258 }
1259 }
1260}
1261
1262pub fn load_config(
1271 workspace: Option<&Path>,
1272 overrides: Option<&AgentConfig>,
1273) -> Result<AgentConfig, Box<figment::Error>> {
1274 let mut figment = Figment::from(Serialized::defaults(AgentConfig::default()));
1275
1276 if let Some(config_dir) = directories::ProjectDirs::from("dev", "rustant", "rustant") {
1278 let user_config = config_dir.config_dir().join("config.toml");
1279 if user_config.exists() {
1280 figment = figment.merge(Toml::file(&user_config));
1281 }
1282 }
1283
1284 if let Some(ws) = workspace {
1286 let ws_config = ws.join(".rustant").join("config.toml");
1287 if ws_config.exists() {
1288 figment = figment.merge(Toml::file(&ws_config));
1289 }
1290 }
1291
1292 figment = figment.merge(Env::prefixed("RUSTANT_").split("__"));
1294
1295 if let Some(overrides) = overrides {
1297 figment = figment.merge(Serialized::defaults(overrides));
1298 }
1299
1300 let mut config: AgentConfig = figment.extract().map_err(Box::new)?;
1301 resolve_credentials(&mut config);
1302 auto_migrate_channel_secrets(&mut config, workspace);
1303 Ok(config)
1304}
1305
1306pub fn resolve_credentials(config: &mut AgentConfig) {
1316 let key_value = config.llm.api_key.clone();
1318 if let Some(key) = key_value
1319 && let Some(service) = key.strip_prefix("keychain:")
1320 {
1321 let store = crate::credentials::KeyringCredentialStore::new();
1322 match crate::credentials::CredentialStore::get_key(&store, service) {
1323 Ok(resolved_key) => {
1324 config.llm.api_key = Some(resolved_key);
1325 tracing::info!("Resolved API key from keyring service: {}", service);
1326 return; }
1328 Err(e) => {
1329 tracing::warn!("Failed to resolve keyring credential '{}': {}", service, e);
1330 }
1331 }
1332 }
1333
1334 if config.llm.api_key.is_none()
1336 && let Some(ref cs_key) = config.llm.credential_store_key
1337 {
1338 let store = crate::credentials::KeyringCredentialStore::new();
1339 match crate::credentials::CredentialStore::get_key(&store, cs_key) {
1340 Ok(resolved_key) => {
1341 config.llm.api_key = Some(resolved_key);
1342 tracing::info!(
1343 "Resolved API key from credential store for provider: {}",
1344 cs_key
1345 );
1346 }
1347 Err(e) => {
1348 tracing::debug!(
1349 "No credential in keyring for '{}': {} (will try env var)",
1350 cs_key,
1351 e
1352 );
1353 }
1354 }
1355 }
1356}
1357
1358fn auto_migrate_channel_secrets(config: &mut AgentConfig, workspace: Option<&Path>) {
1364 use crate::credentials::{CredentialStore, KeyringCredentialStore};
1365 use crate::secret_ref::SecretRef;
1366
1367 let needs_slack_migration = config
1368 .channels
1369 .as_ref()
1370 .and_then(|c| c.slack.as_ref())
1371 .map(|s| s.bot_token.is_inline())
1372 .unwrap_or(false);
1373
1374 if !needs_slack_migration {
1375 return;
1376 }
1377
1378 let store = KeyringCredentialStore::new();
1379 let slack = config
1380 .channels
1381 .as_ref()
1382 .and_then(|c| c.slack.as_ref())
1383 .unwrap();
1384 let plaintext = slack.bot_token.as_str().to_string();
1385
1386 if plaintext.is_empty() {
1387 return;
1388 }
1389
1390 if let Err(e) = store.store_key("channel:slack:bot_token", &plaintext) {
1392 tracing::warn!("Failed to migrate Slack token to keychain: {}", e);
1393 return;
1394 }
1395
1396 tracing::info!("Migrated Slack bot_token from plaintext to keychain");
1397
1398 if let Some(channels) = config.channels.as_mut()
1400 && let Some(slack) = channels.slack.as_mut()
1401 {
1402 slack.bot_token = SecretRef::keychain("channel:slack:bot_token");
1403 }
1404
1405 if let Some(ws) = workspace {
1407 let config_path = ws.join(".rustant").join("config.toml");
1408 if config_path.exists()
1409 && let Ok(toml_str) = toml::to_string_pretty(config)
1410 && let Err(e) = std::fs::write(&config_path, &toml_str)
1411 {
1412 tracing::warn!("Failed to rewrite config after migration: {}", e);
1413 }
1414 }
1415}
1416
1417pub fn config_exists(workspace: Option<&Path>) -> bool {
1423 if let Some(config_dir) = directories::ProjectDirs::from("dev", "rustant", "rustant")
1425 && config_dir.config_dir().join("config.toml").exists()
1426 {
1427 return true;
1428 }
1429
1430 if let Some(ws) = workspace
1432 && ws.join(".rustant").join("config.toml").exists()
1433 {
1434 return true;
1435 }
1436
1437 false
1438}
1439
1440pub fn update_channel_config(
1446 workspace: &std::path::Path,
1447 channel_name: &str,
1448 channel_toml: toml::Value,
1449) -> anyhow::Result<std::path::PathBuf> {
1450 let config_dir = workspace.join(".rustant");
1451 std::fs::create_dir_all(&config_dir)?;
1452 let config_path = config_dir.join("config.toml");
1453
1454 let mut config: AgentConfig = if config_path.exists() {
1456 let content = std::fs::read_to_string(&config_path)?;
1457 toml::from_str(&content).unwrap_or_default()
1458 } else {
1459 AgentConfig::default()
1460 };
1461
1462 let mut table: toml::Value = toml::Value::try_from(&config)?;
1464
1465 let channels_table = table
1467 .as_table_mut()
1468 .ok_or_else(|| anyhow::anyhow!("config is not a TOML table"))?
1469 .entry("channels")
1470 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
1471
1472 if let Some(ch_table) = channels_table.as_table_mut() {
1474 ch_table.insert(channel_name.to_string(), channel_toml);
1475 }
1476
1477 config = table.try_into()?;
1479 let toml_str = toml::to_string_pretty(&config)?;
1480 std::fs::write(&config_path, &toml_str)?;
1481
1482 Ok(config_path)
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487 use super::*;
1488 use std::sync::Mutex;
1489
1490 static ENV_MUTEX: Mutex<()> = Mutex::new(());
1492
1493 #[test]
1494 fn test_default_config() {
1495 let config = AgentConfig::default();
1496 assert_eq!(config.llm.provider, "openai");
1497 assert_eq!(config.llm.model, "gpt-4o");
1498 assert_eq!(config.safety.approval_mode, ApprovalMode::Safe);
1499 assert_eq!(config.memory.window_size, 20);
1500 assert!(!config.ui.vim_mode);
1501 assert!(config.tools.enable_builtins);
1502 }
1503
1504 #[test]
1505 fn test_approval_mode_display() {
1506 assert_eq!(ApprovalMode::Safe.to_string(), "safe");
1507 assert_eq!(ApprovalMode::Cautious.to_string(), "cautious");
1508 assert_eq!(ApprovalMode::Paranoid.to_string(), "paranoid");
1509 assert_eq!(ApprovalMode::Yolo.to_string(), "yolo");
1510 }
1511
1512 #[test]
1513 fn test_config_serialization_roundtrip() {
1514 let config = AgentConfig::default();
1515 let toml_str = toml::to_string(&config).unwrap();
1516 let deserialized: AgentConfig = toml::from_str(&toml_str).unwrap();
1517 assert_eq!(deserialized.llm.model, config.llm.model);
1518 assert_eq!(
1519 deserialized.safety.approval_mode,
1520 config.safety.approval_mode
1521 );
1522 assert_eq!(deserialized.memory.window_size, config.memory.window_size);
1523 }
1524
1525 #[test]
1526 fn test_load_config_defaults() {
1527 let config = load_config(None, None).unwrap();
1528 assert_eq!(config.llm.provider, "openai");
1529 assert_eq!(config.safety.max_iterations, 50);
1530 }
1531
1532 #[test]
1533 fn test_load_config_with_overrides() {
1534 let mut overrides = AgentConfig::default();
1535 overrides.llm.model = "claude-sonnet".to_string();
1536 overrides.safety.max_iterations = 50;
1537
1538 let config = load_config(None, Some(&overrides)).unwrap();
1539 assert_eq!(config.llm.model, "claude-sonnet");
1540 assert_eq!(config.safety.max_iterations, 50);
1541 }
1542
1543 #[test]
1544 fn test_load_config_from_workspace() {
1545 let _lock = ENV_MUTEX.lock().unwrap();
1546 unsafe { std::env::remove_var("RUSTANT_SAFETY__APPROVAL_MODE") };
1548
1549 let dir = tempfile::tempdir().unwrap();
1550 let rustant_dir = dir.path().join(".rustant");
1551 std::fs::create_dir_all(&rustant_dir).unwrap();
1552 std::fs::write(
1553 rustant_dir.join("config.toml"),
1554 r#"
1555[llm]
1556model = "gpt-4o-mini"
1557provider = "openai"
1558api_key_env = "OPENAI_API_KEY"
1559max_tokens = 4096
1560temperature = 0.7
1561context_window = 128000
1562input_cost_per_million = 2.5
1563output_cost_per_million = 10.0
1564
1565[safety]
1566max_iterations = 100
1567approval_mode = "cautious"
1568allowed_paths = ["src/**"]
1569denied_paths = []
1570allowed_commands = ["cargo"]
1571ask_commands = []
1572denied_commands = []
1573allowed_hosts = []
1574
1575[memory]
1576window_size = 12
1577compression_threshold = 0.7
1578enable_persistence = false
1579
1580[ui]
1581theme = "dark"
1582vim_mode = false
1583show_cost = true
1584use_tui = false
1585
1586[tools]
1587enable_builtins = true
1588default_timeout_secs = 30
1589max_output_bytes = 1048576
1590"#,
1591 )
1592 .unwrap();
1593
1594 let config = load_config(Some(dir.path()), None).unwrap();
1595 assert_eq!(config.llm.model, "gpt-4o-mini");
1596 assert_eq!(config.safety.max_iterations, 100);
1597 assert_eq!(config.safety.approval_mode, ApprovalMode::Cautious);
1598 }
1599
1600 #[test]
1604 fn test_env_var_override_approval_mode() {
1605 let _lock = ENV_MUTEX.lock().unwrap();
1606
1607 unsafe { std::env::set_var("RUSTANT_SAFETY__APPROVAL_MODE", "yolo") };
1609 let config = load_config(None, None).unwrap();
1610 assert_eq!(
1611 config.safety.approval_mode,
1612 ApprovalMode::Yolo,
1613 "RUSTANT_SAFETY__APPROVAL_MODE=yolo should override default 'safe'"
1614 );
1615
1616 let dir = tempfile::tempdir().unwrap();
1618 let rustant_dir = dir.path().join(".rustant");
1619 std::fs::create_dir_all(&rustant_dir).unwrap();
1620 std::fs::write(
1621 rustant_dir.join("config.toml"),
1622 r#"
1623[safety]
1624approval_mode = "safe"
1625max_iterations = 50
1626allowed_paths = ["src/**"]
1627denied_paths = []
1628allowed_commands = ["cargo"]
1629ask_commands = []
1630denied_commands = []
1631allowed_hosts = []
1632"#,
1633 )
1634 .unwrap();
1635
1636 let config = load_config(Some(dir.path()), None).unwrap();
1637 assert_eq!(
1638 config.safety.approval_mode,
1639 ApprovalMode::Yolo,
1640 "Env var RUSTANT_SAFETY__APPROVAL_MODE=yolo should override workspace config 'safe'"
1641 );
1642
1643 unsafe { std::env::remove_var("RUSTANT_SAFETY__APPROVAL_MODE") };
1645 }
1646
1647 #[test]
1648 fn test_safety_config_defaults() {
1649 let config = SafetyConfig::default();
1650 assert!(config.allowed_paths.contains(&"src/**".to_string()));
1651 assert!(config.denied_paths.contains(&".env*".to_string()));
1652 assert!(config.allowed_commands.contains(&"cargo".to_string()));
1653 assert!(config.denied_commands.contains(&"sudo".to_string()));
1654 }
1655
1656 #[test]
1657 fn test_llm_config_defaults() {
1658 let config = LlmConfig::default();
1659 assert_eq!(config.context_window, 128_000);
1660 assert_eq!(config.max_tokens, 4096);
1661 assert!((config.temperature - 0.7).abs() < f32::EPSILON);
1662 }
1663
1664 #[test]
1665 fn test_llm_config_validate_defaults_clean() {
1666 let config = LlmConfig::default();
1667 let warnings = config.validate();
1668 assert!(
1669 warnings.is_empty(),
1670 "Default LlmConfig should have no warnings, got: {:?}",
1671 warnings
1672 );
1673 }
1674
1675 #[test]
1676 fn test_llm_config_validate_max_tokens_exceeds_context() {
1677 let config = LlmConfig {
1678 max_tokens: 200_000,
1679 context_window: 128_000,
1680 ..Default::default()
1681 };
1682 let warnings = config.validate();
1683 assert_eq!(warnings.len(), 1);
1684 assert!(warnings[0].contains("max_tokens"));
1685 assert!(warnings[0].contains("context_window"));
1686 }
1687
1688 #[test]
1689 fn test_llm_config_validate_bad_temperature() {
1690 let config = LlmConfig {
1691 temperature: 3.0,
1692 ..Default::default()
1693 };
1694 let warnings = config.validate();
1695 assert_eq!(warnings.len(), 1);
1696 assert!(warnings[0].contains("temperature"));
1697 }
1698
1699 #[test]
1700 fn test_safety_denied_paths_include_sensitive_defaults() {
1701 let config = SafetyConfig::default();
1702 assert!(config.denied_paths.contains(&".ssh/**".to_string()));
1703 assert!(config.denied_paths.contains(&".aws/**".to_string()));
1704 assert!(config.denied_paths.contains(&"**/*.pem".to_string()));
1705 assert!(config.denied_paths.contains(&"**/*id_rsa*".to_string()));
1706 assert!(config.denied_paths.contains(&"**/*id_ed25519*".to_string()));
1707 }
1708
1709 #[test]
1710 fn test_memory_config_defaults() {
1711 let config = MemoryConfig::default();
1712 assert_eq!(config.window_size, 20);
1713 assert!((config.compression_threshold - 0.7).abs() < f32::EPSILON);
1714 assert!(config.enable_persistence);
1715 }
1716
1717 #[test]
1718 fn test_approval_mode_serde() {
1719 let json = serde_json::to_string(&ApprovalMode::Paranoid).unwrap();
1720 assert_eq!(json, "\"paranoid\"");
1721 let mode: ApprovalMode = serde_json::from_str("\"yolo\"").unwrap();
1722 assert_eq!(mode, ApprovalMode::Yolo);
1723 }
1724
1725 #[test]
1726 #[allow(clippy::field_reassign_with_default)]
1727 fn test_agent_config_with_gateway() {
1728 let mut config = AgentConfig::default();
1729 config.gateway = Some(crate::gateway::GatewayConfig::default());
1730 let json = serde_json::to_string(&config).unwrap();
1731 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1732 assert!(deserialized.gateway.is_some());
1733 let gw = deserialized.gateway.unwrap();
1734 assert_eq!(gw.port, 8080);
1735 }
1736
1737 #[test]
1738 #[allow(clippy::field_reassign_with_default)]
1739 fn test_agent_config_with_search() {
1740 let mut config = AgentConfig::default();
1741 config.search = Some(crate::search::SearchConfig::default());
1742 let json = serde_json::to_string(&config).unwrap();
1743 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1744 assert!(deserialized.search.is_some());
1745 let sc = deserialized.search.unwrap();
1746 assert_eq!(sc.max_results, 10);
1747 }
1748
1749 #[test]
1750 #[allow(clippy::field_reassign_with_default)]
1751 fn test_agent_config_with_flush() {
1752 let mut config = AgentConfig::default();
1753 config.flush = Some(crate::memory::FlushConfig::default());
1754 let json = serde_json::to_string(&config).unwrap();
1755 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1756 assert!(deserialized.flush.is_some());
1757 let fc = deserialized.flush.unwrap();
1758 assert!(!fc.enabled);
1759 assert_eq!(fc.interval_secs, 300);
1760 }
1761
1762 #[test]
1763 fn test_agent_config_backward_compat_no_optional_fields() {
1764 let json = serde_json::json!({
1766 "llm": LlmConfig::default(),
1767 "safety": SafetyConfig::default(),
1768 "memory": MemoryConfig::default(),
1769 "ui": UiConfig::default(),
1770 "tools": ToolsConfig::default()
1771 });
1772 let config: AgentConfig = serde_json::from_value(json).unwrap();
1773 assert!(config.gateway.is_none());
1774 assert!(config.search.is_none());
1775 assert!(config.flush.is_none());
1776 assert!(config.multi_agent.is_none());
1777 }
1778
1779 #[test]
1780 #[allow(clippy::field_reassign_with_default)]
1781 fn test_agent_config_with_multi_agent() {
1782 let mut config = AgentConfig::default();
1783 config.multi_agent = Some(MultiAgentConfig::default());
1784 let json = serde_json::to_string(&config).unwrap();
1785 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1786 assert!(deserialized.multi_agent.is_some());
1787 let ma = deserialized.multi_agent.unwrap();
1788 assert!(!ma.enabled);
1789 assert_eq!(ma.max_agents, 8);
1790 assert_eq!(ma.max_mailbox_size, 1000);
1791 }
1792
1793 #[test]
1794 fn test_injection_detection_config_defaults() {
1795 let config = InjectionDetectionConfig::default();
1796 assert!(config.enabled);
1797 assert!((config.threshold - 0.5).abs() < f32::EPSILON);
1798 assert!(config.scan_tool_outputs);
1799 }
1800
1801 #[test]
1802 fn test_safety_config_includes_injection_detection() {
1803 let config = SafetyConfig::default();
1804 assert!(config.injection_detection.enabled);
1805 let json = serde_json::to_string(&config).unwrap();
1807 let deserialized: SafetyConfig = serde_json::from_str(&json).unwrap();
1808 assert!(deserialized.injection_detection.enabled);
1809 assert!(deserialized.injection_detection.scan_tool_outputs);
1810 }
1811
1812 #[test]
1813 #[allow(clippy::field_reassign_with_default)]
1814 fn test_multi_agent_config_with_resource_limits() {
1815 let mut config = MultiAgentConfig::default();
1816 config.default_resource_limits = crate::multi::ResourceLimits {
1817 max_memory_mb: Some(256),
1818 max_tokens_per_turn: Some(2048),
1819 max_tool_calls: Some(20),
1820 max_runtime_secs: Some(120),
1821 };
1822 let json = serde_json::to_string(&config).unwrap();
1823 let deserialized: MultiAgentConfig = serde_json::from_str(&json).unwrap();
1824 assert_eq!(
1825 deserialized.default_resource_limits.max_memory_mb,
1826 Some(256)
1827 );
1828 assert_eq!(
1829 deserialized.default_resource_limits.max_tool_calls,
1830 Some(20)
1831 );
1832 }
1833
1834 #[test]
1835 #[allow(clippy::field_reassign_with_default)]
1836 fn test_multi_agent_config_with_workspace_base() {
1837 let mut config = MultiAgentConfig::default();
1838 config.default_workspace_base = Some("/tmp/rustant-workspaces".into());
1839 let json = serde_json::to_string(&config).unwrap();
1840 let deserialized: MultiAgentConfig = serde_json::from_str(&json).unwrap();
1841 assert_eq!(
1842 deserialized.default_workspace_base.as_deref(),
1843 Some("/tmp/rustant-workspaces")
1844 );
1845 }
1846
1847 #[test]
1848 fn test_multi_agent_config_backward_compat() {
1849 let json = serde_json::json!({
1851 "enabled": true,
1852 "max_agents": 4,
1853 "max_mailbox_size": 500
1854 });
1855 let config: MultiAgentConfig = serde_json::from_value(json).unwrap();
1856 assert!(config.enabled);
1857 assert_eq!(config.max_agents, 4);
1858 assert!(config.default_resource_limits.max_memory_mb.is_none());
1859 assert!(config.default_workspace_base.is_none());
1860 }
1861
1862 #[test]
1863 fn test_multi_agent_config_defaults() {
1864 let config = MultiAgentConfig::default();
1865 assert!(!config.enabled);
1866 assert_eq!(config.max_agents, 8);
1867 assert_eq!(config.max_mailbox_size, 1000);
1868 assert!(config.default_resource_limits.max_memory_mb.is_none());
1869 assert!(config.default_workspace_base.is_none());
1870 }
1871
1872 #[test]
1873 fn test_intelligence_config_defaults() {
1874 let config = IntelligenceConfig::default();
1875 assert!(config.enabled);
1876 assert_eq!(config.defaults.auto_reply, AutoReplyMode::FullAuto);
1877 assert_eq!(config.defaults.digest, DigestFrequency::Off);
1878 assert!(config.defaults.smart_scheduling);
1879 assert_eq!(config.defaults.escalation_threshold, MessagePriority::High);
1880 assert!(config.quiet_hours.is_none());
1881 assert_eq!(config.max_reply_tokens, 500);
1882 assert_eq!(config.digest_dir, PathBuf::from(".rustant/digests"));
1883 assert_eq!(config.reminders_dir, PathBuf::from(".rustant/reminders"));
1884 }
1885
1886 #[test]
1887 fn test_intelligence_config_for_channel() {
1888 let mut config = IntelligenceConfig::default();
1889 config.channels.insert(
1890 "email".to_string(),
1891 ChannelIntelligenceConfig {
1892 auto_reply: AutoReplyMode::DraftOnly,
1893 digest: DigestFrequency::Daily,
1894 smart_scheduling: false,
1895 escalation_threshold: MessagePriority::Urgent,
1896 default_followup_minutes: 60,
1897 },
1898 );
1899
1900 let email = config.for_channel("email");
1902 assert_eq!(email.auto_reply, AutoReplyMode::DraftOnly);
1903 assert_eq!(email.digest, DigestFrequency::Daily);
1904 assert!(!email.smart_scheduling);
1905
1906 let slack = config.for_channel("slack");
1908 assert_eq!(slack.auto_reply, AutoReplyMode::FullAuto);
1909 assert_eq!(slack.digest, DigestFrequency::Off);
1910 }
1911
1912 #[test]
1913 fn test_intelligence_config_toml_deserialization() {
1914 let toml_str = r#"
1915 [llm]
1916 provider = "openai"
1917 model = "gpt-4o"
1918 api_key_env = "OPENAI_API_KEY"
1919 max_tokens = 4096
1920 temperature = 0.7
1921 context_window = 128000
1922 input_cost_per_million = 2.5
1923 output_cost_per_million = 10.0
1924 use_streaming = true
1925
1926 [safety]
1927 approval_mode = "safe"
1928 allowed_paths = ["src/**"]
1929 denied_paths = []
1930 allowed_commands = ["cargo"]
1931 ask_commands = []
1932 denied_commands = []
1933 allowed_hosts = []
1934 max_iterations = 25
1935
1936 [memory]
1937 window_size = 12
1938 compression_threshold = 0.7
1939 enable_persistence = false
1940
1941 [ui]
1942 theme = "dark"
1943 vim_mode = false
1944 show_cost = true
1945 use_tui = false
1946
1947 [tools]
1948 enable_builtins = true
1949 default_timeout_secs = 30
1950 max_output_bytes = 1048576
1951
1952 [intelligence]
1953 enabled = true
1954 max_reply_tokens = 1000
1955
1956 [intelligence.defaults]
1957 auto_reply = "auto_with_approval"
1958 digest = "daily"
1959 smart_scheduling = true
1960 escalation_threshold = "urgent"
1961
1962 [intelligence.channels.email]
1963 auto_reply = "draft_only"
1964 digest = "weekly"
1965
1966 [intelligence.quiet_hours]
1967 start = "22:00"
1968 end = "07:00"
1969 "#;
1970
1971 let config: AgentConfig = toml::from_str(toml_str).unwrap();
1972 let intel = config.intelligence.unwrap();
1973 assert!(intel.enabled);
1974 assert_eq!(intel.max_reply_tokens, 1000);
1975 assert_eq!(intel.defaults.auto_reply, AutoReplyMode::AutoWithApproval);
1976 assert_eq!(intel.defaults.digest, DigestFrequency::Daily);
1977 assert_eq!(intel.defaults.escalation_threshold, MessagePriority::Urgent);
1978
1979 let email = intel.for_channel("email");
1980 assert_eq!(email.auto_reply, AutoReplyMode::DraftOnly);
1981 assert_eq!(email.digest, DigestFrequency::Weekly);
1982
1983 let quiet = intel.quiet_hours.unwrap();
1984 assert_eq!(quiet.start, "22:00");
1985 assert_eq!(quiet.end, "07:00");
1986 }
1987
1988 #[test]
1989 fn test_auto_reply_mode_serde() {
1990 assert_eq!(
1991 serde_json::from_str::<AutoReplyMode>("\"full_auto\"").unwrap(),
1992 AutoReplyMode::FullAuto
1993 );
1994 assert_eq!(
1995 serde_json::from_str::<AutoReplyMode>("\"disabled\"").unwrap(),
1996 AutoReplyMode::Disabled
1997 );
1998 assert_eq!(
1999 serde_json::from_str::<AutoReplyMode>("\"draft_only\"").unwrap(),
2000 AutoReplyMode::DraftOnly
2001 );
2002 }
2003
2004 #[test]
2005 fn test_message_priority_ordering() {
2006 assert!(MessagePriority::Low < MessagePriority::Normal);
2007 assert!(MessagePriority::Normal < MessagePriority::High);
2008 assert!(MessagePriority::High < MessagePriority::Urgent);
2009 }
2010
2011 #[test]
2012 fn test_agent_config_with_intelligence_none() {
2013 let config = AgentConfig::default();
2015 assert!(config.intelligence.is_none());
2016 }
2017
2018 #[test]
2021 fn test_channel_config_validate_defaults_clean() {
2022 let config = ChannelIntelligenceConfig::default();
2023 let warnings = config.validate();
2024 assert!(
2025 warnings.is_empty(),
2026 "Default config should have no warnings, got: {:?}",
2027 warnings
2028 );
2029 }
2030
2031 #[test]
2032 fn test_channel_config_validate_zero_followup() {
2033 let config = ChannelIntelligenceConfig {
2034 default_followup_minutes: 0,
2035 ..Default::default()
2036 };
2037 let warnings = config.validate();
2038 assert_eq!(warnings.len(), 1);
2039 assert!(warnings[0].contains("immediately"));
2040 }
2041
2042 #[test]
2043 fn test_channel_config_validate_huge_followup() {
2044 let config = ChannelIntelligenceConfig {
2045 default_followup_minutes: u32::MAX,
2046 ..Default::default()
2047 };
2048 let warnings = config.validate();
2049 assert_eq!(warnings.len(), 1);
2050 assert!(warnings[0].contains("unusually large"));
2051 }
2052
2053 #[test]
2054 fn test_channel_config_validate_low_escalation() {
2055 let config = ChannelIntelligenceConfig {
2056 escalation_threshold: MessagePriority::Low,
2057 ..Default::default()
2058 };
2059 let warnings = config.validate();
2060 assert_eq!(warnings.len(), 1);
2061 assert!(warnings[0].contains("all messages will be escalated"));
2062 }
2063
2064 #[test]
2065 fn test_intelligence_config_validate_clean() {
2066 let config = IntelligenceConfig::default();
2067 let warnings = config.validate();
2068 assert!(
2069 warnings.is_empty(),
2070 "Default config should have no warnings, got: {:?}",
2071 warnings
2072 );
2073 }
2074
2075 #[test]
2076 fn test_intelligence_config_validate_bad_quiet_hours() {
2077 let config = IntelligenceConfig {
2078 quiet_hours: Some(crate::scheduler::QuietHours {
2079 start: "25:00".to_string(),
2080 end: "abc".to_string(),
2081 }),
2082 ..Default::default()
2083 };
2084 let warnings = config.validate();
2085 assert_eq!(warnings.len(), 2);
2086 assert!(warnings[0].contains("start"));
2087 assert!(warnings[1].contains("end"));
2088 }
2089
2090 #[test]
2091 fn test_intelligence_config_validate_zero_reply_tokens() {
2092 let config = IntelligenceConfig {
2093 max_reply_tokens: 0,
2094 ..Default::default()
2095 };
2096 let warnings = config.validate();
2097 assert_eq!(warnings.len(), 1);
2098 assert!(warnings[0].contains("auto-replies will be empty"));
2099 }
2100
2101 #[test]
2102 fn test_intelligence_config_validate_per_channel() {
2103 let mut config = IntelligenceConfig::default();
2104 config.channels.insert(
2105 "email".to_string(),
2106 ChannelIntelligenceConfig {
2107 escalation_threshold: MessagePriority::Low,
2108 default_followup_minutes: 0,
2109 ..Default::default()
2110 },
2111 );
2112 let warnings = config.validate();
2113 assert_eq!(warnings.len(), 2);
2114 assert!(warnings.iter().all(|w| w.starts_with("[channel:email]")));
2115 }
2116
2117 #[test]
2118 fn test_is_valid_time_format() {
2119 assert!(super::is_valid_time_format("00:00"));
2120 assert!(super::is_valid_time_format("23:59"));
2121 assert!(super::is_valid_time_format("12:30"));
2122 assert!(!super::is_valid_time_format("24:00"));
2123 assert!(!super::is_valid_time_format("12:60"));
2124 assert!(!super::is_valid_time_format("abc"));
2125 assert!(!super::is_valid_time_format("1:30"));
2126 assert!(!super::is_valid_time_format(""));
2127 }
2128
2129 #[test]
2132 fn test_council_config_defaults() {
2133 let config = CouncilConfig::default();
2134 assert!(!config.enabled);
2135 assert!(config.members.is_empty());
2136 assert_eq!(config.voting_strategy, VotingStrategy::ChairmanSynthesis);
2137 assert!(config.enable_peer_review);
2138 assert!(config.chairman_model.is_none());
2139 assert_eq!(config.max_member_tokens, 2048);
2140 assert!(config.auto_detect);
2141 }
2142
2143 #[test]
2144 fn test_council_config_serialization_roundtrip() {
2145 let config = CouncilConfig {
2146 enabled: true,
2147 members: vec![
2148 CouncilMemberConfig {
2149 provider: "openai".to_string(),
2150 model: "gpt-4o".to_string(),
2151 api_key_env: "OPENAI_API_KEY".to_string(),
2152 base_url: None,
2153 weight: 1.0,
2154 },
2155 CouncilMemberConfig {
2156 provider: "anthropic".to_string(),
2157 model: "claude-sonnet-4-20250514".to_string(),
2158 api_key_env: "ANTHROPIC_API_KEY".to_string(),
2159 base_url: None,
2160 weight: 1.5,
2161 },
2162 ],
2163 voting_strategy: VotingStrategy::HighestScore,
2164 enable_peer_review: false,
2165 chairman_model: Some("gpt-4o".to_string()),
2166 max_member_tokens: 4096,
2167 auto_detect: false,
2168 };
2169 let json = serde_json::to_string(&config).unwrap();
2170 let deserialized: CouncilConfig = serde_json::from_str(&json).unwrap();
2171 assert!(deserialized.enabled);
2172 assert_eq!(deserialized.members.len(), 2);
2173 assert_eq!(deserialized.voting_strategy, VotingStrategy::HighestScore);
2174 assert!(!deserialized.enable_peer_review);
2175 assert_eq!(deserialized.chairman_model, Some("gpt-4o".to_string()));
2176 assert_eq!(deserialized.max_member_tokens, 4096);
2177 }
2178
2179 #[test]
2180 fn test_voting_strategy_serde() {
2181 assert_eq!(
2182 serde_json::from_str::<VotingStrategy>("\"chairman_synthesis\"").unwrap(),
2183 VotingStrategy::ChairmanSynthesis
2184 );
2185 assert_eq!(
2186 serde_json::from_str::<VotingStrategy>("\"highest_score\"").unwrap(),
2187 VotingStrategy::HighestScore
2188 );
2189 assert_eq!(
2190 serde_json::from_str::<VotingStrategy>("\"majority_consensus\"").unwrap(),
2191 VotingStrategy::MajorityConsensus
2192 );
2193 let json = serde_json::to_string(&VotingStrategy::MajorityConsensus).unwrap();
2195 assert_eq!(json, "\"majority_consensus\"");
2196 }
2197
2198 #[test]
2199 #[allow(clippy::field_reassign_with_default)]
2200 fn test_agent_config_with_council() {
2201 let config = AgentConfig::default();
2203 assert!(config.council.is_none());
2204
2205 let mut config = AgentConfig::default();
2207 config.council = Some(CouncilConfig::default());
2208 let json = serde_json::to_string(&config).unwrap();
2209 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
2210 assert!(deserialized.council.is_some());
2211 let council = deserialized.council.unwrap();
2212 assert!(!council.enabled);
2213 assert!(council.members.is_empty());
2214 }
2215}