Skip to main content

heliosdb_proxy/
config.rs

1//! Proxy Configuration
2//!
3//! Configuration management for HeliosDB Proxy.
4
5use crate::{ProxyError, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8use std::time::Duration;
9
10// =============================================================================
11// POOL MODE TYPES
12// =============================================================================
13
14/// Connection pooling mode
15///
16/// Determines when connections are returned to the pool.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum PoolingMode {
20    /// Session mode: 1:1 client-to-backend mapping
21    #[default]
22    Session,
23    /// Transaction mode: Return after COMMIT/ROLLBACK
24    Transaction,
25    /// Statement mode: Return after each statement
26    Statement,
27}
28
29/// Prepared statement handling mode
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum PreparedStatementMode {
33    /// Disable prepared statements
34    #[default]
35    Disable,
36    /// Track and recreate on new connections
37    Track,
38    /// Use protocol-level named statements
39    Named,
40}
41
42/// Pool mode configuration
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PoolModeConfig {
45    /// Default pooling mode
46    #[serde(default)]
47    pub mode: PoolingMode,
48    /// Maximum connections per node
49    #[serde(default = "default_pool_mode_max_size")]
50    pub max_pool_size: u32,
51    /// Minimum idle connections
52    #[serde(default = "default_pool_mode_min_idle")]
53    pub min_idle: u32,
54    /// Idle timeout (seconds)
55    #[serde(default = "default_pool_mode_idle_timeout")]
56    pub idle_timeout_secs: u64,
57    /// Max connection lifetime (seconds)
58    #[serde(default = "default_pool_mode_max_lifetime")]
59    pub max_lifetime_secs: u64,
60    /// Acquire timeout (seconds)
61    #[serde(default = "default_pool_mode_acquire_timeout")]
62    pub acquire_timeout_secs: u64,
63    /// Reset query to run when returning connection to pool
64    #[serde(default = "default_reset_query")]
65    pub reset_query: String,
66    /// Prepared statement mode
67    #[serde(default)]
68    pub prepared_statement_mode: PreparedStatementMode,
69}
70
71fn default_pool_mode_max_size() -> u32 {
72    100
73}
74
75fn default_pool_mode_min_idle() -> u32 {
76    10
77}
78
79fn default_pool_mode_idle_timeout() -> u64 {
80    600
81}
82
83fn default_pool_mode_max_lifetime() -> u64 {
84    3600
85}
86
87fn default_pool_mode_acquire_timeout() -> u64 {
88    5
89}
90
91fn default_reset_query() -> String {
92    "DISCARD ALL".to_string()
93}
94
95impl Default for PoolModeConfig {
96    fn default() -> Self {
97        Self {
98            mode: PoolingMode::default(),
99            max_pool_size: default_pool_mode_max_size(),
100            min_idle: default_pool_mode_min_idle(),
101            idle_timeout_secs: default_pool_mode_idle_timeout(),
102            max_lifetime_secs: default_pool_mode_max_lifetime(),
103            acquire_timeout_secs: default_pool_mode_acquire_timeout(),
104            reset_query: default_reset_query(),
105            prepared_statement_mode: PreparedStatementMode::default(),
106        }
107    }
108}
109
110impl PoolModeConfig {
111    /// Create config for session mode
112    pub fn session_mode() -> Self {
113        Self {
114            mode: PoolingMode::Session,
115            prepared_statement_mode: PreparedStatementMode::Named,
116            ..Default::default()
117        }
118    }
119
120    /// Create config for transaction mode
121    pub fn transaction_mode() -> Self {
122        Self {
123            mode: PoolingMode::Transaction,
124            prepared_statement_mode: PreparedStatementMode::Track,
125            ..Default::default()
126        }
127    }
128
129    /// Create config for statement mode
130    pub fn statement_mode() -> Self {
131        Self {
132            mode: PoolingMode::Statement,
133            prepared_statement_mode: PreparedStatementMode::Disable,
134            ..Default::default()
135        }
136    }
137
138    /// Get idle timeout as Duration
139    pub fn idle_timeout(&self) -> Duration {
140        Duration::from_secs(self.idle_timeout_secs)
141    }
142
143    /// Get max lifetime as Duration
144    pub fn max_lifetime(&self) -> Duration {
145        Duration::from_secs(self.max_lifetime_secs)
146    }
147
148    /// Get acquire timeout as Duration
149    pub fn acquire_timeout(&self) -> Duration {
150        Duration::from_secs(self.acquire_timeout_secs)
151    }
152}
153
154// =============================================================================
155// MAIN PROXY CONFIG
156// =============================================================================
157
158/// Proxy configuration
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ProxyConfig {
161    /// Listen address for client connections
162    pub listen_address: String,
163    /// Admin API address
164    pub admin_address: String,
165    /// Bearer token required on admin API requests. When set, every admin
166    /// endpoint except liveness probes (`/health*`, `/livez`, `/readyz`)
167    /// requires `Authorization: Bearer <token>`. Absent (default) = open
168    /// (current behaviour) — set this for any non-loopback deployment.
169    #[serde(default)]
170    pub admin_token: Option<String>,
171    /// Enable TR (Transaction Replay)
172    pub tr_enabled: bool,
173    /// TR mode
174    pub tr_mode: TrMode,
175    /// Connection pool configuration
176    pub pool: PoolConfig,
177    /// Pool mode configuration (Session/Transaction/Statement)
178    #[serde(default)]
179    pub pool_mode: PoolModeConfig,
180    /// Load balancer configuration
181    pub load_balancer: LoadBalancerConfig,
182    /// Health check configuration
183    pub health: HealthConfig,
184    /// Backend nodes
185    pub nodes: Vec<NodeConfig>,
186    /// TLS configuration
187    pub tls: Option<TlsConfig>,
188    /// Write timeout during failover (seconds)
189    /// When primary is unavailable, wait this long for a new primary before returning error
190    #[serde(default = "default_write_timeout_secs")]
191    pub write_timeout_secs: u64,
192    /// Plugin system configuration. Only consumed when the `wasm-plugins`
193    /// feature is enabled; on a feature-off build, values are parsed and
194    /// ignored so existing configs don't break.
195    #[serde(default)]
196    pub plugins: PluginToml,
197    /// pg_hba-style connection admission rules, evaluated in order before any
198    /// backend connection is opened. Empty (the default) means admit all
199    /// (current behaviour preserved).
200    #[serde(default)]
201    pub hba: Vec<HbaRule>,
202    /// Client authentication mode. Absent/default = pass-through (the proxy
203    /// relays the client's auth to the backend, current behaviour).
204    #[serde(default)]
205    pub auth: AuthConfig,
206    /// MCP (Model Context Protocol) agent gateway. Disabled by default.
207    #[serde(default)]
208    pub mcp: McpConfig,
209    /// Per-agent SQL contracts (scoped grants). Referenced by id from the
210    /// MCP gateway (`[mcp] contract`). Empty by default.
211    #[serde(default)]
212    pub agent_contracts: Vec<crate::agent_contract::AgentContract>,
213    /// HTTP SQL gateway (Neon-serverless-driver compatible). Disabled by
214    /// default — lets edge/serverless clients run SQL over HTTP.
215    #[serde(default)]
216    pub http_gateway: HttpGatewayConfig,
217    /// Continuous traffic mirroring to a secondary backend. Disabled by
218    /// default — the on-ramp to a PG->Nano migration mirror.
219    #[serde(default)]
220    pub mirror: MirrorConfig,
221    /// Instant branch databases. Disabled by default — provisions
222    /// CREATE DATABASE ... TEMPLATE clones through the proxy.
223    #[serde(default)]
224    pub branch: BranchConfig,
225    /// SQL-comment routing hints (`/*helios:route=primary*/`). Disabled by
226    /// default — when enabled, the proxy parses hints from query SQL and
227    /// applies them as a route override that wins over the default verb
228    /// routing (but never over a plugin `Block`). Only consumed when the
229    /// `routing-hints` feature is compiled in; parsed-and-ignored otherwise.
230    #[serde(default)]
231    pub routing_hints: RoutingHintsConfig,
232    /// Multi-dimensional rate limiting (token bucket + concurrency). Disabled
233    /// by default. Only enforced when the `rate-limiting` feature is compiled
234    /// in; parsed-and-ignored otherwise.
235    #[serde(default)]
236    pub rate_limit: RateLimitToml,
237    /// Per-node circuit breaker (trip failing backends out of rotation,
238    /// fast-fail while open). Disabled by default. Only enforced when the
239    /// `circuit-breaker` feature is compiled in.
240    #[serde(default)]
241    pub circuit_breaker: CircuitBreakerToml,
242    /// Query analytics (fingerprinting, per-query statistics, slow-query log,
243    /// pattern detection). Disabled by default. Only active when the
244    /// `query-analytics` feature is compiled in.
245    #[serde(default)]
246    pub analytics: AnalyticsToml,
247    /// Replica-lag-aware routing + read-your-writes. Disabled by default. Only
248    /// enforced when the `lag-routing` feature is compiled in.
249    #[serde(default)]
250    pub lag_routing: LagRoutingToml,
251    /// Query-result cache (L1 hot / L2 warm). Disabled by default. Only active
252    /// when the `query-cache` feature is compiled in.
253    #[serde(default)]
254    pub cache: CacheToml,
255    /// SQL query rewriting (rules engine). Disabled by default. Only active
256    /// when the `query-rewriting` feature is compiled in.
257    #[serde(default)]
258    pub query_rewrite: QueryRewriteToml,
259    /// Multi-tenancy (per-tenant row isolation via injected predicates).
260    /// Disabled by default. Only active when the `multi-tenancy` feature is
261    /// compiled in.
262    #[serde(default)]
263    pub multi_tenancy: MultiTenancyToml,
264    /// Schema/workload-aware routing (route OLAP queries to an analytics node).
265    /// Disabled by default. Only active when the `schema-routing` feature is on.
266    #[serde(default)]
267    pub schema_routing: SchemaRoutingToml,
268    /// GraphQL-to-SQL gateway (separate HTTP listener). Disabled by default.
269    /// Only active when the `graphql-gateway` feature is compiled in.
270    #[serde(default)]
271    pub graphql_gateway: GraphqlGatewayConfig,
272    /// Proxy-side unnamed-`Parse` promotion (Batch H). When a client re-sends an
273    /// identical unnamed extended `Parse` (the dominant pgbench/ORM pattern),
274    /// the proxy skips forwarding it to a backend that already holds that exact
275    /// unnamed statement and synthesizes the `ParseComplete` locally — cutting
276    /// the per-cycle re-`Parse` overhead. Default on; a kill-switch for drivers
277    /// that somehow depend on the redundant round trip.
278    #[serde(default = "default_true")]
279    pub optimize_unnamed_parse: bool,
280    /// How long a graceful binary-handoff drain (SIGUSR2) keeps serving
281    /// in-flight connections before the old process exits (Batch H). After this
282    /// many seconds, any still-open connections are dropped so the handoff
283    /// completes in bounded time. Overridable at runtime via the
284    /// `HELIOS_DRAIN_TIMEOUT_SECS` env var.
285    #[serde(default = "default_drain_timeout_secs")]
286    pub shutdown_drain_timeout_secs: u64,
287}
288
289fn default_drain_timeout_secs() -> u64 {
290    60
291}
292
293/// Branch-database configuration: the maintenance connection the proxy uses
294/// to provision `CREATE DATABASE <branch> TEMPLATE <base>` clones.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct BranchConfig {
297    #[serde(default)]
298    pub enabled: bool,
299    #[serde(default = "default_localhost")]
300    pub backend_host: String,
301    #[serde(default = "default_pg_port")]
302    pub backend_port: u16,
303    /// A role with CREATEDB privilege.
304    #[serde(default = "default_pg_user")]
305    pub admin_user: String,
306    pub admin_password: Option<String>,
307    /// Maintenance database to issue CREATE/DROP DATABASE against (not the
308    /// branch itself). Defaults to "postgres".
309    #[serde(default = "default_admin_db")]
310    pub admin_database: String,
311    /// Default template database to branch from when a request omits `base`.
312    #[serde(default = "default_admin_db")]
313    pub base_database: String,
314}
315
316impl Default for BranchConfig {
317    fn default() -> Self {
318        Self {
319            enabled: false,
320            backend_host: default_localhost(),
321            backend_port: default_pg_port(),
322            admin_user: default_pg_user(),
323            admin_password: None,
324            admin_database: default_admin_db(),
325            base_database: default_admin_db(),
326        }
327    }
328}
329
330fn default_admin_db() -> String {
331    "postgres".to_string()
332}
333
334/// Traffic-mirror configuration: replay a sampled share of live (simple-query)
335/// writes to a secondary backend, asynchronously and off the client hot path.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct MirrorConfig {
338    #[serde(default)]
339    pub enabled: bool,
340    /// Fraction of eligible statements to mirror, 0.0..=1.0.
341    #[serde(default = "default_sample_rate")]
342    pub sample_rate: f64,
343    /// Mirror only write/DDL statements (default). When false, all simple
344    /// queries are mirrored.
345    #[serde(default = "default_true_bool")]
346    pub writes_only: bool,
347    /// Bounded queue depth; when full, statements are dropped (and counted)
348    /// rather than blocking the client path.
349    #[serde(default = "default_mirror_queue")]
350    pub queue_size: usize,
351    #[serde(default = "default_localhost")]
352    pub backend_host: String,
353    #[serde(default = "default_pg_port")]
354    pub backend_port: u16,
355    #[serde(default = "default_pg_user")]
356    pub backend_user: String,
357    pub backend_password: Option<String>,
358    pub backend_database: Option<String>,
359    /// Source (primary) connection used by `POST /api/migration/snapshot` to
360    /// read existing data when bootstrapping the secondary. Defaults mirror
361    /// the listener-side backend; set explicitly for a snapshot.
362    #[serde(default = "default_localhost")]
363    pub source_host: String,
364    #[serde(default = "default_pg_port")]
365    pub source_port: u16,
366    #[serde(default = "default_pg_user")]
367    pub source_user: String,
368    pub source_password: Option<String>,
369    pub source_database: Option<String>,
370}
371
372impl Default for MirrorConfig {
373    fn default() -> Self {
374        Self {
375            enabled: false,
376            sample_rate: 1.0,
377            writes_only: true,
378            queue_size: 10_000,
379            backend_host: default_localhost(),
380            backend_port: default_pg_port(),
381            backend_user: default_pg_user(),
382            backend_password: None,
383            backend_database: None,
384            source_host: default_localhost(),
385            source_port: default_pg_port(),
386            source_user: default_pg_user(),
387            source_password: None,
388            source_database: None,
389        }
390    }
391}
392
393fn default_sample_rate() -> f64 {
394    1.0
395}
396fn default_mirror_queue() -> usize {
397    10_000
398}
399
400/// HTTP SQL gateway configuration. A Neon-`@neondatabase/serverless`-style
401/// `POST /sql` endpoint that runs one statement over the backend PG-wire
402/// client and returns `{ command, rowCount, rows, fields }`.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct HttpGatewayConfig {
405    #[serde(default)]
406    pub enabled: bool,
407    #[serde(default = "default_http_gw_listen")]
408    pub listen_address: String,
409    #[serde(default = "default_localhost")]
410    pub backend_host: String,
411    #[serde(default = "default_pg_port")]
412    pub backend_port: u16,
413    #[serde(default = "default_pg_user")]
414    pub backend_user: String,
415    pub backend_password: Option<String>,
416    pub backend_database: Option<String>,
417    /// Optional Bearer token required on requests.
418    #[serde(default)]
419    pub auth_token: Option<String>,
420}
421
422impl Default for HttpGatewayConfig {
423    fn default() -> Self {
424        Self {
425            enabled: false,
426            listen_address: default_http_gw_listen(),
427            backend_host: default_localhost(),
428            backend_port: default_pg_port(),
429            backend_user: default_pg_user(),
430            backend_password: None,
431            backend_database: None,
432            auth_token: None,
433        }
434    }
435}
436
437fn default_http_gw_listen() -> String {
438    "127.0.0.1:9093".to_string()
439}
440
441/// MCP agent-gateway configuration. When enabled, the proxy exposes a native
442/// MCP server so AI agents call `query`/`list_tables`/`explain` tools instead
443/// of opening raw SQL connections — each call gated by the gateway's policy
444/// (read-only by default) and logged.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct McpConfig {
447    #[serde(default)]
448    pub enabled: bool,
449    /// HTTP listen address for the MCP JSON-RPC endpoint.
450    #[serde(default = "default_mcp_listen")]
451    pub listen_address: String,
452    /// Backend the gateway runs tool SQL against.
453    #[serde(default = "default_localhost")]
454    pub backend_host: String,
455    #[serde(default = "default_pg_port")]
456    pub backend_port: u16,
457    #[serde(default = "default_pg_user")]
458    pub backend_user: String,
459    pub backend_password: Option<String>,
460    pub backend_database: Option<String>,
461    /// When true (default), the gateway refuses write/DDL statements — agents
462    /// get a read-only database surface.
463    #[serde(default = "default_true_bool")]
464    pub read_only: bool,
465    /// Name of an `[[agent_contracts]]` entry to enforce on every tool call
466    /// (scoped grants + repair hints). None = only the `read_only` guardrail.
467    #[serde(default)]
468    pub contract: Option<String>,
469}
470
471impl Default for McpConfig {
472    fn default() -> Self {
473        Self {
474            enabled: false,
475            listen_address: default_mcp_listen(),
476            backend_host: default_localhost(),
477            backend_port: default_pg_port(),
478            backend_user: default_pg_user(),
479            backend_password: None,
480            backend_database: None,
481            read_only: true,
482            contract: None,
483        }
484    }
485}
486
487fn default_mcp_listen() -> String {
488    "127.0.0.1:9092".to_string()
489}
490fn default_localhost() -> String {
491    "127.0.0.1".to_string()
492}
493fn default_pg_port() -> u16 {
494    5432
495}
496fn default_pg_user() -> String {
497    "postgres".to_string()
498}
499fn default_true_bool() -> bool {
500    true
501}
502
503/// Client-side authentication configuration.
504#[derive(Debug, Clone, Serialize, Deserialize, Default)]
505pub struct AuthConfig {
506    /// `passthrough` (default) relays client auth to the backend.
507    /// `scram` makes the proxy terminate SCRAM-SHA-256 itself against
508    /// `auth_file`, becoming the auth boundary (foundation for pooling).
509    #[serde(default)]
510    pub mode: AuthMode,
511    /// Path to a pgbouncer-style user list (`user:secret`, secret = plaintext
512    /// or a `SCRAM-SHA-256$...` verifier). Required when `mode = "scram"`.
513    #[serde(default)]
514    pub auth_file: Option<String>,
515}
516
517/// Proxy client-authentication mode.
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
519#[serde(rename_all = "lowercase")]
520pub enum AuthMode {
521    /// Relay the client's auth exchange straight to the backend.
522    #[default]
523    Passthrough,
524    /// Terminate SCRAM-SHA-256 at the proxy against `auth_file`.
525    Scram,
526}
527
528/// A single pg_hba-style admission rule. The first rule whose `user`,
529/// `database`, and `address` all match the incoming connection decides the
530/// outcome (`allow`/`reject`). If no rule matches, the connection is
531/// admitted (rules are an explicit deny/allow list, not default-deny — add a
532/// trailing `{ action = "reject", user = "all", database = "all", address =
533/// "all" }` for default-deny).
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct HbaRule {
536    /// "allow" or "reject".
537    pub action: HbaAction,
538    /// Matching PostgreSQL user, or "all".
539    #[serde(default = "hba_all")]
540    pub user: String,
541    /// Matching database, or "all".
542    #[serde(default = "hba_all")]
543    pub database: String,
544    /// Matching client address: "all", a bare IP, or a CIDR (e.g.
545    /// "10.0.0.0/8", "::1/128").
546    #[serde(default = "hba_all")]
547    pub address: String,
548}
549
550fn hba_all() -> String {
551    "all".to_string()
552}
553
554/// Admission action for an [`HbaRule`].
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "lowercase")]
557pub enum HbaAction {
558    Allow,
559    Reject,
560}
561
562fn default_write_timeout_secs() -> u64 {
563    30 // 30 seconds default write timeout during failover
564}
565
566/// A table exposed by the GraphQL gateway, with its selectable columns.
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568#[serde(default)]
569pub struct GqlTableToml {
570    pub name: String,
571    pub columns: Vec<String>,
572}
573
574/// GraphQL-to-SQL gateway configuration. A separate HTTP listener; only active
575/// when the `graphql-gateway` feature is compiled in AND `enabled = true`.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577#[serde(default)]
578pub struct GraphqlGatewayConfig {
579    /// Serve the GraphQL gateway. Default `false`.
580    pub enabled: bool,
581    /// HTTP listen address (e.g. `0.0.0.0:9091`).
582    pub listen_address: String,
583    /// Backend the generated SQL runs against.
584    pub backend_host: String,
585    pub backend_port: u16,
586    pub backend_user: String,
587    pub backend_password: Option<String>,
588    pub backend_database: Option<String>,
589    /// Optional Bearer token required on requests.
590    pub auth_token: Option<String>,
591    /// Tables exposed as GraphQL types.
592    pub tables: Vec<GqlTableToml>,
593}
594
595impl Default for GraphqlGatewayConfig {
596    fn default() -> Self {
597        Self {
598            enabled: false,
599            listen_address: "0.0.0.0:9091".to_string(),
600            backend_host: "127.0.0.1".to_string(),
601            backend_port: 5432,
602            backend_user: "postgres".to_string(),
603            backend_password: None,
604            backend_database: None,
605            auth_token: None,
606            tables: Vec::new(),
607        }
608    }
609}
610
611/// Schema/workload-aware routing configuration (always present). Only active
612/// when the `schema-routing` feature is compiled in AND `enabled = true`.
613#[derive(Debug, Clone, Default, Serialize, Deserialize)]
614#[serde(default)]
615pub struct SchemaRoutingToml {
616    /// Route analytical (OLAP) queries — aggregations, GROUP BY, window
617    /// functions — to a dedicated node. Default `false`.
618    pub enabled: bool,
619    /// Name of the node analytical queries are routed to.
620    pub analytics_node: String,
621}
622
623/// Multi-tenancy configuration (always present). Converted to a
624/// `multi_tenancy::TenantManager` at startup; only active when the
625/// `multi-tenancy` feature is compiled in AND `enabled = true`.
626#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(default)]
628pub struct MultiTenancyToml {
629    /// Enforce per-tenant row isolation. Default `false`.
630    pub enabled: bool,
631    /// Which connection attribute names the tenant: a startup parameter name
632    /// (e.g. `application_name`, `user`) or the literal `database`.
633    pub identify_by: String,
634    /// The row-level tenant column injected into queries (e.g. `tenant_id`).
635    pub tenant_column: String,
636    /// Tables that are tenant-scoped (get the filter injected). Other tables
637    /// pass through unchanged.
638    pub tenant_tables: Vec<String>,
639    /// Known tenant ids.
640    pub tenants: Vec<String>,
641}
642
643impl Default for MultiTenancyToml {
644    fn default() -> Self {
645        Self {
646            enabled: false,
647            identify_by: "application_name".to_string(),
648            tenant_column: "tenant_id".to_string(),
649            tenant_tables: Vec::new(),
650            tenants: Vec::new(),
651        }
652    }
653}
654
655/// A single SQL-rewrite rule in TOML form. Maps to a `rewriter::RewriteRule`:
656/// `match_table`/`match_regex` choose which queries it applies to (default: all),
657/// and the first set transformation field is applied.
658#[derive(Debug, Clone, Serialize, Deserialize, Default)]
659#[serde(default)]
660pub struct RewriteRuleToml {
661    /// Apply to queries referencing this table.
662    pub match_table: Option<String>,
663    /// Apply to queries matching this regex.
664    pub match_regex: Option<String>,
665    /// Rewrite `match_table` -> this table name.
666    pub replace_table_with: Option<String>,
667    /// Append `AND <expr>` to the query's WHERE clause.
668    pub append_where: Option<String>,
669    /// Add a `LIMIT n` to an unbounded query.
670    pub add_limit: Option<u32>,
671}
672
673/// SQL query-rewriting configuration (always present). Converted to a
674/// `rewriter::QueryRewriter` at startup; only active when the `query-rewriting`
675/// feature is compiled in AND `enabled = true`.
676#[derive(Debug, Clone, Default, Serialize, Deserialize)]
677#[serde(default)]
678pub struct QueryRewriteToml {
679    /// Rewrite query SQL on the path per the rules below. Default `false`.
680    pub enabled: bool,
681    /// Ordered rewrite rules.
682    pub rules: Vec<RewriteRuleToml>,
683}
684
685/// Query-result cache configuration (TOML-friendly, always present). Converted
686/// to `crate::cache::CacheConfig` at startup and only active when the
687/// `query-cache` feature is compiled in AND `enabled = true`.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689#[serde(default)]
690pub struct CacheToml {
691    /// Serve read SELECT results from an in-process L1/L2 cache. Default `false`.
692    pub enabled: bool,
693    /// Time-to-live for cached results, seconds.
694    pub ttl_secs: u64,
695    /// Maximum single result size to cache, bytes (larger results bypass).
696    pub max_result_bytes: usize,
697}
698
699impl Default for CacheToml {
700    fn default() -> Self {
701        Self {
702            enabled: false,
703            ttl_secs: 300,
704            max_result_bytes: 1024 * 1024,
705        }
706    }
707}
708
709/// Replica-lag-aware routing + read-your-writes configuration (always present;
710/// only enforced when the `lag-routing` feature is compiled in AND enabled).
711#[derive(Debug, Clone, Serialize, Deserialize)]
712#[serde(default)]
713pub struct LagRoutingToml {
714    /// Enable lag-aware read routing + read-your-writes. Default `false`.
715    pub enabled: bool,
716    /// Reads issued within this many milliseconds after a write in the same
717    /// session are pinned to the primary (read-your-writes), so the client
718    /// observes its own writes despite replica lag. 0 disables the window.
719    pub ryw_window_ms: u64,
720    /// Exclude a standby from read routing when its measured replication lag
721    /// exceeds this many bytes. 0 = no lag-based exclusion (default; the proxy
722    /// does not yet populate per-node lag without a configured monitor).
723    pub max_lag_bytes: u64,
724}
725
726impl Default for LagRoutingToml {
727    fn default() -> Self {
728        Self {
729            enabled: false,
730            ryw_window_ms: 500,
731            max_lag_bytes: 0,
732        }
733    }
734}
735
736/// Query-analytics configuration (TOML-friendly, always present). Converted to
737/// `crate::analytics::AnalyticsConfig` at startup and only active when the
738/// `query-analytics` feature is compiled in AND `enabled = true`.
739#[derive(Debug, Clone, Serialize, Deserialize)]
740#[serde(default)]
741pub struct AnalyticsToml {
742    /// Record per-query statistics, slow-query log, and pattern detection.
743    /// Default `false`.
744    pub enabled: bool,
745    /// Queries slower than this (milliseconds) are added to the slow-query log.
746    pub slow_query_ms: u64,
747    /// Maximum distinct query fingerprints to track.
748    pub max_fingerprints: u32,
749}
750
751impl Default for AnalyticsToml {
752    fn default() -> Self {
753        Self {
754            enabled: false,
755            slow_query_ms: 1000,
756            max_fingerprints: 10000,
757        }
758    }
759}
760
761/// Circuit-breaker configuration (TOML-friendly, always present). Converted to
762/// `crate::circuit_breaker::ManagerConfig` at startup and only enforced when
763/// the `circuit-breaker` feature is compiled in AND `enabled = true`.
764#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(default)]
766pub struct CircuitBreakerToml {
767    /// Trip backends out of rotation after repeated failures. Default `false`.
768    pub enabled: bool,
769    /// Consecutive failures (within the failure window) that open a node's
770    /// circuit.
771    pub failure_threshold: u32,
772    /// How long a circuit stays open before a half-open probe is allowed.
773    pub open_secs: u64,
774    /// Successful probes required to close a half-open circuit.
775    pub success_threshold: u32,
776}
777
778impl Default for CircuitBreakerToml {
779    fn default() -> Self {
780        Self {
781            enabled: false,
782            failure_threshold: 5,
783            open_secs: 10,
784            success_threshold: 3,
785        }
786    }
787}
788
789/// How rate-limit buckets are keyed.
790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
791#[serde(rename_all = "snake_case")]
792pub enum RateLimitKeyBy {
793    /// One bucket per authenticated user (startup `user` param).
794    #[default]
795    User,
796    /// One bucket per client IP address.
797    ClientIp,
798    /// One bucket per target database.
799    Database,
800    /// A single global bucket for the whole proxy.
801    Global,
802}
803
804/// Rate-limiting configuration (TOML-friendly, always present so configs
805/// round-trip on any build). Converted to `crate::rate_limit::RateLimitConfig`
806/// at startup and only enforced when the `rate-limiting` feature is compiled
807/// in AND `enabled = true`.
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(default)]
810pub struct RateLimitToml {
811    /// Enforce rate limits. Default `false`.
812    pub enabled: bool,
813    /// Sustained queries per second per bucket.
814    pub default_qps: u32,
815    /// Burst capacity (token-bucket depth) per bucket.
816    pub default_burst: u32,
817    /// Max concurrent in-flight queries per bucket (0 = use the engine default).
818    pub max_concurrent: u32,
819    /// What each bucket is keyed on.
820    pub key_by: RateLimitKeyBy,
821}
822
823impl Default for RateLimitToml {
824    fn default() -> Self {
825        Self {
826            enabled: false,
827            default_qps: 1000,
828            default_burst: 2000,
829            max_concurrent: 0,
830            key_by: RateLimitKeyBy::User,
831        }
832    }
833}
834
835/// SQL-comment routing-hint configuration.
836///
837/// Always present on `ProxyConfig` so configs round-trip on any build, but the
838/// hints are only parsed and honored when the `routing-hints` feature is
839/// compiled in AND `enabled = true`.
840#[derive(Debug, Clone, Serialize, Deserialize)]
841#[serde(default)]
842pub struct RoutingHintsConfig {
843    /// Parse and honor `/*helios:...*/` routing hints. Default `false`
844    /// (preserves the pure verb-based routing behaviour).
845    pub enabled: bool,
846    /// Strip the hint comment from the SQL before forwarding to the backend.
847    /// Default `true`. Hint comments are valid SQL comments, so leaving them
848    /// in is harmless; stripping keeps backend query logs clean.
849    pub strip_hints: bool,
850}
851
852impl Default for RoutingHintsConfig {
853    fn default() -> Self {
854        Self {
855            enabled: false,
856            strip_hints: true,
857        }
858    }
859}
860
861impl Default for ProxyConfig {
862    fn default() -> Self {
863        Self {
864            listen_address: "0.0.0.0:5432".to_string(),
865            admin_address: "0.0.0.0:9090".to_string(),
866            admin_token: None,
867            tr_enabled: true,
868            tr_mode: TrMode::Session,
869            pool: PoolConfig::default(),
870            pool_mode: PoolModeConfig::default(),
871            load_balancer: LoadBalancerConfig::default(),
872            health: HealthConfig::default(),
873            nodes: Vec::new(),
874            tls: None,
875            write_timeout_secs: default_write_timeout_secs(),
876            plugins: PluginToml::default(),
877            hba: Vec::new(),
878            auth: AuthConfig::default(),
879            mcp: McpConfig::default(),
880            agent_contracts: Vec::new(),
881            http_gateway: HttpGatewayConfig::default(),
882            mirror: MirrorConfig::default(),
883            branch: BranchConfig::default(),
884            routing_hints: RoutingHintsConfig::default(),
885            rate_limit: RateLimitToml::default(),
886            circuit_breaker: CircuitBreakerToml::default(),
887            analytics: AnalyticsToml::default(),
888            lag_routing: LagRoutingToml::default(),
889            cache: CacheToml::default(),
890            query_rewrite: QueryRewriteToml::default(),
891            multi_tenancy: MultiTenancyToml::default(),
892            schema_routing: SchemaRoutingToml::default(),
893            graphql_gateway: GraphqlGatewayConfig::default(),
894            optimize_unnamed_parse: true,
895            shutdown_drain_timeout_secs: default_drain_timeout_secs(),
896        }
897    }
898}
899
900// =============================================================================
901// PLUGIN SYSTEM CONFIG (TOML-friendly shape)
902// =============================================================================
903
904/// Plugin-system configuration, in a TOML-friendly shape.
905///
906/// Always present on `ProxyConfig` so existing configs round-trip, but only
907/// consumed when the `wasm-plugins` feature is enabled. When
908/// `plugins.enabled` is `false` (the default), plugin loading is skipped
909/// entirely and every plugin-hook call site becomes a zero-cost no-op.
910///
911/// Converted to `crate::plugins::PluginRuntimeConfig` at startup via a
912/// feature-gated `From` impl in `src/plugins/config.rs`.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct PluginToml {
915    /// Enable the plugin subsystem. Defaults to `false` — plugins are
916    /// strictly opt-in.
917    #[serde(default)]
918    pub enabled: bool,
919    /// Directory to scan at startup for `.wasm` plugin files.
920    #[serde(default = "default_plugin_dir")]
921    pub plugin_dir: String,
922    /// Watch `plugin_dir` for file changes and reload plugins hot.
923    #[serde(default)]
924    pub hot_reload: bool,
925    /// Memory limit per plugin instance, in megabytes.
926    #[serde(default = "default_plugin_memory_mb")]
927    pub memory_limit_mb: usize,
928    /// Execution timeout per hook call, in milliseconds.
929    #[serde(default = "default_plugin_timeout_ms")]
930    pub timeout_ms: u64,
931    /// Maximum number of concurrently-loaded plugins.
932    #[serde(default = "default_plugin_max")]
933    pub max_plugins: usize,
934    /// Enable per-call CPU-cycle (fuel) metering to bound plugin runtime.
935    #[serde(default = "default_true")]
936    pub fuel_metering: bool,
937    /// Fuel units allowed per hook call when `fuel_metering = true`.
938    #[serde(default = "default_plugin_fuel")]
939    pub fuel_limit: u64,
940    /// Optional Ed25519 trust-root directory. When set, every loaded
941    /// .wasm requires a sidecar .sig that verifies against one of
942    /// the *.pub files in this directory. When omitted, signatures
943    /// are not checked (preserves the dev-loop ergonomic of dropping
944    /// unsigned .wasm files in the plugin dir).
945    #[serde(default)]
946    pub trust_root: Option<String>,
947}
948
949fn default_plugin_dir() -> String {
950    "/etc/heliosproxy/plugins".to_string()
951}
952fn default_plugin_memory_mb() -> usize {
953    64
954}
955fn default_plugin_timeout_ms() -> u64 {
956    100
957}
958fn default_plugin_max() -> usize {
959    20
960}
961fn default_true() -> bool {
962    true
963}
964fn default_plugin_fuel() -> u64 {
965    1_000_000
966}
967
968impl Default for PluginToml {
969    fn default() -> Self {
970        Self {
971            enabled: false,
972            plugin_dir: default_plugin_dir(),
973            hot_reload: false,
974            memory_limit_mb: default_plugin_memory_mb(),
975            timeout_ms: default_plugin_timeout_ms(),
976            max_plugins: default_plugin_max(),
977            fuel_metering: true,
978            fuel_limit: default_plugin_fuel(),
979            trust_root: None,
980        }
981    }
982}
983
984impl ProxyConfig {
985    /// Get write timeout as Duration
986    pub fn write_timeout(&self) -> Duration {
987        Duration::from_secs(self.write_timeout_secs)
988    }
989
990    /// Load configuration from file
991    pub fn from_file(path: &str) -> Result<Self> {
992        let path = Path::new(path);
993
994        if !path.exists() {
995            return Err(ProxyError::Config(format!(
996                "Configuration file not found: {}",
997                path.display()
998            )));
999        }
1000
1001        let contents = std::fs::read_to_string(path)
1002            .map_err(|e| ProxyError::Config(format!("Failed to read config: {}", e)))?;
1003
1004        let config: Self = toml::from_str(&contents)
1005            .map_err(|e| ProxyError::Config(format!("Failed to parse config: {}", e)))?;
1006
1007        config.validate()?;
1008
1009        Ok(config)
1010    }
1011
1012    /// Add a node from host:port string
1013    pub fn add_node(&mut self, host_port: &str, role: &str) -> Result<()> {
1014        let parts: Vec<&str> = host_port.rsplitn(2, ':').collect();
1015        if parts.len() != 2 {
1016            return Err(ProxyError::Config(format!(
1017                "Invalid host:port format: {}",
1018                host_port
1019            )));
1020        }
1021
1022        let port: u16 = parts[0]
1023            .parse()
1024            .map_err(|_| ProxyError::Config(format!("Invalid port: {}", parts[0])))?;
1025
1026        let host = parts[1].to_string();
1027
1028        let role = match role {
1029            "primary" => NodeRole::Primary,
1030            "standby" => NodeRole::Standby,
1031            "replica" => NodeRole::ReadReplica,
1032            _ => return Err(ProxyError::Config(format!("Unknown role: {}", role))),
1033        };
1034
1035        self.nodes.push(NodeConfig {
1036            host,
1037            port,
1038            http_port: default_http_port(),
1039            role,
1040            weight: 100,
1041            enabled: true,
1042            name: None,
1043        });
1044
1045        Ok(())
1046    }
1047
1048    /// Validate configuration
1049    pub fn validate(&self) -> Result<()> {
1050        // Must have at least one node
1051        if self.nodes.is_empty() {
1052            return Err(ProxyError::Config(
1053                "No backend nodes configured".to_string(),
1054            ));
1055        }
1056
1057        // Must have a primary node
1058        let has_primary = self.nodes.iter().any(|n| n.role == NodeRole::Primary);
1059        if !has_primary {
1060            return Err(ProxyError::Config("No primary node configured".to_string()));
1061        }
1062
1063        // Validate pool config
1064        if self.pool.max_connections < self.pool.min_connections {
1065            return Err(ProxyError::Config(
1066                "max_connections must be >= min_connections".to_string(),
1067            ));
1068        }
1069
1070        Ok(())
1071    }
1072
1073    /// Get primary node
1074    pub fn primary_node(&self) -> Option<&NodeConfig> {
1075        self.nodes
1076            .iter()
1077            .find(|n| n.role == NodeRole::Primary && n.enabled)
1078    }
1079
1080    /// Get standby nodes
1081    pub fn standby_nodes(&self) -> Vec<&NodeConfig> {
1082        self.nodes
1083            .iter()
1084            .filter(|n| n.role == NodeRole::Standby && n.enabled)
1085            .collect()
1086    }
1087
1088    /// Get all enabled nodes
1089    pub fn enabled_nodes(&self) -> Vec<&NodeConfig> {
1090        self.nodes.iter().filter(|n| n.enabled).collect()
1091    }
1092}
1093
1094/// TR (Transaction Replay) mode
1095#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1096#[serde(rename_all = "lowercase")]
1097#[derive(Default)]
1098pub enum TrMode {
1099    /// No transaction replay
1100    None,
1101    /// Re-establish session only
1102    #[default]
1103    Session,
1104    /// Re-execute SELECT queries
1105    Select,
1106    /// Full transaction replay
1107    Transaction,
1108}
1109
1110/// Connection pool configuration
1111#[derive(Debug, Clone, Serialize, Deserialize)]
1112pub struct PoolConfig {
1113    /// Minimum connections per node
1114    pub min_connections: usize,
1115    /// Maximum connections per node
1116    pub max_connections: usize,
1117    /// Connection idle timeout (seconds)
1118    pub idle_timeout_secs: u64,
1119    /// Maximum connection lifetime (seconds)
1120    pub max_lifetime_secs: u64,
1121    /// Connection acquire timeout (seconds)
1122    pub acquire_timeout_secs: u64,
1123    /// Test connection before use
1124    pub test_on_acquire: bool,
1125}
1126
1127impl Default for PoolConfig {
1128    fn default() -> Self {
1129        Self {
1130            min_connections: 2,
1131            max_connections: 100,
1132            idle_timeout_secs: 300,
1133            max_lifetime_secs: 1800,
1134            acquire_timeout_secs: 30,
1135            test_on_acquire: true,
1136        }
1137    }
1138}
1139
1140impl PoolConfig {
1141    /// Get idle timeout as Duration
1142    pub fn idle_timeout(&self) -> Duration {
1143        Duration::from_secs(self.idle_timeout_secs)
1144    }
1145
1146    /// Get max lifetime as Duration
1147    pub fn max_lifetime(&self) -> Duration {
1148        Duration::from_secs(self.max_lifetime_secs)
1149    }
1150
1151    /// Get acquire timeout as Duration
1152    pub fn acquire_timeout(&self) -> Duration {
1153        Duration::from_secs(self.acquire_timeout_secs)
1154    }
1155}
1156
1157/// Load balancer configuration
1158#[derive(Debug, Clone, Serialize, Deserialize)]
1159pub struct LoadBalancerConfig {
1160    /// Routing strategy for read queries
1161    pub read_strategy: Strategy,
1162    /// Enable read/write splitting
1163    pub read_write_split: bool,
1164    /// Latency threshold for unhealthy marking (ms)
1165    pub latency_threshold_ms: u64,
1166}
1167
1168impl Default for LoadBalancerConfig {
1169    fn default() -> Self {
1170        Self {
1171            read_strategy: Strategy::RoundRobin,
1172            read_write_split: true,
1173            latency_threshold_ms: 100,
1174        }
1175    }
1176}
1177
1178/// Load balancing strategy
1179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1180#[serde(rename_all = "snake_case")]
1181pub enum Strategy {
1182    /// Round-robin across nodes
1183    RoundRobin,
1184    /// Weighted round-robin
1185    WeightedRoundRobin,
1186    /// Route to least loaded node
1187    LeastConnections,
1188    /// Route to lowest latency node
1189    LatencyBased,
1190    /// Random selection
1191    Random,
1192}
1193
1194/// Health check configuration
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1196pub struct HealthConfig {
1197    /// Check interval (seconds)
1198    pub check_interval_secs: u64,
1199    /// Check timeout (seconds)
1200    pub check_timeout_secs: u64,
1201    /// Failures before marking unhealthy
1202    pub failure_threshold: u32,
1203    /// Successes before marking healthy
1204    pub success_threshold: u32,
1205    /// Health check query
1206    pub check_query: String,
1207}
1208
1209impl Default for HealthConfig {
1210    fn default() -> Self {
1211        Self {
1212            check_interval_secs: 5,
1213            check_timeout_secs: 3,
1214            failure_threshold: 3,
1215            success_threshold: 2,
1216            check_query: "SELECT 1".to_string(),
1217        }
1218    }
1219}
1220
1221impl HealthConfig {
1222    /// Get check interval as Duration
1223    pub fn check_interval(&self) -> Duration {
1224        Duration::from_secs(self.check_interval_secs)
1225    }
1226
1227    /// Get check timeout as Duration
1228    pub fn check_timeout(&self) -> Duration {
1229        Duration::from_secs(self.check_timeout_secs)
1230    }
1231}
1232
1233/// Backend node configuration
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1235pub struct NodeConfig {
1236    /// Node host
1237    pub host: String,
1238    /// Node port (PostgreSQL protocol)
1239    pub port: u16,
1240    /// Node HTTP API port (for SQL API forwarding)
1241    /// Defaults to 8080 if not specified
1242    #[serde(default = "default_http_port")]
1243    pub http_port: u16,
1244    /// Node role
1245    pub role: NodeRole,
1246    /// Weight for load balancing
1247    pub weight: u32,
1248    /// Whether node is enabled
1249    pub enabled: bool,
1250    /// Optional node name for logging
1251    pub name: Option<String>,
1252}
1253
1254fn default_http_port() -> u16 {
1255    8080
1256}
1257
1258impl NodeConfig {
1259    /// Get address string
1260    pub fn address(&self) -> String {
1261        format!("{}:{}", self.host, self.port)
1262    }
1263
1264    /// Get display name
1265    pub fn display_name(&self) -> &str {
1266        self.name.as_deref().unwrap_or(&self.host)
1267    }
1268}
1269
1270/// Node role
1271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1272#[serde(rename_all = "lowercase")]
1273pub enum NodeRole {
1274    /// Primary node (accepts writes)
1275    Primary,
1276    /// Standby node (can be promoted)
1277    Standby,
1278    /// Read replica (read-only, cannot be promoted)
1279    #[serde(rename = "replica")]
1280    ReadReplica,
1281}
1282
1283/// TLS configuration
1284#[derive(Debug, Clone, Serialize, Deserialize)]
1285pub struct TlsConfig {
1286    /// Enable TLS for client connections
1287    pub enabled: bool,
1288    /// Path to certificate file
1289    pub cert_path: String,
1290    /// Path to private key file
1291    pub key_path: String,
1292    /// Path to CA certificate (for client verification)
1293    pub ca_path: Option<String>,
1294    /// Require client certificates
1295    pub require_client_cert: bool,
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300    use super::*;
1301
1302    #[test]
1303    fn test_default_config() {
1304        let config = ProxyConfig::default();
1305        assert_eq!(config.listen_address, "0.0.0.0:5432");
1306        assert!(config.tr_enabled);
1307    }
1308
1309    #[test]
1310    fn test_add_node() {
1311        let mut config = ProxyConfig::default();
1312        config.add_node("localhost:5432", "primary").unwrap();
1313        config.add_node("localhost:5433", "standby").unwrap();
1314
1315        assert_eq!(config.nodes.len(), 2);
1316        assert!(config.primary_node().is_some());
1317        assert_eq!(config.standby_nodes().len(), 1);
1318    }
1319
1320    #[test]
1321    fn test_validate_no_nodes() {
1322        let config = ProxyConfig::default();
1323        assert!(config.validate().is_err());
1324    }
1325
1326    #[test]
1327    fn test_validate_no_primary() {
1328        let mut config = ProxyConfig::default();
1329        config.add_node("localhost:5432", "standby").unwrap();
1330        assert!(config.validate().is_err());
1331    }
1332
1333    #[test]
1334    fn test_validate_success() {
1335        let mut config = ProxyConfig::default();
1336        config.add_node("localhost:5432", "primary").unwrap();
1337        assert!(config.validate().is_ok());
1338    }
1339
1340    #[test]
1341    fn test_pool_config_durations() {
1342        let config = PoolConfig::default();
1343        assert_eq!(config.idle_timeout(), Duration::from_secs(300));
1344        assert_eq!(config.max_lifetime(), Duration::from_secs(1800));
1345    }
1346
1347    #[test]
1348    fn test_pool_mode_default() {
1349        let config = PoolModeConfig::default();
1350        assert_eq!(config.mode, PoolingMode::Session);
1351        assert_eq!(config.max_pool_size, 100);
1352        assert_eq!(config.min_idle, 10);
1353        assert_eq!(config.reset_query, "DISCARD ALL");
1354    }
1355
1356    #[test]
1357    fn test_pool_mode_session() {
1358        let config = PoolModeConfig::session_mode();
1359        assert_eq!(config.mode, PoolingMode::Session);
1360        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Named);
1361    }
1362
1363    #[test]
1364    fn test_pool_mode_transaction() {
1365        let config = PoolModeConfig::transaction_mode();
1366        assert_eq!(config.mode, PoolingMode::Transaction);
1367        assert_eq!(config.prepared_statement_mode, PreparedStatementMode::Track);
1368    }
1369
1370    #[test]
1371    fn test_pool_mode_statement() {
1372        let config = PoolModeConfig::statement_mode();
1373        assert_eq!(config.mode, PoolingMode::Statement);
1374        assert_eq!(
1375            config.prepared_statement_mode,
1376            PreparedStatementMode::Disable
1377        );
1378    }
1379
1380    #[test]
1381    fn test_pool_mode_durations() {
1382        let config = PoolModeConfig::default();
1383        assert_eq!(config.idle_timeout(), Duration::from_secs(600));
1384        assert_eq!(config.max_lifetime(), Duration::from_secs(3600));
1385        assert_eq!(config.acquire_timeout(), Duration::from_secs(5));
1386    }
1387
1388    #[test]
1389    fn test_proxy_config_has_pool_mode() {
1390        let config = ProxyConfig::default();
1391        assert_eq!(config.pool_mode.mode, PoolingMode::Session);
1392    }
1393
1394    /// `plugins` defaults to `enabled = false` so adding the field to
1395    /// `ProxyConfig` doesn't spontaneously turn on the plugin subsystem
1396    /// for existing deployments.
1397    #[test]
1398    fn test_plugin_toml_default_is_disabled() {
1399        let config = ProxyConfig::default();
1400        assert!(!config.plugins.enabled);
1401        assert_eq!(config.plugins.plugin_dir, "/etc/heliosproxy/plugins");
1402        assert_eq!(config.plugins.memory_limit_mb, 64);
1403        assert_eq!(config.plugins.timeout_ms, 100);
1404    }
1405
1406    /// Existing TOML configs (written before this field existed) must
1407    /// round-trip through `Deserialize` without failing. The `plugins`
1408    /// section is `#[serde(default)]`, so omitting it yields the default.
1409    #[test]
1410    fn test_proxy_config_toml_without_plugins_section_still_parses() {
1411        let toml_text = r#"
1412            listen_address = "0.0.0.0:5432"
1413            admin_address = "0.0.0.0:9090"
1414            tr_enabled = true
1415            tr_mode = "session"
1416            nodes = []
1417
1418            [pool]
1419            min_connections = 2
1420            max_connections = 10
1421            idle_timeout_secs = 300
1422            max_lifetime_secs = 1800
1423            acquire_timeout_secs = 30
1424            test_on_acquire = true
1425
1426            [load_balancer]
1427            read_strategy = "round_robin"
1428            read_write_split = true
1429            latency_threshold_ms = 100
1430
1431            [health]
1432            check_interval_secs = 5
1433            check_timeout_secs = 3
1434            failure_threshold = 3
1435            success_threshold = 2
1436            check_query = "SELECT 1"
1437        "#;
1438        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
1439        assert!(!config.plugins.enabled);
1440    }
1441
1442    /// A `[plugins]` section with overrides round-trips and populates the
1443    /// struct correctly.
1444    #[test]
1445    fn test_plugin_toml_overrides_parse() {
1446        let toml_text = r#"
1447            listen_address = "0.0.0.0:5432"
1448            admin_address = "0.0.0.0:9090"
1449            tr_enabled = true
1450            tr_mode = "session"
1451            nodes = []
1452
1453            [pool]
1454            min_connections = 2
1455            max_connections = 10
1456            idle_timeout_secs = 300
1457            max_lifetime_secs = 1800
1458            acquire_timeout_secs = 30
1459            test_on_acquire = true
1460
1461            [load_balancer]
1462            read_strategy = "round_robin"
1463            read_write_split = true
1464            latency_threshold_ms = 100
1465
1466            [health]
1467            check_interval_secs = 5
1468            check_timeout_secs = 3
1469            failure_threshold = 3
1470            success_threshold = 2
1471            check_query = "SELECT 1"
1472
1473            [plugins]
1474            enabled = true
1475            plugin_dir = "/tmp/helios-plugins"
1476            hot_reload = true
1477            memory_limit_mb = 128
1478            timeout_ms = 250
1479        "#;
1480        let config: ProxyConfig = toml::from_str(toml_text).expect("parse");
1481        assert!(config.plugins.enabled);
1482        assert_eq!(config.plugins.plugin_dir, "/tmp/helios-plugins");
1483        assert!(config.plugins.hot_reload);
1484        assert_eq!(config.plugins.memory_limit_mb, 128);
1485        assert_eq!(config.plugins.timeout_ms, 250);
1486        // Un-specified fields retain their defaults.
1487        assert_eq!(config.plugins.max_plugins, 20);
1488        assert!(config.plugins.fuel_metering);
1489    }
1490}