Skip to main content

mssql_client/
config.rs

1//! Client configuration.
2//!
3//! Supporting types (`RedirectConfig`, `TimeoutConfig`, `RetryPolicy`) live in
4//! the `types` submodule and are re-exported here for convenience.
5//!
6//! ## Connection strings
7//!
8//! [`Config::from_connection_string`] parses the ADO.NET
9//! `SqlConnection.ConnectionString` format: case-insensitive `Key=Value` pairs
10//! separated by `;`, with surrounding whitespace trimmed. Unknown keywords are
11//! ignored (logged at debug); recognized-but-unsupported keywords are logged at
12//! info rather than silently dropped.
13//!
14//! ```rust,no_run
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! use mssql_client::Config;
17//!
18//! let config = Config::from_connection_string(
19//!     "Server=tcp:myserver.database.windows.net,1433;Database=mydb;\
20//!      User Id=user;Password=secret;Encrypt=strict",
21//! )?;
22//! # let _ = config;
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! **Quoting.** A value with `;` or `=` may be wrapped in single or double
28//! quotes; double the quote char to escape it: `Password="my;pass"`,
29//! `Password='it''s complex'`.
30//!
31//! ## Keyword reference
32//!
33//! ### Server
34//!
35//! | Keyword | Aliases | Default | Description |
36//! |---------|---------|---------|-------------|
37//! | `Server` | `Data Source`, `Addr`, `Address`, `Network Address`, `Host` | `localhost` | Hostname or IP. Forms: `host`, `host,1433` (comma port), `host\INSTANCE` (named instance, resolved via SQL Browser), `tcp:host` (Azure prefix, stripped). `.` and `(local)` normalize to `127.0.0.1`; `np:` / `lpc:` (named pipes / shared memory) are rejected. |
38//! | `Port` | — | `1433` | TCP port. |
39//!
40//! ### Authentication and database
41//!
42//! | Keyword | Aliases | Default | Description |
43//! |---------|---------|---------|-------------|
44//! | `User Id` | `UID`, `User` | (empty) | SQL Server login username. Reinterpreted by `Authentication` (see below). |
45//! | `Password` | `PWD` | (empty) | SQL Server login password. Reinterpreted by `Authentication` (see below). |
46//! | `Database` | `Initial Catalog` | none | Target database. |
47//! | `Authentication` | — | none | Authentication method (ADO.NET `SqlAuthenticationMethod`; spaced forms like `Active Directory Service Principal` also accepted). `SqlPassword`: SQL authentication (the default). `ActiveDirectoryServicePrincipal`: Azure AD service principal — `User Id=<client-id>@<tenant-id>`, `Password=<client secret>`; requires the `azure-identity` feature. `ActiveDirectoryManagedIdentity` (alias `ActiveDirectoryMSI`): Azure managed identity — optional `User Id=<client-id>` selects a user-assigned identity; requires the `azure-identity` feature. `ActiveDirectoryDefault`: a credential chain (managed identity, then the signed-in `az`/`azd` CLI session); requires the `azure-identity` feature. Azure AD methods log in via the FEDAUTH SecurityToken workflow and require an encrypted connection. Certificate-based service principals have no connection-string keyword (matching ADO.NET, which configures them programmatically) — construct [`Credentials::certificate`](mssql_auth::Credentials::certificate) and pass it to [`Config::credentials`] with the `cert-auth` feature. The interactive Entra values (`ActiveDirectoryPassword`, `ActiveDirectoryInteractive`, `ActiveDirectoryDeviceCodeFlow`) are recognized but not implemented as built-in flows — `azure_identity` does not ship those credentials. For these scenarios, acquire the token yourself (MSAL, the `oauth2` crate, `az account get-access-token`, or any broker) and pass it as [`Credentials::azure_token`](mssql_auth::Credentials::azure_token), which logs in over the same FEDAUTH SecurityToken path (this mirrors .NET's `SqlConnection.AccessToken`). |
48//!
49//! ### Security
50//!
51//! | Keyword | Aliases | Default | Description |
52//! |---------|---------|---------|-------------|
53//! | `Encrypt` | — | `false` | `strict` (TDS 8.0, SQL Server 2022+), `mandatory`/`true`, `optional`, `no_tls`, or a boolean. |
54//! | `TrustServerCertificate` | `Trust Server Certificate` | `false` | Skip certificate validation (development only). |
55//!
56//! ### Timeouts
57//!
58//! | Keyword | Aliases | Default | Description |
59//! |---------|---------|---------|-------------|
60//! | `Connect Timeout` | `Connection Timeout`, `Timeout` | `15` | TCP connect timeout (seconds; `0` = none). |
61//! | `Command Timeout` | — | `30` | Query execution timeout (seconds; `0` = none). |
62//!
63//! ### Application identification
64//!
65//! | Keyword | Aliases | Default | Description |
66//! |---------|---------|---------|-------------|
67//! | `Application Name` | `App` | `mssql-client` | Appears in `sys.dm_exec_sessions`. |
68//! | `ApplicationIntent` | `Application Intent` | `ReadWrite` | `ReadOnly` routes to an AlwaysOn AG readable secondary. |
69//! | `Workstation ID` | `WSID` | machine hostname | Sent in LOGIN7 HostName; appears in `sys.dm_exec_sessions.host_name`. |
70//! | `Current Language` | `Language` | server default | Session language for server messages. |
71//!
72//! ### Connection resiliency
73//!
74//! | Keyword | Aliases | Default | Description |
75//! |---------|---------|---------|-------------|
76//! | `ConnectRetryCount` | `Connect Retry Count` | `3` | Reconnect attempts on idle-connection failure (wires to `RetryPolicy`). |
77//! | `ConnectRetryInterval` | `Connect Retry Interval` | `0` | Seconds between reconnect attempts. |
78//! | `MultiSubnetFailover` | `Multi Subnet Failover` | `false` | Race parallel TCP connects to all resolved IPs (AlwaysOn AG listeners). |
79//!
80//! ### Advanced
81//!
82//! | Keyword | Aliases | Default | Description |
83//! |---------|---------|---------|-------------|
84//! | `MultipleActiveResultSets` | `MARS` | `false` | MARS (not fully supported). |
85//! | `Packet Size` | — | `4096` | TDS packet size in bytes. |
86//! | `SendStringParametersAsUnicode` | `Send String Parameters As Unicode` | `true` | When `false`, sends `String`/`&str` params as VARCHAR (Windows-1252) instead of NVARCHAR (UTF-16) so SQL Server can index-seek VARCHAR columns. |
87//!
88//! Booleans accept `true`/`false`/`yes`/`no`/`1`/`0` (case-insensitive); an
89//! invalid boolean is an error, not a silent default.
90//!
91//! ### Recognized but not supported
92//!
93//! Logged at info level rather than silently dropped:
94//!
95//! | Keyword(s) | Guidance |
96//! |------------|----------|
97//! | `Max Pool Size`, `Min Pool Size`, `Pooling`, `Connection Lifetime`, `Load Balance Timeout` | Use the pool crate's `PoolConfig` instead. |
98//! | `Failover Partner` | Database mirroring failover not implemented. |
99//! | `Persist Security Info` | Passwords are never returned in connection strings. |
100//! | `Network Library`, `Enlist`, `Replication`, `Transaction Binding`, `Type System Version`, `User Instance`, `AttachDbFilename`, `Context Connection`, `Asynchronous Processing` | .NET-specific, not applicable. |
101//!
102//! **Common mistakes.** Use a comma (not a colon) for the port outside the
103//! `tcp:` prefix (`host,1433`); quote passwords containing `;`; use
104//! `user@server` for Azure SQL logins; and prefer `Encrypt=strict` or `true`
105//! (never `TrustServerCertificate=true`) in production. Never log a full
106//! connection string — it contains the password.
107
108mod types;
109pub use types::*;
110
111use std::time::Duration;
112
113use mssql_auth::Credentials;
114#[cfg(feature = "tls")]
115use mssql_tls::TlsConfig;
116use tds_protocol::version::TdsVersion;
117
118/// Parse a boolean value from a connection string keyword.
119///
120/// Per the ADO.NET specification, boolean keywords accept:
121/// `true`, `false`, `yes`, `no`, `1`, `0` (case-insensitive).
122/// Returns an error for any other value, preventing silent misconfiguration.
123fn parse_conn_bool(key: &str, value: &str) -> Result<bool, crate::error::Error> {
124    match value.to_lowercase().as_str() {
125        "true" | "yes" | "1" => Ok(true),
126        "false" | "no" | "0" => Ok(false),
127        _ => Err(crate::error::Error::Config(format!(
128            "invalid boolean value for '{key}': '{value}' (expected true/false/yes/no/1/0)"
129        ))),
130    }
131}
132
133/// Split a connection string into key-value pairs, respecting quoted values.
134///
135/// Per the ADO.NET specification:
136/// - Values containing semicolons must be enclosed in double (`"`) or single (`'`) quotes
137/// - Doubled quotes inside are escapes: `""` → `"`, `''` → `'`
138/// - Leading/trailing whitespace around values is trimmed (but preserved inside quotes)
139///
140/// Returns pairs of `(key, value)` where the value has quotes stripped and escapes resolved.
141fn split_connection_string(conn_str: &str) -> Result<Vec<(String, String)>, crate::error::Error> {
142    let mut pairs = Vec::new();
143    let chars: Vec<char> = conn_str.chars().collect();
144    let len = chars.len();
145    let mut i = 0;
146
147    while i < len {
148        // Skip whitespace and semicolons between pairs
149        while i < len && (chars[i] == ';' || chars[i].is_whitespace()) {
150            i += 1;
151        }
152        if i >= len {
153            break;
154        }
155
156        // Read key (up to '=')
157        let key_start = i;
158        while i < len && chars[i] != '=' {
159            i += 1;
160        }
161        if i >= len {
162            // Trailing text with no '=' — skip it (could be trailing whitespace)
163            let remaining = chars[key_start..].iter().collect::<String>();
164            if remaining.trim().is_empty() {
165                break;
166            }
167            return Err(crate::error::Error::Config(format!(
168                "invalid key-value pair (missing '='): '{remaining}'"
169            )));
170        }
171        let key: String = chars[key_start..i].iter().collect();
172        i += 1; // skip '='
173
174        // Read value — may be quoted or unquoted
175        // Skip leading whitespace in value
176        while i < len && chars[i].is_whitespace() {
177            i += 1;
178        }
179
180        let value = if i < len && (chars[i] == '"' || chars[i] == '\'') {
181            // Quoted value: read until matching unescaped closing quote
182            let quote_char = chars[i];
183            i += 1; // skip opening quote
184            let mut val = String::new();
185            loop {
186                if i >= len {
187                    return Err(crate::error::Error::Config(format!(
188                        "unterminated quoted value for key '{}'",
189                        key.trim()
190                    )));
191                }
192                if chars[i] == quote_char {
193                    // Check for escaped quote (doubled: "" or '')
194                    if i + 1 < len && chars[i + 1] == quote_char {
195                        val.push(quote_char);
196                        i += 2;
197                    } else {
198                        i += 1; // skip closing quote
199                        break;
200                    }
201                } else {
202                    val.push(chars[i]);
203                    i += 1;
204                }
205            }
206            // Skip to next semicolon or end
207            while i < len && chars[i] != ';' {
208                i += 1;
209            }
210            val
211        } else {
212            // Unquoted value: read until semicolon or end
213            let val_start = i;
214            while i < len && chars[i] != ';' {
215                i += 1;
216            }
217            chars[val_start..i].iter().collect::<String>()
218        };
219
220        let key_trimmed = key.trim().to_string();
221        if !key_trimmed.is_empty() {
222            pairs.push((key_trimmed, value));
223        }
224    }
225
226    Ok(pairs)
227}
228
229/// Convert a connection string value to `Option<String>`, treating empty strings as `None`.
230///
231/// In ADO.NET, specifying a keyword with an empty value (e.g., `Database=;`) resets it
232/// to its default. We represent this as `None` for optional fields.
233fn non_empty(value: &str) -> Option<String> {
234    if value.is_empty() {
235        None
236    } else {
237        Some(value.to_string())
238    }
239}
240
241/// Configuration for connecting to SQL Server.
242///
243/// This struct is marked `#[non_exhaustive]` to allow adding new fields
244/// in future releases without breaking semver. Use [`Config::default()`]
245/// or [`Config::from_connection_string()`] to construct instances.
246#[derive(Debug, Clone)]
247#[non_exhaustive]
248pub struct Config {
249    /// Server hostname or IP address.
250    pub host: String,
251
252    /// Server port (default: 1433).
253    pub port: u16,
254
255    /// Database name.
256    pub database: Option<String>,
257
258    /// Authentication credentials.
259    pub credentials: Credentials,
260
261    /// TLS configuration (only available when `tls` feature is enabled).
262    #[cfg(feature = "tls")]
263    pub tls: TlsConfig,
264
265    /// Application name (shown in SQL Server management tools).
266    pub application_name: String,
267
268    /// Connection timeout.
269    pub connect_timeout: Duration,
270
271    /// Command timeout.
272    pub command_timeout: Duration,
273
274    /// Maximum size in bytes of a single buffered response (default: 0 =
275    /// unlimited).
276    ///
277    /// Query responses are buffered in full before rows are decoded (see the
278    /// crate-level notes on streaming), so a very large SELECT is otherwise
279    /// unbounded client memory. When the cap is exceeded the call fails with
280    /// [`Error::ResponseTooLarge`](crate::Error::ResponseTooLarge) and the
281    /// connection is discarded (the response was abandoned mid-stream).
282    /// Paginate, narrow the SELECT, or raise the cap.
283    pub max_response_size: usize,
284
285    /// TDS packet size.
286    pub packet_size: u16,
287
288    /// Whether to use TDS 8.0 strict mode.
289    pub strict_mode: bool,
290
291    /// Whether to trust the server certificate.
292    pub trust_server_certificate: bool,
293
294    /// Instance name (for named instances).
295    pub instance: Option<String>,
296
297    /// Whether to enable MARS (Multiple Active Result Sets).
298    pub mars: bool,
299
300    /// Whether to require encryption (TLS).
301    /// When true, the connection will use TLS even if the server doesn't require it.
302    /// When false, encryption is used only if the server requires it.
303    pub encrypt: bool,
304
305    /// Disable TLS entirely and connect with plaintext.
306    ///
307    /// **⚠️ SECURITY WARNING:** This completely disables TLS/SSL encryption.
308    /// Credentials and data will be transmitted in plaintext. Only use this
309    /// for development/testing on trusted networks with legacy SQL Server
310    /// instances that don't support modern TLS versions.
311    ///
312    /// This option exists for compatibility with legacy SQL Server versions
313    /// (2008 and earlier) that may only support TLS 1.0/1.1, which modern
314    /// TLS libraries (like rustls) don't support for security reasons.
315    ///
316    /// When `true`:
317    /// - Overrides the `encrypt` setting
318    /// - Sends `ENCRYPT_NOT_SUP` in PreLogin
319    /// - No TLS handshake occurs
320    /// - All traffic including login credentials is unencrypted
321    ///
322    /// **Do not use in production without understanding the security implications.**
323    pub no_tls: bool,
324
325    /// Redirect handling configuration (for Azure SQL).
326    pub redirect: RedirectConfig,
327
328    /// Retry policy for transient error handling.
329    pub retry: RetryPolicy,
330
331    /// Timeout configuration for various connection phases.
332    pub timeouts: TimeoutConfig,
333
334    /// Requested TDS protocol version.
335    ///
336    /// This specifies which TDS protocol version to request during connection.
337    /// The server may negotiate a lower version if it doesn't support the requested version.
338    ///
339    /// Supported versions:
340    /// - `TdsVersion::V7_3A` - SQL Server 2008
341    /// - `TdsVersion::V7_3B` - SQL Server 2008 R2
342    /// - `TdsVersion::V7_4` - SQL Server 2012+ (default)
343    /// - `TdsVersion::V8_0` - SQL Server 2022+ strict mode (requires `strict_mode = true`)
344    ///
345    /// Note: When `strict_mode` is enabled, this is ignored and TDS 8.0 is used.
346    pub tds_version: TdsVersion,
347
348    /// Application workload intent for AlwaysOn Availability Group routing.
349    ///
350    /// When set to [`ApplicationIntent::ReadOnly`], SQL Server routes the
351    /// connection to a readable secondary replica. Sent in LOGIN7 TypeFlags
352    /// as the `READONLY_INTENT` bit.
353    pub application_intent: ApplicationIntent,
354
355    /// Client workstation name sent to SQL Server in the LOGIN7 HostName field.
356    ///
357    /// Used for auditing via `sys.dm_exec_sessions.host_name`.
358    /// When `None`, the driver sends the machine hostname (from the `COMPUTERNAME`
359    /// or `HOSTNAME` environment variable). Set via `Workstation ID` or `WSID`
360    /// in connection strings.
361    pub workstation_id: Option<String>,
362
363    /// Session language for server warning/error messages.
364    ///
365    /// When set, sent in LOGIN7's Language field. The language name can be
366    /// up to 128 characters. Set via `Language` or `Current Language` in
367    /// connection strings.
368    pub language: Option<String>,
369
370    /// Enable MultiSubnetFailover for AlwaysOn Availability Group listeners.
371    ///
372    /// When `true`, the driver resolves the server hostname to all IP addresses
373    /// and attempts parallel TCP connections simultaneously. The first successful
374    /// connection wins and all others are cancelled. This reduces connection time
375    /// when the AG listener spans multiple subnets.
376    ///
377    /// Set via `MultiSubnetFailover=True` in connection strings.
378    ///
379    /// Default: `false`
380    pub multi_subnet_failover: bool,
381
382    /// Whether to send `String`/`&str` parameters as NVARCHAR (Unicode).
383    ///
384    /// When `true` (default), string parameters are sent as NVARCHAR using
385    /// UTF-16LE encoding. This is safe for all character sets but prevents
386    /// SQL Server from using index seeks on VARCHAR columns (due to implicit
387    /// NVARCHAR→VARCHAR conversion).
388    ///
389    /// When `false`, string parameters are sent as VARCHAR using Windows-1252
390    /// encoding. This allows index seeks on VARCHAR columns but may lose data
391    /// for characters outside the Windows-1252 range.
392    ///
393    /// Set via `SendStringParametersAsUnicode=false` in connection strings.
394    ///
395    /// Default: `true`
396    pub send_string_parameters_as_unicode: bool,
397
398    /// Enable the client-side prepared-statement cache for parameterized
399    /// [`query`](crate::Client::query) calls.
400    ///
401    /// When `true`, a parameterized query is prepared once per connection
402    /// (`sp_prepare`) and subsequent identical queries reuse the handle
403    /// (`sp_execute`), trading a one-time cold-path round-trip for cheaper
404    /// repeated execution. When `false` (default), every parameterized query
405    /// uses `sp_executesql` (the server still reuses plans).
406    ///
407    /// This is an opt-in first increment (the `query` path only; not
408    /// `query_stream`, `query_multiple`, or Always Encrypted, which fall back
409    /// to `sp_executesql`). Set via `Statement Cache=true` in connection
410    /// strings.
411    ///
412    /// Default: `false`
413    pub statement_cache: bool,
414
415    /// Always Encrypted configuration.
416    ///
417    /// When `Some`, the client will negotiate Always Encrypted support with the
418    /// server and transparently decrypt encrypted column values in result sets.
419    ///
420    /// Set via `Column Encryption Setting=Enabled` in connection strings, or
421    /// programmatically via [`Config::with_column_encryption`].
422    ///
423    /// Wrapped in `Arc` because `EncryptionConfig` contains trait objects (key store
424    /// providers) which cannot implement `Clone`. The `Arc` allows `Config` to remain
425    /// `Clone` while sharing the encryption configuration.
426    #[cfg(feature = "always-encrypted")]
427    pub column_encryption: Option<std::sync::Arc<crate::encryption::EncryptionConfig>>,
428}
429
430impl Default for Config {
431    fn default() -> Self {
432        let timeouts = TimeoutConfig::default();
433        Self {
434            host: "localhost".to_string(),
435            port: 1433,
436            database: None,
437            credentials: Credentials::sql_server("", ""),
438            #[cfg(feature = "tls")]
439            tls: TlsConfig::default(),
440            application_name: "mssql-client".to_string(),
441            connect_timeout: timeouts.connect_timeout,
442            command_timeout: timeouts.command_timeout,
443            max_response_size: 0,
444            packet_size: 4096,
445            strict_mode: false,
446            trust_server_certificate: false,
447            instance: None,
448            mars: false,
449            encrypt: true, // Default to encrypted for security
450            no_tls: false, // Never plaintext by default
451            redirect: RedirectConfig::default(),
452            retry: RetryPolicy::default(),
453            timeouts,
454            tds_version: TdsVersion::V7_4, // Default to TDS 7.4 for broad compatibility
455            application_intent: ApplicationIntent::default(),
456            workstation_id: None,
457            language: None,
458            multi_subnet_failover: false,
459            send_string_parameters_as_unicode: true,
460            statement_cache: false,
461            #[cfg(feature = "always-encrypted")]
462            column_encryption: None,
463        }
464    }
465}
466
467impl Config {
468    /// Create a new configuration with default values.
469    #[must_use]
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    /// Parse a connection string into configuration.
475    ///
476    /// Supports ADO.NET-style connection strings with full quoting support:
477    /// ```text
478    /// Server=localhost;Database=mydb;User Id=sa;Password="complex;pass";
479    /// ```
480    ///
481    /// Values containing semicolons can be enclosed in double or single quotes
482    /// per the ADO.NET specification. The `tcp:` prefix from Azure Portal
483    /// connection strings is automatically stripped.
484    pub fn from_connection_string(conn_str: &str) -> Result<Self, crate::error::Error> {
485        let mut config = Self::default();
486        let pairs = split_connection_string(conn_str)?;
487
488        // The Authentication keyword is recorded during the loop and applied
489        // afterwards, so the outcome does not depend on whether it appears
490        // before or after User Id / Password.
491        let mut authentication: Option<String> = None;
492
493        for (key, value) in &pairs {
494            let key = key.trim().to_lowercase();
495            let value = value.trim();
496
497            match key.as_str() {
498                // --- Server / Data Source (ADO.NET aliases: Addr, Address, Network Address) ---
499                "server" | "data source" | "addr" | "address" | "network address" | "host" => {
500                    // Strip tcp: prefix (common in Azure Portal connection strings).
501                    // Reject np: (Named Pipes) and lpc: (Shared Memory) — not supported.
502                    // All prefix checks are case-insensitive per ADO.NET conventions.
503                    let lower_value = value.to_lowercase();
504                    let server_value = if lower_value.starts_with("tcp:") {
505                        &value[4..]
506                    } else if lower_value.starts_with("np:") {
507                        return Err(crate::error::Error::Config(
508                            "Named Pipes connections (np:) are not supported. Use TCP connections instead."
509                                .into(),
510                        ));
511                    } else if lower_value.starts_with("lpc:") {
512                        return Err(crate::error::Error::Config(
513                            "Shared Memory connections (lpc:) are not supported. Use TCP connections instead."
514                                .into(),
515                        ));
516                    } else {
517                        value
518                    };
519
520                    // Handle host,port or host\instance format
521                    if let Some((host, port_or_instance)) = server_value.split_once(',') {
522                        config.host = host.to_string();
523                        config.port = port_or_instance.trim().parse().map_err(|_| {
524                            crate::error::Error::Config(format!("invalid port: {port_or_instance}"))
525                        })?;
526                    } else if let Some((host, instance)) = server_value.split_once('\\') {
527                        config.host = host.to_string();
528                        config.instance = non_empty(instance);
529                    } else {
530                        config.host = server_value.to_string();
531                    }
532                }
533                "port" => {
534                    config.port = value.parse().map_err(|_| {
535                        crate::error::Error::Config(format!("invalid port: {value}"))
536                    })?;
537                }
538                // --- Database ---
539                "database" | "initial catalog" => {
540                    config.database = non_empty(value);
541                }
542                // --- Credentials ---
543                "user id" | "uid" | "user" => {
544                    if let Credentials::SqlServer { password, .. } = &config.credentials {
545                        config.credentials =
546                            Credentials::sql_server(value.to_string(), password.clone());
547                    }
548                }
549                "password" | "pwd" => {
550                    if let Credentials::SqlServer { username, .. } = &config.credentials {
551                        config.credentials =
552                            Credentials::sql_server(username.clone(), value.to_string());
553                    }
554                }
555                // ADO.NET accepts both spaced ("Active Directory Service
556                // Principal") and compact (ActiveDirectoryServicePrincipal)
557                // forms; normalize for the post-loop match.
558                "authentication" => {
559                    authentication = non_empty(value).map(|v| v.to_lowercase().replace(' ', ""));
560                }
561                // --- Application ---
562                "application name" | "app" => {
563                    config.application_name = value.to_string();
564                }
565                "applicationintent" | "application intent" => {
566                    config.application_intent = match value.to_lowercase().as_str() {
567                        "readonly" => ApplicationIntent::ReadOnly,
568                        "readwrite" => ApplicationIntent::ReadWrite,
569                        _ => {
570                            return Err(crate::error::Error::Config(format!(
571                                "invalid ApplicationIntent: '{value}' (expected ReadOnly or ReadWrite)"
572                            )));
573                        }
574                    };
575                }
576                "workstation id" | "wsid" => {
577                    config.workstation_id = non_empty(value);
578                }
579                "current language" | "language" => {
580                    config.language = non_empty(value);
581                }
582                // --- Timeouts (ADO.NET alias: Timeout) ---
583                "connect timeout" | "connection timeout" | "timeout" => {
584                    let secs: u64 = value.parse().map_err(|_| {
585                        crate::error::Error::Config(format!("invalid timeout: {value}"))
586                    })?;
587                    config.connect_timeout = Duration::from_secs(secs);
588                }
589                "command timeout" => {
590                    let secs: u64 = value.parse().map_err(|_| {
591                        crate::error::Error::Config(format!("invalid timeout: {value}"))
592                    })?;
593                    config.command_timeout = Duration::from_secs(secs);
594                }
595                // --- Security ---
596                "trustservercertificate" | "trust server certificate" => {
597                    config.trust_server_certificate = parse_conn_bool(&key, value)?;
598                }
599                "encrypt" => {
600                    // Encrypt supports several non-boolean values beyond true/false:
601                    // - "strict" = TDS 8.0 strict mode (always encrypted transport)
602                    // - "mandatory" / "true" / "yes" / "1" = require TLS
603                    // - "optional" / "false" / "no" / "0" = TLS only if server requires
604                    // - "no_tls" = Tiberius-compatible plaintext mode for legacy servers
605                    //
606                    // "mandatory" and "optional" are Microsoft.Data.SqlClient v5+ aliases.
607                    if value.eq_ignore_ascii_case("strict") {
608                        config.strict_mode = true;
609                        config.encrypt = true;
610                        config.no_tls = false;
611                    } else if value.eq_ignore_ascii_case("mandatory") {
612                        config.encrypt = true;
613                        config.no_tls = false;
614                    } else if value.eq_ignore_ascii_case("optional") {
615                        config.encrypt = false;
616                        config.no_tls = false;
617                    } else if value.eq_ignore_ascii_case("no_tls") {
618                        config.no_tls = true;
619                        config.encrypt = false;
620                    } else {
621                        // Standard boolean values (true/false/yes/no/1/0)
622                        let enabled = parse_conn_bool(&key, value)?;
623                        config.encrypt = enabled;
624                        config.no_tls = false;
625                    }
626                }
627                "integrated security" | "trusted_connection" => {
628                    // Accepts standard booleans + "sspi" (ADO.NET strongly-recommended value)
629                    let enabled =
630                        value.eq_ignore_ascii_case("sspi") || parse_conn_bool(&key, value)?;
631                    if enabled {
632                        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
633                        {
634                            config.credentials = Credentials::Integrated;
635                        }
636                        #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
637                        {
638                            return Err(crate::error::Error::Config(
639                                "Integrated Security requires the 'integrated-auth' (Linux/macOS) \
640                                 or 'sspi-auth' (Windows) feature to be enabled"
641                                    .into(),
642                            ));
643                        }
644                    }
645                }
646                // --- Always Encrypted ---
647                "column encryption setting" | "columnencryptionsetting" => {
648                    #[cfg(feature = "always-encrypted")]
649                    if value.eq_ignore_ascii_case("enabled") {
650                        config.column_encryption = Some(std::sync::Arc::new(
651                            crate::encryption::EncryptionConfig::new(),
652                        ));
653                    }
654                    #[cfg(not(feature = "always-encrypted"))]
655                    if value.eq_ignore_ascii_case("enabled") {
656                        return Err(crate::error::Error::Config(
657                            "Column Encryption Setting=Enabled requires the 'always-encrypted' feature. \
658                             Enable it in your Cargo.toml: mssql-client = { features = [\"always-encrypted\"] }"
659                                .to_string(),
660                        ));
661                    }
662                }
663                // --- Protocol ---
664                "multipleactiveresultsets" | "mars" => {
665                    config.mars = parse_conn_bool(&key, value)?;
666                }
667                "packet size" => {
668                    config.packet_size = value.parse().map_err(|_| {
669                        crate::error::Error::Config(format!("invalid packet size: {value}"))
670                    })?;
671                }
672                "tdsversion" | "tds version" | "protocolversion" | "protocol version" => {
673                    config.tds_version = TdsVersion::parse(value).ok_or_else(|| {
674                        crate::error::Error::Config(format!(
675                            "invalid TDS version: {value}. Supported values: 7.3, 7.3A, 7.3B, 7.4, 8.0"
676                        ))
677                    })?;
678                    if config.tds_version.is_tds_8() {
679                        config.strict_mode = true;
680                    }
681                }
682                // --- Connection resiliency ---
683                "connectretrycount" | "connect retry count" => {
684                    config.retry.max_retries = value.parse().map_err(|_| {
685                        crate::error::Error::Config(format!("invalid ConnectRetryCount: '{value}'"))
686                    })?;
687                }
688                "connectretryinterval" | "connect retry interval" => {
689                    let secs: u64 = value.parse().map_err(|_| {
690                        crate::error::Error::Config(format!(
691                            "invalid ConnectRetryInterval: '{value}'"
692                        ))
693                    })?;
694                    config.retry.initial_backoff = Duration::from_secs(secs);
695                }
696                // --- Pool keywords: recognized but must be set via PoolConfig ---
697                "max pool size"
698                | "min pool size"
699                | "pooling"
700                | "connection lifetime"
701                | "load balance timeout" => {
702                    tracing::info!(
703                        key = key.as_str(),
704                        value = value,
705                        "connection string keyword '{}' is recognized but pool settings \
706                         must be configured via PoolConfig, not the connection string",
707                        key,
708                    );
709                }
710                // --- MultiSubnetFailover ---
711                "multisubnetfailover" | "multi subnet failover" => {
712                    config.multi_subnet_failover = parse_conn_bool(&key, value)?;
713                }
714                // --- String parameter encoding ---
715                "sendstringparametersasunicode" | "send string parameters as unicode" => {
716                    config.send_string_parameters_as_unicode = parse_conn_bool(&key, value)?;
717                }
718                // --- Prepared-statement cache (opt-in) ---
719                "statement cache" | "statementcache" => {
720                    config.statement_cache = parse_conn_bool(&key, value)?;
721                }
722                // --- Known ADO.NET keywords not supported by this driver ---
723                "failover partner"
724                | "persist security info"
725                | "persistsecurityinfo"
726                | "enlist"
727                | "replication"
728                | "transaction binding"
729                | "type system version"
730                | "user instance"
731                | "attachdbfilename"
732                | "extended properties"
733                | "initial file name"
734                | "context connection"
735                | "network library"
736                | "network"
737                | "net"
738                | "asynchronous processing"
739                | "async"
740                | "transparentnetworkipresolution"
741                | "poolblockingperiod"
742                | "hostnameincertificate"
743                | "servercertificate" => {
744                    tracing::info!(
745                        key = key.as_str(),
746                        value = value,
747                        "connection string keyword '{}' is recognized but not supported by this driver",
748                        key,
749                    );
750                }
751                _ => {
752                    tracing::debug!(
753                        key = key.as_str(),
754                        value = value,
755                        "ignoring unknown connection string option"
756                    );
757                }
758            }
759        }
760
761        if let Some(method) = authentication {
762            config.apply_authentication_keyword(&method)?;
763        }
764
765        Ok(config)
766    }
767
768    /// Apply a (normalized) `Authentication=` connection-string value.
769    ///
770    /// Called after the keyword loop: `User Id` / `Password` have their final
771    /// values, which the Azure AD methods reinterpret (client id / secret).
772    /// Values follow ADO.NET `SqlAuthenticationMethod` and ADR-002.
773    fn apply_authentication_keyword(&mut self, method: &str) -> Result<(), crate::error::Error> {
774        // ADO.NET semantics: Authentication and Integrated Security are
775        // mutually exclusive, whatever the Authentication value.
776        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
777        if matches!(self.credentials, Credentials::Integrated) {
778            return Err(crate::error::Error::Config(
779                "the Authentication keyword cannot be combined with Integrated Security".into(),
780            ));
781        }
782
783        match method {
784            // Explicit SQL authentication: User Id / Password already hold
785            // the credentials; nothing to transform.
786            "sqlpassword" => {}
787            "activedirectorymanagedidentity" | "activedirectorymsi" => {
788                #[cfg(not(feature = "azure-identity"))]
789                return Err(crate::error::Error::Config(format!(
790                    "Authentication={method} requires the 'azure-identity' feature. \
791                     Enable it in your Cargo.toml: \
792                     mssql-client = {{ features = [\"azure-identity\"] }}"
793                )));
794                #[cfg(feature = "azure-identity")]
795                {
796                    // Optional User Id selects a user-assigned identity.
797                    let client_id = match &self.credentials {
798                        Credentials::SqlServer { username, .. } if !username.is_empty() => {
799                            Some(username.clone())
800                        }
801                        _ => None,
802                    };
803                    self.credentials = Credentials::AzureManagedIdentity { client_id };
804                }
805            }
806            "activedirectoryserviceprincipal" => {
807                #[cfg(not(feature = "azure-identity"))]
808                return Err(crate::error::Error::Config(format!(
809                    "Authentication={method} requires the 'azure-identity' feature. \
810                     Enable it in your Cargo.toml: \
811                     mssql-client = {{ features = [\"azure-identity\"] }}"
812                )));
813                #[cfg(feature = "azure-identity")]
814                {
815                    let Credentials::SqlServer { username, password } = &self.credentials else {
816                        return Err(crate::error::Error::Config(
817                            "Authentication=ActiveDirectoryServicePrincipal requires \
818                             User Id and Password (client id and secret)"
819                                .into(),
820                        ));
821                    };
822                    // The tenant rides in User Id: client-side token
823                    // acquisition (FEDAUTH SecurityToken workflow) needs it
824                    // before any server contact.
825                    let Some((client_id, tenant_id)) = username.split_once('@') else {
826                        return Err(crate::error::Error::Config(
827                            "Authentication=ActiveDirectoryServicePrincipal requires \
828                             User Id=<client-id>@<tenant-id> (the tenant id is needed \
829                             for client-side token acquisition)"
830                                .into(),
831                        ));
832                    };
833                    if client_id.is_empty() || tenant_id.is_empty() {
834                        return Err(crate::error::Error::Config(
835                            "Authentication=ActiveDirectoryServicePrincipal: client id \
836                             and tenant id must both be non-empty in \
837                             User Id=<client-id>@<tenant-id>"
838                                .into(),
839                        ));
840                    }
841                    if password.is_empty() {
842                        return Err(crate::error::Error::Config(
843                            "Authentication=ActiveDirectoryServicePrincipal requires \
844                             Password=<client secret>"
845                                .into(),
846                        ));
847                    }
848                    let (client_id, tenant_id) = (client_id.to_string(), tenant_id.to_string());
849                    let client_secret = password.clone();
850                    self.credentials = Credentials::AzureServicePrincipal {
851                        tenant_id: tenant_id.into(),
852                        client_id: client_id.into(),
853                        client_secret,
854                    };
855                }
856            }
857            "activedirectorydefault" => {
858                #[cfg(not(feature = "azure-identity"))]
859                return Err(crate::error::Error::Config(format!(
860                    "Authentication={method} requires the 'azure-identity' feature. \
861                     Enable it in your Cargo.toml: \
862                     mssql-client = {{ features = [\"azure-identity\"] }}"
863                )));
864                #[cfg(feature = "azure-identity")]
865                {
866                    // Default credential chain: managed identity, then the
867                    // signed-in az/azd CLI session. Token acquired at connect.
868                    self.credentials = Credentials::AzureDefault;
869                }
870            }
871            "activedirectorypassword"
872            | "activedirectoryintegrated"
873            | "activedirectoryinteractive"
874            | "activedirectorydevicecodeflow" => {
875                return Err(crate::error::Error::Config(format!(
876                    "Authentication value '{method}' is not supported: azure_identity does \
877                     not ship the interactive / username-password / device-code credentials. \
878                     Acquire the token yourself and pass it via Credentials::azure_token. \
879                     Built-in Authentication values: SqlPassword, \
880                     ActiveDirectoryServicePrincipal, ActiveDirectoryManagedIdentity \
881                     (alias ActiveDirectoryMSI), ActiveDirectoryDefault"
882                )));
883            }
884            other => {
885                return Err(crate::error::Error::Config(format!(
886                    "invalid Authentication value: '{other}'. Supported values: \
887                     SqlPassword, ActiveDirectoryServicePrincipal, \
888                     ActiveDirectoryManagedIdentity (alias ActiveDirectoryMSI), \
889                     ActiveDirectoryDefault"
890                )));
891            }
892        }
893
894        Ok(())
895    }
896
897    /// Set the server host.
898    #[must_use]
899    pub fn host(mut self, host: impl Into<String>) -> Self {
900        self.host = host.into();
901        self
902    }
903
904    /// Set the server port.
905    #[must_use]
906    pub fn port(mut self, port: u16) -> Self {
907        self.port = port;
908        self
909    }
910
911    /// Set the database name.
912    #[must_use]
913    pub fn database(mut self, database: impl Into<String>) -> Self {
914        self.database = Some(database.into());
915        self
916    }
917
918    /// Set the credentials.
919    #[must_use]
920    pub fn credentials(mut self, credentials: Credentials) -> Self {
921        self.credentials = credentials;
922        self
923    }
924
925    /// Set the application name.
926    #[must_use]
927    pub fn application_name(mut self, name: impl Into<String>) -> Self {
928        self.application_name = name.into();
929        self
930    }
931
932    /// Set the connect timeout.
933    #[must_use]
934    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
935        self.connect_timeout = timeout;
936        self
937    }
938
939    /// Set trust server certificate option.
940    #[must_use]
941    pub fn trust_server_certificate(mut self, trust: bool) -> Self {
942        self.trust_server_certificate = trust;
943        #[cfg(feature = "tls")]
944        {
945            self.tls = self.tls.trust_server_certificate(trust);
946        }
947        self
948    }
949
950    /// Enable TDS 8.0 strict mode.
951    #[must_use]
952    pub fn strict_mode(mut self, enabled: bool) -> Self {
953        self.strict_mode = enabled;
954        #[cfg(feature = "tls")]
955        {
956            self.tls = self.tls.strict_mode(enabled);
957        }
958        if enabled {
959            self.tds_version = TdsVersion::V8_0;
960        }
961        self
962    }
963
964    /// Set the TDS protocol version.
965    ///
966    /// This specifies which TDS protocol version to request during connection.
967    /// The server may negotiate a lower version if it doesn't support the requested version.
968    ///
969    /// # Examples
970    ///
971    /// ```no_run
972    /// use mssql_client::Config;
973    /// use tds_protocol::version::TdsVersion;
974    ///
975    /// // Connect to SQL Server 2008
976    /// let config = Config::new()
977    ///     .host("legacy-server")
978    ///     .tds_version(TdsVersion::V7_3A);
979    ///
980    /// // Connect to SQL Server 2008 R2
981    /// let config = Config::new()
982    ///     .host("legacy-server")
983    ///     .tds_version(TdsVersion::V7_3B);
984    /// ```
985    ///
986    /// Note: When `strict_mode` is enabled, this is ignored and TDS 8.0 is used.
987    #[must_use]
988    pub fn tds_version(mut self, version: TdsVersion) -> Self {
989        self.tds_version = version;
990        // If TDS 8.0 is requested, automatically enable strict mode
991        if version.is_tds_8() {
992            self.strict_mode = true;
993            #[cfg(feature = "tls")]
994            {
995                self.tls = self.tls.strict_mode(true);
996            }
997        }
998        self
999    }
1000
1001    /// Enable or disable TLS encryption.
1002    ///
1003    /// When `true` (default), the connection will use TLS encryption.
1004    /// When `false`, encryption is used only if the server requires it.
1005    ///
1006    /// **Warning:** Disabling encryption is insecure and should only be
1007    /// used for development/testing on trusted networks.
1008    #[must_use]
1009    pub fn encrypt(mut self, enabled: bool) -> Self {
1010        self.encrypt = enabled;
1011        self
1012    }
1013
1014    /// Disable TLS entirely and connect with plaintext (Tiberius-compatible).
1015    ///
1016    /// **⚠️ SECURITY WARNING:** This completely disables TLS/SSL encryption.
1017    /// Credentials and all data will be transmitted in plaintext over the network.
1018    ///
1019    /// # When to use this
1020    ///
1021    /// This option exists for compatibility with legacy SQL Server versions
1022    /// (2008 and earlier) that may only support TLS 1.0/1.1. Modern TLS libraries
1023    /// like rustls require TLS 1.2 or higher for security reasons, making it
1024    /// impossible to establish encrypted connections to these older servers.
1025    ///
1026    /// # Security implications
1027    ///
1028    /// When enabled:
1029    /// - Login credentials are sent in plaintext
1030    /// - All query data is transmitted without encryption
1031    /// - Network traffic can be intercepted and read by attackers
1032    ///
1033    /// **Only use this for development/testing on isolated, trusted networks.**
1034    ///
1035    /// # Example
1036    ///
1037    /// ```rust,no_run
1038    /// # fn ex() -> Result<(), mssql_client::Error> {
1039    /// use mssql_client::Config;
1040    ///
1041    /// // Connection string (Tiberius-compatible)
1042    /// let config = Config::from_connection_string(
1043    ///     "Server=legacy-server;User Id=sa;Password=secret;Encrypt=no_tls"
1044    /// )?;
1045    ///
1046    /// // Builder API
1047    /// let config = Config::new()
1048    ///     .host("legacy-server")
1049    ///     .no_tls(true);
1050    /// # let _ = config;
1051    /// # Ok(())
1052    /// # }
1053    /// ```
1054    #[must_use]
1055    pub fn no_tls(mut self, enabled: bool) -> Self {
1056        self.no_tls = enabled;
1057        if enabled {
1058            self.encrypt = false;
1059        }
1060        self
1061    }
1062
1063    /// Enable Always Encrypted with the given encryption configuration.
1064    ///
1065    /// When enabled, the client will negotiate Always Encrypted support during
1066    /// connection and transparently decrypt encrypted column values.
1067    ///
1068    /// # Example
1069    ///
1070    /// ```rust,no_run
1071    /// # #[cfg(feature = "always-encrypted")]
1072    /// # fn ex() {
1073    /// use mssql_client::{Config, EncryptionConfig};
1074    /// use mssql_auth::InMemoryKeyStore;
1075    ///
1076    /// let key_store = InMemoryKeyStore::new();
1077    /// let config = Config::new()
1078    ///     .with_column_encryption(
1079    ///         EncryptionConfig::new().with_provider(key_store)
1080    ///     );
1081    /// # let _ = config;
1082    /// # }
1083    /// ```
1084    #[cfg(feature = "always-encrypted")]
1085    #[must_use]
1086    pub fn with_column_encryption(mut self, config: crate::encryption::EncryptionConfig) -> Self {
1087        self.column_encryption = Some(std::sync::Arc::new(config));
1088        self
1089    }
1090
1091    /// Create a new configuration with a different host (for routing).
1092    #[must_use]
1093    pub fn with_host(mut self, host: &str) -> Self {
1094        self.host = host.to_string();
1095        self
1096    }
1097
1098    /// Create a new configuration with a different port (for routing).
1099    #[must_use]
1100    pub fn with_port(mut self, port: u16) -> Self {
1101        self.port = port;
1102        self
1103    }
1104
1105    /// Enable or disable the client-side prepared-statement cache.
1106    ///
1107    /// See [`Config::statement_cache`]. Off by default.
1108    #[must_use]
1109    pub fn with_statement_cache(mut self, enabled: bool) -> Self {
1110        self.statement_cache = enabled;
1111        self
1112    }
1113
1114    /// Set the redirect handling configuration.
1115    #[must_use]
1116    pub fn redirect(mut self, redirect: RedirectConfig) -> Self {
1117        self.redirect = redirect;
1118        self
1119    }
1120
1121    /// Set the maximum number of redirect attempts.
1122    #[must_use]
1123    pub fn max_redirects(mut self, max: u8) -> Self {
1124        self.redirect.max_redirects = max;
1125        self
1126    }
1127
1128    /// Set the retry policy for transient error handling.
1129    #[must_use]
1130    pub fn retry(mut self, retry: RetryPolicy) -> Self {
1131        self.retry = retry;
1132        self
1133    }
1134
1135    /// Set the maximum number of retry attempts.
1136    #[must_use]
1137    pub fn max_retries(mut self, max: u32) -> Self {
1138        self.retry.max_retries = max;
1139        self
1140    }
1141
1142    /// Set the timeout configuration.
1143    #[must_use]
1144    pub fn timeouts(mut self, timeouts: TimeoutConfig) -> Self {
1145        // Sync the legacy fields for backward compatibility first
1146        self.connect_timeout = timeouts.connect_timeout;
1147        self.command_timeout = timeouts.command_timeout;
1148        self.timeouts = timeouts;
1149        self
1150    }
1151
1152    /// Set the application workload intent for AlwaysOn AG routing.
1153    #[must_use]
1154    pub fn application_intent(mut self, intent: ApplicationIntent) -> Self {
1155        self.application_intent = intent;
1156        self
1157    }
1158
1159    /// Set the client workstation name sent to SQL Server in LOGIN7.
1160    ///
1161    /// This appears in `sys.dm_exec_sessions.host_name` for auditing.
1162    /// When not set, the driver sends the machine hostname automatically.
1163    #[must_use]
1164    pub fn workstation_id(mut self, id: impl Into<String>) -> Self {
1165        self.workstation_id = Some(id.into());
1166        self
1167    }
1168
1169    /// Set the session language for server messages.
1170    ///
1171    /// The language name can be up to 128 characters (e.g., `"us_english"`).
1172    #[must_use]
1173    pub fn language(mut self, lang: impl Into<String>) -> Self {
1174        self.language = Some(lang.into());
1175        self
1176    }
1177
1178    /// Enable MultiSubnetFailover for AlwaysOn Availability Group listeners.
1179    ///
1180    /// When enabled, the driver resolves the server hostname to all IP addresses
1181    /// and races parallel TCP connections. The first successful connection wins.
1182    #[must_use]
1183    pub fn multi_subnet_failover(mut self, enabled: bool) -> Self {
1184        self.multi_subnet_failover = enabled;
1185        self
1186    }
1187
1188    /// Control whether string parameters are sent as NVARCHAR (Unicode) or VARCHAR.
1189    ///
1190    /// When `false`, `String`/`&str` parameters are sent as VARCHAR using
1191    /// Windows-1252 encoding, which allows SQL Server to use index seeks on
1192    /// VARCHAR columns.
1193    ///
1194    /// Default: `true` (NVARCHAR)
1195    #[must_use]
1196    pub fn send_string_parameters_as_unicode(mut self, enabled: bool) -> Self {
1197        self.send_string_parameters_as_unicode = enabled;
1198        self
1199    }
1200}
1201
1202#[cfg(test)]
1203#[allow(clippy::unwrap_used)]
1204mod tests {
1205    use super::*;
1206
1207    #[cfg(feature = "azure-identity")]
1208    #[test]
1209    fn test_authentication_active_directory_default() {
1210        let config = Config::from_connection_string(
1211            "Server=db.example.com;Database=app;Authentication=ActiveDirectoryDefault;",
1212        )
1213        .unwrap();
1214        assert!(matches!(
1215            config.credentials,
1216            mssql_auth::Credentials::AzureDefault
1217        ));
1218        // Spaced ADO.NET form should parse identically.
1219        let spaced = Config::from_connection_string(
1220            "Server=db.example.com;Authentication=Active Directory Default;",
1221        )
1222        .unwrap();
1223        assert!(matches!(
1224            spaced.credentials,
1225            mssql_auth::Credentials::AzureDefault
1226        ));
1227    }
1228
1229    #[test]
1230    fn test_connection_string_parsing() {
1231        let config = Config::from_connection_string(
1232            "Server=localhost;Database=test;User Id=sa;Password=secret;",
1233        )
1234        .unwrap();
1235
1236        assert_eq!(config.host, "localhost");
1237        assert_eq!(config.database, Some("test".to_string()));
1238    }
1239
1240    #[test]
1241    fn test_connection_string_with_port() {
1242        let config =
1243            Config::from_connection_string("Server=localhost,1434;Database=test;").unwrap();
1244
1245        assert_eq!(config.host, "localhost");
1246        assert_eq!(config.port, 1434);
1247    }
1248
1249    #[test]
1250    fn test_connection_string_with_instance() {
1251        let config =
1252            Config::from_connection_string("Server=localhost\\SQLEXPRESS;Database=test;").unwrap();
1253
1254        assert_eq!(config.host, "localhost");
1255        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
1256    }
1257
1258    #[test]
1259    fn test_connection_string_dot_instance() {
1260        // "." is a standard ADO.NET alias for localhost
1261        let config = Config::from_connection_string("Server=.\\SQLEXPRESS;Database=test;").unwrap();
1262
1263        assert_eq!(config.host, ".");
1264        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
1265    }
1266
1267    #[test]
1268    fn test_connection_string_local_instance() {
1269        // "(local)" is a standard ADO.NET alias for localhost
1270        let config =
1271            Config::from_connection_string("Server=(local)\\SQLEXPRESS;Database=test;").unwrap();
1272
1273        assert_eq!(config.host, "(local)");
1274        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
1275    }
1276
1277    #[test]
1278    fn test_redirect_config_defaults() {
1279        let config = RedirectConfig::default();
1280        assert_eq!(config.max_redirects, 2);
1281        assert!(config.follow_redirects);
1282    }
1283
1284    #[test]
1285    fn test_redirect_config_builder() {
1286        let config = RedirectConfig::new()
1287            .max_redirects(5)
1288            .follow_redirects(false);
1289        assert_eq!(config.max_redirects, 5);
1290        assert!(!config.follow_redirects);
1291    }
1292
1293    #[test]
1294    fn test_redirect_config_no_follow() {
1295        let config = RedirectConfig::no_follow();
1296        assert_eq!(config.max_redirects, 0);
1297        assert!(!config.follow_redirects);
1298    }
1299
1300    #[test]
1301    fn test_config_redirect_builder() {
1302        let config = Config::new().max_redirects(3);
1303        assert_eq!(config.redirect.max_redirects, 3);
1304
1305        let config2 = Config::new().redirect(RedirectConfig::no_follow());
1306        assert!(!config2.redirect.follow_redirects);
1307    }
1308
1309    #[test]
1310    fn test_retry_policy_defaults() {
1311        let policy = RetryPolicy::default();
1312        assert_eq!(policy.max_retries, 3);
1313        assert_eq!(policy.initial_backoff, Duration::from_millis(100));
1314        assert_eq!(policy.max_backoff, Duration::from_secs(30));
1315        assert!((policy.backoff_multiplier - 2.0).abs() < f64::EPSILON);
1316        assert!(policy.jitter);
1317    }
1318
1319    #[test]
1320    fn test_retry_policy_builder() {
1321        let policy = RetryPolicy::new()
1322            .max_retries(5)
1323            .initial_backoff(Duration::from_millis(200))
1324            .max_backoff(Duration::from_secs(60))
1325            .backoff_multiplier(3.0)
1326            .jitter(false);
1327
1328        assert_eq!(policy.max_retries, 5);
1329        assert_eq!(policy.initial_backoff, Duration::from_millis(200));
1330        assert_eq!(policy.max_backoff, Duration::from_secs(60));
1331        assert!((policy.backoff_multiplier - 3.0).abs() < f64::EPSILON);
1332        assert!(!policy.jitter);
1333    }
1334
1335    #[test]
1336    fn test_retry_policy_no_retry() {
1337        let policy = RetryPolicy::no_retry();
1338        assert_eq!(policy.max_retries, 0);
1339        assert!(!policy.should_retry(0));
1340    }
1341
1342    #[test]
1343    fn test_retry_policy_should_retry() {
1344        let policy = RetryPolicy::new().max_retries(3);
1345        assert!(policy.should_retry(0));
1346        assert!(policy.should_retry(1));
1347        assert!(policy.should_retry(2));
1348        assert!(!policy.should_retry(3));
1349        assert!(!policy.should_retry(4));
1350    }
1351
1352    #[test]
1353    fn test_retry_policy_backoff_calculation() {
1354        let policy = RetryPolicy::new()
1355            .initial_backoff(Duration::from_millis(100))
1356            .backoff_multiplier(2.0)
1357            .max_backoff(Duration::from_secs(10))
1358            .jitter(false);
1359
1360        assert_eq!(policy.backoff_for_attempt(0), Duration::ZERO);
1361        assert_eq!(policy.backoff_for_attempt(1), Duration::from_millis(100));
1362        assert_eq!(policy.backoff_for_attempt(2), Duration::from_millis(200));
1363        assert_eq!(policy.backoff_for_attempt(3), Duration::from_millis(400));
1364    }
1365
1366    #[test]
1367    fn test_retry_policy_backoff_capped() {
1368        let policy = RetryPolicy::new()
1369            .initial_backoff(Duration::from_secs(1))
1370            .backoff_multiplier(10.0)
1371            .max_backoff(Duration::from_secs(5))
1372            .jitter(false);
1373
1374        // Attempt 3 would be 1s * 10^2 = 100s, but capped at 5s
1375        assert_eq!(policy.backoff_for_attempt(3), Duration::from_secs(5));
1376    }
1377
1378    #[test]
1379    fn test_config_retry_builder() {
1380        let config = Config::new().max_retries(5);
1381        assert_eq!(config.retry.max_retries, 5);
1382
1383        let config2 = Config::new().retry(RetryPolicy::no_retry());
1384        assert_eq!(config2.retry.max_retries, 0);
1385    }
1386
1387    #[test]
1388    fn test_timeout_config_defaults() {
1389        let config = TimeoutConfig::default();
1390        assert_eq!(config.connect_timeout, Duration::from_secs(15));
1391        assert_eq!(config.tls_timeout, Duration::from_secs(10));
1392        assert_eq!(config.login_timeout, Duration::from_secs(30));
1393        assert_eq!(config.command_timeout, Duration::from_secs(30));
1394        assert_eq!(config.idle_timeout, Duration::from_secs(300));
1395        assert_eq!(config.keepalive_interval, Some(Duration::from_secs(30)));
1396    }
1397
1398    #[test]
1399    fn test_timeout_config_builder() {
1400        let config = TimeoutConfig::new()
1401            .connect_timeout(Duration::from_secs(5))
1402            .tls_timeout(Duration::from_secs(3))
1403            .login_timeout(Duration::from_secs(10))
1404            .command_timeout(Duration::from_secs(60))
1405            .idle_timeout(Duration::from_secs(600))
1406            .keepalive_interval(Some(Duration::from_secs(60)));
1407
1408        assert_eq!(config.connect_timeout, Duration::from_secs(5));
1409        assert_eq!(config.tls_timeout, Duration::from_secs(3));
1410        assert_eq!(config.login_timeout, Duration::from_secs(10));
1411        assert_eq!(config.command_timeout, Duration::from_secs(60));
1412        assert_eq!(config.idle_timeout, Duration::from_secs(600));
1413        assert_eq!(config.keepalive_interval, Some(Duration::from_secs(60)));
1414    }
1415
1416    #[test]
1417    fn test_timeout_config_no_keepalive() {
1418        let config = TimeoutConfig::new().no_keepalive();
1419        assert_eq!(config.keepalive_interval, None);
1420    }
1421
1422    #[test]
1423    fn test_timeout_config_total_connect() {
1424        let config = TimeoutConfig::new()
1425            .connect_timeout(Duration::from_secs(5))
1426            .tls_timeout(Duration::from_secs(3))
1427            .login_timeout(Duration::from_secs(10));
1428
1429        // 5 + 3 + 10 = 18 seconds
1430        assert_eq!(config.total_connect_timeout(), Duration::from_secs(18));
1431    }
1432
1433    #[test]
1434    fn test_config_timeouts_builder() {
1435        let timeouts = TimeoutConfig::new()
1436            .connect_timeout(Duration::from_secs(5))
1437            .command_timeout(Duration::from_secs(60));
1438
1439        let config = Config::new().timeouts(timeouts);
1440        assert_eq!(config.timeouts.connect_timeout, Duration::from_secs(5));
1441        assert_eq!(config.timeouts.command_timeout, Duration::from_secs(60));
1442        // Check that legacy fields are synced
1443        assert_eq!(config.connect_timeout, Duration::from_secs(5));
1444        assert_eq!(config.command_timeout, Duration::from_secs(60));
1445    }
1446
1447    #[test]
1448    fn test_tds_version_default() {
1449        let config = Config::default();
1450        assert_eq!(config.tds_version, TdsVersion::V7_4);
1451        assert!(!config.strict_mode);
1452    }
1453
1454    #[test]
1455    fn test_tds_version_builder() {
1456        let config = Config::new().tds_version(TdsVersion::V7_3A);
1457        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1458        assert!(!config.strict_mode);
1459
1460        let config = Config::new().tds_version(TdsVersion::V7_3B);
1461        assert_eq!(config.tds_version, TdsVersion::V7_3B);
1462        assert!(!config.strict_mode);
1463
1464        // TDS 8.0 should automatically enable strict mode
1465        let config = Config::new().tds_version(TdsVersion::V8_0);
1466        assert_eq!(config.tds_version, TdsVersion::V8_0);
1467        assert!(config.strict_mode);
1468    }
1469
1470    #[test]
1471    fn test_strict_mode_sets_tds_8() {
1472        let config = Config::new().strict_mode(true);
1473        assert!(config.strict_mode);
1474        assert_eq!(config.tds_version, TdsVersion::V8_0);
1475    }
1476
1477    #[test]
1478    fn test_connection_string_tds_version() {
1479        // Test TDS 7.3
1480        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3;").unwrap();
1481        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1482
1483        // Test TDS 7.3A explicitly
1484        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3A;").unwrap();
1485        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1486
1487        // Test TDS 7.3B
1488        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3B;").unwrap();
1489        assert_eq!(config.tds_version, TdsVersion::V7_3B);
1490
1491        // Test TDS 7.4
1492        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.4;").unwrap();
1493        assert_eq!(config.tds_version, TdsVersion::V7_4);
1494
1495        // Test TDS 8.0 enables strict mode
1496        let config = Config::from_connection_string("Server=localhost;TDSVersion=8.0;").unwrap();
1497        assert_eq!(config.tds_version, TdsVersion::V8_0);
1498        assert!(config.strict_mode);
1499
1500        // Test alternative key names
1501        let config =
1502            Config::from_connection_string("Server=localhost;ProtocolVersion=7.3;").unwrap();
1503        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1504    }
1505
1506    #[test]
1507    fn test_connection_string_invalid_tds_version() {
1508        let result = Config::from_connection_string("Server=localhost;TDSVersion=invalid;");
1509        assert!(result.is_err());
1510
1511        let result = Config::from_connection_string("Server=localhost;TDSVersion=9.0;");
1512        assert!(result.is_err());
1513    }
1514
1515    #[test]
1516    fn test_connection_string_no_tls() {
1517        // no_tls should disable TLS entirely
1518        let config = Config::from_connection_string("Server=legacy;Encrypt=no_tls;").unwrap();
1519        assert!(config.no_tls);
1520        assert!(!config.encrypt);
1521        assert!(!config.strict_mode);
1522
1523        // Case insensitive
1524        let config = Config::from_connection_string("Server=legacy;Encrypt=no_tls;").unwrap();
1525        assert!(config.no_tls);
1526
1527        // Encrypt=true should disable no_tls
1528        let config = Config::from_connection_string("Server=localhost;Encrypt=true;").unwrap();
1529        assert!(!config.no_tls);
1530        assert!(config.encrypt);
1531
1532        // Encrypt=strict should disable no_tls
1533        let config = Config::from_connection_string("Server=localhost;Encrypt=strict;").unwrap();
1534        assert!(!config.no_tls);
1535        assert!(config.encrypt);
1536        assert!(config.strict_mode);
1537
1538        // Encrypt=mandatory (Microsoft.Data.SqlClient v5+ alias for true)
1539        let config = Config::from_connection_string("Server=localhost;Encrypt=mandatory;").unwrap();
1540        assert!(config.encrypt);
1541        assert!(!config.no_tls);
1542
1543        // Encrypt=optional (Microsoft.Data.SqlClient v5+ alias for false)
1544        let config = Config::from_connection_string("Server=localhost;Encrypt=optional;").unwrap();
1545        assert!(!config.encrypt);
1546        assert!(!config.no_tls);
1547    }
1548
1549    #[test]
1550    fn test_no_tls_builder() {
1551        // Builder method
1552        let config = Config::new().no_tls(true);
1553        assert!(config.no_tls);
1554        assert!(!config.encrypt);
1555
1556        // Disable
1557        let config = Config::new().no_tls(true).no_tls(false);
1558        assert!(!config.no_tls);
1559    }
1560
1561    #[test]
1562    #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
1563    fn test_connection_string_integrated_security() {
1564        // "Integrated Security=true" should set Credentials::Integrated
1565        let config =
1566            Config::from_connection_string("Server=localhost;Integrated Security=true;").unwrap();
1567        assert_eq!(
1568            config.credentials.method_name(),
1569            "Integrated Authentication"
1570        );
1571
1572        // "yes" variant
1573        let config =
1574            Config::from_connection_string("Server=localhost;Integrated Security=yes;").unwrap();
1575        assert_eq!(
1576            config.credentials.method_name(),
1577            "Integrated Authentication"
1578        );
1579
1580        // "sspi" variant
1581        let config =
1582            Config::from_connection_string("Server=localhost;Integrated Security=sspi;").unwrap();
1583        assert_eq!(
1584            config.credentials.method_name(),
1585            "Integrated Authentication"
1586        );
1587
1588        // "1" variant
1589        let config =
1590            Config::from_connection_string("Server=localhost;Integrated Security=1;").unwrap();
1591        assert_eq!(
1592            config.credentials.method_name(),
1593            "Integrated Authentication"
1594        );
1595
1596        // Trusted_Connection synonym
1597        let config =
1598            Config::from_connection_string("Server=localhost;Trusted_Connection=true;").unwrap();
1599        assert_eq!(
1600            config.credentials.method_name(),
1601            "Integrated Authentication"
1602        );
1603    }
1604
1605    #[test]
1606    #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
1607    fn test_connection_string_integrated_security_without_feature() {
1608        // Should return an error when the feature is not enabled
1609        let result = Config::from_connection_string("Server=localhost;Integrated Security=true;");
1610        assert!(result.is_err());
1611        let err = result.unwrap_err().to_string();
1612        assert!(err.contains("integrated-auth"));
1613    }
1614
1615    // =======================================================================
1616    // ADO.NET conformance tests (quoted values, aliases, boolean validation)
1617    // =======================================================================
1618
1619    #[test]
1620    fn test_parse_conn_bool_all_values() {
1621        assert!(parse_conn_bool("test", "true").unwrap());
1622        assert!(parse_conn_bool("test", "True").unwrap());
1623        assert!(parse_conn_bool("test", "TRUE").unwrap());
1624        assert!(parse_conn_bool("test", "yes").unwrap());
1625        assert!(parse_conn_bool("test", "Yes").unwrap());
1626        assert!(parse_conn_bool("test", "1").unwrap());
1627
1628        assert!(!parse_conn_bool("test", "false").unwrap());
1629        assert!(!parse_conn_bool("test", "False").unwrap());
1630        assert!(!parse_conn_bool("test", "FALSE").unwrap());
1631        assert!(!parse_conn_bool("test", "no").unwrap());
1632        assert!(!parse_conn_bool("test", "No").unwrap());
1633        assert!(!parse_conn_bool("test", "0").unwrap());
1634
1635        // Invalid values should error
1636        assert!(parse_conn_bool("test", "banana").is_err());
1637        assert!(parse_conn_bool("test", "tru").is_err());
1638        assert!(parse_conn_bool("test", "").is_err());
1639    }
1640
1641    #[test]
1642    fn test_boolean_validation_trust_server_certificate() {
1643        // Valid boolean → ok
1644        let config =
1645            Config::from_connection_string("Server=localhost;TrustServerCertificate=true;")
1646                .unwrap();
1647        assert!(config.trust_server_certificate);
1648
1649        let config =
1650            Config::from_connection_string("Server=localhost;TrustServerCertificate=no;").unwrap();
1651        assert!(!config.trust_server_certificate);
1652
1653        // Invalid boolean → error (previously silently set to false!)
1654        let result =
1655            Config::from_connection_string("Server=localhost;TrustServerCertificate=banana;");
1656        assert!(result.is_err());
1657        assert!(result.unwrap_err().to_string().contains("invalid boolean"));
1658    }
1659
1660    #[test]
1661    fn test_boolean_validation_mars() {
1662        let config = Config::from_connection_string("Server=localhost;MARS=true;").unwrap();
1663        assert!(config.mars);
1664
1665        // Typo → error instead of silent false
1666        let result = Config::from_connection_string("Server=localhost;MARS=tru;");
1667        assert!(result.is_err());
1668    }
1669
1670    #[test]
1671    fn test_quoted_value_semicolon() {
1672        // Password with semicolons — must be quoted per ADO.NET spec
1673        let config = Config::from_connection_string(
1674            r#"Server=localhost;User Id=sa;Password="my;complex;pass";"#,
1675        )
1676        .unwrap();
1677        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1678            assert_eq!(password.as_ref(), "my;complex;pass");
1679        } else {
1680            unreachable!("expected SqlServer credentials");
1681        }
1682    }
1683
1684    #[test]
1685    fn test_quoted_value_single_quotes() {
1686        let config =
1687            Config::from_connection_string("Server=localhost;User Id=sa;Password='my;pass';")
1688                .unwrap();
1689        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1690            assert_eq!(password.as_ref(), "my;pass");
1691        } else {
1692            unreachable!("expected SqlServer credentials");
1693        }
1694    }
1695
1696    #[test]
1697    fn test_quoted_value_escaped_double_quotes() {
1698        // Doubled quotes → single quote per ADO.NET spec
1699        let config = Config::from_connection_string(
1700            r#"Server=localhost;User Id=sa;Password="has ""quotes""";"#,
1701        )
1702        .unwrap();
1703        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1704            assert_eq!(password.as_ref(), r#"has "quotes""#);
1705        } else {
1706            unreachable!("expected SqlServer credentials");
1707        }
1708    }
1709
1710    #[test]
1711    fn test_quoted_value_escaped_single_quotes() {
1712        let config =
1713            Config::from_connection_string("Server=localhost;User Id=sa;Password='it''s complex';")
1714                .unwrap();
1715        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1716            assert_eq!(password.as_ref(), "it's complex");
1717        } else {
1718            unreachable!("expected SqlServer credentials");
1719        }
1720    }
1721
1722    #[test]
1723    fn test_quoted_value_unterminated() {
1724        let result = Config::from_connection_string(r#"Server=localhost;Password="unterminated;"#);
1725        assert!(result.is_err());
1726        assert!(result.unwrap_err().to_string().contains("unterminated"));
1727    }
1728
1729    #[test]
1730    fn test_tcp_prefix_stripped() {
1731        // Azure Portal format: tcp:hostname,port
1732        let config = Config::from_connection_string(
1733            "Server=tcp:myserver.database.windows.net,1433;Database=mydb;",
1734        )
1735        .unwrap();
1736        assert_eq!(config.host, "myserver.database.windows.net");
1737        assert_eq!(config.port, 1433);
1738    }
1739
1740    #[test]
1741    fn test_tcp_prefix_mixed_case() {
1742        // Protocol prefixes are case-insensitive per ADO.NET
1743        let config = Config::from_connection_string("Server=Tcp:myhost,1433;").unwrap();
1744        assert_eq!(config.host, "myhost");
1745
1746        let config = Config::from_connection_string("Server=TCP:myhost,1433;").unwrap();
1747        assert_eq!(config.host, "myhost");
1748    }
1749
1750    #[test]
1751    fn test_tcp_prefix_with_instance() {
1752        let config =
1753            Config::from_connection_string("Server=tcp:myhost\\INST;Database=test;").unwrap();
1754        assert_eq!(config.host, "myhost");
1755        assert_eq!(config.instance, Some("INST".to_string()));
1756    }
1757
1758    #[test]
1759    fn test_np_prefix_rejected() {
1760        let result =
1761            Config::from_connection_string(r"Server=np:\\myhost\pipe\sql\query;Database=test;");
1762        assert!(result.is_err());
1763        assert!(result.unwrap_err().to_string().contains("Named Pipes"));
1764
1765        // Case-insensitive rejection
1766        let result =
1767            Config::from_connection_string(r"Server=NP:\\myhost\pipe\sql\query;Database=test;");
1768        assert!(result.is_err());
1769    }
1770
1771    #[test]
1772    fn test_lpc_prefix_rejected() {
1773        let result = Config::from_connection_string("Server=lpc:myhost;Database=test;");
1774        assert!(result.is_err());
1775        assert!(result.unwrap_err().to_string().contains("Shared Memory"));
1776    }
1777
1778    #[test]
1779    fn test_server_alias_addr() {
1780        let config = Config::from_connection_string("Addr=myhost;").unwrap();
1781        assert_eq!(config.host, "myhost");
1782    }
1783
1784    #[test]
1785    fn test_server_alias_address() {
1786        let config = Config::from_connection_string("Address=myhost,1434;").unwrap();
1787        assert_eq!(config.host, "myhost");
1788        assert_eq!(config.port, 1434);
1789    }
1790
1791    #[test]
1792    fn test_server_alias_network_address() {
1793        let config = Config::from_connection_string("Network Address=myhost;").unwrap();
1794        assert_eq!(config.host, "myhost");
1795    }
1796
1797    #[test]
1798    fn test_timeout_alias() {
1799        let config = Config::from_connection_string("Server=localhost;Timeout=30;").unwrap();
1800        assert_eq!(config.connect_timeout, Duration::from_secs(30));
1801    }
1802
1803    #[test]
1804    fn test_application_intent_readonly() {
1805        let config =
1806            Config::from_connection_string("Server=localhost;ApplicationIntent=ReadOnly;").unwrap();
1807        assert_eq!(config.application_intent, ApplicationIntent::ReadOnly);
1808    }
1809
1810    #[test]
1811    fn test_application_intent_readwrite() {
1812        let config =
1813            Config::from_connection_string("Server=localhost;Application Intent=ReadWrite;")
1814                .unwrap();
1815        assert_eq!(config.application_intent, ApplicationIntent::ReadWrite);
1816    }
1817
1818    #[test]
1819    fn test_application_intent_invalid() {
1820        let result = Config::from_connection_string("Server=localhost;ApplicationIntent=banana;");
1821        assert!(result.is_err());
1822        assert!(
1823            result
1824                .unwrap_err()
1825                .to_string()
1826                .contains("ApplicationIntent")
1827        );
1828    }
1829
1830    #[test]
1831    fn test_workstation_id() {
1832        let config =
1833            Config::from_connection_string("Server=localhost;Workstation ID=MYPC;").unwrap();
1834        assert_eq!(config.workstation_id, Some("MYPC".to_string()));
1835    }
1836
1837    #[test]
1838    fn test_wsid_alias() {
1839        let config =
1840            Config::from_connection_string("Server=localhost;WSID=MYWORKSTATION;").unwrap();
1841        assert_eq!(config.workstation_id, Some("MYWORKSTATION".to_string()));
1842    }
1843
1844    #[test]
1845    fn test_language() {
1846        let config =
1847            Config::from_connection_string("Server=localhost;Language=us_english;").unwrap();
1848        assert_eq!(config.language, Some("us_english".to_string()));
1849    }
1850
1851    #[test]
1852    fn test_current_language_alias() {
1853        let config =
1854            Config::from_connection_string("Server=localhost;Current Language=Deutsch;").unwrap();
1855        assert_eq!(config.language, Some("Deutsch".to_string()));
1856    }
1857
1858    #[test]
1859    fn test_connect_retry_count() {
1860        let config =
1861            Config::from_connection_string("Server=localhost;ConnectRetryCount=5;").unwrap();
1862        assert_eq!(config.retry.max_retries, 5);
1863    }
1864
1865    #[test]
1866    fn test_connect_retry_interval() {
1867        let config =
1868            Config::from_connection_string("Server=localhost;ConnectRetryInterval=15;").unwrap();
1869        assert_eq!(config.retry.initial_backoff, Duration::from_secs(15));
1870    }
1871
1872    #[test]
1873    fn test_pool_keywords_accepted_without_error() {
1874        // Pool keywords should be recognized (not error) but not affect Config
1875        let result = Config::from_connection_string(
1876            "Server=localhost;Max Pool Size=10;Min Pool Size=2;Pooling=true;",
1877        );
1878        assert!(result.is_ok());
1879    }
1880
1881    #[test]
1882    fn test_known_unsupported_keywords_accepted() {
1883        // Known ADO.NET keywords we don't support should not error
1884        let result = Config::from_connection_string(
1885            "Server=localhost;Failover Partner=backup;Persist Security Info=false;",
1886        );
1887        assert!(result.is_ok());
1888    }
1889
1890    #[test]
1891    fn test_multi_subnet_failover_connection_string() {
1892        let config =
1893            Config::from_connection_string("Server=ag-listener;MultiSubnetFailover=true;").unwrap();
1894        assert!(config.multi_subnet_failover);
1895
1896        // Space-separated variant
1897        let config =
1898            Config::from_connection_string("Server=ag-listener;Multi Subnet Failover=true;")
1899                .unwrap();
1900        assert!(config.multi_subnet_failover);
1901
1902        // Disabled
1903        let config =
1904            Config::from_connection_string("Server=ag-listener;MultiSubnetFailover=false;")
1905                .unwrap();
1906        assert!(!config.multi_subnet_failover);
1907
1908        // Default is false
1909        let config = Config::from_connection_string("Server=localhost;").unwrap();
1910        assert!(!config.multi_subnet_failover);
1911    }
1912
1913    #[test]
1914    fn test_multi_subnet_failover_builder() {
1915        let config = Config::new().multi_subnet_failover(true);
1916        assert!(config.multi_subnet_failover);
1917
1918        let config = Config::new().multi_subnet_failover(false);
1919        assert!(!config.multi_subnet_failover);
1920    }
1921
1922    #[test]
1923    fn test_multi_subnet_failover_invalid_value() {
1924        let result = Config::from_connection_string("Server=localhost;MultiSubnetFailover=banana;");
1925        assert!(result.is_err());
1926    }
1927
1928    #[test]
1929    fn test_application_intent_builder() {
1930        let config = Config::new().application_intent(ApplicationIntent::ReadOnly);
1931        assert_eq!(config.application_intent, ApplicationIntent::ReadOnly);
1932    }
1933
1934    #[test]
1935    fn test_workstation_id_builder() {
1936        let config = Config::new().workstation_id("MY-PC");
1937        assert_eq!(config.workstation_id, Some("MY-PC".to_string()));
1938    }
1939
1940    #[test]
1941    fn test_language_builder() {
1942        let config = Config::new().language("us_english");
1943        assert_eq!(config.language, Some("us_english".to_string()));
1944    }
1945
1946    #[test]
1947    fn test_send_string_parameters_as_unicode_connection_string() {
1948        let config =
1949            Config::from_connection_string("Server=localhost;SendStringParametersAsUnicode=false;")
1950                .unwrap();
1951        assert!(!config.send_string_parameters_as_unicode);
1952
1953        // Space-separated variant
1954        let config = Config::from_connection_string(
1955            "Server=localhost;Send String Parameters As Unicode=false;",
1956        )
1957        .unwrap();
1958        assert!(!config.send_string_parameters_as_unicode);
1959
1960        // Enabled explicitly
1961        let config =
1962            Config::from_connection_string("Server=localhost;SendStringParametersAsUnicode=true;")
1963                .unwrap();
1964        assert!(config.send_string_parameters_as_unicode);
1965
1966        // Default is true
1967        let config = Config::from_connection_string("Server=localhost;").unwrap();
1968        assert!(config.send_string_parameters_as_unicode);
1969    }
1970
1971    #[test]
1972    fn test_send_string_parameters_as_unicode_builder() {
1973        let config = Config::new().send_string_parameters_as_unicode(false);
1974        assert!(!config.send_string_parameters_as_unicode);
1975
1976        let config = Config::new().send_string_parameters_as_unicode(true);
1977        assert!(config.send_string_parameters_as_unicode);
1978    }
1979
1980    #[test]
1981    fn test_send_string_parameters_as_unicode_invalid_value() {
1982        let result = Config::from_connection_string(
1983            "Server=localhost;SendStringParametersAsUnicode=banana;",
1984        );
1985        assert!(result.is_err());
1986    }
1987
1988    #[test]
1989    fn test_statement_cache_default_off() {
1990        assert!(!Config::new().statement_cache);
1991    }
1992
1993    #[test]
1994    fn test_statement_cache_connection_string() {
1995        let config =
1996            Config::from_connection_string("Server=localhost;Statement Cache=true;").unwrap();
1997        assert!(config.statement_cache);
1998
1999        let config =
2000            Config::from_connection_string("Server=localhost;StatementCache=false;").unwrap();
2001        assert!(!config.statement_cache);
2002    }
2003
2004    #[test]
2005    fn test_statement_cache_builder() {
2006        assert!(Config::new().with_statement_cache(true).statement_cache);
2007        assert!(!Config::new().with_statement_cache(false).statement_cache);
2008    }
2009
2010    #[test]
2011    fn test_statement_cache_invalid_value() {
2012        let result = Config::from_connection_string("Server=localhost;Statement Cache=banana;");
2013        assert!(result.is_err());
2014    }
2015
2016    #[test]
2017    fn test_empty_values_become_none() {
2018        // Per ADO.NET, empty values reset optional fields to default (None)
2019        let config =
2020            Config::from_connection_string("Server=localhost;Database=;Language=;").unwrap();
2021        assert_eq!(config.database, None);
2022        assert_eq!(config.language, None);
2023    }
2024}