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