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    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/// 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).
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: true,
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        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; // Already resolved, no need to check credential_store_key
1326                }
1327                Err(e) => {
1328                    tracing::warn!("Failed to resolve keyring credential '{}': {}", service, e);
1329                }
1330            }
1331        }
1332    }
1333
1334    // 2. Resolve from credential_store_key (set by `rustant setup`)
1335    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
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        if let Some(slack) = channels.slack.as_mut() {
1401            slack.bot_token = SecretRef::keychain("channel:slack:bot_token");
1402        }
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            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
1418/// Check whether any Rustant configuration file exists (user-level or workspace-level).
1419///
1420/// Returns `true` if a config file is found at either:
1421/// - `~/.config/rustant/config.toml` (user-level, via `directories` crate)
1422/// - `<workspace>/.rustant/config.toml` (workspace-level)
1423pub fn config_exists(workspace: Option<&Path>) -> bool {
1424    // Check user-level config
1425    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    // Check workspace-level config
1432    if let Some(ws) = workspace {
1433        if ws.join(".rustant").join("config.toml").exists() {
1434            return true;
1435        }
1436    }
1437
1438    false
1439}
1440
1441/// Update a specific channel's configuration in the workspace config file.
1442///
1443/// Loads the existing `.rustant/config.toml`, sets or replaces the named channel's
1444/// config, preserves all other channels and settings, and writes back.
1445/// Returns the path to the config file.
1446pub 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    // Load existing config or start from defaults
1456    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    // Serialize to a TOML table so we can set the channel dynamically
1464    let mut table: toml::Value = toml::Value::try_from(&config)?;
1465
1466    // Ensure [channels] table exists
1467    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    // Set channels.<channel_name> = channel_toml
1474    if let Some(ch_table) = channels_table.as_table_mut() {
1475        ch_table.insert(channel_name.to_string(), channel_toml);
1476    }
1477
1478    // Deserialize back to verify it's valid, then write
1479    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    /// Shared mutex for tests that read/write RUSTANT_* env vars to avoid races.
1492    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        // Clear any stray env var from parallel tests
1548        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 that RUSTANT_SAFETY__APPROVAL_MODE env var overrides both defaults and
1602    /// workspace config. Combined into one test to avoid race conditions between
1603    /// `set_var`/`remove_var` calls across parallel test threads.
1604    #[test]
1605    fn test_env_var_override_approval_mode() {
1606        let _lock = ENV_MUTEX.lock().unwrap();
1607
1608        // Part 1: env var overrides default (no workspace config)
1609        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        // Part 2: env var overrides workspace config file
1618        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        // Cleanup
1645        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        // Deserialize config without gateway/search/flush — all should be None
1766        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        // Serialization roundtrip
1807        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        // Deserialize config without new fields — should use defaults
1851        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        // email channel gets override
1902        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        // slack channel falls back to defaults
1908        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        // Verify backward compat: AgentConfig without intelligence field still works
2015        let config = AgentConfig::default();
2016        assert!(config.intelligence.is_none());
2017    }
2018
2019    // --- S13: Config Validation Tests ---
2020
2021    #[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    // --- Council Config Tests ---
2131
2132    #[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        // Round-trip
2195        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        // Backward compat: council is None by default
2203        let config = AgentConfig::default();
2204        assert!(config.council.is_none());
2205
2206        // With council set
2207        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}