Skip to main content

rustant_core/
config.rs

1//! Configuration system for Rustant.
2//!
3//! Uses `figment` for layered configuration: defaults -> config file -> environment -> CLI args.
4//! Configuration is loaded from `~/.config/rustant/config.toml` and/or `.rustant/config.toml`
5//! in the workspace directory.
6
7use 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/// Top-level configuration for the Rustant agent.
33#[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    /// Optional WebSocket gateway configuration.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub gateway: Option<GatewayConfig>,
43    /// Optional hybrid search configuration.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub search: Option<SearchConfig>,
46    /// Optional memory flush configuration.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub flush: Option<FlushConfig>,
49    /// Optional channels configuration.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub channels: Option<ChannelsConfig>,
52    /// Optional multi-agent configuration.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub multi_agent: Option<MultiAgentConfig>,
55    /// Optional workflow engine configuration.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub workflow: Option<WorkflowConfig>,
58    /// Optional browser automation configuration.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub browser: Option<BrowserConfig>,
61    /// Optional scheduler configuration.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub scheduler: Option<SchedulerConfig>,
64    /// Optional voice and audio configuration.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub voice: Option<VoiceConfig>,
67    /// Optional token budget configuration.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub budget: Option<BudgetConfig>,
70    /// Optional cross-session knowledge distillation configuration.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub knowledge: Option<KnowledgeConfig>,
73    /// Optional channel intelligence configuration.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub intelligence: Option<IntelligenceConfig>,
76    /// Optional meeting recording and transcription configuration.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub meeting: Option<MeetingConfig>,
79    /// Optional LLM Council configuration (multi-model deliberation).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub council: Option<CouncilConfig>,
82    /// Optional plan mode configuration.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub plan: Option<crate::plan::PlanConfig>,
85    /// Optional CDC (Change Data Capture) configuration for channel polling.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub cdc: Option<crate::channels::cdc::CdcConfig>,
88    /// External MCP server configurations (e.g., Chrome DevTools MCP).
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub mcp_servers: Vec<ExternalMcpServerConfig>,
91    /// Optional MCP safety policy configuration.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub mcp_safety: Option<McpSafetyConfig>,
94}
95
96/// Meeting recording and transcription configuration.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MeetingConfig {
99    /// Whether meeting features are enabled.
100    pub enabled: bool,
101    /// Default Notes.app folder for meeting transcripts.
102    pub notes_folder: String,
103    /// Audio recording format (wav).
104    pub audio_format: String,
105    /// Audio sample rate in Hz.
106    pub sample_rate: u32,
107    /// Maximum recording duration in minutes.
108    pub max_duration_mins: u64,
109    /// Whether to auto-detect virtual audio devices (BlackHole, Loopback).
110    pub auto_detect_virtual_audio: bool,
111    /// Whether to auto-transcribe when recording stops.
112    pub auto_transcribe: bool,
113    /// Whether to auto-summarize after transcription.
114    pub auto_summarize: bool,
115    /// Seconds of silence before auto-stopping a recording (0 = disabled).
116    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/// Configuration for an external MCP server (e.g., Chrome DevTools MCP).
136///
137/// Example TOML:
138/// ```toml
139/// [[mcp_servers]]
140/// name = "chrome-devtools"
141/// command = "npx"
142/// args = ["chrome-devtools-mcp@latest"]
143/// auto_connect = true
144/// ```
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ExternalMcpServerConfig {
147    /// Server name (used as identifier).
148    pub name: String,
149    /// Command to start the server.
150    pub command: String,
151    /// Arguments to pass to the command.
152    #[serde(default)]
153    pub args: Vec<String>,
154    /// Working directory for the server process.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub working_dir: Option<PathBuf>,
157    /// Environment variables to set.
158    #[serde(default)]
159    pub env: HashMap<String, String>,
160    /// Whether to auto-connect on startup.
161    #[serde(default = "default_true")]
162    pub auto_connect: bool,
163}
164
165/// MCP safety policy configuration.
166///
167/// Controls security checks applied to tool calls received via MCP (Model Context Protocol).
168/// When enabled, tools called via MCP are gated by risk level, deny lists, injection scanning,
169/// rate limiting, and schema validation before execution.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct McpSafetyConfig {
172    /// Whether MCP safety checks are enabled.
173    pub enabled: bool,
174    /// Maximum risk level allowed for MCP tool calls.
175    /// Tools above this level are rejected unless explicitly in `allowed_tools`.
176    /// Uses string representation: "read_only", "write", "execute", "network", "destructive".
177    pub max_risk_level: String,
178    /// Tools explicitly allowed regardless of risk level.
179    #[serde(default)]
180    pub allowed_tools: Vec<String>,
181    /// Tools explicitly denied via MCP (always rejected).
182    #[serde(default)]
183    pub denied_tools: Vec<String>,
184    /// Whether to scan tool arguments and outputs for injection patterns.
185    pub scan_inputs: bool,
186    /// Whether to scan tool outputs for injection patterns (warn-prefix, not block).
187    pub scan_outputs: bool,
188    /// Whether to log MCP tool calls to the audit trail.
189    pub audit_enabled: bool,
190    /// Maximum tool calls per minute (0 = unlimited).
191    pub max_calls_per_minute: usize,
192}
193
194impl McpSafetyConfig {
195    /// Parse the `max_risk_level` string into a `RiskLevel`.
196    ///
197    /// Returns `Write` as the default if the string is unrecognized.
198    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/// Configuration for the workflow engine.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct WorkflowConfig {
229    /// Whether the workflow engine is enabled.
230    pub enabled: bool,
231    /// Directory containing custom workflow definitions.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub workflow_dir: Option<PathBuf>,
234    /// Maximum concurrent workflow runs.
235    pub max_concurrent_runs: usize,
236    /// Default timeout per step in seconds.
237    pub default_step_timeout_secs: u64,
238    /// Path for persisting workflow state.
239    #[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/// Configuration for the browser automation system.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct BrowserConfig {
258    /// Whether browser automation is enabled.
259    pub enabled: bool,
260    /// How to connect to Chrome: auto (try connect, then launch), connect, or launch.
261    pub connection_mode: BrowserConnectionMode,
262    /// Remote debugging port for connecting to or launching Chrome.
263    pub debug_port: u16,
264    /// WebSocket URL for direct connection (overrides port-based discovery).
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub ws_url: Option<String>,
267    /// Path to the Chrome/Chromium binary.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub chrome_path: Option<String>,
270    /// Whether to run headless (no visible window).
271    pub headless: bool,
272    /// Default viewport width in pixels.
273    pub default_viewport_width: u32,
274    /// Default viewport height in pixels.
275    pub default_viewport_height: u32,
276    /// Default timeout per operation in seconds.
277    pub default_timeout_secs: u64,
278    /// If non-empty, only these domains are allowed.
279    #[serde(default)]
280    pub allowed_domains: Vec<String>,
281    /// These domains are always blocked.
282    #[serde(default)]
283    pub blocked_domains: Vec<String>,
284    /// Whether to use an isolated browser profile (temp dir per session).
285    pub isolate_profile: bool,
286    /// Persistent user data directory. When set, browser state (cookies, history)
287    /// persists across sessions. Ignored when `isolate_profile` is true.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub user_data_dir: Option<PathBuf>,
290    /// Maximum number of open pages/tabs.
291    pub max_pages: usize,
292}
293
294/// How to connect to Chrome for browser automation.
295#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "lowercase")]
297pub enum BrowserConnectionMode {
298    /// Try connecting to an existing Chrome instance first, then launch a new one.
299    #[default]
300    Auto,
301    /// Only connect to an existing Chrome instance (fail if none found).
302    Connect,
303    /// Always launch a new Chrome instance (previous behavior).
304    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/// Configuration for the scheduler system.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SchedulerConfig {
341    /// Whether the scheduler is enabled.
342    pub enabled: bool,
343    /// Cron job definitions.
344    #[serde(default)]
345    pub cron_jobs: Vec<crate::scheduler::CronJobConfig>,
346    /// Optional heartbeat configuration.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub heartbeat: Option<crate::scheduler::HeartbeatConfig>,
349    /// Optional port for webhook listener.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub webhook_port: Option<u16>,
352    /// Maximum number of concurrent background jobs.
353    pub max_background_jobs: usize,
354    /// Path for persisting scheduler state.
355    #[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/// Configuration for the voice and audio system.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct VoiceConfig {
375    /// Whether voice features are enabled.
376    pub enabled: bool,
377    /// STT provider: "openai", "whisper-local", "mock".
378    pub stt_provider: String,
379    /// Whisper model size (for local): "tiny", "base", "small", "medium", "large".
380    pub stt_model: String,
381    /// Language code for STT (e.g., "en").
382    pub stt_language: String,
383    /// TTS provider: "openai", "mock".
384    pub tts_provider: String,
385    /// TTS voice name.
386    pub tts_voice: String,
387    /// TTS speech speed multiplier.
388    pub tts_speed: f32,
389    /// Whether VAD (voice activity detection) is enabled.
390    pub vad_enabled: bool,
391    /// VAD energy threshold (0.0-1.0).
392    pub vad_threshold: f32,
393    /// Wake word phrases (e.g., ["hey rustant"]).
394    #[serde(default)]
395    pub wake_words: Vec<String>,
396    /// Wake word sensitivity (0.0-1.0).
397    pub wake_sensitivity: f32,
398    /// Whether to auto-speak responses.
399    pub auto_speak: bool,
400    /// Maximum listening duration in seconds.
401    pub max_listen_secs: u64,
402    /// Audio input device name (None = system default).
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub input_device: Option<String>,
405    /// Audio output device name (None = system default).
406    #[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/// Configuration for the multi-agent system.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct MultiAgentConfig {
435    /// Whether multi-agent mode is enabled.
436    pub enabled: bool,
437    /// Maximum number of concurrent agents.
438    pub max_agents: usize,
439    /// Maximum messages per agent mailbox.
440    pub max_mailbox_size: usize,
441    /// Default resource limits applied to new agents.
442    #[serde(default)]
443    pub default_resource_limits: crate::multi::ResourceLimits,
444    /// Default base directory for agent workspaces.
445    #[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// Channel Intelligence Configuration
462
463/// Auto-reply mode for channel intelligence.
464#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
465#[serde(rename_all = "snake_case")]
466pub enum AutoReplyMode {
467    /// Never auto-reply to channel messages.
468    Disabled,
469    /// Generate draft replies but do not send, store for user review.
470    DraftOnly,
471    /// Auto-reply for routine messages, queue high-priority for approval.
472    AutoWithApproval,
473    /// Send all replies automatically.
474    #[default]
475    FullAuto,
476}
477
478/// Frequency for generating channel digest summaries.
479#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
480#[serde(rename_all = "snake_case")]
481pub enum DigestFrequency {
482    /// No digests generated.
483    #[default]
484    Off,
485    /// Generate digest every hour.
486    Hourly,
487    /// Generate digest once per day.
488    Daily,
489    /// Generate digest once per week.
490    Weekly,
491}
492
493/// Priority level for classifying channel messages.
494#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
495#[serde(rename_all = "snake_case")]
496pub enum MessagePriority {
497    /// Low priority, informational, no action needed.
498    Low = 0,
499    /// Normal priority, standard messages.
500    #[default]
501    Normal = 1,
502    /// High priority, needs timely attention.
503    High = 2,
504    /// Urgent, needs immediate attention.
505    Urgent = 3,
506}
507
508/// Per-channel intelligence settings.
509///
510/// These can be overridden per-channel in the `[intelligence.channels.<name>]` section.
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct ChannelIntelligenceConfig {
513    /// Auto-reply mode for this channel.
514    #[serde(default)]
515    pub auto_reply: AutoReplyMode,
516    /// Digest generation frequency.
517    #[serde(default)]
518    pub digest: DigestFrequency,
519    /// Whether to auto-schedule follow-ups for urgent messages.
520    #[serde(default = "default_true")]
521    pub smart_scheduling: bool,
522    /// Priority threshold for escalation, messages at or above this level get escalated.
523    #[serde(default)]
524    pub escalation_threshold: MessagePriority,
525    /// Default follow-up reminder delay in minutes (default: 60).
526    #[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/// Top-level channel intelligence configuration.
543///
544/// Controls autonomous message handling across all channels.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct IntelligenceConfig {
547    /// Whether channel intelligence is enabled globally.
548    #[serde(default = "default_true")]
549    pub enabled: bool,
550    /// Default settings for all channels (overridden per-channel).
551    #[serde(default)]
552    pub defaults: ChannelIntelligenceConfig,
553    /// Per-channel overrides keyed by channel name (e.g., "email", "slack").
554    #[serde(default)]
555    pub channels: HashMap<String, ChannelIntelligenceConfig>,
556    /// Quiet hours: suppress auto-actions during these times.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub quiet_hours: Option<crate::scheduler::QuietHours>,
559    /// Directory for digest file export (default: ".rustant/digests").
560    #[serde(default = "default_digest_dir")]
561    pub digest_dir: PathBuf,
562    /// Directory for ICS calendar/reminder export (default: ".rustant/reminders").
563    #[serde(default = "default_reminders_dir")]
564    pub reminders_dir: PathBuf,
565    /// Maximum tokens per auto-reply LLM call (cost control).
566    #[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    /// Validate this channel intelligence config and return any warnings.
606    ///
607    /// Returns an empty Vec if the config is valid. Returns human-readable
608    /// warning messages for problematic values (backward compatible — does not error).
609    pub fn validate(&self) -> Vec<String> {
610        let mut warnings = Vec::new();
611
612        // S13: Warn on zero followup minutes (likely a mistake)
613        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        // S13: Warn on extremely large followup minutes (> 30 days)
620        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        // S13: Warn on Low escalation threshold (everything escalates)
629        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    /// Get the intelligence config for a specific channel, falling back to defaults.
640    pub fn for_channel(&self, channel_name: &str) -> &ChannelIntelligenceConfig {
641        self.channels.get(channel_name).unwrap_or(&self.defaults)
642    }
643
644    /// Check if the current time is within quiet hours.
645    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    /// Validate the entire intelligence config and return any warnings.
654    ///
655    /// Checks the default config and all per-channel overrides, plus quiet hours format.
656    pub fn validate(&self) -> Vec<String> {
657        let mut warnings = Vec::new();
658
659        // Validate defaults
660        for w in self.defaults.validate() {
661            warnings.push(format!("[defaults] {}", w));
662        }
663
664        // Validate per-channel overrides
665        for (name, cfg) in &self.channels {
666            for w in cfg.validate() {
667                warnings.push(format!("[channel:{}] {}", name, w));
668            }
669        }
670
671        // S13: Validate quiet hours time format
672        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        // S13: Warn on zero max_reply_tokens
688        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
696/// Check if a string is a valid HH:MM time format.
697fn 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/// Configuration for retry behavior on transient API errors.
712#[derive(Debug, Clone, Serialize, Deserialize)]
713pub struct RetryConfig {
714    /// Maximum number of retry attempts.
715    pub max_retries: u32,
716    /// Initial backoff delay in milliseconds.
717    pub initial_backoff_ms: u64,
718    /// Maximum backoff delay in milliseconds.
719    pub max_backoff_ms: u64,
720    /// Multiplier for exponential backoff.
721    pub backoff_multiplier: f64,
722    /// Whether to add random jitter to backoff delays.
723    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/// Configuration for messaging channels.
739#[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/// LLM provider configuration.
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct LlmConfig {
772    /// Provider name: "openai", "anthropic", "local".
773    pub provider: String,
774    /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514").
775    pub model: String,
776    /// Environment variable name containing the API key.
777    pub api_key_env: String,
778    /// Optional base URL override for the API endpoint.
779    pub base_url: Option<String>,
780    /// Maximum tokens to generate in a response.
781    pub max_tokens: usize,
782    /// Default temperature for generation.
783    pub temperature: f32,
784    /// Context window size for the model.
785    pub context_window: usize,
786    /// Cost per 1M input tokens (USD).
787    pub input_cost_per_million: f64,
788    /// Cost per 1M output tokens (USD).
789    pub output_cost_per_million: f64,
790    /// Whether to use streaming for LLM responses (enables token-by-token output).
791    pub use_streaming: bool,
792    /// Optional fallback providers tried in order if the primary fails.
793    #[serde(default)]
794    pub fallback_providers: Vec<FallbackProviderConfig>,
795    /// Optional credential store key (provider name in the OS credential store).
796    /// If set, the API key is loaded from the credential store instead of the env var.
797    #[serde(default, skip_serializing_if = "Option::is_none")]
798    pub credential_store_key: Option<String>,
799    /// Authentication method: "api_key" (default) or "oauth".
800    /// When set to "oauth", the provider will use an OAuth token from the credential
801    /// store instead of a traditional API key.
802    #[serde(default)]
803    pub auth_method: String,
804    /// Optional direct API key value.
805    /// If the value starts with "keychain:", the remainder is used as a keyring
806    /// service name and the actual key is resolved at startup via `resolve_credentials()`.
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub api_key: Option<String>,
809    /// Retry configuration for transient API errors (429, 5xx, timeouts).
810    #[serde(default)]
811    pub retry: RetryConfig,
812}
813
814/// Configuration for a fallback LLM provider.
815#[derive(Debug, Clone, Serialize, Deserialize)]
816pub struct FallbackProviderConfig {
817    /// Provider name: "openai", "anthropic", etc.
818    pub provider: String,
819    /// Model identifier.
820    pub model: String,
821    /// Environment variable name containing the API key.
822    pub api_key_env: String,
823    /// Optional base URL override.
824    #[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    /// Validate this LLM config and return any warnings.
852    ///
853    /// Returns an empty Vec if the config is valid. Returns human-readable
854    /// warning messages for problematic values (backward compatible — does not error).
855    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/// Approval mode controlling how much autonomy the agent has.
874#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(rename_all = "lowercase")]
876pub enum ApprovalMode {
877    /// Only read operations are auto-approved; all writes require approval.
878    #[default]
879    Safe,
880    /// All reversible operations are auto-approved; destructive requires approval.
881    Cautious,
882    /// Every single action requires explicit approval.
883    Paranoid,
884    /// All operations are auto-approved (use at own risk).
885    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/// Safety and permission configuration.
900#[derive(Debug, Clone, Serialize, Deserialize)]
901pub struct SafetyConfig {
902    pub approval_mode: ApprovalMode,
903    /// Glob patterns for allowed file paths (relative to workspace).
904    pub allowed_paths: Vec<String>,
905    /// Glob patterns for denied file paths.
906    pub denied_paths: Vec<String>,
907    /// Allowed shell command prefixes.
908    pub allowed_commands: Vec<String>,
909    /// Commands that always require approval.
910    pub ask_commands: Vec<String>,
911    /// Commands that are never allowed.
912    pub denied_commands: Vec<String>,
913    /// Allowed network hosts.
914    pub allowed_hosts: Vec<String>,
915    /// Maximum iterations before the agent pauses.
916    pub max_iterations: usize,
917    /// Prompt injection detection settings.
918    #[serde(default)]
919    pub injection_detection: InjectionDetectionConfig,
920    /// Optional adaptive trust configuration.
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub adaptive_trust: Option<AdaptiveTrustConfig>,
923    /// Maximum tool calls per minute (0 = unlimited).
924    #[serde(default)]
925    pub max_tool_calls_per_minute: usize,
926}
927
928/// Configuration for the prompt injection detection system.
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct InjectionDetectionConfig {
931    /// Whether injection detection is enabled.
932    pub enabled: bool,
933    /// Risk score threshold (0.0 - 1.0) above which content is considered suspicious.
934    pub threshold: f32,
935    /// Whether to scan tool outputs for indirect injection attempts.
936    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/// Configuration for the adaptive trust gradient system.
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct AdaptiveTrustConfig {
952    /// Whether adaptive trust is enabled.
953    pub enabled: bool,
954    /// Number of consecutive approvals required before a tool is auto-promoted.
955    pub trust_escalation_threshold: usize,
956    /// Anomaly score [0, 1] above which trust is de-escalated.
957    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                // macOS daily assistant commands
999                "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/// Memory system configuration.
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1035pub struct MemoryConfig {
1036    /// Number of recent messages to keep verbatim in short-term memory.
1037    pub window_size: usize,
1038    /// Fraction of context window at which to trigger compression (0.0 - 1.0).
1039    pub compression_threshold: f32,
1040    /// Path for persistent long-term memory storage.
1041    pub persist_path: Option<PathBuf>,
1042    /// Whether to enable long-term memory persistence.
1043    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/// UI configuration.
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1059pub struct UiConfig {
1060    /// Color theme name.
1061    pub theme: String,
1062    /// Whether to enable vim keybindings.
1063    pub vim_mode: bool,
1064    /// Whether to show cost information in the UI.
1065    pub show_cost: bool,
1066    /// Whether to use the TUI (false = simple REPL, default).
1067    pub use_tui: bool,
1068    /// Whether verbose output is enabled (shows tool execution details).
1069    #[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/// Tools configuration.
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct ToolsConfig {
1088    /// Whether to enable built-in tools.
1089    pub enable_builtins: bool,
1090    /// Timeout for tool execution in seconds.
1091    pub default_timeout_secs: u64,
1092    /// Maximum output size from a tool in bytes.
1093    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, // 1MB
1102        }
1103    }
1104}
1105
1106/// Token budget configuration for cost control.
1107#[derive(Debug, Clone, Serialize, Deserialize)]
1108pub struct BudgetConfig {
1109    /// Maximum cost in USD per session (0.0 = unlimited).
1110    pub session_limit_usd: f64,
1111    /// Maximum cost in USD per task (0.0 = unlimited).
1112    pub task_limit_usd: f64,
1113    /// Maximum total tokens per session (0 = unlimited).
1114    pub session_token_limit: usize,
1115    /// Whether to warn (false) or halt (true) when budget is exceeded.
1116    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/// Configuration for cross-session knowledge distillation.
1131#[derive(Debug, Clone, Serialize, Deserialize)]
1132pub struct KnowledgeConfig {
1133    /// Whether knowledge distillation is enabled.
1134    pub enabled: bool,
1135    /// Maximum number of distilled rules to inject into the system prompt.
1136    pub max_rules: usize,
1137    /// Minimum number of corrections/facts before distillation is triggered.
1138    pub min_entries_for_distillation: usize,
1139    /// Path to the local knowledge store file.
1140    #[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// --- LLM Council Configuration ---
1156
1157/// Voting strategy for the LLM council.
1158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1159#[serde(rename_all = "snake_case")]
1160pub enum VotingStrategy {
1161    /// Chairman model synthesizes all member responses into a final answer (default).
1162    #[default]
1163    ChairmanSynthesis,
1164    /// Pick the response with the highest peer review score.
1165    HighestScore,
1166    /// Extract and combine consensus elements from all responses.
1167    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/// Configuration for a single LLM council member.
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct CouncilMemberConfig {
1183    /// Provider name: "openai", "anthropic", "gemini", "ollama".
1184    pub provider: String,
1185    /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4-20250514").
1186    pub model: String,
1187    /// Environment variable name containing the API key (empty for Ollama localhost).
1188    #[serde(default)]
1189    pub api_key_env: String,
1190    /// Optional base URL override (e.g., "http://127.0.0.1:11434/v1" for Ollama).
1191    #[serde(default, skip_serializing_if = "Option::is_none")]
1192    pub base_url: Option<String>,
1193    /// Voting weight for this member (default 1.0).
1194    #[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/// Configuration for the LLM Council (multi-model deliberation).
1215///
1216/// Inspired by [karpathy/llm-council](https://github.com/karpathy/llm-council).
1217/// When enabled and configured with 2+ members, planning tasks are sent to multiple
1218/// models for deliberation, optional peer review, and chairman synthesis.
1219#[derive(Debug, Clone, Serialize, Deserialize)]
1220pub struct CouncilConfig {
1221    /// Whether the council feature is enabled.
1222    #[serde(default)]
1223    pub enabled: bool,
1224    /// Council members (at least 2, recommended 3+).
1225    #[serde(default)]
1226    pub members: Vec<CouncilMemberConfig>,
1227    /// Voting/synthesis strategy.
1228    #[serde(default)]
1229    pub voting_strategy: VotingStrategy,
1230    /// Whether to enable peer review stage (each model reviews others' responses).
1231    #[serde(default = "default_true")]
1232    pub enable_peer_review: bool,
1233    /// Explicit chairman model name. If None, auto-selects the model with largest context window.
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub chairman_model: Option<String>,
1236    /// Maximum tokens per member response (cost control).
1237    #[serde(default = "default_max_member_tokens")]
1238    pub max_member_tokens: usize,
1239    /// Whether to auto-detect available providers from env vars and Ollama.
1240    #[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
1262/// Load configuration from layered sources.
1263///
1264/// Priority (highest to lowest):
1265/// 1. Explicit overrides (passed as argument)
1266/// 2. Environment variables (prefixed with `RUSTANT_`)
1267/// 3. Workspace-local config (`.rustant/config.toml`)
1268/// 4. User config (`~/.config/rustant/config.toml`)
1269/// 5. Built-in defaults
1270pub 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    // User-level config
1277    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    // Workspace-level config
1285    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    // Environment variables (RUSTANT_LLM__MODEL, RUSTANT_SAFETY__APPROVAL_MODE, etc.)
1293    figment = figment.merge(Env::prefixed("RUSTANT_").split("__"));
1294
1295    // Explicit overrides
1296    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
1306/// Resolve credential references in config.
1307///
1308/// Tries these sources in order of priority:
1309/// 1. `api_key` field with `"keychain:"` prefix — resolves from OS keyring by service name
1310/// 2. `credential_store_key` field — resolves from OS keyring by provider name
1311/// 3. (At provider init time) environment variable via `api_key_env`
1312///
1313/// The resolved key is stored in `config.llm.api_key` so providers can read it
1314/// without needing direct access to the credential store.
1315pub fn resolve_credentials(config: &mut AgentConfig) {
1316    // 1. Resolve "keychain:" prefix in api_key field
1317    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; // Already resolved, no need to check credential_store_key
1327            }
1328            Err(e) => {
1329                tracing::warn!("Failed to resolve keyring credential '{}': {}", service, e);
1330            }
1331        }
1332    }
1333
1334    // 2. Resolve from credential_store_key (set by `rustant setup`)
1335    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
1358/// Auto-migrate plaintext channel secrets to the OS keychain.
1359///
1360/// If `channels.slack.bot_token` contains an inline plaintext token,
1361/// migrate it to the keychain and update the in-memory config to use a
1362/// `keychain:` reference. Optionally rewrites the config file.
1363fn 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    // Store in keychain
1391    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    // Update in-memory config
1399    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    // Best-effort: rewrite config file with keychain reference
1406    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
1417/// Check whether any Rustant configuration file exists (user-level or workspace-level).
1418///
1419/// Returns `true` if a config file is found at either:
1420/// - `~/.config/rustant/config.toml` (user-level, via `directories` crate)
1421/// - `<workspace>/.rustant/config.toml` (workspace-level)
1422pub fn config_exists(workspace: Option<&Path>) -> bool {
1423    // Check user-level config
1424    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    // Check workspace-level config
1431    if let Some(ws) = workspace
1432        && ws.join(".rustant").join("config.toml").exists()
1433    {
1434        return true;
1435    }
1436
1437    false
1438}
1439
1440/// Update a specific channel's configuration in the workspace config file.
1441///
1442/// Loads the existing `.rustant/config.toml`, sets or replaces the named channel's
1443/// config, preserves all other channels and settings, and writes back.
1444/// Returns the path to the config file.
1445pub 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    // Load existing config or start from defaults
1455    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    // Serialize to a TOML table so we can set the channel dynamically
1463    let mut table: toml::Value = toml::Value::try_from(&config)?;
1464
1465    // Ensure [channels] table exists
1466    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    // Set channels.<channel_name> = channel_toml
1473    if let Some(ch_table) = channels_table.as_table_mut() {
1474        ch_table.insert(channel_name.to_string(), channel_toml);
1475    }
1476
1477    // Deserialize back to verify it's valid, then write
1478    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    /// Shared mutex for tests that read/write RUSTANT_* env vars to avoid races.
1491    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        // Clear any stray env var from parallel tests
1547        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 that RUSTANT_SAFETY__APPROVAL_MODE env var overrides both defaults and
1601    /// workspace config. Combined into one test to avoid race conditions between
1602    /// `set_var`/`remove_var` calls across parallel test threads.
1603    #[test]
1604    fn test_env_var_override_approval_mode() {
1605        let _lock = ENV_MUTEX.lock().unwrap();
1606
1607        // Part 1: env var overrides default (no workspace config)
1608        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        // Part 2: env var overrides workspace config file
1617        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        // Cleanup
1644        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        // Deserialize config without gateway/search/flush — all should be None
1765        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        // Serialization roundtrip
1806        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        // Deserialize config without new fields — should use defaults
1850        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        // email channel gets override
1901        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        // slack channel falls back to defaults
1907        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        // Verify backward compat: AgentConfig without intelligence field still works
2014        let config = AgentConfig::default();
2015        assert!(config.intelligence.is_none());
2016    }
2017
2018    // --- S13: Config Validation Tests ---
2019
2020    #[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    // --- Council Config Tests ---
2130
2131    #[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        // Round-trip
2194        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        // Backward compat: council is None by default
2202        let config = AgentConfig::default();
2203        assert!(config.council.is_none());
2204
2205        // With council set
2206        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}