Skip to main content

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