Skip to main content

heartbit_core/config/
mod.rs

1//! TOML configuration types for agents, orchestrators, LLM providers, and integrations.
2
3mod agent;
4mod daemon;
5mod guardrails;
6mod memory;
7mod persona;
8mod provider;
9mod sensor;
10
11// Re-export everything so the public API doesn't change.
12pub use agent::{
13    AgentConfig, AgentProviderConfig, ContextStrategyConfig, DispatchMode, McpResourceMode,
14    McpServerEntry, OrchestratorConfig, SessionPruneConfigToml, SpawnConfig,
15};
16pub use daemon::{
17    ActiveHoursConfig, AuthConfig, DaemonAuditConfig, DaemonConfig, DaemonMcpServerConfig,
18    DaemonMemoryConfig, HeartbitPulseConfig, IdempotencyConfig, KafkaConfig, MetricsConfig,
19    ScheduleEntry, TokenExchangeConfig, WsConfig,
20};
21pub use guardrails::{
22    ActionBudgetConfig, ActionBudgetRuleConfig, BehavioralConfig, BehavioralRuleConfig,
23    GuardrailsConfig, InjectionConfig, InputConstraintConfig, LlmJudgeConfig, PiiConfig,
24    SecretPatternConfig, SecretScanConfig, ToolPolicyConfig, ToolPolicyRuleConfig,
25};
26pub use memory::{
27    EmbeddingConfig, KnowledgeConfig, KnowledgeSourceConfig, LspConfig, MemoryConfig,
28    RestateConfig, TelemetryConfig, WorkspaceConfig,
29};
30pub use persona::{PersonaConfig, PersonaPhase};
31pub use provider::{
32    CascadeConfig, CascadeGateConfig, CascadeTierConfig, ProviderCircuitConfig, ProviderConfig,
33    RetryProviderConfig,
34};
35pub use sensor::{
36    SalienceConfig, SensorConfig, SensorRoutingConfig, SensorSourceConfig, StoryCorrelationConfig,
37    TokenBudgetConfig,
38};
39
40pub use crate::agent::routing::RoutingMode;
41
42use serde::{Deserialize, Serialize};
43
44use crate::Error;
45use crate::agent::permission::PermissionRule;
46use crate::agent::tool_filter::ToolProfile;
47use crate::llm::types::ReasoningEffort;
48
49/// Known builtin tool names for validation of `builtin_tools` allowlists.
50pub const KNOWN_BUILTINS: &[&str] = &[
51    "bash",
52    "read",
53    "write",
54    "edit",
55    "grep",
56    "glob",
57    "list",
58    "patch",
59    "webfetch",
60    "websearch",
61    "image_generate",
62    "tts",
63    "skill",
64    "todoread",
65    "todowrite",
66    "question",
67    "twitter_post",
68    "todo_manage",
69];
70
71/// Sensory modality — the type of information a sensor captures.
72///
73/// Defined in config so it's always available for TOML deserialization
74/// even when the `sensor` feature is disabled. Re-exported from `sensor` module.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SensorModality {
78    /// Email body, RSS content, chat messages.
79    Text,
80    /// Photos, screenshots, documents-as-images.
81    Image,
82    /// Voice notes, calls, podcasts.
83    Audio,
84    /// Weather JSON, GPS coordinates, API responses.
85    Structured,
86}
87
88impl std::fmt::Display for SensorModality {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            SensorModality::Text => write!(f, "text"),
92            SensorModality::Image => write!(f, "image"),
93            SensorModality::Audio => write!(f, "audio"),
94            SensorModality::Structured => write!(f, "structured"),
95        }
96    }
97}
98
99pub use crate::types::TrustLevel;
100
101/// Parse a workflow type string into the enum.
102pub fn parse_workflow_type(s: &str) -> Result<crate::agent::workflow::WorkflowType, Error> {
103    use crate::agent::workflow::WorkflowType;
104    match s.to_lowercase().as_str() {
105        "dag" => Ok(WorkflowType::Dag),
106        "sequential" => Ok(WorkflowType::Sequential),
107        "parallel" => Ok(WorkflowType::Parallel),
108        "loop" => Ok(WorkflowType::Loop),
109        "debate" => Ok(WorkflowType::Debate),
110        "voting" => Ok(WorkflowType::Voting),
111        "mixture" => Ok(WorkflowType::Mixture),
112        _ => Err(Error::Config(format!(
113            "invalid workflow_type '{}': must be dag, sequential, parallel, loop, debate, voting, or mixture",
114            s
115        ))),
116    }
117}
118
119/// Parse a tool profile string into the enum.
120pub fn parse_tool_profile(s: &str) -> Result<ToolProfile, Error> {
121    match s.to_lowercase().as_str() {
122        "conversational" => Ok(ToolProfile::Conversational),
123        "standard" => Ok(ToolProfile::Standard),
124        "full" => Ok(ToolProfile::Full),
125        _ => Err(Error::Config(format!(
126            "invalid tool_profile '{}': must be conversational, standard, or full",
127            s
128        ))),
129    }
130}
131
132/// Parse a reasoning effort string into the enum.
133pub fn parse_reasoning_effort(s: &str) -> Result<ReasoningEffort, Error> {
134    match s.to_lowercase().as_str() {
135        "high" => Ok(ReasoningEffort::High),
136        "medium" => Ok(ReasoningEffort::Medium),
137        "low" => Ok(ReasoningEffort::Low),
138        "none" => Ok(ReasoningEffort::None),
139        _ => Err(Error::Config(format!(
140            "invalid reasoning_effort '{}': must be high, medium, low, or none",
141            s
142        ))),
143    }
144}
145
146/// Shared default helper used by multiple submodules.
147fn default_true() -> bool {
148    true
149}
150
151/// Application-layer filesystem sandbox configuration.
152///
153/// When present, a `CorePathPolicy` is built from `allowed_dirs` and
154/// `deny_globs` and applied to all filesystem builtins (read, write, edit,
155/// patch). On Linux with the `sandbox` feature, bash also gets a wrapped
156/// `SandboxPolicy` for Landlock kernel enforcement.
157#[derive(Debug, Clone, Default, Deserialize)]
158pub struct SandboxConfig {
159    /// Directories the agent is allowed to access. When empty, the policy
160    /// denies all directory-level access (files may still pass glob checks).
161    #[serde(default)]
162    pub allowed_dirs: Vec<std::path::PathBuf>,
163    /// Glob patterns for paths to deny regardless of `allowed_dirs`.
164    /// Example: `["**/.env", "**/secrets/**"]`.
165    #[serde(default)]
166    pub deny_globs: Vec<String>,
167}
168
169/// Top-level configuration loaded from `heartbit.toml`.
170#[derive(Debug, Deserialize)]
171pub struct HeartbitConfig {
172    /// LLM provider selection and API key configuration.
173    #[serde(default)]
174    pub provider: ProviderConfig,
175    /// Orchestrator-level defaults applied to all sub-agents.
176    #[serde(default)]
177    pub orchestrator: OrchestratorConfig,
178    /// Per-agent configurations. Each entry defines one sub-agent.
179    #[serde(default)]
180    pub agents: Vec<AgentConfig>,
181    /// Custom variables for template prompt substitution.
182    /// Available as `{var_name}` in system_prompt, template prompts, and skill content.
183    #[serde(default)]
184    pub variables: std::collections::HashMap<String, String>,
185    /// Restate durable workflow configuration.
186    pub restate: Option<RestateConfig>,
187    /// OpenTelemetry tracing configuration.
188    pub telemetry: Option<TelemetryConfig>,
189    /// Persistent memory backend configuration.
190    pub memory: Option<MemoryConfig>,
191    /// Knowledge base (RAG) configuration.
192    pub knowledge: Option<KnowledgeConfig>,
193    /// Declarative permission rules applied to all agents.
194    /// Rules are evaluated in order — first match wins.
195    #[serde(default)]
196    pub permissions: Vec<PermissionRule>,
197    /// Optional LSP integration for diagnostics after file-modifying tools.
198    pub lsp: Option<LspConfig>,
199    /// Daemon mode configuration for Kafka-backed long-running execution.
200    pub daemon: Option<DaemonConfig>,
201    /// Optional workspace configuration for agent home directories.
202    pub workspace: Option<WorkspaceConfig>,
203    /// Guardrails configuration for injection detection, PII, and tool policies.
204    #[serde(default)]
205    pub guardrails: Option<GuardrailsConfig>,
206    /// Application-layer filesystem sandbox. When set, restricts file access
207    /// to `allowed_dirs` and blocks `deny_globs`. On Linux + sandbox feature,
208    /// bash subprocess also gets Landlock enforcement.
209    #[serde(default)]
210    pub sandbox: Option<SandboxConfig>,
211    /// Persona instances declared in this config (Phase 0: parsed and
212    /// lexically validated; the registry lookup happens at daemon startup
213    /// once persona crates are loaded).
214    #[serde(default, rename = "persona")]
215    pub personas: Vec<PersonaConfig>,
216}
217
218impl HeartbitConfig {
219    /// Parse a TOML string into a `HeartbitConfig` and validate it.
220    ///
221    /// Returns `Err(Error::Config)` when the TOML is malformed or when a
222    /// field violates an invariant (e.g. zero `max_turns`, missing provider
223    /// when not running in cloud-delegated daemon mode, duplicate agent names).
224    ///
225    /// # Example
226    ///
227    /// ```rust
228    /// use heartbit_core::config::HeartbitConfig;
229    ///
230    /// let toml_text = r#"
231    /// [provider]
232    /// name = "anthropic"
233    /// model = "claude-sonnet-4-20250514"
234    ///
235    /// [[agents]]
236    /// name = "assistant"
237    /// description = "A helpful general-purpose assistant."
238    /// system_prompt = "You are a helpful assistant."
239    /// max_turns = 10
240    /// max_tokens = 4096
241    /// "#;
242    ///
243    /// let config = HeartbitConfig::from_toml(toml_text).expect("valid config");
244    /// assert_eq!(config.agents.len(), 1);
245    /// assert_eq!(config.agents[0].name, "assistant");
246    /// ```
247    pub fn from_toml(content: &str) -> Result<Self, Error> {
248        let config: Self = toml::from_str(content).map_err(|e| Error::Config(e.to_string()))?;
249        config.validate()?;
250        Ok(config)
251    }
252
253    /// Read and parse a TOML config file.
254    pub fn from_file(path: &std::path::Path) -> Result<Self, Error> {
255        let content = std::fs::read_to_string(path)
256            .map_err(|e| Error::Config(format!("failed to read {}: {e}", path.display())))?;
257        Self::from_toml(&content)
258    }
259
260    fn validate(&self) -> Result<(), Error> {
261        // Validate top-level provider (skip when running as cloud-delegated runtime
262        // where per-request provider keys are used instead of a global config).
263        let daemon_only = self.daemon.is_some() && self.agents.is_empty();
264        if !daemon_only {
265            if self.provider.name.is_empty() {
266                return Err(Error::Config("provider.name must not be empty".into()));
267            }
268            if self.provider.model.is_empty() {
269                return Err(Error::Config("provider.model must not be empty".into()));
270            }
271        }
272        if self.orchestrator.max_turns == 0 {
273            return Err(Error::Config(
274                "orchestrator.max_turns must be at least 1".into(),
275            ));
276        }
277        if self.orchestrator.max_tokens == 0 {
278            return Err(Error::Config(
279                "orchestrator.max_tokens must be at least 1".into(),
280            ));
281        }
282        // Validate orchestrator context strategy
283        match &self.orchestrator.context_strategy {
284            Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
285                return Err(Error::Config(
286                    "orchestrator.context_strategy.max_tokens must be at least 1".into(),
287                ));
288            }
289            Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
290                return Err(Error::Config(
291                    "orchestrator.context_strategy.threshold must be at least 1".into(),
292                ));
293            }
294            _ => {}
295        }
296        if self.orchestrator.summarize_threshold == Some(0) {
297            return Err(Error::Config(
298                "orchestrator.summarize_threshold must be at least 1".into(),
299            ));
300        }
301        if matches!(
302            self.orchestrator.context_strategy,
303            Some(ContextStrategyConfig::Summarize { .. })
304                | Some(ContextStrategyConfig::SlidingWindow { .. })
305        ) && self.orchestrator.summarize_threshold.is_some()
306        {
307            return Err(Error::Config(
308                "orchestrator: cannot set both context_strategy \
309                 and summarize_threshold; use one or the other"
310                    .into(),
311            ));
312        }
313        if self.orchestrator.tool_timeout_seconds == Some(0) {
314            return Err(Error::Config(
315                "orchestrator.tool_timeout_seconds must be at least 1".into(),
316            ));
317        }
318        if self.orchestrator.max_tool_output_bytes == Some(0) {
319            return Err(Error::Config(
320                "orchestrator.max_tool_output_bytes must be at least 1".into(),
321            ));
322        }
323        if self.orchestrator.run_timeout_seconds == Some(0) {
324            return Err(Error::Config(
325                "orchestrator.run_timeout_seconds must be at least 1".into(),
326            ));
327        }
328        if let Some(ref effort) = self.orchestrator.reasoning_effort {
329            parse_reasoning_effort(effort)?;
330        }
331        if self.orchestrator.tool_output_compression_threshold == Some(0) {
332            return Err(Error::Config(
333                "orchestrator.tool_output_compression_threshold must be at least 1".into(),
334            ));
335        }
336        if self.orchestrator.max_tools_per_turn == Some(0) {
337            return Err(Error::Config(
338                "orchestrator.max_tools_per_turn must be at least 1".into(),
339            ));
340        }
341        if let Some(ref profile) = self.orchestrator.tool_profile {
342            parse_tool_profile(profile)?;
343        }
344        if self.orchestrator.max_identical_tool_calls == Some(0) {
345            return Err(Error::Config(
346                "orchestrator.max_identical_tool_calls must be at least 1".into(),
347            ));
348        }
349        if self.orchestrator.max_fuzzy_identical_tool_calls == Some(0) {
350            return Err(Error::Config(
351                "orchestrator.max_fuzzy_identical_tool_calls must be at least 1".into(),
352            ));
353        }
354        if self.orchestrator.max_tool_calls_per_turn == Some(0) {
355            return Err(Error::Config(
356                "orchestrator.max_tool_calls_per_turn must be at least 1".into(),
357            ));
358        }
359
360        // Validate spawn config
361        if let Some(ref spawn) = self.orchestrator.spawn {
362            if spawn.max_spawned_agents == 0 {
363                return Err(Error::Config(
364                    "orchestrator.spawn.max_spawned_agents must be at least 1".into(),
365                ));
366            }
367            if spawn.max_turns == 0 {
368                return Err(Error::Config(
369                    "orchestrator.spawn.max_turns must be at least 1".into(),
370                ));
371            }
372            if spawn.max_tokens == 0 {
373                return Err(Error::Config(
374                    "orchestrator.spawn.max_tokens must be at least 1".into(),
375                ));
376            }
377            if spawn.max_total_tokens == 0 {
378                return Err(Error::Config(
379                    "orchestrator.spawn.max_total_tokens must be at least 1".into(),
380                ));
381            }
382            if spawn.tool_allowlist.is_empty() {
383                tracing::warn!(
384                    "orchestrator.spawn.tool_allowlist is empty — spawned agents will be reasoning-only"
385                );
386            }
387        }
388
389        // Validate cascade config: enabled requires at least one tier
390        if let Some(ref cascade) = self.provider.cascade
391            && cascade.enabled
392            && cascade.tiers.is_empty()
393        {
394            return Err(Error::Config(
395                "provider.cascade.enabled is true but no tiers are configured; \
396                 add at least one [[provider.cascade.tiers]] entry"
397                    .into(),
398            ));
399        }
400        // Validate cascade tier models are non-empty
401        if let Some(ref cascade) = self.provider.cascade {
402            for (i, tier) in cascade.tiers.iter().enumerate() {
403                if tier.model.is_empty() {
404                    return Err(Error::Config(format!(
405                        "provider.cascade.tiers[{i}].model must not be empty"
406                    )));
407                }
408            }
409        }
410
411        // Validate retry config: base_delay_ms <= max_delay_ms
412        if let Some(ref retry) = self.provider.retry
413            && retry.base_delay_ms > retry.max_delay_ms
414        {
415            return Err(Error::Config(format!(
416                "provider.retry.base_delay_ms ({}) must not exceed max_delay_ms ({})",
417                retry.base_delay_ms, retry.max_delay_ms
418            )));
419        }
420
421        // Validate circuit breaker config (B5b)
422        if let Some(0) = self.provider.circuit.failure_threshold {
423            return Err(Error::Config(
424                "provider.circuit.failure_threshold must be > 0".into(),
425            ));
426        }
427        if let Some(0) = self.provider.circuit.initial_open_duration_seconds {
428            return Err(Error::Config(
429                "provider.circuit.initial_open_duration_seconds must be > 0".into(),
430            ));
431        }
432        if let Some(0) = self.provider.circuit.max_open_duration_seconds {
433            return Err(Error::Config(
434                "provider.circuit.max_open_duration_seconds must be > 0".into(),
435            ));
436        }
437        // backoff_multiplier must be finite and strictly positive.
438        // - 0.0 makes record_failure's HalfOpen branch produce a zero-duration backoff (degenerate).
439        // - Negative values panic in Duration::from_secs_f64.
440        // - NaN/Inf panic in Duration::from_secs_f64.
441        if let Some(m) = self.provider.circuit.backoff_multiplier
442            && (!m.is_finite() || m <= 0.0)
443        {
444            return Err(Error::Config(
445                "provider.circuit.backoff_multiplier must be > 0 and finite".into(),
446            ));
447        }
448        if let Some(0) = self.orchestrator.max_tokens_in_flight_per_tenant {
449            return Err(Error::Config(
450                "orchestrator.max_tokens_in_flight_per_tenant must be > 0".into(),
451            ));
452        }
453
454        // Ensure agent names are unique
455        let mut seen = std::collections::HashSet::new();
456        for agent in &self.agents {
457            if agent.name.is_empty() {
458                return Err(Error::Config("agent name must not be empty".into()));
459            }
460            if !seen.insert(&agent.name) {
461                return Err(Error::Config(format!(
462                    "duplicate agent name: '{}'",
463                    agent.name
464                )));
465            }
466            // Validate context strategy max_tokens > 0
467            match &agent.context_strategy {
468                Some(ContextStrategyConfig::SlidingWindow { max_tokens }) if *max_tokens == 0 => {
469                    return Err(Error::Config(format!(
470                        "agent '{}': context_strategy.max_tokens must be at least 1",
471                        agent.name
472                    )));
473                }
474                Some(ContextStrategyConfig::Summarize { threshold }) if *threshold == 0 => {
475                    return Err(Error::Config(format!(
476                        "agent '{}': context_strategy.threshold must be at least 1",
477                        agent.name
478                    )));
479                }
480                _ => {}
481            }
482            if agent.max_turns == Some(0) {
483                return Err(Error::Config(format!(
484                    "agent '{}': max_turns must be at least 1",
485                    agent.name
486                )));
487            }
488            if agent.max_tokens == Some(0) {
489                return Err(Error::Config(format!(
490                    "agent '{}': max_tokens must be at least 1",
491                    agent.name
492                )));
493            }
494            if agent.tool_timeout_seconds == Some(0) {
495                return Err(Error::Config(format!(
496                    "agent '{}': tool_timeout_seconds must be at least 1",
497                    agent.name
498                )));
499            }
500            if agent.max_tool_output_bytes == Some(0) {
501                return Err(Error::Config(format!(
502                    "agent '{}': max_tool_output_bytes must be at least 1",
503                    agent.name
504                )));
505            }
506            if agent.run_timeout_seconds == Some(0) {
507                return Err(Error::Config(format!(
508                    "agent '{}': run_timeout_seconds must be at least 1",
509                    agent.name
510                )));
511            }
512            // Validate per-agent provider config
513            if let Some(ref p) = agent.provider {
514                if p.name.is_empty() {
515                    return Err(Error::Config(format!(
516                        "agent '{}': provider.name must not be empty",
517                        agent.name
518                    )));
519                }
520                if p.model.is_empty() {
521                    return Err(Error::Config(format!(
522                        "agent '{}': provider.model must not be empty",
523                        agent.name
524                    )));
525                }
526            }
527            // Validate MCP server entries
528            for (i, entry) in agent.mcp_servers.iter().enumerate() {
529                if entry.url().is_empty() {
530                    return Err(Error::Config(format!(
531                        "agent '{}': mcp_servers[{i}].url must not be empty",
532                        agent.name
533                    )));
534                }
535            }
536            // Validate A2A agent entries
537            for (i, entry) in agent.a2a_agents.iter().enumerate() {
538                if entry.url().is_empty() {
539                    return Err(Error::Config(format!(
540                        "agent '{}': a2a_agents[{i}].url must not be empty",
541                        agent.name
542                    )));
543                }
544            }
545            if agent.summarize_threshold == Some(0) {
546                return Err(Error::Config(format!(
547                    "agent '{}': summarize_threshold must be at least 1",
548                    agent.name
549                )));
550            }
551            if matches!(
552                agent.context_strategy,
553                Some(ContextStrategyConfig::Summarize { .. })
554                    | Some(ContextStrategyConfig::SlidingWindow { .. })
555            ) && agent.summarize_threshold.is_some()
556            {
557                return Err(Error::Config(format!(
558                    "agent '{}': cannot set both context_strategy and summarize_threshold; \
559                     use one or the other",
560                    agent.name
561                )));
562            }
563            if let Some(ref effort) = agent.reasoning_effort {
564                parse_reasoning_effort(effort).map_err(|_| {
565                    Error::Config(format!(
566                        "agent '{}': invalid reasoning_effort '{}': must be high, medium, low, or none",
567                        agent.name, effort
568                    ))
569                })?;
570            }
571            if agent.tool_output_compression_threshold == Some(0) {
572                return Err(Error::Config(format!(
573                    "agent '{}': tool_output_compression_threshold must be at least 1",
574                    agent.name
575                )));
576            }
577            if agent.max_tools_per_turn == Some(0) {
578                return Err(Error::Config(format!(
579                    "agent '{}': max_tools_per_turn must be at least 1",
580                    agent.name
581                )));
582            }
583            if agent.max_identical_tool_calls == Some(0) {
584                return Err(Error::Config(format!(
585                    "agent '{}': max_identical_tool_calls must be at least 1",
586                    agent.name
587                )));
588            }
589            if agent.max_fuzzy_identical_tool_calls == Some(0) {
590                return Err(Error::Config(format!(
591                    "agent '{}': max_fuzzy_identical_tool_calls must be at least 1",
592                    agent.name
593                )));
594            }
595            if agent.max_tool_calls_per_turn == Some(0) {
596                return Err(Error::Config(format!(
597                    "agent '{}': max_tool_calls_per_turn must be at least 1",
598                    agent.name
599                )));
600            }
601            if agent.max_total_tokens == Some(0) {
602                return Err(Error::Config(format!(
603                    "agent '{}': max_total_tokens must be at least 1",
604                    agent.name
605                )));
606            }
607            if let Some(ref profile) = agent.tool_profile {
608                parse_tool_profile(profile).map_err(|_| {
609                    Error::Config(format!(
610                        "agent '{}': invalid tool_profile '{}': must be conversational, standard, or full",
611                        agent.name, profile
612                    ))
613                })?;
614            }
615            if let Some(ref bt) = agent.builtin_tools {
616                for name in bt {
617                    if !KNOWN_BUILTINS.contains(&name.as_str()) {
618                        return Err(Error::Config(format!(
619                            "agent '{}': unknown builtin tool '{}'; known builtins: {}",
620                            agent.name,
621                            name,
622                            KNOWN_BUILTINS.join(", ")
623                        )));
624                    }
625                }
626            }
627        }
628
629        // Validate knowledge config
630        if let Some(ref knowledge) = self.knowledge {
631            if knowledge.chunk_size == 0 {
632                return Err(Error::Config(
633                    "knowledge.chunk_size must be at least 1".into(),
634                ));
635            }
636            if knowledge.chunk_overlap >= knowledge.chunk_size {
637                return Err(Error::Config(format!(
638                    "knowledge.chunk_overlap ({}) must be less than chunk_size ({})",
639                    knowledge.chunk_overlap, knowledge.chunk_size
640                )));
641            }
642        }
643
644        // Validate daemon config
645        if let Some(ref daemon) = self.daemon {
646            if daemon.max_concurrent_tasks == 0 {
647                return Err(Error::Config(
648                    "daemon.max_concurrent_tasks must be at least 1".into(),
649                ));
650            }
651            if daemon.audit.prune_interval_minutes == Some(0) {
652                return Err(Error::Config(
653                    "daemon.audit.prune_interval_minutes must be at least 1".into(),
654                ));
655            }
656            if daemon.audit.retain_days == Some(0) {
657                return Err(Error::Config(
658                    "daemon.audit.retain_days must be at least 1 if set".into(),
659                ));
660            }
661            if daemon.idempotency.ttl_hours == Some(0) {
662                return Err(Error::Config(
663                    "daemon.idempotency.ttl_hours must be at least 1 if set".into(),
664                ));
665            }
666            if daemon.idempotency.sweep_interval_minutes == Some(0) {
667                return Err(Error::Config(
668                    "daemon.idempotency.sweep_interval_minutes must be at least 1 if set".into(),
669                ));
670            }
671            if let Some(ref kafka) = daemon.kafka {
672                if kafka.brokers.is_empty() {
673                    return Err(Error::Config(
674                        "daemon.kafka.brokers must not be empty".into(),
675                    ));
676                }
677                if kafka.consumer_group.is_empty() {
678                    return Err(Error::Config(
679                        "daemon.kafka.consumer_group must not be empty".into(),
680                    ));
681                }
682                if kafka.commands_topic.is_empty() {
683                    return Err(Error::Config(
684                        "daemon.kafka.commands_topic must not be empty".into(),
685                    ));
686                }
687                if kafka.events_topic.is_empty() {
688                    return Err(Error::Config(
689                        "daemon.kafka.events_topic must not be empty".into(),
690                    ));
691                }
692            }
693            // Validate auth config
694            if let Some(ref auth) = daemon.auth {
695                if auth.bearer_tokens.is_empty() && auth.jwks_url.is_none() {
696                    return Err(Error::Config(
697                        "daemon.auth requires at least bearer_tokens or jwks_url".into(),
698                    ));
699                }
700                for (i, token) in auth.bearer_tokens.iter().enumerate() {
701                    if token.is_empty() {
702                        return Err(Error::Config(format!(
703                            "daemon.auth.bearer_tokens[{i}] must not be empty"
704                        )));
705                    }
706                }
707                if let Some(ref url) = auth.jwks_url
708                    && url.is_empty()
709                {
710                    return Err(Error::Config(
711                        "daemon.auth.jwks_url must not be empty".into(),
712                    ));
713                }
714                if let Some(ref te) = auth.token_exchange {
715                    if te.exchange_url.is_empty() {
716                        return Err(Error::Config(
717                            "daemon.auth.token_exchange.exchange_url must not be empty".into(),
718                        ));
719                    }
720                    if te.client_id.is_empty() {
721                        return Err(Error::Config(
722                            "daemon.auth.token_exchange.client_id must not be empty".into(),
723                        ));
724                    }
725                    if te.client_secret.is_empty() {
726                        return Err(Error::Config(
727                            "daemon.auth.token_exchange.client_secret must not be empty".into(),
728                        ));
729                    }
730                    if te.tenant_id.is_none() && te.agent_token.is_empty() {
731                        return Err(Error::Config(
732                            "daemon.auth.token_exchange: set tenant_id for auto-fetch, or provide a static agent_token".into(),
733                        ));
734                    }
735                }
736            }
737        }
738
739        // Persona blocks: lexical validation + duplicate-name check.
740        let mut seen_persona_names = std::collections::HashSet::new();
741        for persona in &self.personas {
742            persona.validate()?;
743            if !seen_persona_names.insert(persona.name.clone()) {
744                return Err(Error::Config(format!(
745                    "duplicate persona name: '{}'",
746                    persona.name
747                )));
748            }
749        }
750
751        Ok(())
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    #[allow(unused_imports)]
758    use super::guardrails::default_pii_detectors;
759    use super::*;
760
761    #[test]
762    fn parse_full_config() {
763        let toml = r#"
764[provider]
765name = "anthropic"
766model = "claude-sonnet-4-20250514"
767
768[orchestrator]
769max_turns = 15
770max_tokens = 8192
771
772[[agents]]
773name = "researcher"
774description = "Research specialist"
775system_prompt = "You are a research specialist."
776
777[[agents]]
778name = "coder"
779description = "Coding expert"
780system_prompt = "You are a coding expert."
781mcp_servers = ["http://localhost:8000/mcp"]
782
783[restate]
784endpoint = "http://localhost:9070"
785"#;
786
787        let config = HeartbitConfig::from_toml(toml).unwrap();
788
789        assert_eq!(config.provider.name, "anthropic");
790        assert_eq!(config.provider.model, "claude-sonnet-4-20250514");
791        assert_eq!(config.orchestrator.max_turns, 15);
792        assert_eq!(config.orchestrator.max_tokens, 8192);
793        assert_eq!(config.agents.len(), 2);
794        assert_eq!(config.agents[0].name, "researcher");
795        assert_eq!(config.agents[0].mcp_servers.len(), 0);
796        assert_eq!(config.agents[1].name, "coder");
797        assert_eq!(
798            config.agents[1].mcp_servers,
799            vec![McpServerEntry::Simple("http://localhost:8000/mcp".into())]
800        );
801
802        let restate = config.restate.unwrap();
803        assert_eq!(restate.endpoint, "http://localhost:9070");
804    }
805
806    #[test]
807    fn parse_minimal_config() {
808        let toml = r#"
809[provider]
810name = "anthropic"
811model = "claude-sonnet-4-20250514"
812"#;
813
814        let config = HeartbitConfig::from_toml(toml).unwrap();
815
816        assert_eq!(config.provider.name, "anthropic");
817        assert_eq!(config.orchestrator.max_turns, 10);
818        assert_eq!(config.orchestrator.max_tokens, 4096);
819        assert!(config.agents.is_empty());
820        assert!(config.restate.is_none());
821    }
822
823    #[test]
824    fn missing_required_provider_field() {
825        let toml = r#"
826[provider]
827name = "anthropic"
828"#;
829        let err = HeartbitConfig::from_toml(toml).unwrap_err();
830        let msg = err.to_string();
831        assert!(
832            msg.contains("model"),
833            "error should mention missing field: {msg}"
834        );
835    }
836
837    #[test]
838    fn missing_provider_section() {
839        let toml = r#"
840[[agents]]
841name = "researcher"
842description = "Research"
843system_prompt = "You research."
844"#;
845        let err = HeartbitConfig::from_toml(toml).unwrap_err();
846        let msg = err.to_string();
847        assert!(
848            msg.contains("provider"),
849            "error should mention missing section: {msg}"
850        );
851    }
852
853    #[test]
854    fn invalid_toml_syntax() {
855        let toml = "this is not valid toml {{{";
856        let err = HeartbitConfig::from_toml(toml).unwrap_err();
857        assert!(matches!(err, Error::Config(_)));
858    }
859
860    #[test]
861    fn from_file_nonexistent_path() {
862        let err = HeartbitConfig::from_file(std::path::Path::new("/nonexistent/heartbit.toml"))
863            .unwrap_err();
864        let msg = err.to_string();
865        assert!(msg.contains("failed to read"), "error: {msg}");
866    }
867
868    #[test]
869    fn orchestrator_defaults_applied() {
870        let toml = r#"
871[provider]
872name = "openrouter"
873model = "anthropic/claude-sonnet-4"
874
875[orchestrator]
876"#;
877        let config = HeartbitConfig::from_toml(toml).unwrap();
878        assert_eq!(config.orchestrator.max_turns, 10);
879        assert_eq!(config.orchestrator.max_tokens, 4096);
880        assert!(config.orchestrator.context_strategy.is_none());
881        assert!(config.orchestrator.summarize_threshold.is_none());
882        assert!(config.orchestrator.tool_timeout_seconds.is_none());
883        assert!(config.orchestrator.max_tool_output_bytes.is_none());
884    }
885
886    #[test]
887    fn orchestrator_context_strategy_parses() {
888        let toml = r#"
889[provider]
890name = "anthropic"
891model = "claude-sonnet-4-20250514"
892
893[orchestrator.context_strategy]
894type = "sliding_window"
895max_tokens = 16000
896"#;
897        let config = HeartbitConfig::from_toml(toml).unwrap();
898        assert_eq!(
899            config.orchestrator.context_strategy,
900            Some(ContextStrategyConfig::SlidingWindow { max_tokens: 16000 })
901        );
902        assert!(config.orchestrator.summarize_threshold.is_none());
903    }
904
905    #[test]
906    fn agent_config_mcp_servers_default_empty() {
907        let toml = r#"
908[provider]
909name = "anthropic"
910model = "claude-sonnet-4-20250514"
911
912[[agents]]
913name = "basic"
914description = "Basic agent"
915system_prompt = "You are basic."
916"#;
917        let config = HeartbitConfig::from_toml(toml).unwrap();
918        assert!(config.agents[0].mcp_servers.is_empty());
919    }
920
921    #[test]
922    fn agent_max_total_tokens_parses() {
923        let toml = r#"
924[provider]
925name = "anthropic"
926model = "claude-sonnet-4-20250514"
927
928[[agents]]
929name = "quoter"
930description = "Quoter agent"
931system_prompt = "You quote."
932max_total_tokens = 100000
933"#;
934        let config = HeartbitConfig::from_toml(toml).unwrap();
935        assert_eq!(config.agents[0].max_total_tokens, Some(100000));
936    }
937
938    #[test]
939    fn agent_max_total_tokens_defaults_none() {
940        let toml = r#"
941[provider]
942name = "anthropic"
943model = "claude-sonnet-4-20250514"
944
945[[agents]]
946name = "basic"
947description = "Basic agent"
948system_prompt = "You are basic."
949"#;
950        let config = HeartbitConfig::from_toml(toml).unwrap();
951        assert!(config.agents[0].max_total_tokens.is_none());
952    }
953
954    #[test]
955    fn config_rejects_zero_agent_max_total_tokens() {
956        let toml = r#"
957[provider]
958name = "anthropic"
959model = "claude-sonnet-4-20250514"
960
961[[agents]]
962name = "quoter"
963description = "Quoter"
964system_prompt = "Quote."
965max_total_tokens = 0
966"#;
967        let err = HeartbitConfig::from_toml(toml).unwrap_err();
968        assert!(
969            err.to_string()
970                .contains("max_total_tokens must be at least 1"),
971            "error: {err}"
972        );
973    }
974
975    #[test]
976    fn parse_context_strategy_unlimited() {
977        let toml = r#"
978[provider]
979name = "anthropic"
980model = "claude-sonnet-4-20250514"
981
982[[agents]]
983name = "test"
984description = "Test"
985system_prompt = "You test."
986context_strategy = { type = "unlimited" }
987"#;
988        let config = HeartbitConfig::from_toml(toml).unwrap();
989        assert_eq!(
990            config.agents[0].context_strategy,
991            Some(ContextStrategyConfig::Unlimited)
992        );
993    }
994
995    #[test]
996    fn parse_context_strategy_sliding_window() {
997        let toml = r#"
998[provider]
999name = "anthropic"
1000model = "claude-sonnet-4-20250514"
1001
1002[[agents]]
1003name = "test"
1004description = "Test"
1005system_prompt = "You test."
1006context_strategy = { type = "sliding_window", max_tokens = 100000 }
1007"#;
1008        let config = HeartbitConfig::from_toml(toml).unwrap();
1009        assert_eq!(
1010            config.agents[0].context_strategy,
1011            Some(ContextStrategyConfig::SlidingWindow { max_tokens: 100000 })
1012        );
1013    }
1014
1015    #[test]
1016    fn parse_context_strategy_summarize() {
1017        let toml = r#"
1018[provider]
1019name = "anthropic"
1020model = "claude-sonnet-4-20250514"
1021
1022[[agents]]
1023name = "test"
1024description = "Test"
1025system_prompt = "You test."
1026context_strategy = { type = "summarize", threshold = 80000 }
1027"#;
1028        let config = HeartbitConfig::from_toml(toml).unwrap();
1029        assert_eq!(
1030            config.agents[0].context_strategy,
1031            Some(ContextStrategyConfig::Summarize { threshold: 80000 })
1032        );
1033    }
1034
1035    #[test]
1036    fn context_strategy_defaults_to_none() {
1037        let toml = r#"
1038[provider]
1039name = "anthropic"
1040model = "claude-sonnet-4-20250514"
1041
1042[[agents]]
1043name = "test"
1044description = "Test"
1045system_prompt = "You test."
1046"#;
1047        let config = HeartbitConfig::from_toml(toml).unwrap();
1048        assert!(config.agents[0].context_strategy.is_none());
1049    }
1050
1051    #[test]
1052    fn parse_memory_config_in_memory() {
1053        let toml = r#"
1054[provider]
1055name = "anthropic"
1056model = "claude-sonnet-4-20250514"
1057
1058[memory]
1059type = "in_memory"
1060"#;
1061        let config = HeartbitConfig::from_toml(toml).unwrap();
1062        assert!(matches!(config.memory, Some(MemoryConfig::InMemory)));
1063    }
1064
1065    #[test]
1066    fn parse_memory_config_postgres() {
1067        let toml = r#"
1068[provider]
1069name = "anthropic"
1070model = "claude-sonnet-4-20250514"
1071
1072[memory]
1073type = "postgres"
1074database_url = "postgresql://localhost/heartbit"
1075"#;
1076        let config = HeartbitConfig::from_toml(toml).unwrap();
1077        match &config.memory {
1078            Some(MemoryConfig::Postgres {
1079                database_url,
1080                embedding,
1081            }) => {
1082                assert_eq!(database_url, "postgresql://localhost/heartbit");
1083                assert!(embedding.is_none(), "embedding should default to None");
1084            }
1085            other => panic!("expected Postgres config, got: {other:?}"),
1086        }
1087    }
1088
1089    #[test]
1090    fn parse_memory_config_postgres_with_embedding() {
1091        let toml = r#"
1092[provider]
1093name = "anthropic"
1094model = "claude-sonnet-4-20250514"
1095
1096[memory]
1097type = "postgres"
1098database_url = "postgresql://localhost/heartbit"
1099
1100[memory.embedding]
1101provider = "openai"
1102model = "text-embedding-3-large"
1103api_key_env = "MY_OPENAI_KEY"
1104base_url = "https://custom-api.example.com"
1105dimension = 3072
1106"#;
1107        let config = HeartbitConfig::from_toml(toml).unwrap();
1108        match &config.memory {
1109            Some(MemoryConfig::Postgres {
1110                database_url,
1111                embedding,
1112            }) => {
1113                assert_eq!(database_url, "postgresql://localhost/heartbit");
1114                let emb = embedding.as_ref().expect("embedding config should be set");
1115                assert_eq!(emb.provider, "openai");
1116                assert_eq!(emb.model, "text-embedding-3-large");
1117                assert_eq!(emb.api_key_env, "MY_OPENAI_KEY");
1118                assert_eq!(
1119                    emb.base_url.as_deref(),
1120                    Some("https://custom-api.example.com")
1121                );
1122                assert_eq!(emb.dimension, Some(3072));
1123            }
1124            other => panic!("expected Postgres config, got: {other:?}"),
1125        }
1126    }
1127
1128    #[test]
1129    fn parse_memory_config_embedding_defaults() {
1130        let toml = r#"
1131[provider]
1132name = "anthropic"
1133model = "claude-sonnet-4-20250514"
1134
1135[memory]
1136type = "postgres"
1137database_url = "postgresql://localhost/heartbit"
1138
1139[memory.embedding]
1140"#;
1141        let config = HeartbitConfig::from_toml(toml).unwrap();
1142        match &config.memory {
1143            Some(MemoryConfig::Postgres { embedding, .. }) => {
1144                let emb = embedding.as_ref().expect("embedding config should be set");
1145                assert_eq!(emb.provider, "none");
1146                assert_eq!(emb.model, "text-embedding-3-small");
1147                assert_eq!(emb.api_key_env, "OPENAI_API_KEY");
1148                assert!(emb.base_url.is_none());
1149                assert!(emb.dimension.is_none());
1150            }
1151            other => panic!("expected Postgres config, got: {other:?}"),
1152        }
1153    }
1154
1155    #[test]
1156    fn memory_config_defaults_to_none() {
1157        let toml = r#"
1158[provider]
1159name = "anthropic"
1160model = "claude-sonnet-4-20250514"
1161"#;
1162        let config = HeartbitConfig::from_toml(toml).unwrap();
1163        assert!(config.memory.is_none());
1164    }
1165
1166    #[test]
1167    fn zero_max_turns_rejected() {
1168        let toml = r#"
1169[provider]
1170name = "anthropic"
1171model = "claude-sonnet-4-20250514"
1172
1173[orchestrator]
1174max_turns = 0
1175"#;
1176        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1177        let msg = err.to_string();
1178        assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
1179    }
1180
1181    #[test]
1182    fn zero_max_tokens_rejected() {
1183        let toml = r#"
1184[provider]
1185name = "anthropic"
1186model = "claude-sonnet-4-20250514"
1187
1188[orchestrator]
1189max_tokens = 0
1190"#;
1191        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1192        let msg = err.to_string();
1193        assert!(
1194            msg.contains("max_tokens must be at least 1"),
1195            "error: {msg}"
1196        );
1197    }
1198
1199    #[test]
1200    fn parse_tool_timeout_seconds() {
1201        let toml = r#"
1202[provider]
1203name = "anthropic"
1204model = "claude-sonnet-4-20250514"
1205
1206[[agents]]
1207name = "test"
1208description = "Test"
1209system_prompt = "You test."
1210tool_timeout_seconds = 60
1211"#;
1212        let config = HeartbitConfig::from_toml(toml).unwrap();
1213        assert_eq!(config.agents[0].tool_timeout_seconds, Some(60));
1214    }
1215
1216    #[test]
1217    fn tool_timeout_defaults_to_none() {
1218        let toml = r#"
1219[provider]
1220name = "anthropic"
1221model = "claude-sonnet-4-20250514"
1222
1223[[agents]]
1224name = "test"
1225description = "Test"
1226system_prompt = "You test."
1227"#;
1228        let config = HeartbitConfig::from_toml(toml).unwrap();
1229        assert!(config.agents[0].tool_timeout_seconds.is_none());
1230    }
1231
1232    #[test]
1233    fn parse_max_tool_output_bytes() {
1234        let toml = r#"
1235[provider]
1236name = "anthropic"
1237model = "claude-sonnet-4-20250514"
1238
1239[[agents]]
1240name = "test"
1241description = "Test"
1242system_prompt = "You test."
1243max_tool_output_bytes = 16384
1244"#;
1245        let config = HeartbitConfig::from_toml(toml).unwrap();
1246        assert_eq!(config.agents[0].max_tool_output_bytes, Some(16384));
1247    }
1248
1249    #[test]
1250    fn max_tool_output_bytes_defaults_to_none() {
1251        let toml = r#"
1252[provider]
1253name = "anthropic"
1254model = "claude-sonnet-4-20250514"
1255
1256[[agents]]
1257name = "test"
1258description = "Test"
1259system_prompt = "You test."
1260"#;
1261        let config = HeartbitConfig::from_toml(toml).unwrap();
1262        assert!(config.agents[0].max_tool_output_bytes.is_none());
1263    }
1264
1265    #[test]
1266    fn parse_per_agent_max_turns() {
1267        let toml = r#"
1268[provider]
1269name = "anthropic"
1270model = "claude-sonnet-4-20250514"
1271
1272[[agents]]
1273name = "browser"
1274description = "Browser"
1275system_prompt = "Browse."
1276max_turns = 20
1277"#;
1278        let config = HeartbitConfig::from_toml(toml).unwrap();
1279        assert_eq!(config.agents[0].max_turns, Some(20));
1280    }
1281
1282    #[test]
1283    fn parse_per_agent_max_tokens() {
1284        let toml = r#"
1285[provider]
1286name = "anthropic"
1287model = "claude-sonnet-4-20250514"
1288
1289[[agents]]
1290name = "writer"
1291description = "Writer"
1292system_prompt = "Write."
1293max_tokens = 16384
1294"#;
1295        let config = HeartbitConfig::from_toml(toml).unwrap();
1296        assert_eq!(config.agents[0].max_tokens, Some(16384));
1297    }
1298
1299    #[test]
1300    fn per_agent_limits_default_to_none() {
1301        let toml = r#"
1302[provider]
1303name = "anthropic"
1304model = "claude-sonnet-4-20250514"
1305
1306[[agents]]
1307name = "test"
1308description = "Test"
1309system_prompt = "You test."
1310"#;
1311        let config = HeartbitConfig::from_toml(toml).unwrap();
1312        assert!(config.agents[0].max_turns.is_none());
1313        assert!(config.agents[0].max_tokens.is_none());
1314    }
1315
1316    #[test]
1317    fn per_agent_zero_max_turns_rejected() {
1318        let toml = r#"
1319[provider]
1320name = "anthropic"
1321model = "claude-sonnet-4-20250514"
1322
1323[[agents]]
1324name = "test"
1325description = "Test"
1326system_prompt = "You test."
1327max_turns = 0
1328"#;
1329        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1330        let msg = err.to_string();
1331        assert!(msg.contains("max_turns must be at least 1"), "error: {msg}");
1332    }
1333
1334    #[test]
1335    fn per_agent_zero_max_tokens_rejected() {
1336        let toml = r#"
1337[provider]
1338name = "anthropic"
1339model = "claude-sonnet-4-20250514"
1340
1341[[agents]]
1342name = "test"
1343description = "Test"
1344system_prompt = "You test."
1345max_tokens = 0
1346"#;
1347        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1348        let msg = err.to_string();
1349        assert!(
1350            msg.contains("max_tokens must be at least 1"),
1351            "error: {msg}"
1352        );
1353    }
1354
1355    #[test]
1356    fn parse_response_schema() {
1357        let toml = r#"
1358[provider]
1359name = "anthropic"
1360model = "claude-sonnet-4-20250514"
1361
1362[[agents]]
1363name = "analyst"
1364description = "Analyst"
1365system_prompt = "Analyze."
1366
1367[agents.response_schema]
1368type = "object"
1369
1370[agents.response_schema.properties.score]
1371type = "number"
1372
1373[agents.response_schema.properties.summary]
1374type = "string"
1375"#;
1376        let config = HeartbitConfig::from_toml(toml).unwrap();
1377        let schema = config.agents[0].response_schema.as_ref().unwrap();
1378        assert_eq!(schema["type"], "object");
1379        assert_eq!(schema["properties"]["score"]["type"], "number");
1380        assert_eq!(schema["properties"]["summary"]["type"], "string");
1381    }
1382
1383    #[test]
1384    fn response_schema_defaults_to_none() {
1385        let toml = r#"
1386[provider]
1387name = "anthropic"
1388model = "claude-sonnet-4-20250514"
1389
1390[[agents]]
1391name = "test"
1392description = "Test"
1393system_prompt = "Test."
1394"#;
1395        let config = HeartbitConfig::from_toml(toml).unwrap();
1396        assert!(config.agents[0].response_schema.is_none());
1397    }
1398
1399    #[test]
1400    fn rejects_duplicate_persona_names() {
1401        let toml_text = r#"
1402            [provider]
1403            name = "anthropic"
1404            model = "claude-sonnet-4-20250514"
1405
1406            [[persona]]
1407            name = "x"
1408            recipe = "heartbit-ghost:x"
1409
1410            [[persona]]
1411            name = "x"
1412            recipe = "heartbit-ghost:x"
1413        "#;
1414        let err = HeartbitConfig::from_toml(toml_text).unwrap_err();
1415        let msg = format!("{:?}", err);
1416        assert!(msg.contains("duplicate persona name"), "got: {}", msg);
1417    }
1418
1419    #[test]
1420    fn parses_persona_block_round_trip() {
1421        let toml_text = r#"
1422            [provider]
1423            name = "anthropic"
1424            model = "claude-sonnet-4-20250514"
1425
1426            [[persona]]
1427            name = "x"
1428            recipe = "heartbit-ghost:x"
1429            authorship_mode = "autonomous_undisclosed"
1430            phase = "calibration"
1431        "#;
1432        let config = HeartbitConfig::from_toml(toml_text).expect("parses");
1433        assert_eq!(config.personas.len(), 1);
1434        assert_eq!(config.personas[0].name, "x");
1435        assert_eq!(config.personas[0].recipe, "heartbit-ghost:x");
1436    }
1437
1438    #[test]
1439    fn duplicate_agent_names_rejected() {
1440        let toml = r#"
1441[provider]
1442name = "anthropic"
1443model = "claude-sonnet-4-20250514"
1444
1445[[agents]]
1446name = "researcher"
1447description = "First"
1448system_prompt = "First."
1449
1450[[agents]]
1451name = "researcher"
1452description = "Second"
1453system_prompt = "Second."
1454"#;
1455        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1456        let msg = err.to_string();
1457        assert!(msg.contains("duplicate agent name"), "error: {msg}");
1458    }
1459
1460    #[test]
1461    fn per_agent_zero_summarize_threshold_rejected() {
1462        let toml = r#"
1463[provider]
1464name = "anthropic"
1465model = "claude-sonnet-4-20250514"
1466
1467[[agents]]
1468name = "test"
1469description = "Test"
1470system_prompt = "Test."
1471summarize_threshold = 0
1472"#;
1473        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1474        let msg = err.to_string();
1475        assert!(
1476            msg.contains("summarize_threshold must be at least 1"),
1477            "error: {msg}"
1478        );
1479    }
1480
1481    #[test]
1482    fn per_agent_summarize_threshold_with_context_strategy_rejected() {
1483        let toml = r#"
1484[provider]
1485name = "anthropic"
1486model = "claude-sonnet-4-20250514"
1487
1488[[agents]]
1489name = "test"
1490description = "Test"
1491system_prompt = "Test."
1492summarize_threshold = 8000
1493
1494[agents.context_strategy]
1495type = "sliding_window"
1496max_tokens = 50000
1497"#;
1498        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1499        let msg = err.to_string();
1500        assert!(
1501            msg.contains("cannot set both context_strategy and summarize_threshold"),
1502            "error: {msg}"
1503        );
1504    }
1505
1506    #[test]
1507    fn per_agent_summarize_threshold_parses() {
1508        let toml = r#"
1509[provider]
1510name = "anthropic"
1511model = "claude-sonnet-4-20250514"
1512
1513[[agents]]
1514name = "test"
1515description = "Test"
1516system_prompt = "Test."
1517summarize_threshold = 8000
1518"#;
1519        let config = HeartbitConfig::from_toml(toml).unwrap();
1520        assert_eq!(config.agents[0].summarize_threshold, Some(8000));
1521    }
1522
1523    #[test]
1524    fn parse_retry_config() {
1525        let toml = r#"
1526[provider]
1527name = "anthropic"
1528model = "claude-sonnet-4-20250514"
1529
1530[provider.retry]
1531max_retries = 5
1532base_delay_ms = 1000
1533max_delay_ms = 60000
1534"#;
1535        let config = HeartbitConfig::from_toml(toml).unwrap();
1536        let retry = config.provider.retry.unwrap();
1537        assert_eq!(retry.max_retries, 5);
1538        assert_eq!(retry.base_delay_ms, 1000);
1539        assert_eq!(retry.max_delay_ms, 60000);
1540    }
1541
1542    #[test]
1543    fn retry_config_defaults_to_none() {
1544        let toml = r#"
1545[provider]
1546name = "anthropic"
1547model = "claude-sonnet-4-20250514"
1548"#;
1549        let config = HeartbitConfig::from_toml(toml).unwrap();
1550        assert!(config.provider.retry.is_none());
1551    }
1552
1553    #[test]
1554    fn retry_config_uses_defaults_for_missing_fields() {
1555        let toml = r#"
1556[provider]
1557name = "anthropic"
1558model = "claude-sonnet-4-20250514"
1559
1560[provider.retry]
1561"#;
1562        let config = HeartbitConfig::from_toml(toml).unwrap();
1563        let retry = config.provider.retry.unwrap();
1564        assert_eq!(retry.max_retries, 3);
1565        assert_eq!(retry.base_delay_ms, 500);
1566        assert_eq!(retry.max_delay_ms, 30000);
1567    }
1568
1569    #[test]
1570    fn zero_context_strategy_max_tokens_rejected() {
1571        let toml = r#"
1572[provider]
1573name = "anthropic"
1574model = "claude-sonnet-4-20250514"
1575
1576[[agents]]
1577name = "test"
1578description = "Test"
1579system_prompt = "You test."
1580context_strategy = { type = "sliding_window", max_tokens = 0 }
1581"#;
1582        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1583        let msg = err.to_string();
1584        assert!(
1585            msg.contains("context_strategy.max_tokens must be at least 1"),
1586            "error: {msg}"
1587        );
1588    }
1589
1590    #[test]
1591    fn zero_summarize_threshold_rejected() {
1592        let toml = r#"
1593[provider]
1594name = "anthropic"
1595model = "claude-sonnet-4-20250514"
1596
1597[[agents]]
1598name = "test"
1599description = "Test"
1600system_prompt = "You test."
1601context_strategy = { type = "summarize", threshold = 0 }
1602"#;
1603        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1604        let msg = err.to_string();
1605        assert!(
1606            msg.contains("context_strategy.threshold must be at least 1"),
1607            "error: {msg}"
1608        );
1609    }
1610
1611    #[test]
1612    fn retry_base_delay_exceeds_max_delay_rejected() {
1613        let toml = r#"
1614[provider]
1615name = "anthropic"
1616model = "claude-sonnet-4-20250514"
1617
1618[provider.retry]
1619base_delay_ms = 60000
1620max_delay_ms = 1000
1621"#;
1622        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1623        let msg = err.to_string();
1624        assert!(
1625            msg.contains("base_delay_ms") && msg.contains("max_delay_ms"),
1626            "error: {msg}"
1627        );
1628    }
1629
1630    #[test]
1631    fn retry_base_delay_equals_max_delay_accepted() {
1632        let toml = r#"
1633[provider]
1634name = "anthropic"
1635model = "claude-sonnet-4-20250514"
1636
1637[provider.retry]
1638base_delay_ms = 5000
1639max_delay_ms = 5000
1640"#;
1641        let config = HeartbitConfig::from_toml(toml).unwrap();
1642        let retry = config.provider.retry.unwrap();
1643        assert_eq!(retry.base_delay_ms, 5000);
1644        assert_eq!(retry.max_delay_ms, 5000);
1645    }
1646
1647    #[test]
1648    fn zero_tool_timeout_seconds_rejected() {
1649        let toml = r#"
1650[provider]
1651name = "anthropic"
1652model = "claude-sonnet-4-20250514"
1653
1654[[agents]]
1655name = "test"
1656description = "Test"
1657system_prompt = "You test."
1658tool_timeout_seconds = 0
1659"#;
1660        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1661        let msg = err.to_string();
1662        assert!(
1663            msg.contains("tool_timeout_seconds must be at least 1"),
1664            "error: {msg}"
1665        );
1666    }
1667
1668    #[test]
1669    fn zero_max_tool_output_bytes_rejected() {
1670        let toml = r#"
1671[provider]
1672name = "anthropic"
1673model = "claude-sonnet-4-20250514"
1674
1675[[agents]]
1676name = "test"
1677description = "Test"
1678system_prompt = "You test."
1679max_tool_output_bytes = 0
1680"#;
1681        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1682        let msg = err.to_string();
1683        assert!(
1684            msg.contains("max_tool_output_bytes must be at least 1"),
1685            "error: {msg}"
1686        );
1687    }
1688
1689    #[test]
1690    fn empty_agent_name_rejected() {
1691        let toml = r#"
1692[provider]
1693name = "anthropic"
1694model = "claude-sonnet-4-20250514"
1695
1696[[agents]]
1697name = ""
1698description = "Test"
1699system_prompt = "You test."
1700"#;
1701        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1702        let msg = err.to_string();
1703        assert!(msg.contains("agent name must not be empty"), "error: {msg}");
1704    }
1705
1706    #[test]
1707    fn parse_knowledge_config_with_all_source_types() {
1708        let toml = r#"
1709[provider]
1710name = "anthropic"
1711model = "claude-sonnet-4-20250514"
1712
1713[knowledge]
1714chunk_size = 2000
1715chunk_overlap = 400
1716
1717[[knowledge.sources]]
1718type = "file"
1719path = "README.md"
1720
1721[[knowledge.sources]]
1722type = "glob"
1723pattern = "docs/**/*.md"
1724
1725[[knowledge.sources]]
1726type = "url"
1727url = "https://docs.example.com/api"
1728"#;
1729        let config = HeartbitConfig::from_toml(toml).unwrap();
1730        let knowledge = config.knowledge.unwrap();
1731        assert_eq!(knowledge.chunk_size, 2000);
1732        assert_eq!(knowledge.chunk_overlap, 400);
1733        assert_eq!(knowledge.sources.len(), 3);
1734        assert!(matches!(
1735            knowledge.sources[0],
1736            KnowledgeSourceConfig::File { .. }
1737        ));
1738        assert!(matches!(
1739            knowledge.sources[1],
1740            KnowledgeSourceConfig::Glob { .. }
1741        ));
1742        assert!(matches!(
1743            knowledge.sources[2],
1744            KnowledgeSourceConfig::Url { .. }
1745        ));
1746    }
1747
1748    #[test]
1749    fn knowledge_config_defaults() {
1750        let toml = r#"
1751[provider]
1752name = "anthropic"
1753model = "claude-sonnet-4-20250514"
1754
1755[knowledge]
1756"#;
1757        let config = HeartbitConfig::from_toml(toml).unwrap();
1758        let knowledge = config.knowledge.unwrap();
1759        assert_eq!(knowledge.chunk_size, 1000);
1760        assert_eq!(knowledge.chunk_overlap, 200);
1761        assert!(knowledge.sources.is_empty());
1762    }
1763
1764    #[test]
1765    fn knowledge_config_defaults_to_none() {
1766        let toml = r#"
1767[provider]
1768name = "anthropic"
1769model = "claude-sonnet-4-20250514"
1770"#;
1771        let config = HeartbitConfig::from_toml(toml).unwrap();
1772        assert!(config.knowledge.is_none());
1773    }
1774
1775    #[test]
1776    fn knowledge_zero_chunk_size_rejected() {
1777        let toml = r#"
1778[provider]
1779name = "anthropic"
1780model = "claude-sonnet-4-20250514"
1781
1782[knowledge]
1783chunk_size = 0
1784"#;
1785        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1786        let msg = err.to_string();
1787        assert!(
1788            msg.contains("chunk_size must be at least 1"),
1789            "error: {msg}"
1790        );
1791    }
1792
1793    #[test]
1794    fn knowledge_overlap_exceeds_chunk_size_rejected() {
1795        let toml = r#"
1796[provider]
1797name = "anthropic"
1798model = "claude-sonnet-4-20250514"
1799
1800[knowledge]
1801chunk_size = 100
1802chunk_overlap = 100
1803"#;
1804        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1805        let msg = err.to_string();
1806        assert!(
1807            msg.contains("chunk_overlap") && msg.contains("less than chunk_size"),
1808            "error: {msg}"
1809        );
1810    }
1811
1812    #[test]
1813    fn prompt_caching_defaults_false() {
1814        let toml = r#"
1815[provider]
1816name = "anthropic"
1817model = "claude-sonnet-4-20250514"
1818"#;
1819        let config = HeartbitConfig::from_toml(toml).unwrap();
1820        assert!(!config.provider.prompt_caching);
1821    }
1822
1823    #[test]
1824    fn prompt_caching_parses_true() {
1825        let toml = r#"
1826[provider]
1827name = "anthropic"
1828model = "claude-sonnet-4-20250514"
1829prompt_caching = true
1830"#;
1831        let config = HeartbitConfig::from_toml(toml).unwrap();
1832        assert!(config.provider.prompt_caching);
1833    }
1834
1835    #[test]
1836    fn prompt_caching_backward_compat() {
1837        // Old config without prompt_caching should parse fine
1838        let toml = r#"
1839[provider]
1840name = "anthropic"
1841model = "claude-sonnet-4-20250514"
1842
1843[provider.retry]
1844max_retries = 3
1845"#;
1846        let config = HeartbitConfig::from_toml(toml).unwrap();
1847        assert!(!config.provider.prompt_caching);
1848        assert!(config.provider.retry.is_some());
1849    }
1850
1851    #[test]
1852    fn orchestrator_zero_context_strategy_max_tokens_rejected() {
1853        let toml = r#"
1854[provider]
1855name = "anthropic"
1856model = "claude-sonnet-4-20250514"
1857
1858[orchestrator.context_strategy]
1859type = "sliding_window"
1860max_tokens = 0
1861"#;
1862        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1863        let msg = err.to_string();
1864        assert!(
1865            msg.contains("orchestrator.context_strategy.max_tokens must be at least 1"),
1866            "error: {msg}"
1867        );
1868    }
1869
1870    #[test]
1871    fn orchestrator_zero_context_strategy_threshold_rejected() {
1872        let toml = r#"
1873[provider]
1874name = "anthropic"
1875model = "claude-sonnet-4-20250514"
1876
1877[orchestrator.context_strategy]
1878type = "summarize"
1879threshold = 0
1880"#;
1881        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1882        let msg = err.to_string();
1883        assert!(
1884            msg.contains("orchestrator.context_strategy.threshold must be at least 1"),
1885            "error: {msg}"
1886        );
1887    }
1888
1889    #[test]
1890    fn orchestrator_zero_summarize_threshold_rejected() {
1891        let toml = r#"
1892[provider]
1893name = "anthropic"
1894model = "claude-sonnet-4-20250514"
1895
1896[orchestrator]
1897summarize_threshold = 0
1898"#;
1899        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1900        let msg = err.to_string();
1901        assert!(
1902            msg.contains("orchestrator.summarize_threshold must be at least 1"),
1903            "error: {msg}"
1904        );
1905    }
1906
1907    #[test]
1908    fn orchestrator_summarize_conflict_rejected() {
1909        let toml = r#"
1910[provider]
1911name = "anthropic"
1912model = "claude-sonnet-4-20250514"
1913
1914[orchestrator]
1915summarize_threshold = 8000
1916
1917[orchestrator.context_strategy]
1918type = "summarize"
1919threshold = 16000
1920"#;
1921        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1922        let msg = err.to_string();
1923        assert!(msg.contains("cannot set both"), "error: {msg}");
1924    }
1925
1926    #[test]
1927    fn orchestrator_sliding_window_plus_summarize_threshold_rejected() {
1928        let toml = r#"
1929[provider]
1930name = "anthropic"
1931model = "claude-sonnet-4-20250514"
1932
1933[orchestrator]
1934summarize_threshold = 8000
1935
1936[orchestrator.context_strategy]
1937type = "sliding_window"
1938max_tokens = 16000
1939"#;
1940        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1941        let msg = err.to_string();
1942        assert!(msg.contains("cannot set both"), "error: {msg}");
1943    }
1944
1945    #[test]
1946    fn orchestrator_unlimited_plus_summarize_threshold_allowed() {
1947        let toml = r#"
1948[provider]
1949name = "anthropic"
1950model = "claude-sonnet-4-20250514"
1951
1952[orchestrator]
1953summarize_threshold = 8000
1954
1955[orchestrator.context_strategy]
1956type = "unlimited"
1957"#;
1958        let config = HeartbitConfig::from_toml(toml).unwrap();
1959        assert_eq!(config.orchestrator.summarize_threshold, Some(8000));
1960    }
1961
1962    #[test]
1963    fn orchestrator_zero_tool_timeout_seconds_rejected() {
1964        let toml = r#"
1965[provider]
1966name = "anthropic"
1967model = "claude-sonnet-4-20250514"
1968
1969[orchestrator]
1970tool_timeout_seconds = 0
1971"#;
1972        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1973        let msg = err.to_string();
1974        assert!(
1975            msg.contains("orchestrator.tool_timeout_seconds must be at least 1"),
1976            "error: {msg}"
1977        );
1978    }
1979
1980    #[test]
1981    fn orchestrator_zero_max_tool_output_bytes_rejected() {
1982        let toml = r#"
1983[provider]
1984name = "anthropic"
1985model = "claude-sonnet-4-20250514"
1986
1987[orchestrator]
1988max_tool_output_bytes = 0
1989"#;
1990        let err = HeartbitConfig::from_toml(toml).unwrap_err();
1991        let msg = err.to_string();
1992        assert!(
1993            msg.contains("orchestrator.max_tool_output_bytes must be at least 1"),
1994            "error: {msg}"
1995        );
1996    }
1997
1998    #[test]
1999    fn orchestrator_tool_timeout_parses() {
2000        let toml = r#"
2001[provider]
2002name = "anthropic"
2003model = "claude-sonnet-4-20250514"
2004
2005[orchestrator]
2006tool_timeout_seconds = 120
2007"#;
2008        let config = HeartbitConfig::from_toml(toml).unwrap();
2009        assert_eq!(config.orchestrator.tool_timeout_seconds, Some(120));
2010    }
2011
2012    #[test]
2013    fn orchestrator_max_tool_output_bytes_parses() {
2014        let toml = r#"
2015[provider]
2016name = "anthropic"
2017model = "claude-sonnet-4-20250514"
2018
2019[orchestrator]
2020max_tool_output_bytes = 32768
2021"#;
2022        let config = HeartbitConfig::from_toml(toml).unwrap();
2023        assert_eq!(config.orchestrator.max_tool_output_bytes, Some(32768));
2024    }
2025
2026    #[test]
2027    fn knowledge_overlap_less_than_chunk_size_accepted() {
2028        let toml = r#"
2029[provider]
2030name = "anthropic"
2031model = "claude-sonnet-4-20250514"
2032
2033[knowledge]
2034chunk_size = 100
2035chunk_overlap = 50
2036"#;
2037        let config = HeartbitConfig::from_toml(toml).unwrap();
2038        let knowledge = config.knowledge.unwrap();
2039        assert_eq!(knowledge.chunk_size, 100);
2040        assert_eq!(knowledge.chunk_overlap, 50);
2041    }
2042
2043    #[test]
2044    fn mcp_server_entry_simple_string() {
2045        let toml = r#"
2046[provider]
2047name = "anthropic"
2048model = "claude-sonnet-4-20250514"
2049
2050[[agents]]
2051name = "test"
2052description = "Test"
2053system_prompt = "Test."
2054mcp_servers = ["http://localhost:8000/mcp"]
2055"#;
2056        let config = HeartbitConfig::from_toml(toml).unwrap();
2057        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2058        assert_eq!(
2059            config.agents[0].mcp_servers[0],
2060            McpServerEntry::Simple("http://localhost:8000/mcp".into())
2061        );
2062        assert_eq!(
2063            config.agents[0].mcp_servers[0].url(),
2064            "http://localhost:8000/mcp"
2065        );
2066        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2067    }
2068
2069    #[test]
2070    fn mcp_server_entry_full_with_auth() {
2071        let toml = r#"
2072[provider]
2073name = "anthropic"
2074model = "claude-sonnet-4-20250514"
2075
2076[[agents]]
2077name = "test"
2078description = "Test"
2079system_prompt = "Test."
2080mcp_servers = [{ url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }]
2081"#;
2082        let config = HeartbitConfig::from_toml(toml).unwrap();
2083        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2084        assert_eq!(
2085            config.agents[0].mcp_servers[0].url(),
2086            "http://gateway:8080/mcp"
2087        );
2088        assert_eq!(
2089            config.agents[0].mcp_servers[0].auth_header(),
2090            Some("Bearer tok_xxx")
2091        );
2092    }
2093
2094    #[test]
2095    fn mcp_server_entry_full_without_auth() {
2096        let toml = r#"
2097[provider]
2098name = "anthropic"
2099model = "claude-sonnet-4-20250514"
2100
2101[[agents]]
2102name = "test"
2103description = "Test"
2104system_prompt = "Test."
2105mcp_servers = [{ url = "http://localhost:8000/mcp" }]
2106"#;
2107        let config = HeartbitConfig::from_toml(toml).unwrap();
2108        assert_eq!(
2109            config.agents[0].mcp_servers[0].url(),
2110            "http://localhost:8000/mcp"
2111        );
2112        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2113    }
2114
2115    #[test]
2116    fn mcp_server_entry_mixed_simple_and_full() {
2117        let toml = r#"
2118[provider]
2119name = "anthropic"
2120model = "claude-sonnet-4-20250514"
2121
2122[[agents]]
2123name = "test"
2124description = "Test"
2125system_prompt = "Test."
2126mcp_servers = [
2127    "http://localhost:8000/mcp",
2128    { url = "http://gateway:8080/mcp", auth_header = "Bearer tok_xxx" }
2129]
2130"#;
2131        let config = HeartbitConfig::from_toml(toml).unwrap();
2132        assert_eq!(config.agents[0].mcp_servers.len(), 2);
2133        assert!(config.agents[0].mcp_servers[0].auth_header().is_none());
2134        assert_eq!(
2135            config.agents[0].mcp_servers[1].auth_header(),
2136            Some("Bearer tok_xxx")
2137        );
2138    }
2139
2140    #[test]
2141    fn mcp_server_entry_full_empty_url_rejected() {
2142        let toml = r#"
2143[provider]
2144name = "anthropic"
2145model = "claude-sonnet-4-20250514"
2146
2147[[agents]]
2148name = "test"
2149description = "Test"
2150system_prompt = "Test."
2151mcp_servers = [{ url = "" }]
2152"#;
2153        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2154        let msg = err.to_string();
2155        assert!(msg.contains("url must not be empty"), "error: {msg}");
2156    }
2157
2158    #[test]
2159    fn mcp_server_entry_roundtrip() {
2160        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
2161        let json = serde_json::to_string(&simple).unwrap();
2162        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2163        assert_eq!(simple, parsed);
2164
2165        let full = McpServerEntry::Full {
2166            url: "http://gateway/mcp".into(),
2167            auth_header: Some("Bearer tok".into()),
2168            resource: None,
2169            scopes: None,
2170        };
2171        let json = serde_json::to_string(&full).unwrap();
2172        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2173        assert_eq!(full, parsed);
2174    }
2175
2176    #[test]
2177    fn mcp_server_entry_scopes_resource_roundtrip() {
2178        let full = McpServerEntry::Full {
2179            url: "http://gmail-mcp.example.com/mcp".into(),
2180            auth_header: None,
2181            resource: Some("https://gmail.googleapis.com".into()),
2182            scopes: Some(vec!["gmail.readonly".into(), "gmail.send".into()]),
2183        };
2184        let json = serde_json::to_string(&full).unwrap();
2185        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
2186        assert_eq!(full, parsed);
2187
2188        // resource() falls back to url when resource is None
2189        let no_resource = McpServerEntry::Full {
2190            url: "http://mcp.example.com".into(),
2191            auth_header: None,
2192            resource: None,
2193            scopes: None,
2194        };
2195        assert_eq!(no_resource.resource(), Some("http://mcp.example.com"));
2196
2197        // resource() returns explicit resource when set
2198        assert_eq!(full.resource(), Some("https://gmail.googleapis.com"));
2199
2200        // scopes() returns the configured scopes
2201        assert_eq!(
2202            full.scopes(),
2203            Some(["gmail.readonly".to_string(), "gmail.send".to_string()].as_slice())
2204        );
2205
2206        // Simple variant has resource = url, no scopes
2207        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
2208        assert_eq!(simple.resource(), Some("http://localhost/mcp"));
2209        assert_eq!(simple.scopes(), None);
2210
2211        // Stdio variant has no resource, no scopes
2212        let stdio = McpServerEntry::Stdio {
2213            command: "npx".into(),
2214            args: vec![],
2215            env: Default::default(),
2216        };
2217        assert_eq!(stdio.resource(), None);
2218        assert_eq!(stdio.scopes(), None);
2219    }
2220
2221    #[test]
2222    fn orchestrator_run_timeout_parses() {
2223        let toml = r#"
2224[provider]
2225name = "anthropic"
2226model = "claude-sonnet-4-20250514"
2227
2228[orchestrator]
2229run_timeout_seconds = 300
2230"#;
2231        let config = HeartbitConfig::from_toml(toml).unwrap();
2232        assert_eq!(config.orchestrator.run_timeout_seconds, Some(300));
2233    }
2234
2235    #[test]
2236    fn orchestrator_run_timeout_defaults_to_none() {
2237        let toml = r#"
2238[provider]
2239name = "anthropic"
2240model = "claude-sonnet-4-20250514"
2241"#;
2242        let config = HeartbitConfig::from_toml(toml).unwrap();
2243        assert!(config.orchestrator.run_timeout_seconds.is_none());
2244    }
2245
2246    #[test]
2247    fn orchestrator_zero_run_timeout_rejected() {
2248        let toml = r#"
2249[provider]
2250name = "anthropic"
2251model = "claude-sonnet-4-20250514"
2252
2253[orchestrator]
2254run_timeout_seconds = 0
2255"#;
2256        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2257        let msg = err.to_string();
2258        assert!(
2259            msg.contains("run_timeout_seconds must be at least 1"),
2260            "error: {msg}"
2261        );
2262    }
2263
2264    #[test]
2265    fn agent_run_timeout_parses() {
2266        let toml = r#"
2267[provider]
2268name = "anthropic"
2269model = "claude-sonnet-4-20250514"
2270
2271[[agents]]
2272name = "test"
2273description = "Test"
2274system_prompt = "Test."
2275run_timeout_seconds = 120
2276"#;
2277        let config = HeartbitConfig::from_toml(toml).unwrap();
2278        assert_eq!(config.agents[0].run_timeout_seconds, Some(120));
2279    }
2280
2281    #[test]
2282    fn agent_run_timeout_defaults_to_none() {
2283        let toml = r#"
2284[provider]
2285name = "anthropic"
2286model = "claude-sonnet-4-20250514"
2287
2288[[agents]]
2289name = "test"
2290description = "Test"
2291system_prompt = "Test."
2292"#;
2293        let config = HeartbitConfig::from_toml(toml).unwrap();
2294        assert!(config.agents[0].run_timeout_seconds.is_none());
2295    }
2296
2297    #[test]
2298    fn agent_zero_run_timeout_rejected() {
2299        let toml = r#"
2300[provider]
2301name = "anthropic"
2302model = "claude-sonnet-4-20250514"
2303
2304[[agents]]
2305name = "test"
2306description = "Test"
2307system_prompt = "Test."
2308run_timeout_seconds = 0
2309"#;
2310        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2311        let msg = err.to_string();
2312        assert!(
2313            msg.contains("run_timeout_seconds must be at least 1"),
2314            "error: {msg}"
2315        );
2316    }
2317
2318    #[test]
2319    fn mcp_server_backward_compat_bare_strings() {
2320        // Existing configs with bare string arrays must keep working
2321        let toml = r#"
2322[provider]
2323name = "anthropic"
2324model = "claude-sonnet-4-20250514"
2325
2326[[agents]]
2327name = "coder"
2328description = "Coding expert"
2329system_prompt = "You code."
2330mcp_servers = ["http://localhost:8000/mcp", "http://localhost:9000/mcp"]
2331"#;
2332        let config = HeartbitConfig::from_toml(toml).unwrap();
2333        assert_eq!(config.agents[0].mcp_servers.len(), 2);
2334        assert_eq!(
2335            config.agents[0].mcp_servers[0].url(),
2336            "http://localhost:8000/mcp"
2337        );
2338        assert_eq!(
2339            config.agents[0].mcp_servers[1].url(),
2340            "http://localhost:9000/mcp"
2341        );
2342    }
2343
2344    #[test]
2345    fn per_agent_provider_parses() {
2346        let toml = r#"
2347[provider]
2348name = "anthropic"
2349model = "claude-opus-4-20250514"
2350
2351[[agents]]
2352name = "researcher"
2353description = "Research"
2354system_prompt = "Research."
2355
2356[agents.provider]
2357name = "anthropic"
2358model = "claude-haiku-4-5-20251001"
2359"#;
2360        let config = HeartbitConfig::from_toml(toml).unwrap();
2361        let agent_provider = config.agents[0].provider.as_ref().unwrap();
2362        assert_eq!(agent_provider.name, "anthropic");
2363        assert_eq!(agent_provider.model, "claude-haiku-4-5-20251001");
2364        assert!(!agent_provider.prompt_caching);
2365    }
2366
2367    #[test]
2368    fn per_agent_provider_with_prompt_caching() {
2369        let toml = r#"
2370[provider]
2371name = "anthropic"
2372model = "claude-opus-4-20250514"
2373
2374[[agents]]
2375name = "researcher"
2376description = "Research"
2377system_prompt = "Research."
2378
2379[agents.provider]
2380name = "anthropic"
2381model = "claude-sonnet-4-20250514"
2382prompt_caching = true
2383"#;
2384        let config = HeartbitConfig::from_toml(toml).unwrap();
2385        let agent_provider = config.agents[0].provider.as_ref().unwrap();
2386        assert!(agent_provider.prompt_caching);
2387    }
2388
2389    #[test]
2390    fn per_agent_provider_defaults_to_none() {
2391        let toml = r#"
2392[provider]
2393name = "anthropic"
2394model = "claude-sonnet-4-20250514"
2395
2396[[agents]]
2397name = "test"
2398description = "Test"
2399system_prompt = "Test."
2400"#;
2401        let config = HeartbitConfig::from_toml(toml).unwrap();
2402        assert!(config.agents[0].provider.is_none());
2403    }
2404
2405    #[test]
2406    fn per_agent_provider_empty_model_rejected() {
2407        let toml = r#"
2408[provider]
2409name = "anthropic"
2410model = "claude-sonnet-4-20250514"
2411
2412[[agents]]
2413name = "test"
2414description = "Test"
2415system_prompt = "Test."
2416
2417[agents.provider]
2418name = "anthropic"
2419model = ""
2420"#;
2421        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2422        let msg = err.to_string();
2423        assert!(
2424            msg.contains("provider.model must not be empty"),
2425            "error: {msg}"
2426        );
2427    }
2428
2429    #[test]
2430    fn per_agent_provider_openrouter() {
2431        let toml = r#"
2432[provider]
2433name = "anthropic"
2434model = "claude-opus-4-20250514"
2435
2436[[agents]]
2437name = "cheap"
2438description = "Cheap agent"
2439system_prompt = "Be frugal."
2440
2441[agents.provider]
2442name = "openrouter"
2443model = "anthropic/claude-haiku-4-5"
2444"#;
2445        let config = HeartbitConfig::from_toml(toml).unwrap();
2446        let p = config.agents[0].provider.as_ref().unwrap();
2447        assert_eq!(p.name, "openrouter");
2448        assert_eq!(p.model, "anthropic/claude-haiku-4-5");
2449    }
2450
2451    #[test]
2452    fn mixed_agents_with_and_without_provider() {
2453        let toml = r#"
2454[provider]
2455name = "anthropic"
2456model = "claude-opus-4-20250514"
2457
2458[[agents]]
2459name = "researcher"
2460description = "Research"
2461system_prompt = "Research."
2462
2463[agents.provider]
2464name = "anthropic"
2465model = "claude-haiku-4-5-20251001"
2466
2467[[agents]]
2468name = "coder"
2469description = "Coding"
2470system_prompt = "Code."
2471"#;
2472        let config = HeartbitConfig::from_toml(toml).unwrap();
2473        assert!(config.agents[0].provider.is_some());
2474        assert!(config.agents[1].provider.is_none());
2475    }
2476
2477    #[test]
2478    fn per_agent_provider_empty_name_rejected() {
2479        let toml = r#"
2480[provider]
2481name = "anthropic"
2482model = "claude-sonnet-4-20250514"
2483
2484[[agents]]
2485name = "test"
2486description = "Test"
2487system_prompt = "Test."
2488
2489[agents.provider]
2490name = ""
2491model = "claude-haiku-4-5-20251001"
2492"#;
2493        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2494        let msg = err.to_string();
2495        assert!(
2496            msg.contains("provider.name must not be empty"),
2497            "error: {msg}"
2498        );
2499    }
2500
2501    #[test]
2502    fn enable_squads_config_parsed() {
2503        let toml = r#"
2504[provider]
2505name = "anthropic"
2506model = "claude-sonnet-4-20250514"
2507
2508[orchestrator]
2509enable_squads = false
2510"#;
2511        let config = HeartbitConfig::from_toml(toml).unwrap();
2512        assert_eq!(config.orchestrator.enable_squads, Some(false));
2513    }
2514
2515    #[test]
2516    fn enable_squads_default_auto() {
2517        let toml = r#"
2518[provider]
2519name = "anthropic"
2520model = "claude-sonnet-4-20250514"
2521"#;
2522        let config = HeartbitConfig::from_toml(toml).unwrap();
2523        assert!(
2524            config.orchestrator.enable_squads.is_none(),
2525            "enable_squads should default to None (auto)"
2526        );
2527    }
2528
2529    #[test]
2530    fn enable_squads_true_parsed() {
2531        let toml = r#"
2532[provider]
2533name = "anthropic"
2534model = "claude-sonnet-4-20250514"
2535
2536[orchestrator]
2537enable_squads = true
2538"#;
2539        let config = HeartbitConfig::from_toml(toml).unwrap();
2540        assert_eq!(config.orchestrator.enable_squads, Some(true));
2541    }
2542
2543    #[test]
2544    fn a2a_agents_defaults_empty() {
2545        let toml = r#"
2546[provider]
2547name = "anthropic"
2548model = "claude-sonnet-4-20250514"
2549
2550[[agents]]
2551name = "test"
2552description = "Test"
2553system_prompt = "Test."
2554"#;
2555        let config = HeartbitConfig::from_toml(toml).unwrap();
2556        assert!(config.agents[0].a2a_agents.is_empty());
2557    }
2558
2559    #[test]
2560    fn a2a_agents_parses_simple() {
2561        let toml = r#"
2562[provider]
2563name = "anthropic"
2564model = "claude-sonnet-4-20250514"
2565
2566[[agents]]
2567name = "test"
2568description = "Test"
2569system_prompt = "Test."
2570a2a_agents = ["http://localhost:9000"]
2571"#;
2572        let config = HeartbitConfig::from_toml(toml).unwrap();
2573        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2574        assert_eq!(
2575            config.agents[0].a2a_agents[0].url(),
2576            "http://localhost:9000"
2577        );
2578        assert!(config.agents[0].a2a_agents[0].auth_header().is_none());
2579    }
2580
2581    #[test]
2582    fn a2a_agents_parses_full_with_auth() {
2583        let toml = r#"
2584[provider]
2585name = "anthropic"
2586model = "claude-sonnet-4-20250514"
2587
2588[[agents]]
2589name = "test"
2590description = "Test"
2591system_prompt = "Test."
2592a2a_agents = [{ url = "http://gateway:8080", auth_header = "Bearer tok_a2a" }]
2593"#;
2594        let config = HeartbitConfig::from_toml(toml).unwrap();
2595        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2596        assert_eq!(config.agents[0].a2a_agents[0].url(), "http://gateway:8080");
2597        assert_eq!(
2598            config.agents[0].a2a_agents[0].auth_header(),
2599            Some("Bearer tok_a2a")
2600        );
2601    }
2602
2603    #[test]
2604    fn a2a_agents_empty_url_rejected() {
2605        let toml = r#"
2606[provider]
2607name = "anthropic"
2608model = "claude-sonnet-4-20250514"
2609
2610[[agents]]
2611name = "test"
2612description = "Test"
2613system_prompt = "Test."
2614a2a_agents = [""]
2615"#;
2616        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2617        let msg = err.to_string();
2618        assert!(
2619            msg.contains("a2a_agents") && msg.contains("url must not be empty"),
2620            "error: {msg}"
2621        );
2622    }
2623
2624    #[test]
2625    fn a2a_agents_mixed_with_mcp_servers() {
2626        let toml = r#"
2627[provider]
2628name = "anthropic"
2629model = "claude-sonnet-4-20250514"
2630
2631[[agents]]
2632name = "hybrid"
2633description = "Hybrid agent"
2634system_prompt = "You are hybrid."
2635mcp_servers = ["http://localhost:8000/mcp"]
2636a2a_agents = ["http://localhost:9000"]
2637"#;
2638        let config = HeartbitConfig::from_toml(toml).unwrap();
2639        assert_eq!(config.agents[0].mcp_servers.len(), 1);
2640        assert_eq!(config.agents[0].a2a_agents.len(), 1);
2641    }
2642
2643    #[test]
2644    fn mcp_server_entry_simple_empty_url_rejected() {
2645        let toml = r#"
2646[provider]
2647name = "anthropic"
2648model = "claude-sonnet-4-20250514"
2649
2650[[agents]]
2651name = "test"
2652description = "Test"
2653system_prompt = "Test."
2654mcp_servers = [""]
2655"#;
2656        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2657        let msg = err.to_string();
2658        assert!(msg.contains("url must not be empty"), "error: {msg}");
2659    }
2660
2661    #[test]
2662    fn config_enable_reflection_orchestrator() {
2663        let toml = r#"
2664[provider]
2665name = "anthropic"
2666model = "claude-sonnet-4-20250514"
2667
2668[orchestrator]
2669enable_reflection = true
2670
2671[[agents]]
2672name = "a"
2673description = "A"
2674system_prompt = "s"
2675"#;
2676        let config = HeartbitConfig::from_toml(toml).unwrap();
2677        assert_eq!(config.orchestrator.enable_reflection, Some(true));
2678    }
2679
2680    #[test]
2681    fn config_enable_reflection_per_agent() {
2682        let toml = r#"
2683[provider]
2684name = "anthropic"
2685model = "claude-sonnet-4-20250514"
2686
2687[[agents]]
2688name = "reflective"
2689description = "R"
2690system_prompt = "s"
2691enable_reflection = true
2692"#;
2693        let config = HeartbitConfig::from_toml(toml).unwrap();
2694        assert_eq!(config.agents[0].enable_reflection, Some(true));
2695    }
2696
2697    #[test]
2698    fn config_rejects_zero_compression_threshold() {
2699        let toml = r#"
2700[provider]
2701name = "anthropic"
2702model = "claude-sonnet-4-20250514"
2703
2704[orchestrator]
2705tool_output_compression_threshold = 0
2706"#;
2707        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2708        assert!(
2709            err.to_string()
2710                .contains("tool_output_compression_threshold")
2711        );
2712    }
2713
2714    #[test]
2715    fn config_rejects_zero_max_tools_per_turn() {
2716        let toml = r#"
2717[provider]
2718name = "anthropic"
2719model = "claude-sonnet-4-20250514"
2720
2721[orchestrator]
2722max_tools_per_turn = 0
2723"#;
2724        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2725        assert!(err.to_string().contains("max_tools_per_turn"));
2726    }
2727
2728    #[test]
2729    fn config_rejects_zero_agent_compression_threshold() {
2730        let toml = r#"
2731[provider]
2732name = "anthropic"
2733model = "claude-sonnet-4-20250514"
2734
2735[[agents]]
2736name = "a"
2737description = "d"
2738system_prompt = "s"
2739tool_output_compression_threshold = 0
2740"#;
2741        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2742        assert!(
2743            err.to_string()
2744                .contains("tool_output_compression_threshold"),
2745            "error: {err}"
2746        );
2747    }
2748
2749    #[test]
2750    fn config_rejects_zero_agent_max_tools_per_turn() {
2751        let toml = r#"
2752[provider]
2753name = "anthropic"
2754model = "claude-sonnet-4-20250514"
2755
2756[[agents]]
2757name = "a"
2758description = "d"
2759system_prompt = "s"
2760max_tools_per_turn = 0
2761"#;
2762        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2763        assert!(
2764            err.to_string().contains("max_tools_per_turn"),
2765            "error: {err}"
2766        );
2767    }
2768
2769    #[test]
2770    fn config_rejects_zero_orchestrator_max_identical_tool_calls() {
2771        let toml = r#"
2772[provider]
2773name = "anthropic"
2774model = "claude-sonnet-4-20250514"
2775
2776[orchestrator]
2777max_identical_tool_calls = 0
2778"#;
2779        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2780        assert!(
2781            err.to_string().contains("max_identical_tool_calls"),
2782            "error: {err}"
2783        );
2784    }
2785
2786    #[test]
2787    fn config_rejects_zero_agent_max_identical_tool_calls() {
2788        let toml = r#"
2789[provider]
2790name = "anthropic"
2791model = "claude-sonnet-4-20250514"
2792
2793[[agents]]
2794name = "a"
2795description = "d"
2796system_prompt = "s"
2797max_identical_tool_calls = 0
2798"#;
2799        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2800        assert!(
2801            err.to_string().contains("max_identical_tool_calls"),
2802            "error: {err}"
2803        );
2804    }
2805
2806    #[test]
2807    fn config_parses_max_identical_tool_calls() {
2808        let toml = r#"
2809[provider]
2810name = "anthropic"
2811model = "claude-sonnet-4-20250514"
2812
2813[orchestrator]
2814max_identical_tool_calls = 5
2815
2816[[agents]]
2817name = "a"
2818description = "d"
2819system_prompt = "s"
2820max_identical_tool_calls = 3
2821"#;
2822        let config = HeartbitConfig::from_toml(toml).unwrap();
2823        assert_eq!(config.orchestrator.max_identical_tool_calls, Some(5));
2824        assert_eq!(config.agents[0].max_identical_tool_calls, Some(3));
2825    }
2826
2827    #[test]
2828    fn config_max_identical_tool_calls_defaults_to_none() {
2829        let toml = r#"
2830[provider]
2831name = "anthropic"
2832model = "claude-sonnet-4-20250514"
2833
2834[[agents]]
2835name = "a"
2836description = "d"
2837system_prompt = "s"
2838"#;
2839        let config = HeartbitConfig::from_toml(toml).unwrap();
2840        assert!(config.orchestrator.max_identical_tool_calls.is_none());
2841        assert!(config.agents[0].max_identical_tool_calls.is_none());
2842    }
2843
2844    // --- max_tool_calls_per_turn Config Tests ---
2845
2846    #[test]
2847    fn config_rejects_zero_orchestrator_max_tool_calls_per_turn() {
2848        let toml = r#"
2849[provider]
2850name = "anthropic"
2851model = "claude-sonnet-4-20250514"
2852
2853[orchestrator]
2854max_tool_calls_per_turn = 0
2855"#;
2856        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2857        assert!(
2858            err.to_string().contains("max_tool_calls_per_turn"),
2859            "error: {err}"
2860        );
2861    }
2862
2863    #[test]
2864    fn config_rejects_zero_agent_max_tool_calls_per_turn() {
2865        let toml = r#"
2866[provider]
2867name = "anthropic"
2868model = "claude-sonnet-4-20250514"
2869
2870[[agents]]
2871name = "a"
2872description = "d"
2873system_prompt = "s"
2874max_tool_calls_per_turn = 0
2875"#;
2876        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2877        assert!(
2878            err.to_string().contains("max_tool_calls_per_turn"),
2879            "error: {err}"
2880        );
2881    }
2882
2883    #[test]
2884    fn config_parses_max_tool_calls_per_turn() {
2885        let toml = r#"
2886[provider]
2887name = "anthropic"
2888model = "claude-sonnet-4-20250514"
2889
2890[orchestrator]
2891max_tool_calls_per_turn = 5
2892
2893[[agents]]
2894name = "a"
2895description = "d"
2896system_prompt = "s"
2897max_tool_calls_per_turn = 3
2898"#;
2899        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2900        assert_eq!(cfg.orchestrator.max_tool_calls_per_turn, Some(5));
2901        assert_eq!(cfg.agents[0].max_tool_calls_per_turn, Some(3));
2902    }
2903
2904    #[test]
2905    fn config_parses_sandbox_section() {
2906        let toml = r#"
2907[provider]
2908name = "anthropic"
2909model = "claude-sonnet-4-20250514"
2910
2911[[agents]]
2912name = "a"
2913description = "d"
2914system_prompt = "s"
2915
2916[sandbox]
2917allowed_dirs = ["/workspace", "/tmp/agent"]
2918deny_globs = ["**/.env", "**/secrets/**"]
2919"#;
2920        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2921        let sb = cfg.sandbox.unwrap();
2922        assert_eq!(sb.allowed_dirs.len(), 2);
2923        assert_eq!(sb.deny_globs.len(), 2);
2924        assert_eq!(sb.deny_globs[0], "**/.env");
2925    }
2926
2927    #[test]
2928    fn config_parses_daemon_audit_section() {
2929        let toml = r#"
2930[provider]
2931name = "anthropic"
2932model = "claude-sonnet-4-20250514"
2933
2934[[agents]]
2935name = "a"
2936description = "d"
2937system_prompt = "s"
2938
2939[daemon]
2940bind = "127.0.0.1:3000"
2941
2942[daemon.audit]
2943retain_days = 30
2944prune_interval_minutes = 120
2945"#;
2946        let cfg = HeartbitConfig::from_toml(toml).unwrap();
2947        let daemon = cfg.daemon.unwrap();
2948        assert_eq!(daemon.audit.retain_days, Some(30));
2949        assert_eq!(daemon.audit.prune_interval_minutes, Some(120));
2950    }
2951
2952    #[test]
2953    fn config_rejects_zero_prune_interval_minutes() {
2954        let toml = r#"
2955[provider]
2956name = "anthropic"
2957model = "claude-sonnet-4-20250514"
2958
2959[daemon]
2960bind = "127.0.0.1:3000"
2961
2962[daemon.audit]
2963prune_interval_minutes = 0
2964"#;
2965        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2966        assert!(
2967            err.to_string().contains("prune_interval_minutes"),
2968            "error: {err}"
2969        );
2970    }
2971
2972    #[test]
2973    fn config_rejects_zero_retain_days() {
2974        let toml = r#"
2975[provider]
2976name = "anthropic"
2977model = "claude-sonnet-4-20250514"
2978
2979[daemon]
2980bind = "127.0.0.1:3000"
2981
2982[daemon.audit]
2983retain_days = 0
2984"#;
2985        let err = HeartbitConfig::from_toml(toml).unwrap_err();
2986        assert!(err.to_string().contains("retain_days"), "error: {err}");
2987    }
2988
2989    #[test]
2990    fn idempotency_config_defaults_are_none() {
2991        let toml = r#"
2992[provider]
2993name = "anthropic"
2994model = "claude-sonnet-4-20250514"
2995
2996[daemon]
2997bind = "127.0.0.1:3000"
2998"#;
2999        let cfg = HeartbitConfig::from_toml(toml).unwrap();
3000        let daemon = cfg.daemon.unwrap();
3001        assert!(daemon.idempotency.ttl_hours.is_none());
3002        assert!(daemon.idempotency.sweep_interval_minutes.is_none());
3003    }
3004
3005    #[test]
3006    fn config_rejects_zero_idempotency_ttl_hours() {
3007        let toml = r#"
3008[provider]
3009name = "anthropic"
3010model = "claude-sonnet-4-20250514"
3011
3012[daemon]
3013bind = "127.0.0.1:3000"
3014
3015[daemon.idempotency]
3016ttl_hours = 0
3017"#;
3018        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3019        assert!(err.to_string().contains("ttl_hours"), "error: {err}");
3020    }
3021
3022    #[test]
3023    fn config_rejects_zero_idempotency_sweep_interval() {
3024        let toml = r#"
3025[provider]
3026name = "anthropic"
3027model = "claude-sonnet-4-20250514"
3028
3029[daemon]
3030bind = "127.0.0.1:3000"
3031
3032[daemon.idempotency]
3033sweep_interval_minutes = 0
3034"#;
3035        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3036        assert!(
3037            err.to_string().contains("sweep_interval_minutes"),
3038            "error: {err}"
3039        );
3040    }
3041
3042    // --- Permission Rules Config Tests ---
3043
3044    #[test]
3045    fn config_parses_permission_rules() {
3046        let toml = r#"
3047[provider]
3048name = "anthropic"
3049model = "claude-sonnet-4-20250514"
3050
3051[[agents]]
3052name = "a"
3053description = "d"
3054system_prompt = "s"
3055
3056[[permissions]]
3057tool = "read_file"
3058action = "allow"
3059
3060[[permissions]]
3061tool = "bash"
3062pattern = "rm *"
3063action = "deny"
3064
3065[[permissions]]
3066tool = "*"
3067pattern = "*.env*"
3068action = "deny"
3069"#;
3070        let config = HeartbitConfig::from_toml(toml).unwrap();
3071        assert_eq!(config.permissions.len(), 3);
3072        assert_eq!(config.permissions[0].tool, "read_file");
3073        assert_eq!(config.permissions[0].pattern, "*"); // default
3074        assert_eq!(
3075            config.permissions[0].action,
3076            crate::agent::permission::PermissionAction::Allow
3077        );
3078        assert_eq!(config.permissions[1].tool, "bash");
3079        assert_eq!(config.permissions[1].pattern, "rm *");
3080        assert_eq!(
3081            config.permissions[1].action,
3082            crate::agent::permission::PermissionAction::Deny
3083        );
3084        assert_eq!(config.permissions[2].tool, "*");
3085        assert_eq!(config.permissions[2].pattern, "*.env*");
3086    }
3087
3088    #[test]
3089    fn config_defaults_to_empty_permissions() {
3090        let toml = r#"
3091[provider]
3092name = "anthropic"
3093model = "claude-sonnet-4-20250514"
3094
3095[[agents]]
3096name = "a"
3097description = "d"
3098system_prompt = "s"
3099"#;
3100        let config = HeartbitConfig::from_toml(toml).unwrap();
3101        assert!(config.permissions.is_empty());
3102    }
3103
3104    #[test]
3105    fn lsp_config_defaults_to_none() {
3106        let toml = r#"
3107[provider]
3108name = "anthropic"
3109model = "claude-sonnet-4-20250514"
3110"#;
3111        let config = HeartbitConfig::from_toml(toml).unwrap();
3112        assert!(config.lsp.is_none());
3113    }
3114
3115    #[test]
3116    fn lsp_config_enabled_defaults_true() {
3117        let toml = r#"
3118[provider]
3119name = "anthropic"
3120model = "claude-sonnet-4-20250514"
3121
3122[lsp]
3123"#;
3124        let config = HeartbitConfig::from_toml(toml).unwrap();
3125        let lsp = config.lsp.unwrap();
3126        assert!(lsp.enabled);
3127    }
3128
3129    #[test]
3130    fn lsp_config_disabled() {
3131        let toml = r#"
3132[provider]
3133name = "anthropic"
3134model = "claude-sonnet-4-20250514"
3135
3136[lsp]
3137enabled = false
3138"#;
3139        let config = HeartbitConfig::from_toml(toml).unwrap();
3140        let lsp = config.lsp.unwrap();
3141        assert!(!lsp.enabled);
3142    }
3143
3144    #[test]
3145    fn parse_session_prune_with_preserve_task() {
3146        let toml = r#"
3147[provider]
3148name = "anthropic"
3149model = "claude-sonnet-4-20250514"
3150
3151[[agents]]
3152name = "test"
3153description = "Test"
3154system_prompt = "You test."
3155
3156[agents.session_prune]
3157keep_recent_n = 3
3158pruned_tool_result_max_bytes = 100
3159preserve_task = false
3160"#;
3161        let config = HeartbitConfig::from_toml(toml).unwrap();
3162        let sp = config.agents[0].session_prune.as_ref().unwrap();
3163        assert_eq!(sp.keep_recent_n, 3);
3164        assert_eq!(sp.pruned_tool_result_max_bytes, 100);
3165        assert!(!sp.preserve_task);
3166    }
3167
3168    #[test]
3169    fn config_telemetry_parses_observability_mode() {
3170        let toml = r#"
3171[provider]
3172name = "anthropic"
3173model = "claude-sonnet-4-20250514"
3174
3175[telemetry]
3176otlp_endpoint = "http://localhost:4317"
3177observability_mode = "analysis"
3178"#;
3179        let config = HeartbitConfig::from_toml(toml).unwrap();
3180        let telemetry = config.telemetry.unwrap();
3181        assert_eq!(telemetry.observability_mode.as_deref(), Some("analysis"));
3182    }
3183
3184    #[test]
3185    fn config_telemetry_observability_mode_defaults_to_none() {
3186        let toml = r#"
3187[provider]
3188name = "anthropic"
3189model = "claude-sonnet-4-20250514"
3190
3191[telemetry]
3192otlp_endpoint = "http://localhost:4317"
3193"#;
3194        let config = HeartbitConfig::from_toml(toml).unwrap();
3195        let telemetry = config.telemetry.unwrap();
3196        assert!(telemetry.observability_mode.is_none());
3197    }
3198
3199    #[test]
3200    fn dispatch_mode_defaults_to_none() {
3201        let toml = r#"
3202[provider]
3203name = "anthropic"
3204model = "claude-sonnet-4-20250514"
3205"#;
3206        let config = HeartbitConfig::from_toml(toml).unwrap();
3207        assert!(config.orchestrator.dispatch_mode.is_none());
3208    }
3209
3210    #[test]
3211    fn dispatch_mode_sequential_parses() {
3212        let toml = r#"
3213[provider]
3214name = "anthropic"
3215model = "claude-sonnet-4-20250514"
3216
3217[orchestrator]
3218dispatch_mode = "sequential"
3219"#;
3220        let config = HeartbitConfig::from_toml(toml).unwrap();
3221        assert_eq!(
3222            config.orchestrator.dispatch_mode,
3223            Some(DispatchMode::Sequential)
3224        );
3225    }
3226
3227    #[test]
3228    fn dispatch_mode_parallel_parses() {
3229        let toml = r#"
3230[provider]
3231name = "anthropic"
3232model = "claude-sonnet-4-20250514"
3233
3234[orchestrator]
3235dispatch_mode = "parallel"
3236"#;
3237        let config = HeartbitConfig::from_toml(toml).unwrap();
3238        assert_eq!(
3239            config.orchestrator.dispatch_mode,
3240            Some(DispatchMode::Parallel)
3241        );
3242    }
3243
3244    #[test]
3245    fn dispatch_mode_invalid_rejected() {
3246        let toml = r#"
3247[provider]
3248name = "anthropic"
3249model = "claude-sonnet-4-20250514"
3250
3251[orchestrator]
3252dispatch_mode = "bananas"
3253"#;
3254        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3255        assert!(matches!(err, Error::Config(_)));
3256    }
3257
3258    #[test]
3259    fn session_prune_preserve_task_defaults_to_true() {
3260        let toml = r#"
3261[provider]
3262name = "anthropic"
3263model = "claude-sonnet-4-20250514"
3264
3265[[agents]]
3266name = "test"
3267description = "Test"
3268system_prompt = "You test."
3269
3270[agents.session_prune]
3271"#;
3272        let config = HeartbitConfig::from_toml(toml).unwrap();
3273        let sp = config.agents[0].session_prune.as_ref().unwrap();
3274        assert_eq!(sp.keep_recent_n, 2); // default
3275        assert_eq!(sp.pruned_tool_result_max_bytes, 200); // default
3276        assert!(sp.preserve_task); // default true
3277    }
3278
3279    #[test]
3280    fn daemon_config_parses() {
3281        let toml = r#"
3282[provider]
3283name = "anthropic"
3284model = "claude-sonnet-4-20250514"
3285
3286[daemon]
3287bind = "0.0.0.0:8080"
3288max_concurrent_tasks = 8
3289
3290[daemon.kafka]
3291brokers = "localhost:9092"
3292"#;
3293        let config = HeartbitConfig::from_toml(toml).unwrap();
3294        let daemon = config.daemon.unwrap();
3295        assert_eq!(daemon.bind, "0.0.0.0:8080");
3296        assert_eq!(daemon.max_concurrent_tasks, 8);
3297        let kafka = daemon.kafka.unwrap();
3298        assert_eq!(kafka.brokers, "localhost:9092");
3299        assert_eq!(kafka.consumer_group, "heartbit-daemon");
3300        assert_eq!(kafka.commands_topic, "heartbit.commands");
3301        assert_eq!(kafka.events_topic, "heartbit.events");
3302        assert_eq!(kafka.dead_letter_topic, "heartbit.dead-letter");
3303    }
3304
3305    #[test]
3306    fn daemon_config_defaults() {
3307        let toml = r#"
3308[provider]
3309name = "anthropic"
3310model = "claude-sonnet-4-20250514"
3311
3312[daemon.kafka]
3313brokers = "localhost:9092"
3314"#;
3315        let config = HeartbitConfig::from_toml(toml).unwrap();
3316        let daemon = config.daemon.unwrap();
3317        assert_eq!(daemon.bind, "127.0.0.1:3000");
3318        assert_eq!(daemon.max_concurrent_tasks, 4);
3319    }
3320
3321    #[test]
3322    fn daemon_config_defaults_to_none() {
3323        let toml = r#"
3324[provider]
3325name = "anthropic"
3326model = "claude-sonnet-4-20250514"
3327"#;
3328        let config = HeartbitConfig::from_toml(toml).unwrap();
3329        assert!(config.daemon.is_none());
3330    }
3331
3332    #[test]
3333    fn daemon_zero_max_concurrent_rejected() {
3334        let toml = r#"
3335[provider]
3336name = "anthropic"
3337model = "claude-sonnet-4-20250514"
3338
3339[daemon]
3340max_concurrent_tasks = 0
3341
3342[daemon.kafka]
3343brokers = "localhost:9092"
3344"#;
3345        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3346        let msg = err.to_string();
3347        assert!(
3348            msg.contains("max_concurrent_tasks must be at least 1"),
3349            "error: {msg}"
3350        );
3351    }
3352
3353    #[test]
3354    fn daemon_empty_brokers_rejected() {
3355        let toml = r#"
3356[provider]
3357name = "anthropic"
3358model = "claude-sonnet-4-20250514"
3359
3360[daemon.kafka]
3361brokers = ""
3362"#;
3363        let err = HeartbitConfig::from_toml(toml).unwrap_err();
3364        let msg = err.to_string();
3365        assert!(msg.contains("brokers must not be empty"), "error: {msg}");
3366    }
3367
3368    #[test]
3369    fn daemon_config_metrics_defaults_to_none() {
3370        let toml = r#"
3371[provider]
3372name = "anthropic"
3373model = "claude-3-5-sonnet"
3374
3375[daemon.kafka]
3376brokers = "localhost:9092"
3377"#;
3378        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3379        let daemon = config.daemon.unwrap();
3380        assert!(daemon.metrics.is_none());
3381    }
3382
3383    #[test]
3384    fn daemon_config_metrics_enabled_explicit() {
3385        let toml = r#"
3386[provider]
3387name = "anthropic"
3388model = "claude-3-5-sonnet"
3389
3390[daemon.kafka]
3391brokers = "localhost:9092"
3392
3393[daemon.metrics]
3394enabled = true
3395"#;
3396        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3397        let daemon = config.daemon.unwrap();
3398        let metrics = daemon.metrics.unwrap();
3399        assert!(metrics.enabled);
3400    }
3401
3402    #[test]
3403    fn daemon_config_metrics_disabled() {
3404        let toml = r#"
3405[provider]
3406name = "anthropic"
3407model = "claude-3-5-sonnet"
3408
3409[daemon.kafka]
3410brokers = "localhost:9092"
3411
3412[daemon.metrics]
3413enabled = false
3414"#;
3415        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3416        let daemon = config.daemon.unwrap();
3417        let metrics = daemon.metrics.unwrap();
3418        assert!(!metrics.enabled);
3419    }
3420
3421    #[test]
3422    fn daemon_config_metrics_section_present_defaults_enabled() {
3423        // When [daemon.metrics] is present but `enabled` is omitted, defaults to true
3424        let toml = r#"
3425[provider]
3426name = "anthropic"
3427model = "claude-3-5-sonnet"
3428
3429[daemon.kafka]
3430brokers = "localhost:9092"
3431
3432[daemon.metrics]
3433"#;
3434        let config: HeartbitConfig = toml::from_str(toml).unwrap();
3435        let daemon = config.daemon.unwrap();
3436        let metrics = daemon.metrics.unwrap();
3437        assert!(metrics.enabled);
3438    }
3439
3440    #[test]
3441    fn sensor_source_name() {
3442        let rss = SensorSourceConfig::Rss {
3443            name: "tech_rss".into(),
3444            feeds: vec!["https://example.com/feed".into()],
3445            interest_keywords: vec![],
3446            poll_interval_seconds: 900,
3447        };
3448        assert_eq!(rss.name(), "tech_rss");
3449
3450        let webhook = SensorSourceConfig::Webhook {
3451            name: "github_events".into(),
3452            path: "/webhooks/github".into(),
3453            secret_env: None,
3454        };
3455        assert_eq!(webhook.name(), "github_events");
3456    }
3457
3458    // Sensor validation tests removed — sensors field moved to gateway crate.
3459
3460    // (sensor_duplicate_source_names removed — field moved to gateway)
3461
3462    #[test]
3463    fn kafka_dead_letter_topic_custom() {
3464        let toml = r#"
3465[provider]
3466name = "anthropic"
3467model = "claude-3-5-sonnet"
3468
3469[daemon.kafka]
3470brokers = "localhost:9092"
3471dead_letter_topic = "my.custom.dead-letter"
3472"#;
3473        let config = HeartbitConfig::from_toml(toml).unwrap();
3474        let daemon = config.daemon.unwrap();
3475        assert_eq!(
3476            daemon.kafka.unwrap().dead_letter_topic,
3477            "my.custom.dead-letter"
3478        );
3479    }
3480
3481    // ws_config_* tests removed — ws field moved to gateway crate.
3482
3483    #[test]
3484    fn daemon_database_url_default_none() {
3485        let toml = r#"
3486[provider]
3487name = "anthropic"
3488model = "claude-3-5-sonnet"
3489
3490[daemon.kafka]
3491brokers = "localhost:9092"
3492"#;
3493        let config = HeartbitConfig::from_toml(toml).unwrap();
3494        assert!(config.daemon.unwrap().database_url.is_none());
3495    }
3496
3497    #[test]
3498    fn daemon_database_url_present() {
3499        let toml = r#"
3500[provider]
3501name = "anthropic"
3502model = "claude-3-5-sonnet"
3503
3504[daemon]
3505database_url = "postgresql://localhost/heartbit_tasks"
3506
3507[daemon.kafka]
3508brokers = "localhost:9092"
3509"#;
3510        let config = HeartbitConfig::from_toml(toml).unwrap();
3511        assert_eq!(
3512            config.daemon.unwrap().database_url.as_deref(),
3513            Some("postgresql://localhost/heartbit_tasks")
3514        );
3515    }
3516
3517    #[test]
3518    fn workspace_config_explicit_root() {
3519        let toml = r#"
3520[provider]
3521name = "anthropic"
3522model = "claude-sonnet-4-20250514"
3523
3524[orchestrator]
3525max_turns = 5
3526max_tokens = 4096
3527
3528[workspace]
3529root = "/custom/workspaces"
3530
3531[[agents]]
3532name = "test"
3533description = "test"
3534system_prompt = "test"
3535"#;
3536        let config = HeartbitConfig::from_toml(toml).unwrap();
3537        let ws = config.workspace.unwrap();
3538        assert_eq!(ws.root, "/custom/workspaces");
3539    }
3540
3541    #[test]
3542    fn workspace_config_default_root() {
3543        let toml = r#"
3544[provider]
3545name = "anthropic"
3546model = "claude-sonnet-4-20250514"
3547
3548[orchestrator]
3549max_turns = 5
3550max_tokens = 4096
3551
3552[workspace]
3553
3554[[agents]]
3555name = "test"
3556description = "test"
3557system_prompt = "test"
3558"#;
3559        let config = HeartbitConfig::from_toml(toml).unwrap();
3560        let ws = config.workspace.unwrap();
3561        // Should use the default path
3562        assert!(ws.root.contains(".heartbit/workspaces"));
3563    }
3564
3565    #[test]
3566    fn workspace_config_absent() {
3567        let toml = r#"
3568[provider]
3569name = "anthropic"
3570model = "claude-sonnet-4-20250514"
3571
3572[orchestrator]
3573max_turns = 5
3574max_tokens = 4096
3575
3576[[agents]]
3577name = "test"
3578description = "test"
3579system_prompt = "test"
3580"#;
3581        let config = HeartbitConfig::from_toml(toml).unwrap();
3582        assert!(config.workspace.is_none());
3583    }
3584
3585    // heartbit_pulse config tests removed — field moved to gateway crate.
3586
3587    #[test]
3588    fn active_hours_parse_valid() {
3589        let ah = ActiveHoursConfig {
3590            start: "08:30".into(),
3591            end: "22:00".into(),
3592        };
3593        assert_eq!(ah.parse_start().unwrap(), (8, 30));
3594        assert_eq!(ah.parse_end().unwrap(), (22, 0));
3595    }
3596
3597    #[test]
3598    fn active_hours_parse_midnight() {
3599        let ah = ActiveHoursConfig {
3600            start: "00:00".into(),
3601            end: "23:59".into(),
3602        };
3603        assert_eq!(ah.parse_start().unwrap(), (0, 0));
3604        assert_eq!(ah.parse_end().unwrap(), (23, 59));
3605    }
3606
3607    #[test]
3608    fn active_hours_parse_invalid_format() {
3609        let ah = ActiveHoursConfig {
3610            start: "8am".into(),
3611            end: "22:00".into(),
3612        };
3613        assert!(ah.parse_start().is_err());
3614    }
3615
3616    #[test]
3617    fn active_hours_parse_out_of_range() {
3618        let ah = ActiveHoursConfig {
3619            start: "25:00".into(),
3620            end: "22:00".into(),
3621        };
3622        assert!(ah.parse_start().is_err());
3623    }
3624
3625    // validate_rejects_pulse tests removed — heartbit_pulse field moved to gateway crate.
3626
3627    #[test]
3628    fn routing_defaults_to_auto_when_missing() {
3629        let toml_str = r#"
3630[provider]
3631name = "anthropic"
3632model = "claude-3-5-sonnet"
3633
3634[[agents]]
3635name = "worker"
3636description = "worker agent"
3637system_prompt = "you are a worker"
3638"#;
3639        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3640        assert_eq!(config.orchestrator.routing, RoutingMode::Auto);
3641        assert!(config.orchestrator.escalation);
3642    }
3643
3644    #[test]
3645    fn routing_parses_always_orchestrate() {
3646        let toml_str = r#"
3647[provider]
3648name = "anthropic"
3649model = "claude-3-5-sonnet"
3650
3651[orchestrator]
3652routing = "always_orchestrate"
3653
3654[[agents]]
3655name = "worker"
3656description = "worker agent"
3657system_prompt = "you are a worker"
3658"#;
3659        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3660        assert_eq!(config.orchestrator.routing, RoutingMode::AlwaysOrchestrate);
3661    }
3662
3663    #[test]
3664    fn routing_parses_single_agent() {
3665        let toml_str = r#"
3666[provider]
3667name = "anthropic"
3668model = "claude-3-5-sonnet"
3669
3670[orchestrator]
3671routing = "single_agent"
3672
3673[[agents]]
3674name = "worker"
3675description = "worker agent"
3676system_prompt = "you are a worker"
3677"#;
3678        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3679        assert_eq!(config.orchestrator.routing, RoutingMode::SingleAgent);
3680    }
3681
3682    #[test]
3683    fn escalation_defaults_to_true() {
3684        let toml_str = r#"
3685[provider]
3686name = "anthropic"
3687model = "claude-3-5-sonnet"
3688
3689[[agents]]
3690name = "worker"
3691description = "worker agent"
3692system_prompt = "you are a worker"
3693"#;
3694        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3695        assert!(config.orchestrator.escalation);
3696    }
3697
3698    #[test]
3699    fn escalation_can_be_disabled() {
3700        let toml_str = r#"
3701[provider]
3702name = "anthropic"
3703model = "claude-3-5-sonnet"
3704
3705[orchestrator]
3706escalation = false
3707
3708[[agents]]
3709name = "worker"
3710description = "worker agent"
3711system_prompt = "you are a worker"
3712"#;
3713        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3714        assert!(!config.orchestrator.escalation);
3715    }
3716
3717    #[test]
3718    fn auth_config_valid() {
3719        let toml_str = r#"
3720[provider]
3721name = "anthropic"
3722model = "claude-sonnet-4-20250514"
3723
3724[daemon.kafka]
3725brokers = "localhost:9092"
3726
3727[daemon.auth]
3728bearer_tokens = ["my-secret-key", "rotation-key-2"]
3729"#;
3730        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3731        let auth = config.daemon.unwrap().auth.unwrap();
3732        assert_eq!(auth.bearer_tokens.len(), 2);
3733        assert_eq!(auth.bearer_tokens[0], "my-secret-key");
3734    }
3735
3736    #[test]
3737    fn auth_config_empty_tokens_rejected() {
3738        let toml_str = r#"
3739[provider]
3740name = "anthropic"
3741model = "claude-sonnet-4-20250514"
3742
3743[daemon.kafka]
3744brokers = "localhost:9092"
3745
3746[daemon.auth]
3747bearer_tokens = []
3748"#;
3749        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3750        assert!(
3751            err.to_string()
3752                .contains("daemon.auth requires at least bearer_tokens or jwks_url"),
3753            "got: {err}"
3754        );
3755    }
3756
3757    #[test]
3758    fn auth_config_empty_token_string_rejected() {
3759        let toml_str = r#"
3760[provider]
3761name = "anthropic"
3762model = "claude-sonnet-4-20250514"
3763
3764[daemon.kafka]
3765brokers = "localhost:9092"
3766
3767[daemon.auth]
3768bearer_tokens = [""]
3769"#;
3770        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3771        assert!(
3772            err.to_string()
3773                .contains("daemon.auth.bearer_tokens[0] must not be empty"),
3774            "got: {err}"
3775        );
3776    }
3777
3778    #[test]
3779    fn auth_config_none_is_valid() {
3780        let toml_str = r#"
3781[provider]
3782name = "anthropic"
3783model = "claude-sonnet-4-20250514"
3784
3785[daemon.kafka]
3786brokers = "localhost:9092"
3787"#;
3788        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3789        assert!(config.daemon.unwrap().auth.is_none());
3790    }
3791
3792    #[test]
3793    fn auth_config_jwks_only_is_valid() {
3794        let toml_str = r#"
3795[provider]
3796name = "anthropic"
3797model = "claude-sonnet-4-20250514"
3798
3799[daemon.kafka]
3800brokers = "localhost:9092"
3801
3802[daemon.auth]
3803jwks_url = "https://idp.example.com/.well-known/jwks.json"
3804issuer = "https://idp.example.com"
3805audience = "heartbit-api"
3806"#;
3807        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3808        let auth = config.daemon.unwrap().auth.unwrap();
3809        assert!(auth.bearer_tokens.is_empty());
3810        assert_eq!(
3811            auth.jwks_url.as_deref(),
3812            Some("https://idp.example.com/.well-known/jwks.json")
3813        );
3814        assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
3815        assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
3816    }
3817
3818    #[test]
3819    fn auth_config_empty_jwks_url_rejected() {
3820        let toml_str = r#"
3821[provider]
3822name = "anthropic"
3823model = "claude-sonnet-4-20250514"
3824
3825[daemon.kafka]
3826brokers = "localhost:9092"
3827
3828[daemon.auth]
3829bearer_tokens = ["valid-token"]
3830jwks_url = ""
3831"#;
3832        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3833        assert!(
3834            err.to_string()
3835                .contains("daemon.auth.jwks_url must not be empty"),
3836            "got: {err}"
3837        );
3838    }
3839
3840    #[test]
3841    fn auth_config_no_tokens_no_jwks_rejected() {
3842        let toml_str = r#"
3843[provider]
3844name = "anthropic"
3845model = "claude-sonnet-4-20250514"
3846
3847[daemon.kafka]
3848brokers = "localhost:9092"
3849
3850[daemon.auth]
3851issuer = "https://idp.example.com"
3852"#;
3853        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3854        assert!(
3855            err.to_string()
3856                .contains("daemon.auth requires at least bearer_tokens or jwks_url"),
3857            "got: {err}"
3858        );
3859    }
3860
3861    // --- Token exchange config ---
3862
3863    #[test]
3864    fn token_exchange_config_valid() {
3865        let toml_str = r#"
3866[provider]
3867name = "anthropic"
3868model = "claude-sonnet-4-20250514"
3869
3870[daemon.kafka]
3871brokers = "localhost:9092"
3872
3873[daemon.auth]
3874jwks_url = "https://idp.example.com/.well-known/jwks.json"
3875
3876[daemon.auth.token_exchange]
3877exchange_url = "https://idp.example.com/oauth/token"
3878client_id = "heartbit-agent"
3879client_secret = "secret123"
3880agent_token = "agent-cred-token"
3881scopes = ["crm:read", "crm:write"]
3882"#;
3883        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3884        let te = config.daemon.unwrap().auth.unwrap().token_exchange.unwrap();
3885        assert_eq!(te.exchange_url, "https://idp.example.com/oauth/token");
3886        assert_eq!(te.client_id, "heartbit-agent");
3887        assert_eq!(te.client_secret, "secret123");
3888        assert_eq!(te.agent_token, "agent-cred-token");
3889        assert_eq!(te.scopes, vec!["crm:read", "crm:write"]);
3890    }
3891
3892    #[test]
3893    fn token_exchange_empty_exchange_url_rejected() {
3894        let toml_str = r#"
3895[provider]
3896name = "anthropic"
3897model = "claude-sonnet-4-20250514"
3898
3899[daemon.kafka]
3900brokers = "localhost:9092"
3901
3902[daemon.auth]
3903jwks_url = "https://idp.example.com/.well-known/jwks.json"
3904
3905[daemon.auth.token_exchange]
3906exchange_url = ""
3907client_id = "heartbit-agent"
3908client_secret = "secret123"
3909agent_token = "agent-cred-token"
3910"#;
3911        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3912        assert!(
3913            err.to_string()
3914                .contains("daemon.auth.token_exchange.exchange_url must not be empty"),
3915            "got: {err}"
3916        );
3917    }
3918
3919    #[test]
3920    fn token_exchange_empty_client_id_rejected() {
3921        let toml_str = r#"
3922[provider]
3923name = "anthropic"
3924model = "claude-sonnet-4-20250514"
3925
3926[daemon.kafka]
3927brokers = "localhost:9092"
3928
3929[daemon.auth]
3930jwks_url = "https://idp.example.com/.well-known/jwks.json"
3931
3932[daemon.auth.token_exchange]
3933exchange_url = "https://idp.example.com/oauth/token"
3934client_id = ""
3935client_secret = "secret123"
3936agent_token = "agent-cred-token"
3937"#;
3938        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3939        assert!(
3940            err.to_string()
3941                .contains("daemon.auth.token_exchange.client_id must not be empty"),
3942            "got: {err}"
3943        );
3944    }
3945
3946    #[test]
3947    fn token_exchange_empty_agent_token_rejected() {
3948        let toml_str = r#"
3949[provider]
3950name = "anthropic"
3951model = "claude-sonnet-4-20250514"
3952
3953[daemon.kafka]
3954brokers = "localhost:9092"
3955
3956[daemon.auth]
3957jwks_url = "https://idp.example.com/.well-known/jwks.json"
3958
3959[daemon.auth.token_exchange]
3960exchange_url = "https://idp.example.com/oauth/token"
3961client_id = "heartbit-agent"
3962client_secret = "secret123"
3963agent_token = ""
3964"#;
3965        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
3966        assert!(
3967            err.to_string()
3968                .contains("daemon.auth.token_exchange: set tenant_id for auto-fetch"),
3969            "got: {err}"
3970        );
3971    }
3972
3973    #[test]
3974    fn token_exchange_none_is_valid() {
3975        let toml_str = r#"
3976[provider]
3977name = "anthropic"
3978model = "claude-sonnet-4-20250514"
3979
3980[daemon.kafka]
3981brokers = "localhost:9092"
3982
3983[daemon.auth]
3984jwks_url = "https://idp.example.com/.well-known/jwks.json"
3985"#;
3986        let config = HeartbitConfig::from_toml(toml_str).unwrap();
3987        assert!(
3988            config
3989                .daemon
3990                .unwrap()
3991                .auth
3992                .unwrap()
3993                .token_exchange
3994                .is_none()
3995        );
3996    }
3997
3998    // --- Cascade config ---
3999
4000    #[test]
4001    fn cascade_config_parses_full() {
4002        let toml_str = r#"
4003[provider]
4004name = "openrouter"
4005model = "anthropic/claude-sonnet-4"
4006
4007[provider.cascade]
4008enabled = true
4009
4010[[provider.cascade.tiers]]
4011model = "anthropic/claude-3.5-haiku"
4012
4013[provider.cascade.gate]
4014type = "heuristic"
4015min_output_tokens = 10
4016accept_tool_calls = false
4017escalate_on_max_tokens = false
4018"#;
4019        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4020        let cascade = config.provider.cascade.unwrap();
4021        assert!(cascade.enabled);
4022        assert_eq!(cascade.tiers.len(), 1);
4023        assert_eq!(cascade.tiers[0].model, "anthropic/claude-3.5-haiku");
4024        match &cascade.gate {
4025            CascadeGateConfig::Heuristic {
4026                min_output_tokens,
4027                accept_tool_calls,
4028                escalate_on_max_tokens,
4029            } => {
4030                assert_eq!(*min_output_tokens, 10);
4031                assert!(!accept_tool_calls);
4032                assert!(!escalate_on_max_tokens);
4033            }
4034        }
4035    }
4036
4037    #[test]
4038    fn cascade_config_defaults_when_absent() {
4039        let toml_str = r#"
4040[provider]
4041name = "anthropic"
4042model = "claude-sonnet-4-20250514"
4043"#;
4044        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4045        assert!(config.provider.cascade.is_none());
4046    }
4047
4048    #[test]
4049    fn cascade_config_gate_defaults() {
4050        let toml_str = r#"
4051[provider]
4052name = "anthropic"
4053model = "claude-sonnet-4-20250514"
4054
4055[provider.cascade]
4056enabled = true
4057
4058[[provider.cascade.tiers]]
4059model = "claude-3.5-haiku"
4060"#;
4061        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4062        let cascade = config.provider.cascade.unwrap();
4063        match &cascade.gate {
4064            CascadeGateConfig::Heuristic {
4065                min_output_tokens,
4066                accept_tool_calls,
4067                escalate_on_max_tokens,
4068            } => {
4069                assert_eq!(*min_output_tokens, 5);
4070                assert!(accept_tool_calls);
4071                assert!(escalate_on_max_tokens);
4072            }
4073        }
4074    }
4075
4076    #[test]
4077    fn validate_rejects_cascade_enabled_without_tiers() {
4078        let toml_str = r#"
4079[provider]
4080name = "anthropic"
4081model = "claude-sonnet-4-20250514"
4082
4083[provider.cascade]
4084enabled = true
4085"#;
4086        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4087        assert!(
4088            err.to_string().contains("no tiers are configured"),
4089            "error: {err}"
4090        );
4091    }
4092
4093    #[test]
4094    fn cascade_disabled_with_tiers_is_valid() {
4095        let toml_str = r#"
4096[provider]
4097name = "anthropic"
4098model = "claude-sonnet-4-20250514"
4099
4100[provider.cascade]
4101enabled = false
4102
4103[[provider.cascade.tiers]]
4104model = "claude-3.5-haiku"
4105"#;
4106        // enabled=false means the tiers are ignored; should not error
4107        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4108        let cascade = config.provider.cascade.unwrap();
4109        assert!(!cascade.enabled);
4110    }
4111
4112    #[test]
4113    fn agent_provider_cascade_config_parses() {
4114        let toml_str = r#"
4115[provider]
4116name = "anthropic"
4117model = "claude-sonnet-4-20250514"
4118
4119[[agents]]
4120name = "researcher"
4121description = "Research agent"
4122system_prompt = "You are a researcher."
4123
4124[agents.provider]
4125name = "openrouter"
4126model = "anthropic/claude-sonnet-4"
4127
4128[agents.provider.cascade]
4129enabled = true
4130
4131[[agents.provider.cascade.tiers]]
4132model = "anthropic/claude-3.5-haiku"
4133"#;
4134        let config = HeartbitConfig::from_toml(toml_str).unwrap();
4135        let agent_cascade = config.agents[0]
4136            .provider
4137            .as_ref()
4138            .unwrap()
4139            .cascade
4140            .as_ref()
4141            .unwrap();
4142        assert!(agent_cascade.enabled);
4143        assert_eq!(agent_cascade.tiers.len(), 1);
4144    }
4145
4146    #[test]
4147    fn validate_rejects_empty_provider_name() {
4148        let toml_str = r#"
4149[provider]
4150name = ""
4151model = "claude-sonnet-4-20250514"
4152"#;
4153        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4154        assert!(
4155            err.to_string().contains("provider.name must not be empty"),
4156            "error: {err}"
4157        );
4158    }
4159
4160    #[test]
4161    fn validate_rejects_empty_provider_model() {
4162        let toml_str = r#"
4163[provider]
4164name = "anthropic"
4165model = ""
4166"#;
4167        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4168        assert!(
4169            err.to_string().contains("provider.model must not be empty"),
4170            "error: {err}"
4171        );
4172    }
4173
4174    #[test]
4175    fn validate_rejects_empty_cascade_tier_model() {
4176        let toml_str = r#"
4177[provider]
4178name = "anthropic"
4179model = "claude-sonnet-4-20250514"
4180
4181[provider.cascade]
4182enabled = true
4183
4184[[provider.cascade.tiers]]
4185model = ""
4186"#;
4187        let err = HeartbitConfig::from_toml(toml_str).unwrap_err();
4188        assert!(
4189            err.to_string()
4190                .contains("provider.cascade.tiers[0].model must not be empty"),
4191            "error: {err}"
4192        );
4193    }
4194
4195    // MCP sensor config tests removed — sensors field moved to gateway crate.
4196    // sensor_source_mcp_serde, sensor_source_mcp_defaults, sensor_source_mcp_with_enrichment,
4197    // sensor_source_mcp_with_auth_header, sensor_source_mcp_simple_server,
4198    // sensor_source_mcp_empty_tool_name_rejected, sensor_source_mcp_empty_kafka_topic_rejected,
4199    // sensor_source_mcp_stdio_server, sensor_source_mcp_stdio_empty_command_rejected,
4200    // sensor_source_mcp_stdio_defaults
4201
4202    #[test]
4203    fn mcp_server_entry_stdio_roundtrip() {
4204        let stdio = McpServerEntry::Stdio {
4205            command: "npx".into(),
4206            args: vec!["-y".into(), "my-mcp-server".into()],
4207            env: std::collections::HashMap::from([("KEY".into(), "val".into())]),
4208        };
4209        let json = serde_json::to_string(&stdio).unwrap();
4210        let parsed: McpServerEntry = serde_json::from_str(&json).unwrap();
4211        assert_eq!(stdio, parsed);
4212    }
4213
4214    #[test]
4215    fn mcp_server_entry_display_name() {
4216        let simple = McpServerEntry::Simple("http://localhost/mcp".into());
4217        assert_eq!(simple.display_name(), "http://localhost/mcp");
4218
4219        let full = McpServerEntry::Full {
4220            url: "http://gateway/mcp".into(),
4221            auth_header: Some("Bearer tok".into()),
4222            resource: None,
4223            scopes: None,
4224        };
4225        assert_eq!(full.display_name(), "http://gateway/mcp");
4226
4227        let stdio = McpServerEntry::Stdio {
4228            command: "npx".into(),
4229            args: vec!["-y".into(), "server".into()],
4230            env: Default::default(),
4231        };
4232        assert_eq!(stdio.display_name(), "npx -y server");
4233
4234        let stdio_no_args = McpServerEntry::Stdio {
4235            command: "my-server".into(),
4236            args: vec![],
4237            env: Default::default(),
4238        };
4239        assert_eq!(stdio_no_args.display_name(), "my-server");
4240    }
4241
4242    // --- Daemon validation tests ---
4243
4244    #[test]
4245    fn validate_daemon_empty_consumer_group() {
4246        let toml = r#"
4247[provider]
4248name = "anthropic"
4249model = "claude-sonnet-4-20250514"
4250
4251[daemon.kafka]
4252brokers = "localhost:9092"
4253consumer_group = ""
4254"#;
4255        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4256        assert!(
4257            err.to_string().contains("consumer_group must not be empty"),
4258            "got: {err}"
4259        );
4260    }
4261
4262    #[test]
4263    fn validate_daemon_empty_commands_topic() {
4264        let toml = r#"
4265[provider]
4266name = "anthropic"
4267model = "claude-sonnet-4-20250514"
4268
4269[daemon.kafka]
4270brokers = "localhost:9092"
4271commands_topic = ""
4272"#;
4273        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4274        assert!(
4275            err.to_string().contains("commands_topic must not be empty"),
4276            "got: {err}"
4277        );
4278    }
4279
4280    #[test]
4281    fn validate_daemon_empty_events_topic() {
4282        let toml = r#"
4283[provider]
4284name = "anthropic"
4285model = "claude-sonnet-4-20250514"
4286
4287[daemon.kafka]
4288brokers = "localhost:9092"
4289events_topic = ""
4290"#;
4291        let err = HeartbitConfig::from_toml(toml).unwrap_err();
4292        assert!(
4293            err.to_string().contains("events_topic must not be empty"),
4294            "got: {err}"
4295        );
4296    }
4297
4298    // --- SensorModality tests (always available, not gated on `sensor` feature) ---
4299
4300    #[test]
4301    fn sensor_modality_serde_roundtrip() {
4302        for modality in [
4303            SensorModality::Text,
4304            SensorModality::Image,
4305            SensorModality::Audio,
4306            SensorModality::Structured,
4307        ] {
4308            let json = serde_json::to_string(&modality).unwrap();
4309            let back: SensorModality = serde_json::from_str(&json).unwrap();
4310            assert_eq!(back, modality);
4311        }
4312    }
4313
4314    #[test]
4315    fn sensor_modality_snake_case() {
4316        assert_eq!(
4317            serde_json::to_string(&SensorModality::Text).unwrap(),
4318            r#""text""#
4319        );
4320        assert_eq!(
4321            serde_json::to_string(&SensorModality::Image).unwrap(),
4322            r#""image""#
4323        );
4324        assert_eq!(
4325            serde_json::to_string(&SensorModality::Audio).unwrap(),
4326            r#""audio""#
4327        );
4328        assert_eq!(
4329            serde_json::to_string(&SensorModality::Structured).unwrap(),
4330            r#""structured""#
4331        );
4332    }
4333
4334    #[test]
4335    fn sensor_modality_display() {
4336        assert_eq!(SensorModality::Text.to_string(), "text");
4337        assert_eq!(SensorModality::Image.to_string(), "image");
4338        assert_eq!(SensorModality::Audio.to_string(), "audio");
4339        assert_eq!(SensorModality::Structured.to_string(), "structured");
4340    }
4341
4342    // --- TrustLevel tests (always available, not gated on `sensor` feature) ---
4343
4344    #[test]
4345    fn trust_level_default_is_unknown() {
4346        assert_eq!(TrustLevel::default(), TrustLevel::Unknown);
4347    }
4348
4349    #[test]
4350    fn trust_level_ordering() {
4351        assert!(TrustLevel::Quarantined < TrustLevel::Unknown);
4352        assert!(TrustLevel::Unknown < TrustLevel::Known);
4353        assert!(TrustLevel::Known < TrustLevel::Verified);
4354        assert!(TrustLevel::Verified < TrustLevel::Owner);
4355    }
4356
4357    #[test]
4358    fn trust_level_serde_roundtrip() {
4359        for t in [
4360            TrustLevel::Quarantined,
4361            TrustLevel::Unknown,
4362            TrustLevel::Known,
4363            TrustLevel::Verified,
4364            TrustLevel::Owner,
4365        ] {
4366            let json = serde_json::to_string(&t).unwrap();
4367            let parsed: TrustLevel = serde_json::from_str(&json).unwrap();
4368            assert_eq!(parsed, t);
4369        }
4370    }
4371
4372    #[test]
4373    fn trust_level_display() {
4374        assert_eq!(TrustLevel::Quarantined.to_string(), "quarantined");
4375        assert_eq!(TrustLevel::Unknown.to_string(), "unknown");
4376        assert_eq!(TrustLevel::Known.to_string(), "known");
4377        assert_eq!(TrustLevel::Verified.to_string(), "verified");
4378        assert_eq!(TrustLevel::Owner.to_string(), "owner");
4379    }
4380
4381    #[test]
4382    fn trust_level_resolve_owner() {
4383        let trust = TrustLevel::resolve(
4384            Some("owner@example.com"),
4385            &["owner@example.com".into()],
4386            &[],
4387            &[],
4388        );
4389        assert_eq!(trust, TrustLevel::Owner);
4390    }
4391
4392    #[test]
4393    fn trust_level_resolve_verified() {
4394        let trust = TrustLevel::resolve(
4395            Some("alice@example.com"),
4396            &[],
4397            &["alice@example.com".into()],
4398            &[],
4399        );
4400        assert_eq!(trust, TrustLevel::Verified);
4401    }
4402
4403    #[test]
4404    fn trust_level_resolve_blocked() {
4405        let trust = TrustLevel::resolve(
4406            Some("spammer@evil.com"),
4407            &[],
4408            &[],
4409            &["spammer@evil.com".into()],
4410        );
4411        assert_eq!(trust, TrustLevel::Quarantined);
4412    }
4413
4414    #[test]
4415    fn trust_level_resolve_unknown() {
4416        let trust = TrustLevel::resolve(Some("stranger@example.com"), &[], &[], &[]);
4417        assert_eq!(trust, TrustLevel::Unknown);
4418    }
4419
4420    #[test]
4421    fn trust_level_resolve_none_sender() {
4422        let trust = TrustLevel::resolve(None, &[], &[], &[]);
4423        assert_eq!(trust, TrustLevel::Unknown);
4424    }
4425
4426    #[test]
4427    fn trust_level_owner_trumps_blocked() {
4428        let trust = TrustLevel::resolve(
4429            Some("owner@example.com"),
4430            &["owner@example.com".into()],
4431            &[],
4432            &["owner@example.com".into()],
4433        );
4434        assert_eq!(trust, TrustLevel::Owner);
4435    }
4436
4437    #[test]
4438    fn trust_level_resolve_case_insensitive() {
4439        let trust = TrustLevel::resolve(
4440            Some("Owner@Example.COM"),
4441            &["owner@example.com".into()],
4442            &[],
4443            &[],
4444        );
4445        assert_eq!(trust, TrustLevel::Owner);
4446    }
4447
4448    // --- Guardrails config tests ---
4449
4450    #[test]
4451    fn guardrails_config_default_empty() {
4452        let config: GuardrailsConfig = toml::from_str("").unwrap();
4453        assert!(config.injection.is_none());
4454        assert!(config.pii.is_none());
4455        assert!(config.tool_policy.is_none());
4456    }
4457
4458    #[test]
4459    fn guardrails_config_roundtrip() {
4460        let toml_str = r#"
4461[injection]
4462threshold = 0.3
4463mode = "warn"
4464
4465[pii]
4466action = "redact"
4467detectors = ["email", "ssn"]
4468
4469[tool_policy]
4470default_action = "allow"
4471
4472[[tool_policy.rules]]
4473tool = "bash"
4474action = "deny"
4475input_constraints = []
4476
4477[[tool_policy.rules]]
4478tool = "gmail_send_*"
4479action = "warn"
4480input_constraints = []
4481"#;
4482        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4483
4484        // Injection
4485        let inj = config.injection.as_ref().unwrap();
4486        assert!((inj.threshold - 0.3).abs() < 0.01);
4487        assert_eq!(inj.mode, "warn");
4488
4489        // PII
4490        let pii = config.pii.as_ref().unwrap();
4491        assert_eq!(pii.action, "redact");
4492        assert_eq!(pii.detectors, vec!["email", "ssn"]);
4493
4494        // Tool policy
4495        let tp = config.tool_policy.as_ref().unwrap();
4496        assert_eq!(tp.default_action, "allow");
4497        assert_eq!(tp.rules.len(), 2);
4498        assert_eq!(tp.rules[0].tool, "bash");
4499        assert_eq!(tp.rules[0].action, "deny");
4500        assert_eq!(tp.rules[1].tool, "gmail_send_*");
4501        assert_eq!(tp.rules[1].action, "warn");
4502
4503        // Verify serialization roundtrip
4504        let serialized = toml::to_string(&config).unwrap();
4505        let _back: GuardrailsConfig = toml::from_str(&serialized).unwrap();
4506    }
4507
4508    #[test]
4509    fn guardrails_config_with_input_constraints() {
4510        let toml_str = r#"
4511[tool_policy]
4512default_action = "deny"
4513
4514[[tool_policy.rules]]
4515tool = "read"
4516action = "allow"
4517
4518[[tool_policy.rules.input_constraints]]
4519path = "path"
4520deny_pattern = "^/etc/"
4521"#;
4522        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4523        let tp = config.tool_policy.unwrap();
4524        assert_eq!(tp.default_action, "deny");
4525        assert_eq!(tp.rules.len(), 1);
4526        assert_eq!(tp.rules[0].input_constraints.len(), 1);
4527        assert_eq!(tp.rules[0].input_constraints[0].path, "path");
4528        assert_eq!(
4529            tp.rules[0].input_constraints[0].deny_pattern.as_deref(),
4530            Some("^/etc/")
4531        );
4532    }
4533
4534    #[test]
4535    fn heartbit_config_with_guardrails() {
4536        let toml_str = r#"
4537[provider]
4538name = "anthropic"
4539model = "claude-sonnet-4-20250514"
4540
4541[guardrails.injection]
4542threshold = 0.5
4543mode = "deny"
4544
4545[guardrails.pii]
4546action = "redact"
4547"#;
4548        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
4549        let guardrails = config.guardrails.unwrap();
4550        assert!(guardrails.injection.is_some());
4551        assert!(guardrails.pii.is_some());
4552        assert!(guardrails.tool_policy.is_none());
4553    }
4554
4555    #[test]
4556    fn guardrails_config_build_empty() {
4557        let config = GuardrailsConfig::default();
4558        assert!(config.is_empty());
4559        let guardrails = config.build().unwrap();
4560        assert!(guardrails.is_empty());
4561    }
4562
4563    #[test]
4564    fn guardrails_config_build_injection() {
4565        let config = GuardrailsConfig {
4566            injection: Some(InjectionConfig {
4567                threshold: 0.3,
4568                mode: "warn".into(),
4569            }),
4570            ..Default::default()
4571        };
4572        let guardrails = config.build().unwrap();
4573        assert_eq!(guardrails.len(), 1);
4574    }
4575
4576    #[test]
4577    fn guardrails_config_build_pii() {
4578        let config = GuardrailsConfig {
4579            pii: Some(PiiConfig {
4580                action: "redact".into(),
4581                detectors: vec!["email".into(), "phone".into()],
4582            }),
4583            ..Default::default()
4584        };
4585        let guardrails = config.build().unwrap();
4586        assert_eq!(guardrails.len(), 1);
4587    }
4588
4589    #[test]
4590    fn guardrails_config_build_tool_policy() {
4591        let config = GuardrailsConfig {
4592            tool_policy: Some(ToolPolicyConfig {
4593                default_action: "allow".into(),
4594                rules: vec![ToolPolicyRuleConfig {
4595                    tool: "bash".into(),
4596                    action: "deny".into(),
4597                    input_constraints: vec![],
4598                }],
4599            }),
4600            ..Default::default()
4601        };
4602        let guardrails = config.build().unwrap();
4603        assert_eq!(guardrails.len(), 1);
4604    }
4605
4606    #[test]
4607    fn guardrails_config_build_all_three() {
4608        let config = GuardrailsConfig {
4609            injection: Some(InjectionConfig {
4610                threshold: 0.5,
4611                mode: "deny".into(),
4612            }),
4613            pii: Some(PiiConfig {
4614                action: "warn".into(),
4615                detectors: default_pii_detectors(),
4616            }),
4617            tool_policy: Some(ToolPolicyConfig {
4618                default_action: "allow".into(),
4619                rules: vec![],
4620            }),
4621            llm_judge: None,
4622            secret_scan: None,
4623            behavioral: None,
4624            action_budget: None,
4625        };
4626        assert!(!config.is_empty());
4627        let guardrails = config.build().unwrap();
4628        assert_eq!(guardrails.len(), 3);
4629    }
4630
4631    #[test]
4632    fn guardrails_config_build_invalid_mode_errors() {
4633        let config = GuardrailsConfig {
4634            injection: Some(InjectionConfig {
4635                threshold: 0.5,
4636                mode: "invalid".into(),
4637            }),
4638            ..Default::default()
4639        };
4640        let err = config.build().err().expect("should fail");
4641        assert!(
4642            err.to_string().contains("invalid injection mode"),
4643            "error: {err}"
4644        );
4645    }
4646
4647    #[test]
4648    fn guardrails_config_build_invalid_pii_action_errors() {
4649        let config = GuardrailsConfig {
4650            pii: Some(PiiConfig {
4651                action: "destroy".into(),
4652                detectors: vec!["email".into()],
4653            }),
4654            ..Default::default()
4655        };
4656        let err = config.build().err().expect("should fail");
4657        assert!(
4658            err.to_string().contains("invalid PII action"),
4659            "error: {err}"
4660        );
4661    }
4662
4663    #[test]
4664    fn guardrails_config_build_invalid_detector_errors() {
4665        let config = GuardrailsConfig {
4666            pii: Some(PiiConfig {
4667                action: "redact".into(),
4668                detectors: vec!["dna_sequence".into()],
4669            }),
4670            ..Default::default()
4671        };
4672        let err = config.build().err().expect("should fail");
4673        assert!(
4674            err.to_string().contains("unknown PII detector"),
4675            "error: {err}"
4676        );
4677    }
4678
4679    #[test]
4680    fn guardrails_config_build_invalid_regex_errors() {
4681        let config = GuardrailsConfig {
4682            tool_policy: Some(ToolPolicyConfig {
4683                default_action: "allow".into(),
4684                rules: vec![ToolPolicyRuleConfig {
4685                    tool: "bash".into(),
4686                    action: "allow".into(),
4687                    input_constraints: vec![InputConstraintConfig {
4688                        path: "command".into(),
4689                        deny_pattern: Some("[invalid".into()),
4690                        max_length: None,
4691                    }],
4692                }],
4693            }),
4694            ..Default::default()
4695        };
4696        let err = config.build().err().expect("should fail");
4697        assert!(
4698            err.to_string().contains("invalid deny_pattern"),
4699            "error: {err}"
4700        );
4701    }
4702
4703    #[test]
4704    fn guardrails_config_build_with_input_constraints() {
4705        let config = GuardrailsConfig {
4706            tool_policy: Some(ToolPolicyConfig {
4707                default_action: "deny".into(),
4708                rules: vec![ToolPolicyRuleConfig {
4709                    tool: "bash".into(),
4710                    action: "allow".into(),
4711                    input_constraints: vec![
4712                        InputConstraintConfig {
4713                            path: "command".into(),
4714                            deny_pattern: Some(r"rm\s+-rf".into()),
4715                            max_length: None,
4716                        },
4717                        InputConstraintConfig {
4718                            path: "command".into(),
4719                            deny_pattern: None,
4720                            max_length: Some(1024),
4721                        },
4722                    ],
4723                }],
4724            }),
4725            ..Default::default()
4726        };
4727        let guardrails = config.build().unwrap();
4728        assert_eq!(guardrails.len(), 1);
4729    }
4730
4731    #[test]
4732    fn guardrails_config_build_from_toml() {
4733        let toml_str = r#"
4734[injection]
4735threshold = 0.4
4736mode = "warn"
4737
4738[pii]
4739action = "deny"
4740detectors = ["email", "ssn"]
4741
4742[tool_policy]
4743default_action = "allow"
4744
4745[[tool_policy.rules]]
4746tool = "bash"
4747action = "deny"
4748
4749[[tool_policy.rules]]
4750tool = "gmail_send_*"
4751action = "warn"
4752"#;
4753        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4754        let guardrails = config.build().unwrap();
4755        assert_eq!(guardrails.len(), 3);
4756    }
4757
4758    #[test]
4759    fn guardrails_config_llm_judge_from_toml() {
4760        let toml_str = r#"
4761[llm_judge]
4762criteria = ["no harmful content", "no personal attacks"]
4763evaluate_tool_inputs = true
4764timeout_seconds = 15
4765max_judge_tokens = 512
4766"#;
4767        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4768        let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
4769        assert_eq!(judge_cfg.criteria.len(), 2);
4770        assert!(judge_cfg.evaluate_tool_inputs);
4771        assert_eq!(judge_cfg.timeout_seconds, 15);
4772        assert_eq!(judge_cfg.max_judge_tokens, 512);
4773    }
4774
4775    #[test]
4776    fn guardrails_config_llm_judge_defaults() {
4777        let toml_str = r#"
4778[llm_judge]
4779criteria = ["safety"]
4780"#;
4781        let config: GuardrailsConfig = toml::from_str(toml_str).unwrap();
4782        let judge_cfg = config.llm_judge.as_ref().expect("llm_judge should be set");
4783        assert!(!judge_cfg.evaluate_tool_inputs);
4784        assert_eq!(judge_cfg.timeout_seconds, 10);
4785        assert_eq!(judge_cfg.max_judge_tokens, 256);
4786    }
4787
4788    #[test]
4789    fn guardrails_config_build_skips_judge_without_provider() {
4790        let config = GuardrailsConfig {
4791            llm_judge: Some(LlmJudgeConfig {
4792                criteria: vec!["safety".into()],
4793                evaluate_tool_inputs: false,
4794                timeout_seconds: 10,
4795                max_judge_tokens: 256,
4796            }),
4797            ..Default::default()
4798        };
4799        // build() delegates to build_with_judge(None) — skips LLM judge
4800        let guardrails = config.build().unwrap();
4801        assert_eq!(guardrails.len(), 0);
4802    }
4803
4804    #[test]
4805    fn guardrails_config_is_empty_with_only_llm_judge() {
4806        let config = GuardrailsConfig {
4807            llm_judge: Some(LlmJudgeConfig {
4808                criteria: vec!["safety".into()],
4809                evaluate_tool_inputs: false,
4810                timeout_seconds: 10,
4811                max_judge_tokens: 256,
4812            }),
4813            ..Default::default()
4814        };
4815        assert!(!config.is_empty());
4816    }
4817
4818    #[test]
4819    fn parse_local_embedding_config() {
4820        let toml = r#"
4821[provider]
4822name = "anthropic"
4823model = "claude-sonnet-4-20250514"
4824
4825[[agents]]
4826name = "test"
4827description = "Test agent"
4828system_prompt = "You are a test agent."
4829
4830[memory]
4831type = "postgres"
4832database_url = "postgresql://localhost/heartbit"
4833
4834[memory.embedding]
4835provider = "local"
4836model = "all-MiniLM-L6-v2"
4837cache_dir = "/tmp/fastembed"
4838"#;
4839
4840        let config = HeartbitConfig::from_toml(toml).unwrap();
4841        let memory = config.memory.expect("memory should be present");
4842        match memory {
4843            MemoryConfig::Postgres { embedding, .. } => {
4844                let emb = embedding.expect("embedding config should be present");
4845                assert_eq!(emb.provider, "local");
4846                assert_eq!(emb.model, "all-MiniLM-L6-v2");
4847                assert_eq!(emb.cache_dir.as_deref(), Some("/tmp/fastembed"));
4848            }
4849            _ => panic!("expected Postgres memory config"),
4850        }
4851    }
4852
4853    #[test]
4854    fn parse_local_embedding_config_defaults() {
4855        // provider = "local" without model or cache_dir — should use defaults
4856        let toml = r#"
4857[provider]
4858name = "anthropic"
4859model = "claude-sonnet-4-20250514"
4860
4861[[agents]]
4862name = "test"
4863description = "Test agent"
4864system_prompt = "You are a test agent."
4865
4866[memory]
4867type = "postgres"
4868database_url = "postgresql://localhost/heartbit"
4869
4870[memory.embedding]
4871provider = "local"
4872"#;
4873
4874        let config = HeartbitConfig::from_toml(toml).unwrap();
4875        let memory = config.memory.expect("memory should be present");
4876        match memory {
4877            MemoryConfig::Postgres { embedding, .. } => {
4878                let emb = embedding.expect("embedding config should be present");
4879                assert_eq!(emb.provider, "local");
4880                // model defaults to "text-embedding-3-small" (OpenAI default)
4881                // CLI handles this by treating it as "unset" → uses AllMiniLML6V2
4882                assert_eq!(emb.model, "text-embedding-3-small");
4883                assert!(emb.cache_dir.is_none());
4884                assert!(emb.base_url.is_none());
4885                assert!(emb.dimension.is_none());
4886            }
4887            _ => panic!("expected Postgres memory config"),
4888        }
4889    }
4890
4891    #[test]
4892    fn auth_config_backward_compat() {
4893        let toml_str = r#"
4894            bearer_tokens = ["tok-abc", "tok-xyz"]
4895        "#;
4896        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4897        assert_eq!(auth.bearer_tokens, vec!["tok-abc", "tok-xyz"]);
4898        assert!(auth.jwks_url.is_none());
4899        assert!(auth.issuer.is_none());
4900        assert!(auth.audience.is_none());
4901        assert!(auth.user_id_claim.is_none());
4902        assert!(auth.tenant_id_claim.is_none());
4903        assert!(auth.roles_claim.is_none());
4904        assert!(auth.token_exchange.is_none());
4905    }
4906
4907    #[test]
4908    fn auth_config_with_jwks() {
4909        let toml_str = r#"
4910            bearer_tokens = ["tok-1"]
4911            jwks_url = "https://idp.example.com/.well-known/jwks.json"
4912            issuer = "https://idp.example.com"
4913            audience = "heartbit-api"
4914            user_id_claim = "sub"
4915            tenant_id_claim = "org_id"
4916            roles_claim = "permissions"
4917        "#;
4918        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4919        assert_eq!(auth.bearer_tokens, vec!["tok-1"]);
4920        assert_eq!(
4921            auth.jwks_url.as_deref(),
4922            Some("https://idp.example.com/.well-known/jwks.json")
4923        );
4924        assert_eq!(auth.issuer.as_deref(), Some("https://idp.example.com"));
4925        assert_eq!(auth.audience.as_deref(), Some("heartbit-api"));
4926        assert_eq!(auth.user_id_claim.as_deref(), Some("sub"));
4927        assert_eq!(auth.tenant_id_claim.as_deref(), Some("org_id"));
4928        assert_eq!(auth.roles_claim.as_deref(), Some("permissions"));
4929    }
4930
4931    #[test]
4932    fn auth_config_empty_is_valid() {
4933        let toml_str = "";
4934        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4935        assert!(auth.bearer_tokens.is_empty());
4936        assert!(auth.jwks_url.is_none());
4937        assert!(auth.issuer.is_none());
4938        assert!(auth.audience.is_none());
4939        assert!(auth.user_id_claim.is_none());
4940        assert!(auth.tenant_id_claim.is_none());
4941        assert!(auth.roles_claim.is_none());
4942    }
4943
4944    #[test]
4945    fn auth_config_mixed() {
4946        let toml_str = r#"
4947            bearer_tokens = ["static-key"]
4948            jwks_url = "https://auth.corp.io/.well-known/jwks.json"
4949            audience = "heartbit"
4950        "#;
4951        let auth: AuthConfig = toml::from_str(toml_str).unwrap();
4952        assert_eq!(auth.bearer_tokens, vec!["static-key"]);
4953        assert_eq!(
4954            auth.jwks_url.as_deref(),
4955            Some("https://auth.corp.io/.well-known/jwks.json")
4956        );
4957        assert!(auth.issuer.is_none());
4958        assert_eq!(auth.audience.as_deref(), Some("heartbit"));
4959        assert!(auth.user_id_claim.is_none());
4960        assert!(auth.tenant_id_claim.is_none());
4961        assert!(auth.roles_claim.is_none());
4962    }
4963
4964    #[test]
4965    fn mcp_resource_mode_default_is_tools() {
4966        let mode: McpResourceMode = Default::default();
4967        assert_eq!(mode, McpResourceMode::Tools);
4968    }
4969
4970    #[test]
4971    fn mcp_resource_mode_deserialize() {
4972        #[derive(Deserialize)]
4973        struct Wrapper {
4974            mode: McpResourceMode,
4975        }
4976        let w: Wrapper = toml::from_str(r#"mode = "tools""#).unwrap();
4977        assert_eq!(w.mode, McpResourceMode::Tools);
4978        let w: Wrapper = toml::from_str(r#"mode = "context""#).unwrap();
4979        assert_eq!(w.mode, McpResourceMode::Context);
4980        let w: Wrapper = toml::from_str(r#"mode = "none""#).unwrap();
4981        assert_eq!(w.mode, McpResourceMode::None);
4982    }
4983
4984    #[test]
4985    fn agent_config_mcp_resources_default() {
4986        let toml_str = r#"
4987[provider]
4988name = "anthropic"
4989model = "claude-sonnet-4-20250514"
4990
4991[orchestrator]
4992max_turns = 10
4993
4994[[agents]]
4995name = "test"
4996description = "A test agent"
4997system_prompt = "You are a test."
4998"#;
4999        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
5000        assert_eq!(config.agents[0].mcp_resources, McpResourceMode::Tools);
5001    }
5002
5003    #[test]
5004    fn agent_config_mcp_resources_explicit() {
5005        let toml_str = r#"
5006[provider]
5007name = "anthropic"
5008model = "claude-sonnet-4-20250514"
5009
5010[orchestrator]
5011max_turns = 10
5012
5013[[agents]]
5014name = "test"
5015description = "A test agent"
5016system_prompt = "You are a test."
5017mcp_resources = "none"
5018"#;
5019        let config: HeartbitConfig = toml::from_str(toml_str).unwrap();
5020        assert_eq!(config.agents[0].mcp_resources, McpResourceMode::None);
5021    }
5022
5023    // daemon_mcp_server tests removed — mcp_server field moved to gateway crate.
5024
5025    #[test]
5026    fn parse_workflow_type_valid() {
5027        use crate::agent::workflow::WorkflowType;
5028        assert_eq!(parse_workflow_type("dag").unwrap(), WorkflowType::Dag);
5029        assert_eq!(parse_workflow_type("DAG").unwrap(), WorkflowType::Dag);
5030        assert_eq!(
5031            parse_workflow_type("sequential").unwrap(),
5032            WorkflowType::Sequential
5033        );
5034        assert_eq!(
5035            parse_workflow_type("parallel").unwrap(),
5036            WorkflowType::Parallel
5037        );
5038        assert_eq!(parse_workflow_type("loop").unwrap(), WorkflowType::Loop);
5039        assert_eq!(parse_workflow_type("debate").unwrap(), WorkflowType::Debate);
5040        assert_eq!(parse_workflow_type("voting").unwrap(), WorkflowType::Voting);
5041        assert_eq!(
5042            parse_workflow_type("mixture").unwrap(),
5043            WorkflowType::Mixture
5044        );
5045    }
5046
5047    #[test]
5048    fn parse_workflow_type_invalid() {
5049        assert!(parse_workflow_type("").is_err());
5050        assert!(parse_workflow_type("unknown").is_err());
5051        assert!(parse_workflow_type("rm -rf /").is_err());
5052    }
5053
5054    #[test]
5055    fn validate_rejects_unknown_builtin() {
5056        let toml = r#"
5057[provider]
5058name = "anthropic"
5059model = "claude-sonnet-4-20250514"
5060
5061[[agents]]
5062name = "researcher"
5063description = "Research specialist"
5064builtin_tools = ["websearch", "nonexistent"]
5065"#;
5066        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5067        let msg = format!("{err}");
5068        assert!(
5069            msg.contains("unknown builtin tool 'nonexistent'"),
5070            "expected unknown builtin error, got: {msg}"
5071        );
5072    }
5073
5074    #[test]
5075    fn agent_config_builtin_tools_deserialization() {
5076        let toml = r#"
5077[provider]
5078name = "anthropic"
5079model = "claude-sonnet-4-20250514"
5080
5081[[agents]]
5082name = "researcher"
5083description = "Research specialist"
5084builtin_tools = ["websearch", "webfetch"]
5085
5086[[agents]]
5087name = "publisher"
5088description = "Publisher"
5089builtin_tools = []
5090"#;
5091        let config = HeartbitConfig::from_toml(toml).unwrap();
5092        assert_eq!(
5093            config.agents[0].builtin_tools,
5094            Some(vec!["websearch".into(), "webfetch".into()])
5095        );
5096        assert_eq!(config.agents[1].builtin_tools, Some(vec![]));
5097    }
5098
5099    #[test]
5100    fn agent_config_builtin_tools_absent_is_none() {
5101        let toml = r#"
5102[provider]
5103name = "anthropic"
5104model = "claude-sonnet-4-20250514"
5105
5106[[agents]]
5107name = "researcher"
5108description = "Research specialist"
5109"#;
5110        let config = HeartbitConfig::from_toml(toml).unwrap();
5111        assert_eq!(config.agents[0].builtin_tools, None);
5112    }
5113
5114    // B5b: circuit breaker + tenant tracker config tests
5115    #[test]
5116    fn b5b_full_config_parses() {
5117        let toml = r#"
5118[provider]
5119name = "anthropic"
5120model = "claude-sonnet-4-20250514"
5121
5122[provider.circuit]
5123failure_threshold = 5
5124initial_open_duration_seconds = 30
5125max_open_duration_seconds = 300
5126backoff_multiplier = 2.0
5127
5128[orchestrator]
5129max_tokens_in_flight_per_tenant = 1000000
5130"#;
5131        let cfg = HeartbitConfig::from_toml(toml).unwrap();
5132        assert_eq!(cfg.provider.circuit.failure_threshold, Some(5));
5133        assert_eq!(cfg.provider.circuit.initial_open_duration_seconds, Some(30));
5134        assert_eq!(cfg.provider.circuit.max_open_duration_seconds, Some(300));
5135        assert_eq!(cfg.provider.circuit.backoff_multiplier, Some(2.0));
5136        assert_eq!(
5137            cfg.orchestrator.max_tokens_in_flight_per_tenant,
5138            Some(1_000_000)
5139        );
5140    }
5141
5142    #[test]
5143    fn b5b_config_zero_failure_threshold_rejected() {
5144        let toml = r#"
5145[provider]
5146name = "anthropic"
5147model = "claude-sonnet-4-20250514"
5148
5149[provider.circuit]
5150failure_threshold = 0
5151"#;
5152        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5153        assert!(
5154            err.to_string()
5155                .contains("provider.circuit.failure_threshold must be > 0"),
5156            "error: {err}"
5157        );
5158    }
5159
5160    #[test]
5161    fn b5b_config_zero_initial_open_duration_rejected() {
5162        let toml = r#"
5163[provider]
5164name = "anthropic"
5165model = "claude-sonnet-4-20250514"
5166
5167[provider.circuit]
5168initial_open_duration_seconds = 0
5169"#;
5170        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5171        assert!(
5172            err.to_string()
5173                .contains("provider.circuit.initial_open_duration_seconds must be > 0"),
5174            "error: {err}"
5175        );
5176    }
5177
5178    #[test]
5179    fn b5b_config_zero_max_open_duration_rejected() {
5180        let toml = r#"
5181[provider]
5182name = "anthropic"
5183model = "claude-sonnet-4-20250514"
5184
5185[provider.circuit]
5186max_open_duration_seconds = 0
5187"#;
5188        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5189        assert!(
5190            err.to_string()
5191                .contains("provider.circuit.max_open_duration_seconds must be > 0"),
5192            "error: {err}"
5193        );
5194    }
5195
5196    #[test]
5197    fn b5b_config_zero_max_tokens_in_flight_rejected() {
5198        let toml = r#"
5199[provider]
5200name = "anthropic"
5201model = "claude-sonnet-4-20250514"
5202
5203[orchestrator]
5204max_tokens_in_flight_per_tenant = 0
5205"#;
5206        let err = HeartbitConfig::from_toml(toml).unwrap_err();
5207        assert!(
5208            err.to_string()
5209                .contains("orchestrator.max_tokens_in_flight_per_tenant must be > 0"),
5210            "error: {err}"
5211        );
5212    }
5213
5214    #[test]
5215    fn b5b_config_invalid_backoff_multiplier_rejected() {
5216        // TOML uses lowercase `nan` / `inf` literals; Rust's f64 Display formats
5217        // f64::NAN as "NaN" and f64::INFINITY as "inf", so we hard-code the TOML
5218        // literal forms here rather than `format!("{}", f64::NAN)`.
5219        for bad in ["0.0", "-0.0", "-1.0", "nan", "inf", "-inf"] {
5220            let toml = format!(
5221                r#"
5222[provider]
5223name = "anthropic"
5224model = "claude-sonnet-4-20250514"
5225
5226[provider.circuit]
5227backoff_multiplier = {bad}
5228"#
5229            );
5230            let err = match HeartbitConfig::from_toml(&toml) {
5231                Ok(cfg) => panic!("backoff_multiplier = {bad} must be rejected, got: {cfg:?}"),
5232                Err(e) => e,
5233            };
5234            assert!(
5235                err.to_string()
5236                    .contains("provider.circuit.backoff_multiplier must be > 0 and finite"),
5237                "value {bad} produced unexpected error: {err}"
5238            );
5239        }
5240    }
5241
5242    #[test]
5243    fn b5b_circuit_config_from_provider_circuit_config() {
5244        use crate::llm::circuit::CircuitConfig;
5245        let pcc = ProviderCircuitConfig {
5246            failure_threshold: Some(3),
5247            initial_open_duration_seconds: Some(10),
5248            max_open_duration_seconds: Some(120),
5249            backoff_multiplier: Some(1.5),
5250        };
5251        let cc = CircuitConfig::from(&pcc);
5252        assert_eq!(cc.failure_threshold, 3);
5253        assert_eq!(cc.initial_open_duration, std::time::Duration::from_secs(10));
5254        assert_eq!(cc.max_open_duration, std::time::Duration::from_secs(120));
5255        assert_eq!(cc.backoff_multiplier, 1.5);
5256    }
5257
5258    #[test]
5259    fn b5b_circuit_config_defaults_when_absent() {
5260        use crate::llm::circuit::CircuitConfig;
5261        let default_cc = CircuitConfig::default();
5262        let pcc = ProviderCircuitConfig::default();
5263        let cc = CircuitConfig::from(&pcc);
5264        assert_eq!(cc.failure_threshold, default_cc.failure_threshold);
5265        assert_eq!(cc.initial_open_duration, default_cc.initial_open_duration);
5266        assert_eq!(cc.max_open_duration, default_cc.max_open_duration);
5267        assert_eq!(cc.backoff_multiplier, default_cc.backoff_multiplier);
5268    }
5269}