1use figment::{
8 providers::{Env, Format, Serialized, Toml},
9 Figment,
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: true,
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 if let Some(service) = key.strip_prefix("keychain:") {
1320 let store = crate::credentials::KeyringCredentialStore::new();
1321 match crate::credentials::CredentialStore::get_key(&store, service) {
1322 Ok(resolved_key) => {
1323 config.llm.api_key = Some(resolved_key);
1324 tracing::info!("Resolved API key from keyring service: {}", service);
1325 return; }
1327 Err(e) => {
1328 tracing::warn!("Failed to resolve keyring credential '{}': {}", service, e);
1329 }
1330 }
1331 }
1332 }
1333
1334 if config.llm.api_key.is_none() {
1336 if let Some(ref cs_key) = config.llm.credential_store_key {
1337 let store = crate::credentials::KeyringCredentialStore::new();
1338 match crate::credentials::CredentialStore::get_key(&store, cs_key) {
1339 Ok(resolved_key) => {
1340 config.llm.api_key = Some(resolved_key);
1341 tracing::info!(
1342 "Resolved API key from credential store for provider: {}",
1343 cs_key
1344 );
1345 }
1346 Err(e) => {
1347 tracing::debug!(
1348 "No credential in keyring for '{}': {} (will try env var)",
1349 cs_key,
1350 e
1351 );
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 if let Some(slack) = channels.slack.as_mut() {
1401 slack.bot_token = SecretRef::keychain("channel:slack:bot_token");
1402 }
1403 }
1404
1405 if let Some(ws) = workspace {
1407 let config_path = ws.join(".rustant").join("config.toml");
1408 if config_path.exists() {
1409 if let Ok(toml_str) = toml::to_string_pretty(config) {
1410 if let Err(e) = std::fs::write(&config_path, &toml_str) {
1411 tracing::warn!("Failed to rewrite config after migration: {}", e);
1412 }
1413 }
1414 }
1415 }
1416}
1417
1418pub fn config_exists(workspace: Option<&Path>) -> bool {
1424 if let Some(config_dir) = directories::ProjectDirs::from("dev", "rustant", "rustant") {
1426 if config_dir.config_dir().join("config.toml").exists() {
1427 return true;
1428 }
1429 }
1430
1431 if let Some(ws) = workspace {
1433 if ws.join(".rustant").join("config.toml").exists() {
1434 return true;
1435 }
1436 }
1437
1438 false
1439}
1440
1441pub fn update_channel_config(
1447 workspace: &std::path::Path,
1448 channel_name: &str,
1449 channel_toml: toml::Value,
1450) -> anyhow::Result<std::path::PathBuf> {
1451 let config_dir = workspace.join(".rustant");
1452 std::fs::create_dir_all(&config_dir)?;
1453 let config_path = config_dir.join("config.toml");
1454
1455 let mut config: AgentConfig = if config_path.exists() {
1457 let content = std::fs::read_to_string(&config_path)?;
1458 toml::from_str(&content).unwrap_or_default()
1459 } else {
1460 AgentConfig::default()
1461 };
1462
1463 let mut table: toml::Value = toml::Value::try_from(&config)?;
1465
1466 let channels_table = table
1468 .as_table_mut()
1469 .ok_or_else(|| anyhow::anyhow!("config is not a TOML table"))?
1470 .entry("channels")
1471 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
1472
1473 if let Some(ch_table) = channels_table.as_table_mut() {
1475 ch_table.insert(channel_name.to_string(), channel_toml);
1476 }
1477
1478 config = table.try_into()?;
1480 let toml_str = toml::to_string_pretty(&config)?;
1481 std::fs::write(&config_path, &toml_str)?;
1482
1483 Ok(config_path)
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::*;
1489 use std::sync::Mutex;
1490
1491 static ENV_MUTEX: Mutex<()> = Mutex::new(());
1493
1494 #[test]
1495 fn test_default_config() {
1496 let config = AgentConfig::default();
1497 assert_eq!(config.llm.provider, "openai");
1498 assert_eq!(config.llm.model, "gpt-4o");
1499 assert_eq!(config.safety.approval_mode, ApprovalMode::Safe);
1500 assert_eq!(config.memory.window_size, 20);
1501 assert!(!config.ui.vim_mode);
1502 assert!(config.tools.enable_builtins);
1503 }
1504
1505 #[test]
1506 fn test_approval_mode_display() {
1507 assert_eq!(ApprovalMode::Safe.to_string(), "safe");
1508 assert_eq!(ApprovalMode::Cautious.to_string(), "cautious");
1509 assert_eq!(ApprovalMode::Paranoid.to_string(), "paranoid");
1510 assert_eq!(ApprovalMode::Yolo.to_string(), "yolo");
1511 }
1512
1513 #[test]
1514 fn test_config_serialization_roundtrip() {
1515 let config = AgentConfig::default();
1516 let toml_str = toml::to_string(&config).unwrap();
1517 let deserialized: AgentConfig = toml::from_str(&toml_str).unwrap();
1518 assert_eq!(deserialized.llm.model, config.llm.model);
1519 assert_eq!(
1520 deserialized.safety.approval_mode,
1521 config.safety.approval_mode
1522 );
1523 assert_eq!(deserialized.memory.window_size, config.memory.window_size);
1524 }
1525
1526 #[test]
1527 fn test_load_config_defaults() {
1528 let config = load_config(None, None).unwrap();
1529 assert_eq!(config.llm.provider, "openai");
1530 assert_eq!(config.safety.max_iterations, 50);
1531 }
1532
1533 #[test]
1534 fn test_load_config_with_overrides() {
1535 let mut overrides = AgentConfig::default();
1536 overrides.llm.model = "claude-sonnet".to_string();
1537 overrides.safety.max_iterations = 50;
1538
1539 let config = load_config(None, Some(&overrides)).unwrap();
1540 assert_eq!(config.llm.model, "claude-sonnet");
1541 assert_eq!(config.safety.max_iterations, 50);
1542 }
1543
1544 #[test]
1545 fn test_load_config_from_workspace() {
1546 let _lock = ENV_MUTEX.lock().unwrap();
1547 unsafe { std::env::remove_var("RUSTANT_SAFETY__APPROVAL_MODE") };
1549
1550 let dir = tempfile::tempdir().unwrap();
1551 let rustant_dir = dir.path().join(".rustant");
1552 std::fs::create_dir_all(&rustant_dir).unwrap();
1553 std::fs::write(
1554 rustant_dir.join("config.toml"),
1555 r#"
1556[llm]
1557model = "gpt-4o-mini"
1558provider = "openai"
1559api_key_env = "OPENAI_API_KEY"
1560max_tokens = 4096
1561temperature = 0.7
1562context_window = 128000
1563input_cost_per_million = 2.5
1564output_cost_per_million = 10.0
1565
1566[safety]
1567max_iterations = 100
1568approval_mode = "cautious"
1569allowed_paths = ["src/**"]
1570denied_paths = []
1571allowed_commands = ["cargo"]
1572ask_commands = []
1573denied_commands = []
1574allowed_hosts = []
1575
1576[memory]
1577window_size = 12
1578compression_threshold = 0.7
1579enable_persistence = false
1580
1581[ui]
1582theme = "dark"
1583vim_mode = false
1584show_cost = true
1585use_tui = false
1586
1587[tools]
1588enable_builtins = true
1589default_timeout_secs = 30
1590max_output_bytes = 1048576
1591"#,
1592 )
1593 .unwrap();
1594
1595 let config = load_config(Some(dir.path()), None).unwrap();
1596 assert_eq!(config.llm.model, "gpt-4o-mini");
1597 assert_eq!(config.safety.max_iterations, 100);
1598 assert_eq!(config.safety.approval_mode, ApprovalMode::Cautious);
1599 }
1600
1601 #[test]
1605 fn test_env_var_override_approval_mode() {
1606 let _lock = ENV_MUTEX.lock().unwrap();
1607
1608 unsafe { std::env::set_var("RUSTANT_SAFETY__APPROVAL_MODE", "yolo") };
1610 let config = load_config(None, None).unwrap();
1611 assert_eq!(
1612 config.safety.approval_mode,
1613 ApprovalMode::Yolo,
1614 "RUSTANT_SAFETY__APPROVAL_MODE=yolo should override default 'safe'"
1615 );
1616
1617 let dir = tempfile::tempdir().unwrap();
1619 let rustant_dir = dir.path().join(".rustant");
1620 std::fs::create_dir_all(&rustant_dir).unwrap();
1621 std::fs::write(
1622 rustant_dir.join("config.toml"),
1623 r#"
1624[safety]
1625approval_mode = "safe"
1626max_iterations = 50
1627allowed_paths = ["src/**"]
1628denied_paths = []
1629allowed_commands = ["cargo"]
1630ask_commands = []
1631denied_commands = []
1632allowed_hosts = []
1633"#,
1634 )
1635 .unwrap();
1636
1637 let config = load_config(Some(dir.path()), None).unwrap();
1638 assert_eq!(
1639 config.safety.approval_mode,
1640 ApprovalMode::Yolo,
1641 "Env var RUSTANT_SAFETY__APPROVAL_MODE=yolo should override workspace config 'safe'"
1642 );
1643
1644 unsafe { std::env::remove_var("RUSTANT_SAFETY__APPROVAL_MODE") };
1646 }
1647
1648 #[test]
1649 fn test_safety_config_defaults() {
1650 let config = SafetyConfig::default();
1651 assert!(config.allowed_paths.contains(&"src/**".to_string()));
1652 assert!(config.denied_paths.contains(&".env*".to_string()));
1653 assert!(config.allowed_commands.contains(&"cargo".to_string()));
1654 assert!(config.denied_commands.contains(&"sudo".to_string()));
1655 }
1656
1657 #[test]
1658 fn test_llm_config_defaults() {
1659 let config = LlmConfig::default();
1660 assert_eq!(config.context_window, 128_000);
1661 assert_eq!(config.max_tokens, 4096);
1662 assert!((config.temperature - 0.7).abs() < f32::EPSILON);
1663 }
1664
1665 #[test]
1666 fn test_llm_config_validate_defaults_clean() {
1667 let config = LlmConfig::default();
1668 let warnings = config.validate();
1669 assert!(
1670 warnings.is_empty(),
1671 "Default LlmConfig should have no warnings, got: {:?}",
1672 warnings
1673 );
1674 }
1675
1676 #[test]
1677 fn test_llm_config_validate_max_tokens_exceeds_context() {
1678 let config = LlmConfig {
1679 max_tokens: 200_000,
1680 context_window: 128_000,
1681 ..Default::default()
1682 };
1683 let warnings = config.validate();
1684 assert_eq!(warnings.len(), 1);
1685 assert!(warnings[0].contains("max_tokens"));
1686 assert!(warnings[0].contains("context_window"));
1687 }
1688
1689 #[test]
1690 fn test_llm_config_validate_bad_temperature() {
1691 let config = LlmConfig {
1692 temperature: 3.0,
1693 ..Default::default()
1694 };
1695 let warnings = config.validate();
1696 assert_eq!(warnings.len(), 1);
1697 assert!(warnings[0].contains("temperature"));
1698 }
1699
1700 #[test]
1701 fn test_safety_denied_paths_include_sensitive_defaults() {
1702 let config = SafetyConfig::default();
1703 assert!(config.denied_paths.contains(&".ssh/**".to_string()));
1704 assert!(config.denied_paths.contains(&".aws/**".to_string()));
1705 assert!(config.denied_paths.contains(&"**/*.pem".to_string()));
1706 assert!(config.denied_paths.contains(&"**/*id_rsa*".to_string()));
1707 assert!(config.denied_paths.contains(&"**/*id_ed25519*".to_string()));
1708 }
1709
1710 #[test]
1711 fn test_memory_config_defaults() {
1712 let config = MemoryConfig::default();
1713 assert_eq!(config.window_size, 20);
1714 assert!((config.compression_threshold - 0.7).abs() < f32::EPSILON);
1715 assert!(config.enable_persistence);
1716 }
1717
1718 #[test]
1719 fn test_approval_mode_serde() {
1720 let json = serde_json::to_string(&ApprovalMode::Paranoid).unwrap();
1721 assert_eq!(json, "\"paranoid\"");
1722 let mode: ApprovalMode = serde_json::from_str("\"yolo\"").unwrap();
1723 assert_eq!(mode, ApprovalMode::Yolo);
1724 }
1725
1726 #[test]
1727 #[allow(clippy::field_reassign_with_default)]
1728 fn test_agent_config_with_gateway() {
1729 let mut config = AgentConfig::default();
1730 config.gateway = Some(crate::gateway::GatewayConfig::default());
1731 let json = serde_json::to_string(&config).unwrap();
1732 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1733 assert!(deserialized.gateway.is_some());
1734 let gw = deserialized.gateway.unwrap();
1735 assert_eq!(gw.port, 8080);
1736 }
1737
1738 #[test]
1739 #[allow(clippy::field_reassign_with_default)]
1740 fn test_agent_config_with_search() {
1741 let mut config = AgentConfig::default();
1742 config.search = Some(crate::search::SearchConfig::default());
1743 let json = serde_json::to_string(&config).unwrap();
1744 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1745 assert!(deserialized.search.is_some());
1746 let sc = deserialized.search.unwrap();
1747 assert_eq!(sc.max_results, 10);
1748 }
1749
1750 #[test]
1751 #[allow(clippy::field_reassign_with_default)]
1752 fn test_agent_config_with_flush() {
1753 let mut config = AgentConfig::default();
1754 config.flush = Some(crate::memory::FlushConfig::default());
1755 let json = serde_json::to_string(&config).unwrap();
1756 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1757 assert!(deserialized.flush.is_some());
1758 let fc = deserialized.flush.unwrap();
1759 assert!(!fc.enabled);
1760 assert_eq!(fc.interval_secs, 300);
1761 }
1762
1763 #[test]
1764 fn test_agent_config_backward_compat_no_optional_fields() {
1765 let json = serde_json::json!({
1767 "llm": LlmConfig::default(),
1768 "safety": SafetyConfig::default(),
1769 "memory": MemoryConfig::default(),
1770 "ui": UiConfig::default(),
1771 "tools": ToolsConfig::default()
1772 });
1773 let config: AgentConfig = serde_json::from_value(json).unwrap();
1774 assert!(config.gateway.is_none());
1775 assert!(config.search.is_none());
1776 assert!(config.flush.is_none());
1777 assert!(config.multi_agent.is_none());
1778 }
1779
1780 #[test]
1781 #[allow(clippy::field_reassign_with_default)]
1782 fn test_agent_config_with_multi_agent() {
1783 let mut config = AgentConfig::default();
1784 config.multi_agent = Some(MultiAgentConfig::default());
1785 let json = serde_json::to_string(&config).unwrap();
1786 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
1787 assert!(deserialized.multi_agent.is_some());
1788 let ma = deserialized.multi_agent.unwrap();
1789 assert!(!ma.enabled);
1790 assert_eq!(ma.max_agents, 8);
1791 assert_eq!(ma.max_mailbox_size, 1000);
1792 }
1793
1794 #[test]
1795 fn test_injection_detection_config_defaults() {
1796 let config = InjectionDetectionConfig::default();
1797 assert!(config.enabled);
1798 assert!((config.threshold - 0.5).abs() < f32::EPSILON);
1799 assert!(config.scan_tool_outputs);
1800 }
1801
1802 #[test]
1803 fn test_safety_config_includes_injection_detection() {
1804 let config = SafetyConfig::default();
1805 assert!(config.injection_detection.enabled);
1806 let json = serde_json::to_string(&config).unwrap();
1808 let deserialized: SafetyConfig = serde_json::from_str(&json).unwrap();
1809 assert!(deserialized.injection_detection.enabled);
1810 assert!(deserialized.injection_detection.scan_tool_outputs);
1811 }
1812
1813 #[test]
1814 #[allow(clippy::field_reassign_with_default)]
1815 fn test_multi_agent_config_with_resource_limits() {
1816 let mut config = MultiAgentConfig::default();
1817 config.default_resource_limits = crate::multi::ResourceLimits {
1818 max_memory_mb: Some(256),
1819 max_tokens_per_turn: Some(2048),
1820 max_tool_calls: Some(20),
1821 max_runtime_secs: Some(120),
1822 };
1823 let json = serde_json::to_string(&config).unwrap();
1824 let deserialized: MultiAgentConfig = serde_json::from_str(&json).unwrap();
1825 assert_eq!(
1826 deserialized.default_resource_limits.max_memory_mb,
1827 Some(256)
1828 );
1829 assert_eq!(
1830 deserialized.default_resource_limits.max_tool_calls,
1831 Some(20)
1832 );
1833 }
1834
1835 #[test]
1836 #[allow(clippy::field_reassign_with_default)]
1837 fn test_multi_agent_config_with_workspace_base() {
1838 let mut config = MultiAgentConfig::default();
1839 config.default_workspace_base = Some("/tmp/rustant-workspaces".into());
1840 let json = serde_json::to_string(&config).unwrap();
1841 let deserialized: MultiAgentConfig = serde_json::from_str(&json).unwrap();
1842 assert_eq!(
1843 deserialized.default_workspace_base.as_deref(),
1844 Some("/tmp/rustant-workspaces")
1845 );
1846 }
1847
1848 #[test]
1849 fn test_multi_agent_config_backward_compat() {
1850 let json = serde_json::json!({
1852 "enabled": true,
1853 "max_agents": 4,
1854 "max_mailbox_size": 500
1855 });
1856 let config: MultiAgentConfig = serde_json::from_value(json).unwrap();
1857 assert!(config.enabled);
1858 assert_eq!(config.max_agents, 4);
1859 assert!(config.default_resource_limits.max_memory_mb.is_none());
1860 assert!(config.default_workspace_base.is_none());
1861 }
1862
1863 #[test]
1864 fn test_multi_agent_config_defaults() {
1865 let config = MultiAgentConfig::default();
1866 assert!(!config.enabled);
1867 assert_eq!(config.max_agents, 8);
1868 assert_eq!(config.max_mailbox_size, 1000);
1869 assert!(config.default_resource_limits.max_memory_mb.is_none());
1870 assert!(config.default_workspace_base.is_none());
1871 }
1872
1873 #[test]
1874 fn test_intelligence_config_defaults() {
1875 let config = IntelligenceConfig::default();
1876 assert!(config.enabled);
1877 assert_eq!(config.defaults.auto_reply, AutoReplyMode::FullAuto);
1878 assert_eq!(config.defaults.digest, DigestFrequency::Off);
1879 assert!(config.defaults.smart_scheduling);
1880 assert_eq!(config.defaults.escalation_threshold, MessagePriority::High);
1881 assert!(config.quiet_hours.is_none());
1882 assert_eq!(config.max_reply_tokens, 500);
1883 assert_eq!(config.digest_dir, PathBuf::from(".rustant/digests"));
1884 assert_eq!(config.reminders_dir, PathBuf::from(".rustant/reminders"));
1885 }
1886
1887 #[test]
1888 fn test_intelligence_config_for_channel() {
1889 let mut config = IntelligenceConfig::default();
1890 config.channels.insert(
1891 "email".to_string(),
1892 ChannelIntelligenceConfig {
1893 auto_reply: AutoReplyMode::DraftOnly,
1894 digest: DigestFrequency::Daily,
1895 smart_scheduling: false,
1896 escalation_threshold: MessagePriority::Urgent,
1897 default_followup_minutes: 60,
1898 },
1899 );
1900
1901 let email = config.for_channel("email");
1903 assert_eq!(email.auto_reply, AutoReplyMode::DraftOnly);
1904 assert_eq!(email.digest, DigestFrequency::Daily);
1905 assert!(!email.smart_scheduling);
1906
1907 let slack = config.for_channel("slack");
1909 assert_eq!(slack.auto_reply, AutoReplyMode::FullAuto);
1910 assert_eq!(slack.digest, DigestFrequency::Off);
1911 }
1912
1913 #[test]
1914 fn test_intelligence_config_toml_deserialization() {
1915 let toml_str = r#"
1916 [llm]
1917 provider = "openai"
1918 model = "gpt-4o"
1919 api_key_env = "OPENAI_API_KEY"
1920 max_tokens = 4096
1921 temperature = 0.7
1922 context_window = 128000
1923 input_cost_per_million = 2.5
1924 output_cost_per_million = 10.0
1925 use_streaming = true
1926
1927 [safety]
1928 approval_mode = "safe"
1929 allowed_paths = ["src/**"]
1930 denied_paths = []
1931 allowed_commands = ["cargo"]
1932 ask_commands = []
1933 denied_commands = []
1934 allowed_hosts = []
1935 max_iterations = 25
1936
1937 [memory]
1938 window_size = 12
1939 compression_threshold = 0.7
1940 enable_persistence = false
1941
1942 [ui]
1943 theme = "dark"
1944 vim_mode = false
1945 show_cost = true
1946 use_tui = false
1947
1948 [tools]
1949 enable_builtins = true
1950 default_timeout_secs = 30
1951 max_output_bytes = 1048576
1952
1953 [intelligence]
1954 enabled = true
1955 max_reply_tokens = 1000
1956
1957 [intelligence.defaults]
1958 auto_reply = "auto_with_approval"
1959 digest = "daily"
1960 smart_scheduling = true
1961 escalation_threshold = "urgent"
1962
1963 [intelligence.channels.email]
1964 auto_reply = "draft_only"
1965 digest = "weekly"
1966
1967 [intelligence.quiet_hours]
1968 start = "22:00"
1969 end = "07:00"
1970 "#;
1971
1972 let config: AgentConfig = toml::from_str(toml_str).unwrap();
1973 let intel = config.intelligence.unwrap();
1974 assert!(intel.enabled);
1975 assert_eq!(intel.max_reply_tokens, 1000);
1976 assert_eq!(intel.defaults.auto_reply, AutoReplyMode::AutoWithApproval);
1977 assert_eq!(intel.defaults.digest, DigestFrequency::Daily);
1978 assert_eq!(intel.defaults.escalation_threshold, MessagePriority::Urgent);
1979
1980 let email = intel.for_channel("email");
1981 assert_eq!(email.auto_reply, AutoReplyMode::DraftOnly);
1982 assert_eq!(email.digest, DigestFrequency::Weekly);
1983
1984 let quiet = intel.quiet_hours.unwrap();
1985 assert_eq!(quiet.start, "22:00");
1986 assert_eq!(quiet.end, "07:00");
1987 }
1988
1989 #[test]
1990 fn test_auto_reply_mode_serde() {
1991 assert_eq!(
1992 serde_json::from_str::<AutoReplyMode>("\"full_auto\"").unwrap(),
1993 AutoReplyMode::FullAuto
1994 );
1995 assert_eq!(
1996 serde_json::from_str::<AutoReplyMode>("\"disabled\"").unwrap(),
1997 AutoReplyMode::Disabled
1998 );
1999 assert_eq!(
2000 serde_json::from_str::<AutoReplyMode>("\"draft_only\"").unwrap(),
2001 AutoReplyMode::DraftOnly
2002 );
2003 }
2004
2005 #[test]
2006 fn test_message_priority_ordering() {
2007 assert!(MessagePriority::Low < MessagePriority::Normal);
2008 assert!(MessagePriority::Normal < MessagePriority::High);
2009 assert!(MessagePriority::High < MessagePriority::Urgent);
2010 }
2011
2012 #[test]
2013 fn test_agent_config_with_intelligence_none() {
2014 let config = AgentConfig::default();
2016 assert!(config.intelligence.is_none());
2017 }
2018
2019 #[test]
2022 fn test_channel_config_validate_defaults_clean() {
2023 let config = ChannelIntelligenceConfig::default();
2024 let warnings = config.validate();
2025 assert!(
2026 warnings.is_empty(),
2027 "Default config should have no warnings, got: {:?}",
2028 warnings
2029 );
2030 }
2031
2032 #[test]
2033 fn test_channel_config_validate_zero_followup() {
2034 let config = ChannelIntelligenceConfig {
2035 default_followup_minutes: 0,
2036 ..Default::default()
2037 };
2038 let warnings = config.validate();
2039 assert_eq!(warnings.len(), 1);
2040 assert!(warnings[0].contains("immediately"));
2041 }
2042
2043 #[test]
2044 fn test_channel_config_validate_huge_followup() {
2045 let config = ChannelIntelligenceConfig {
2046 default_followup_minutes: u32::MAX,
2047 ..Default::default()
2048 };
2049 let warnings = config.validate();
2050 assert_eq!(warnings.len(), 1);
2051 assert!(warnings[0].contains("unusually large"));
2052 }
2053
2054 #[test]
2055 fn test_channel_config_validate_low_escalation() {
2056 let config = ChannelIntelligenceConfig {
2057 escalation_threshold: MessagePriority::Low,
2058 ..Default::default()
2059 };
2060 let warnings = config.validate();
2061 assert_eq!(warnings.len(), 1);
2062 assert!(warnings[0].contains("all messages will be escalated"));
2063 }
2064
2065 #[test]
2066 fn test_intelligence_config_validate_clean() {
2067 let config = IntelligenceConfig::default();
2068 let warnings = config.validate();
2069 assert!(
2070 warnings.is_empty(),
2071 "Default config should have no warnings, got: {:?}",
2072 warnings
2073 );
2074 }
2075
2076 #[test]
2077 fn test_intelligence_config_validate_bad_quiet_hours() {
2078 let config = IntelligenceConfig {
2079 quiet_hours: Some(crate::scheduler::QuietHours {
2080 start: "25:00".to_string(),
2081 end: "abc".to_string(),
2082 }),
2083 ..Default::default()
2084 };
2085 let warnings = config.validate();
2086 assert_eq!(warnings.len(), 2);
2087 assert!(warnings[0].contains("start"));
2088 assert!(warnings[1].contains("end"));
2089 }
2090
2091 #[test]
2092 fn test_intelligence_config_validate_zero_reply_tokens() {
2093 let config = IntelligenceConfig {
2094 max_reply_tokens: 0,
2095 ..Default::default()
2096 };
2097 let warnings = config.validate();
2098 assert_eq!(warnings.len(), 1);
2099 assert!(warnings[0].contains("auto-replies will be empty"));
2100 }
2101
2102 #[test]
2103 fn test_intelligence_config_validate_per_channel() {
2104 let mut config = IntelligenceConfig::default();
2105 config.channels.insert(
2106 "email".to_string(),
2107 ChannelIntelligenceConfig {
2108 escalation_threshold: MessagePriority::Low,
2109 default_followup_minutes: 0,
2110 ..Default::default()
2111 },
2112 );
2113 let warnings = config.validate();
2114 assert_eq!(warnings.len(), 2);
2115 assert!(warnings.iter().all(|w| w.starts_with("[channel:email]")));
2116 }
2117
2118 #[test]
2119 fn test_is_valid_time_format() {
2120 assert!(super::is_valid_time_format("00:00"));
2121 assert!(super::is_valid_time_format("23:59"));
2122 assert!(super::is_valid_time_format("12:30"));
2123 assert!(!super::is_valid_time_format("24:00"));
2124 assert!(!super::is_valid_time_format("12:60"));
2125 assert!(!super::is_valid_time_format("abc"));
2126 assert!(!super::is_valid_time_format("1:30"));
2127 assert!(!super::is_valid_time_format(""));
2128 }
2129
2130 #[test]
2133 fn test_council_config_defaults() {
2134 let config = CouncilConfig::default();
2135 assert!(!config.enabled);
2136 assert!(config.members.is_empty());
2137 assert_eq!(config.voting_strategy, VotingStrategy::ChairmanSynthesis);
2138 assert!(config.enable_peer_review);
2139 assert!(config.chairman_model.is_none());
2140 assert_eq!(config.max_member_tokens, 2048);
2141 assert!(config.auto_detect);
2142 }
2143
2144 #[test]
2145 fn test_council_config_serialization_roundtrip() {
2146 let config = CouncilConfig {
2147 enabled: true,
2148 members: vec![
2149 CouncilMemberConfig {
2150 provider: "openai".to_string(),
2151 model: "gpt-4o".to_string(),
2152 api_key_env: "OPENAI_API_KEY".to_string(),
2153 base_url: None,
2154 weight: 1.0,
2155 },
2156 CouncilMemberConfig {
2157 provider: "anthropic".to_string(),
2158 model: "claude-sonnet-4-20250514".to_string(),
2159 api_key_env: "ANTHROPIC_API_KEY".to_string(),
2160 base_url: None,
2161 weight: 1.5,
2162 },
2163 ],
2164 voting_strategy: VotingStrategy::HighestScore,
2165 enable_peer_review: false,
2166 chairman_model: Some("gpt-4o".to_string()),
2167 max_member_tokens: 4096,
2168 auto_detect: false,
2169 };
2170 let json = serde_json::to_string(&config).unwrap();
2171 let deserialized: CouncilConfig = serde_json::from_str(&json).unwrap();
2172 assert!(deserialized.enabled);
2173 assert_eq!(deserialized.members.len(), 2);
2174 assert_eq!(deserialized.voting_strategy, VotingStrategy::HighestScore);
2175 assert!(!deserialized.enable_peer_review);
2176 assert_eq!(deserialized.chairman_model, Some("gpt-4o".to_string()));
2177 assert_eq!(deserialized.max_member_tokens, 4096);
2178 }
2179
2180 #[test]
2181 fn test_voting_strategy_serde() {
2182 assert_eq!(
2183 serde_json::from_str::<VotingStrategy>("\"chairman_synthesis\"").unwrap(),
2184 VotingStrategy::ChairmanSynthesis
2185 );
2186 assert_eq!(
2187 serde_json::from_str::<VotingStrategy>("\"highest_score\"").unwrap(),
2188 VotingStrategy::HighestScore
2189 );
2190 assert_eq!(
2191 serde_json::from_str::<VotingStrategy>("\"majority_consensus\"").unwrap(),
2192 VotingStrategy::MajorityConsensus
2193 );
2194 let json = serde_json::to_string(&VotingStrategy::MajorityConsensus).unwrap();
2196 assert_eq!(json, "\"majority_consensus\"");
2197 }
2198
2199 #[test]
2200 #[allow(clippy::field_reassign_with_default)]
2201 fn test_agent_config_with_council() {
2202 let config = AgentConfig::default();
2204 assert!(config.council.is_none());
2205
2206 let mut config = AgentConfig::default();
2208 config.council = Some(CouncilConfig::default());
2209 let json = serde_json::to_string(&config).unwrap();
2210 let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
2211 assert!(deserialized.council.is_some());
2212 let council = deserialized.council.unwrap();
2213 assert!(!council.enabled);
2214 assert!(council.members.is_empty());
2215 }
2216}