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    /// Always Encrypted configuration.
258    ///
259    /// When `Some`, the client will negotiate Always Encrypted support with the
260    /// server and transparently decrypt encrypted column values in result sets.
261    ///
262    /// Set via `Column Encryption Setting=Enabled` in connection strings, or
263    /// programmatically via [`Config::with_column_encryption`].
264    ///
265    /// Wrapped in `Arc` because `EncryptionConfig` contains trait objects (key store
266    /// providers) which cannot implement `Clone`. The `Arc` allows `Config` to remain
267    /// `Clone` while sharing the encryption configuration.
268    #[cfg(feature = "always-encrypted")]
269    pub column_encryption: Option<std::sync::Arc<crate::encryption::EncryptionConfig>>,
270}
271
272impl Default for Config {
273    fn default() -> Self {
274        let timeouts = TimeoutConfig::default();
275        Self {
276            host: "localhost".to_string(),
277            port: 1433,
278            database: None,
279            credentials: Credentials::sql_server("", ""),
280            #[cfg(feature = "tls")]
281            tls: TlsConfig::default(),
282            application_name: "mssql-client".to_string(),
283            connect_timeout: timeouts.connect_timeout,
284            command_timeout: timeouts.command_timeout,
285            packet_size: 4096,
286            strict_mode: false,
287            trust_server_certificate: false,
288            instance: None,
289            mars: false,
290            encrypt: true, // Default to encrypted for security
291            no_tls: false, // Never plaintext by default
292            redirect: RedirectConfig::default(),
293            retry: RetryPolicy::default(),
294            timeouts,
295            tds_version: TdsVersion::V7_4, // Default to TDS 7.4 for broad compatibility
296            application_intent: ApplicationIntent::default(),
297            workstation_id: None,
298            language: None,
299            #[cfg(feature = "always-encrypted")]
300            column_encryption: None,
301        }
302    }
303}
304
305impl Config {
306    /// Create a new configuration with default values.
307    #[must_use]
308    pub fn new() -> Self {
309        Self::default()
310    }
311
312    /// Parse a connection string into configuration.
313    ///
314    /// Supports ADO.NET-style connection strings with full quoting support:
315    /// ```text
316    /// Server=localhost;Database=mydb;User Id=sa;Password="complex;pass";
317    /// ```
318    ///
319    /// Values containing semicolons can be enclosed in double or single quotes
320    /// per the ADO.NET specification. The `tcp:` prefix from Azure Portal
321    /// connection strings is automatically stripped.
322    pub fn from_connection_string(conn_str: &str) -> Result<Self, crate::error::Error> {
323        let mut config = Self::default();
324        let pairs = split_connection_string(conn_str)?;
325
326        for (key, value) in &pairs {
327            let key = key.trim().to_lowercase();
328            let value = value.trim();
329
330            match key.as_str() {
331                // --- Server / Data Source (ADO.NET aliases: Addr, Address, Network Address) ---
332                "server" | "data source" | "addr" | "address" | "network address" | "host" => {
333                    // Strip tcp: prefix (common in Azure Portal connection strings).
334                    // Reject np: (Named Pipes) and lpc: (Shared Memory) — not supported.
335                    // All prefix checks are case-insensitive per ADO.NET conventions.
336                    let lower_value = value.to_lowercase();
337                    let server_value = if lower_value.starts_with("tcp:") {
338                        &value[4..]
339                    } else if lower_value.starts_with("np:") {
340                        return Err(crate::error::Error::Config(
341                            "Named Pipes connections (np:) are not supported. Use TCP connections instead."
342                                .into(),
343                        ));
344                    } else if lower_value.starts_with("lpc:") {
345                        return Err(crate::error::Error::Config(
346                            "Shared Memory connections (lpc:) are not supported. Use TCP connections instead."
347                                .into(),
348                        ));
349                    } else {
350                        value
351                    };
352
353                    // Handle host,port or host\instance format
354                    if let Some((host, port_or_instance)) = server_value.split_once(',') {
355                        config.host = host.to_string();
356                        config.port = port_or_instance.trim().parse().map_err(|_| {
357                            crate::error::Error::Config(format!("invalid port: {port_or_instance}"))
358                        })?;
359                    } else if let Some((host, instance)) = server_value.split_once('\\') {
360                        config.host = host.to_string();
361                        config.instance = non_empty(instance);
362                    } else {
363                        config.host = server_value.to_string();
364                    }
365                }
366                "port" => {
367                    config.port = value.parse().map_err(|_| {
368                        crate::error::Error::Config(format!("invalid port: {value}"))
369                    })?;
370                }
371                // --- Database ---
372                "database" | "initial catalog" => {
373                    config.database = non_empty(value);
374                }
375                // --- Credentials ---
376                "user id" | "uid" | "user" => {
377                    if let Credentials::SqlServer { password, .. } = &config.credentials {
378                        config.credentials =
379                            Credentials::sql_server(value.to_string(), password.clone());
380                    }
381                }
382                "password" | "pwd" => {
383                    if let Credentials::SqlServer { username, .. } = &config.credentials {
384                        config.credentials =
385                            Credentials::sql_server(username.clone(), value.to_string());
386                    }
387                }
388                // --- Application ---
389                "application name" | "app" => {
390                    config.application_name = value.to_string();
391                }
392                "applicationintent" | "application intent" => {
393                    config.application_intent = match value.to_lowercase().as_str() {
394                        "readonly" => ApplicationIntent::ReadOnly,
395                        "readwrite" => ApplicationIntent::ReadWrite,
396                        _ => {
397                            return Err(crate::error::Error::Config(format!(
398                                "invalid ApplicationIntent: '{value}' (expected ReadOnly or ReadWrite)"
399                            )));
400                        }
401                    };
402                }
403                "workstation id" | "wsid" => {
404                    config.workstation_id = non_empty(value);
405                }
406                "current language" | "language" => {
407                    config.language = non_empty(value);
408                }
409                // --- Timeouts (ADO.NET alias: Timeout) ---
410                "connect timeout" | "connection timeout" | "timeout" => {
411                    let secs: u64 = value.parse().map_err(|_| {
412                        crate::error::Error::Config(format!("invalid timeout: {value}"))
413                    })?;
414                    config.connect_timeout = Duration::from_secs(secs);
415                }
416                "command timeout" => {
417                    let secs: u64 = value.parse().map_err(|_| {
418                        crate::error::Error::Config(format!("invalid timeout: {value}"))
419                    })?;
420                    config.command_timeout = Duration::from_secs(secs);
421                }
422                // --- Security ---
423                "trustservercertificate" | "trust server certificate" => {
424                    config.trust_server_certificate = parse_conn_bool(&key, value)?;
425                }
426                "encrypt" => {
427                    // Encrypt supports several non-boolean values beyond true/false:
428                    // - "strict" = TDS 8.0 strict mode (always encrypted transport)
429                    // - "mandatory" / "true" / "yes" / "1" = require TLS
430                    // - "optional" / "false" / "no" / "0" = TLS only if server requires
431                    // - "no_tls" = Tiberius-compatible plaintext mode for legacy servers
432                    //
433                    // "mandatory" and "optional" are Microsoft.Data.SqlClient v5+ aliases.
434                    if value.eq_ignore_ascii_case("strict") {
435                        config.strict_mode = true;
436                        config.encrypt = true;
437                        config.no_tls = false;
438                    } else if value.eq_ignore_ascii_case("mandatory") {
439                        config.encrypt = true;
440                        config.no_tls = false;
441                    } else if value.eq_ignore_ascii_case("optional") {
442                        config.encrypt = false;
443                        config.no_tls = false;
444                    } else if value.eq_ignore_ascii_case("no_tls") {
445                        config.no_tls = true;
446                        config.encrypt = false;
447                    } else {
448                        // Standard boolean values (true/false/yes/no/1/0)
449                        let enabled = parse_conn_bool(&key, value)?;
450                        config.encrypt = enabled;
451                        config.no_tls = false;
452                    }
453                }
454                "integrated security" | "trusted_connection" => {
455                    // Accepts standard booleans + "sspi" (ADO.NET strongly-recommended value)
456                    let enabled =
457                        value.eq_ignore_ascii_case("sspi") || parse_conn_bool(&key, value)?;
458                    if enabled {
459                        #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
460                        {
461                            config.credentials = Credentials::Integrated;
462                        }
463                        #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
464                        {
465                            return Err(crate::error::Error::Config(
466                                "Integrated Security requires the 'integrated-auth' (Linux/macOS) \
467                                 or 'sspi-auth' (Windows) feature to be enabled"
468                                    .into(),
469                            ));
470                        }
471                    }
472                }
473                // --- Always Encrypted ---
474                "column encryption setting" | "columnencryptionsetting" => {
475                    #[cfg(feature = "always-encrypted")]
476                    if value.eq_ignore_ascii_case("enabled") {
477                        config.column_encryption = Some(std::sync::Arc::new(
478                            crate::encryption::EncryptionConfig::new(),
479                        ));
480                    }
481                    #[cfg(not(feature = "always-encrypted"))]
482                    if value.eq_ignore_ascii_case("enabled") {
483                        return Err(crate::error::Error::Config(
484                            "Column Encryption Setting=Enabled requires the 'always-encrypted' feature. \
485                             Enable it in your Cargo.toml: mssql-client = { features = [\"always-encrypted\"] }"
486                                .to_string(),
487                        ));
488                    }
489                }
490                // --- Protocol ---
491                "multipleactiveresultsets" | "mars" => {
492                    config.mars = parse_conn_bool(&key, value)?;
493                }
494                "packet size" => {
495                    config.packet_size = value.parse().map_err(|_| {
496                        crate::error::Error::Config(format!("invalid packet size: {value}"))
497                    })?;
498                }
499                "tdsversion" | "tds version" | "protocolversion" | "protocol version" => {
500                    config.tds_version = TdsVersion::parse(value).ok_or_else(|| {
501                        crate::error::Error::Config(format!(
502                            "invalid TDS version: {value}. Supported values: 7.3, 7.3A, 7.3B, 7.4, 8.0"
503                        ))
504                    })?;
505                    if config.tds_version.is_tds_8() {
506                        config.strict_mode = true;
507                    }
508                }
509                // --- Connection resiliency ---
510                "connectretrycount" | "connect retry count" => {
511                    config.retry.max_retries = value.parse().map_err(|_| {
512                        crate::error::Error::Config(format!("invalid ConnectRetryCount: '{value}'"))
513                    })?;
514                }
515                "connectretryinterval" | "connect retry interval" => {
516                    let secs: u64 = value.parse().map_err(|_| {
517                        crate::error::Error::Config(format!(
518                            "invalid ConnectRetryInterval: '{value}'"
519                        ))
520                    })?;
521                    config.retry.initial_backoff = Duration::from_secs(secs);
522                }
523                // --- Pool keywords: recognized but must be set via PoolConfig ---
524                "max pool size"
525                | "min pool size"
526                | "pooling"
527                | "connection lifetime"
528                | "load balance timeout" => {
529                    tracing::info!(
530                        key = key.as_str(),
531                        value = value,
532                        "connection string keyword '{}' is recognized but pool settings \
533                         must be configured via PoolConfig, not the connection string",
534                        key,
535                    );
536                }
537                // --- Known ADO.NET keywords not supported by this driver ---
538                "failover partner"
539                | "multisubnetfailover"
540                | "multi subnet failover"
541                | "persist security info"
542                | "persistsecurityinfo"
543                | "enlist"
544                | "replication"
545                | "transaction binding"
546                | "type system version"
547                | "user instance"
548                | "attachdbfilename"
549                | "extended properties"
550                | "initial file name"
551                | "context connection"
552                | "network library"
553                | "network"
554                | "net"
555                | "asynchronous processing"
556                | "async"
557                | "transparentnetworkipresolution"
558                | "poolblockingperiod"
559                | "authentication"
560                | "hostnameincertificate"
561                | "servercertificate" => {
562                    tracing::info!(
563                        key = key.as_str(),
564                        value = value,
565                        "connection string keyword '{}' is recognized but not supported by this driver",
566                        key,
567                    );
568                }
569                _ => {
570                    tracing::debug!(
571                        key = key.as_str(),
572                        value = value,
573                        "ignoring unknown connection string option"
574                    );
575                }
576            }
577        }
578
579        Ok(config)
580    }
581
582    /// Set the server host.
583    #[must_use]
584    pub fn host(mut self, host: impl Into<String>) -> Self {
585        self.host = host.into();
586        self
587    }
588
589    /// Set the server port.
590    #[must_use]
591    pub fn port(mut self, port: u16) -> Self {
592        self.port = port;
593        self
594    }
595
596    /// Set the database name.
597    #[must_use]
598    pub fn database(mut self, database: impl Into<String>) -> Self {
599        self.database = Some(database.into());
600        self
601    }
602
603    /// Set the credentials.
604    #[must_use]
605    pub fn credentials(mut self, credentials: Credentials) -> Self {
606        self.credentials = credentials;
607        self
608    }
609
610    /// Set the application name.
611    #[must_use]
612    pub fn application_name(mut self, name: impl Into<String>) -> Self {
613        self.application_name = name.into();
614        self
615    }
616
617    /// Set the connect timeout.
618    #[must_use]
619    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
620        self.connect_timeout = timeout;
621        self
622    }
623
624    /// Set trust server certificate option.
625    #[must_use]
626    pub fn trust_server_certificate(mut self, trust: bool) -> Self {
627        self.trust_server_certificate = trust;
628        #[cfg(feature = "tls")]
629        {
630            self.tls = self.tls.trust_server_certificate(trust);
631        }
632        self
633    }
634
635    /// Enable TDS 8.0 strict mode.
636    #[must_use]
637    pub fn strict_mode(mut self, enabled: bool) -> Self {
638        self.strict_mode = enabled;
639        #[cfg(feature = "tls")]
640        {
641            self.tls = self.tls.strict_mode(enabled);
642        }
643        if enabled {
644            self.tds_version = TdsVersion::V8_0;
645        }
646        self
647    }
648
649    /// Set the TDS protocol version.
650    ///
651    /// This specifies which TDS protocol version to request during connection.
652    /// The server may negotiate a lower version if it doesn't support the requested version.
653    ///
654    /// # Examples
655    ///
656    /// ```no_run
657    /// use mssql_client::Config;
658    /// use tds_protocol::version::TdsVersion;
659    ///
660    /// // Connect to SQL Server 2008
661    /// let config = Config::new()
662    ///     .host("legacy-server")
663    ///     .tds_version(TdsVersion::V7_3A);
664    ///
665    /// // Connect to SQL Server 2008 R2
666    /// let config = Config::new()
667    ///     .host("legacy-server")
668    ///     .tds_version(TdsVersion::V7_3B);
669    /// ```
670    ///
671    /// Note: When `strict_mode` is enabled, this is ignored and TDS 8.0 is used.
672    #[must_use]
673    pub fn tds_version(mut self, version: TdsVersion) -> Self {
674        self.tds_version = version;
675        // If TDS 8.0 is requested, automatically enable strict mode
676        if version.is_tds_8() {
677            self.strict_mode = true;
678            #[cfg(feature = "tls")]
679            {
680                self.tls = self.tls.strict_mode(true);
681            }
682        }
683        self
684    }
685
686    /// Enable or disable TLS encryption.
687    ///
688    /// When `true` (default), the connection will use TLS encryption.
689    /// When `false`, encryption is used only if the server requires it.
690    ///
691    /// **Warning:** Disabling encryption is insecure and should only be
692    /// used for development/testing on trusted networks.
693    #[must_use]
694    pub fn encrypt(mut self, enabled: bool) -> Self {
695        self.encrypt = enabled;
696        self
697    }
698
699    /// Disable TLS entirely and connect with plaintext (Tiberius-compatible).
700    ///
701    /// **⚠️ SECURITY WARNING:** This completely disables TLS/SSL encryption.
702    /// Credentials and all data will be transmitted in plaintext over the network.
703    ///
704    /// # When to use this
705    ///
706    /// This option exists for compatibility with legacy SQL Server versions
707    /// (2008 and earlier) that may only support TLS 1.0/1.1. Modern TLS libraries
708    /// like rustls require TLS 1.2 or higher for security reasons, making it
709    /// impossible to establish encrypted connections to these older servers.
710    ///
711    /// # Security implications
712    ///
713    /// When enabled:
714    /// - Login credentials are sent in plaintext
715    /// - All query data is transmitted without encryption
716    /// - Network traffic can be intercepted and read by attackers
717    ///
718    /// **Only use this for development/testing on isolated, trusted networks.**
719    ///
720    /// # Example
721    ///
722    /// ```rust,ignore
723    /// // Connection string (Tiberius-compatible)
724    /// let config = Config::from_connection_string(
725    ///     "Server=legacy-server;User Id=sa;Password=secret;Encrypt=no_tls"
726    /// )?;
727    ///
728    /// // Builder API
729    /// let config = Config::new()
730    ///     .host("legacy-server")
731    ///     .no_tls(true);
732    /// ```
733    #[must_use]
734    pub fn no_tls(mut self, enabled: bool) -> Self {
735        self.no_tls = enabled;
736        if enabled {
737            self.encrypt = false;
738        }
739        self
740    }
741
742    /// Enable Always Encrypted with the given encryption configuration.
743    ///
744    /// When enabled, the client will negotiate Always Encrypted support during
745    /// connection and transparently decrypt encrypted column values.
746    ///
747    /// # Example
748    ///
749    /// ```rust,ignore
750    /// use mssql_client::{Config, EncryptionConfig};
751    /// use mssql_auth::InMemoryKeyStore;
752    ///
753    /// let config = Config::new()
754    ///     .with_column_encryption(
755    ///         EncryptionConfig::new().with_provider(key_store)
756    ///     );
757    /// ```
758    #[cfg(feature = "always-encrypted")]
759    #[must_use]
760    pub fn with_column_encryption(mut self, config: crate::encryption::EncryptionConfig) -> Self {
761        self.column_encryption = Some(std::sync::Arc::new(config));
762        self
763    }
764
765    /// Create a new configuration with a different host (for routing).
766    #[must_use]
767    pub fn with_host(mut self, host: &str) -> Self {
768        self.host = host.to_string();
769        self
770    }
771
772    /// Create a new configuration with a different port (for routing).
773    #[must_use]
774    pub fn with_port(mut self, port: u16) -> Self {
775        self.port = port;
776        self
777    }
778
779    /// Set the redirect handling configuration.
780    #[must_use]
781    pub fn redirect(mut self, redirect: RedirectConfig) -> Self {
782        self.redirect = redirect;
783        self
784    }
785
786    /// Set the maximum number of redirect attempts.
787    #[must_use]
788    pub fn max_redirects(mut self, max: u8) -> Self {
789        self.redirect.max_redirects = max;
790        self
791    }
792
793    /// Set the retry policy for transient error handling.
794    #[must_use]
795    pub fn retry(mut self, retry: RetryPolicy) -> Self {
796        self.retry = retry;
797        self
798    }
799
800    /// Set the maximum number of retry attempts.
801    #[must_use]
802    pub fn max_retries(mut self, max: u32) -> Self {
803        self.retry.max_retries = max;
804        self
805    }
806
807    /// Set the timeout configuration.
808    #[must_use]
809    pub fn timeouts(mut self, timeouts: TimeoutConfig) -> Self {
810        // Sync the legacy fields for backward compatibility first
811        self.connect_timeout = timeouts.connect_timeout;
812        self.command_timeout = timeouts.command_timeout;
813        self.timeouts = timeouts;
814        self
815    }
816
817    /// Set the application workload intent for AlwaysOn AG routing.
818    #[must_use]
819    pub fn application_intent(mut self, intent: ApplicationIntent) -> Self {
820        self.application_intent = intent;
821        self
822    }
823
824    /// Set the client workstation name sent to SQL Server in LOGIN7.
825    ///
826    /// This appears in `sys.dm_exec_sessions.host_name` for auditing.
827    /// When not set, the driver sends the machine hostname automatically.
828    #[must_use]
829    pub fn workstation_id(mut self, id: impl Into<String>) -> Self {
830        self.workstation_id = Some(id.into());
831        self
832    }
833
834    /// Set the session language for server messages.
835    ///
836    /// The language name can be up to 128 characters (e.g., `"us_english"`).
837    #[must_use]
838    pub fn language(mut self, lang: impl Into<String>) -> Self {
839        self.language = Some(lang.into());
840        self
841    }
842}
843
844#[cfg(test)]
845#[allow(clippy::unwrap_used)]
846mod tests {
847    use super::*;
848
849    #[test]
850    fn test_connection_string_parsing() {
851        let config = Config::from_connection_string(
852            "Server=localhost;Database=test;User Id=sa;Password=secret;",
853        )
854        .unwrap();
855
856        assert_eq!(config.host, "localhost");
857        assert_eq!(config.database, Some("test".to_string()));
858    }
859
860    #[test]
861    fn test_connection_string_with_port() {
862        let config =
863            Config::from_connection_string("Server=localhost,1434;Database=test;").unwrap();
864
865        assert_eq!(config.host, "localhost");
866        assert_eq!(config.port, 1434);
867    }
868
869    #[test]
870    fn test_connection_string_with_instance() {
871        let config =
872            Config::from_connection_string("Server=localhost\\SQLEXPRESS;Database=test;").unwrap();
873
874        assert_eq!(config.host, "localhost");
875        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
876    }
877
878    #[test]
879    fn test_connection_string_dot_instance() {
880        // "." is a standard ADO.NET alias for localhost
881        let config = Config::from_connection_string("Server=.\\SQLEXPRESS;Database=test;").unwrap();
882
883        assert_eq!(config.host, ".");
884        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
885    }
886
887    #[test]
888    fn test_connection_string_local_instance() {
889        // "(local)" is a standard ADO.NET alias for localhost
890        let config =
891            Config::from_connection_string("Server=(local)\\SQLEXPRESS;Database=test;").unwrap();
892
893        assert_eq!(config.host, "(local)");
894        assert_eq!(config.instance, Some("SQLEXPRESS".to_string()));
895    }
896
897    #[test]
898    fn test_redirect_config_defaults() {
899        let config = RedirectConfig::default();
900        assert_eq!(config.max_redirects, 2);
901        assert!(config.follow_redirects);
902    }
903
904    #[test]
905    fn test_redirect_config_builder() {
906        let config = RedirectConfig::new()
907            .max_redirects(5)
908            .follow_redirects(false);
909        assert_eq!(config.max_redirects, 5);
910        assert!(!config.follow_redirects);
911    }
912
913    #[test]
914    fn test_redirect_config_no_follow() {
915        let config = RedirectConfig::no_follow();
916        assert_eq!(config.max_redirects, 0);
917        assert!(!config.follow_redirects);
918    }
919
920    #[test]
921    fn test_config_redirect_builder() {
922        let config = Config::new().max_redirects(3);
923        assert_eq!(config.redirect.max_redirects, 3);
924
925        let config2 = Config::new().redirect(RedirectConfig::no_follow());
926        assert!(!config2.redirect.follow_redirects);
927    }
928
929    #[test]
930    fn test_retry_policy_defaults() {
931        let policy = RetryPolicy::default();
932        assert_eq!(policy.max_retries, 3);
933        assert_eq!(policy.initial_backoff, Duration::from_millis(100));
934        assert_eq!(policy.max_backoff, Duration::from_secs(30));
935        assert!((policy.backoff_multiplier - 2.0).abs() < f64::EPSILON);
936        assert!(policy.jitter);
937    }
938
939    #[test]
940    fn test_retry_policy_builder() {
941        let policy = RetryPolicy::new()
942            .max_retries(5)
943            .initial_backoff(Duration::from_millis(200))
944            .max_backoff(Duration::from_secs(60))
945            .backoff_multiplier(3.0)
946            .jitter(false);
947
948        assert_eq!(policy.max_retries, 5);
949        assert_eq!(policy.initial_backoff, Duration::from_millis(200));
950        assert_eq!(policy.max_backoff, Duration::from_secs(60));
951        assert!((policy.backoff_multiplier - 3.0).abs() < f64::EPSILON);
952        assert!(!policy.jitter);
953    }
954
955    #[test]
956    fn test_retry_policy_no_retry() {
957        let policy = RetryPolicy::no_retry();
958        assert_eq!(policy.max_retries, 0);
959        assert!(!policy.should_retry(0));
960    }
961
962    #[test]
963    fn test_retry_policy_should_retry() {
964        let policy = RetryPolicy::new().max_retries(3);
965        assert!(policy.should_retry(0));
966        assert!(policy.should_retry(1));
967        assert!(policy.should_retry(2));
968        assert!(!policy.should_retry(3));
969        assert!(!policy.should_retry(4));
970    }
971
972    #[test]
973    fn test_retry_policy_backoff_calculation() {
974        let policy = RetryPolicy::new()
975            .initial_backoff(Duration::from_millis(100))
976            .backoff_multiplier(2.0)
977            .max_backoff(Duration::from_secs(10))
978            .jitter(false);
979
980        assert_eq!(policy.backoff_for_attempt(0), Duration::ZERO);
981        assert_eq!(policy.backoff_for_attempt(1), Duration::from_millis(100));
982        assert_eq!(policy.backoff_for_attempt(2), Duration::from_millis(200));
983        assert_eq!(policy.backoff_for_attempt(3), Duration::from_millis(400));
984    }
985
986    #[test]
987    fn test_retry_policy_backoff_capped() {
988        let policy = RetryPolicy::new()
989            .initial_backoff(Duration::from_secs(1))
990            .backoff_multiplier(10.0)
991            .max_backoff(Duration::from_secs(5))
992            .jitter(false);
993
994        // Attempt 3 would be 1s * 10^2 = 100s, but capped at 5s
995        assert_eq!(policy.backoff_for_attempt(3), Duration::from_secs(5));
996    }
997
998    #[test]
999    fn test_config_retry_builder() {
1000        let config = Config::new().max_retries(5);
1001        assert_eq!(config.retry.max_retries, 5);
1002
1003        let config2 = Config::new().retry(RetryPolicy::no_retry());
1004        assert_eq!(config2.retry.max_retries, 0);
1005    }
1006
1007    #[test]
1008    fn test_timeout_config_defaults() {
1009        let config = TimeoutConfig::default();
1010        assert_eq!(config.connect_timeout, Duration::from_secs(15));
1011        assert_eq!(config.tls_timeout, Duration::from_secs(10));
1012        assert_eq!(config.login_timeout, Duration::from_secs(30));
1013        assert_eq!(config.command_timeout, Duration::from_secs(30));
1014        assert_eq!(config.idle_timeout, Duration::from_secs(300));
1015        assert_eq!(config.keepalive_interval, Some(Duration::from_secs(30)));
1016    }
1017
1018    #[test]
1019    fn test_timeout_config_builder() {
1020        let config = TimeoutConfig::new()
1021            .connect_timeout(Duration::from_secs(5))
1022            .tls_timeout(Duration::from_secs(3))
1023            .login_timeout(Duration::from_secs(10))
1024            .command_timeout(Duration::from_secs(60))
1025            .idle_timeout(Duration::from_secs(600))
1026            .keepalive_interval(Some(Duration::from_secs(60)));
1027
1028        assert_eq!(config.connect_timeout, Duration::from_secs(5));
1029        assert_eq!(config.tls_timeout, Duration::from_secs(3));
1030        assert_eq!(config.login_timeout, Duration::from_secs(10));
1031        assert_eq!(config.command_timeout, Duration::from_secs(60));
1032        assert_eq!(config.idle_timeout, Duration::from_secs(600));
1033        assert_eq!(config.keepalive_interval, Some(Duration::from_secs(60)));
1034    }
1035
1036    #[test]
1037    fn test_timeout_config_no_keepalive() {
1038        let config = TimeoutConfig::new().no_keepalive();
1039        assert_eq!(config.keepalive_interval, None);
1040    }
1041
1042    #[test]
1043    fn test_timeout_config_total_connect() {
1044        let config = TimeoutConfig::new()
1045            .connect_timeout(Duration::from_secs(5))
1046            .tls_timeout(Duration::from_secs(3))
1047            .login_timeout(Duration::from_secs(10));
1048
1049        // 5 + 3 + 10 = 18 seconds
1050        assert_eq!(config.total_connect_timeout(), Duration::from_secs(18));
1051    }
1052
1053    #[test]
1054    fn test_config_timeouts_builder() {
1055        let timeouts = TimeoutConfig::new()
1056            .connect_timeout(Duration::from_secs(5))
1057            .command_timeout(Duration::from_secs(60));
1058
1059        let config = Config::new().timeouts(timeouts);
1060        assert_eq!(config.timeouts.connect_timeout, Duration::from_secs(5));
1061        assert_eq!(config.timeouts.command_timeout, Duration::from_secs(60));
1062        // Check that legacy fields are synced
1063        assert_eq!(config.connect_timeout, Duration::from_secs(5));
1064        assert_eq!(config.command_timeout, Duration::from_secs(60));
1065    }
1066
1067    #[test]
1068    fn test_tds_version_default() {
1069        let config = Config::default();
1070        assert_eq!(config.tds_version, TdsVersion::V7_4);
1071        assert!(!config.strict_mode);
1072    }
1073
1074    #[test]
1075    fn test_tds_version_builder() {
1076        let config = Config::new().tds_version(TdsVersion::V7_3A);
1077        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1078        assert!(!config.strict_mode);
1079
1080        let config = Config::new().tds_version(TdsVersion::V7_3B);
1081        assert_eq!(config.tds_version, TdsVersion::V7_3B);
1082        assert!(!config.strict_mode);
1083
1084        // TDS 8.0 should automatically enable strict mode
1085        let config = Config::new().tds_version(TdsVersion::V8_0);
1086        assert_eq!(config.tds_version, TdsVersion::V8_0);
1087        assert!(config.strict_mode);
1088    }
1089
1090    #[test]
1091    fn test_strict_mode_sets_tds_8() {
1092        let config = Config::new().strict_mode(true);
1093        assert!(config.strict_mode);
1094        assert_eq!(config.tds_version, TdsVersion::V8_0);
1095    }
1096
1097    #[test]
1098    fn test_connection_string_tds_version() {
1099        // Test TDS 7.3
1100        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3;").unwrap();
1101        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1102
1103        // Test TDS 7.3A explicitly
1104        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3A;").unwrap();
1105        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1106
1107        // Test TDS 7.3B
1108        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.3B;").unwrap();
1109        assert_eq!(config.tds_version, TdsVersion::V7_3B);
1110
1111        // Test TDS 7.4
1112        let config = Config::from_connection_string("Server=localhost;TDSVersion=7.4;").unwrap();
1113        assert_eq!(config.tds_version, TdsVersion::V7_4);
1114
1115        // Test TDS 8.0 enables strict mode
1116        let config = Config::from_connection_string("Server=localhost;TDSVersion=8.0;").unwrap();
1117        assert_eq!(config.tds_version, TdsVersion::V8_0);
1118        assert!(config.strict_mode);
1119
1120        // Test alternative key names
1121        let config =
1122            Config::from_connection_string("Server=localhost;ProtocolVersion=7.3;").unwrap();
1123        assert_eq!(config.tds_version, TdsVersion::V7_3A);
1124    }
1125
1126    #[test]
1127    fn test_connection_string_invalid_tds_version() {
1128        let result = Config::from_connection_string("Server=localhost;TDSVersion=invalid;");
1129        assert!(result.is_err());
1130
1131        let result = Config::from_connection_string("Server=localhost;TDSVersion=9.0;");
1132        assert!(result.is_err());
1133    }
1134
1135    #[test]
1136    fn test_connection_string_no_tls() {
1137        // no_tls should disable TLS entirely
1138        let config = Config::from_connection_string("Server=legacy;Encrypt=no_tls;").unwrap();
1139        assert!(config.no_tls);
1140        assert!(!config.encrypt);
1141        assert!(!config.strict_mode);
1142
1143        // Case insensitive
1144        let config = Config::from_connection_string("Server=legacy;Encrypt=no_tls;").unwrap();
1145        assert!(config.no_tls);
1146
1147        // Encrypt=true should disable no_tls
1148        let config = Config::from_connection_string("Server=localhost;Encrypt=true;").unwrap();
1149        assert!(!config.no_tls);
1150        assert!(config.encrypt);
1151
1152        // Encrypt=strict should disable no_tls
1153        let config = Config::from_connection_string("Server=localhost;Encrypt=strict;").unwrap();
1154        assert!(!config.no_tls);
1155        assert!(config.encrypt);
1156        assert!(config.strict_mode);
1157
1158        // Encrypt=mandatory (Microsoft.Data.SqlClient v5+ alias for true)
1159        let config = Config::from_connection_string("Server=localhost;Encrypt=mandatory;").unwrap();
1160        assert!(config.encrypt);
1161        assert!(!config.no_tls);
1162
1163        // Encrypt=optional (Microsoft.Data.SqlClient v5+ alias for false)
1164        let config = Config::from_connection_string("Server=localhost;Encrypt=optional;").unwrap();
1165        assert!(!config.encrypt);
1166        assert!(!config.no_tls);
1167    }
1168
1169    #[test]
1170    fn test_no_tls_builder() {
1171        // Builder method
1172        let config = Config::new().no_tls(true);
1173        assert!(config.no_tls);
1174        assert!(!config.encrypt);
1175
1176        // Disable
1177        let config = Config::new().no_tls(true).no_tls(false);
1178        assert!(!config.no_tls);
1179    }
1180
1181    #[test]
1182    #[cfg(any(feature = "integrated-auth", feature = "sspi-auth"))]
1183    fn test_connection_string_integrated_security() {
1184        // "Integrated Security=true" should set Credentials::Integrated
1185        let config =
1186            Config::from_connection_string("Server=localhost;Integrated Security=true;").unwrap();
1187        assert_eq!(
1188            config.credentials.method_name(),
1189            "Integrated Authentication"
1190        );
1191
1192        // "yes" variant
1193        let config =
1194            Config::from_connection_string("Server=localhost;Integrated Security=yes;").unwrap();
1195        assert_eq!(
1196            config.credentials.method_name(),
1197            "Integrated Authentication"
1198        );
1199
1200        // "sspi" variant
1201        let config =
1202            Config::from_connection_string("Server=localhost;Integrated Security=sspi;").unwrap();
1203        assert_eq!(
1204            config.credentials.method_name(),
1205            "Integrated Authentication"
1206        );
1207
1208        // "1" variant
1209        let config =
1210            Config::from_connection_string("Server=localhost;Integrated Security=1;").unwrap();
1211        assert_eq!(
1212            config.credentials.method_name(),
1213            "Integrated Authentication"
1214        );
1215
1216        // Trusted_Connection synonym
1217        let config =
1218            Config::from_connection_string("Server=localhost;Trusted_Connection=true;").unwrap();
1219        assert_eq!(
1220            config.credentials.method_name(),
1221            "Integrated Authentication"
1222        );
1223    }
1224
1225    #[test]
1226    #[cfg(not(any(feature = "integrated-auth", feature = "sspi-auth")))]
1227    fn test_connection_string_integrated_security_without_feature() {
1228        // Should return an error when the feature is not enabled
1229        let result = Config::from_connection_string("Server=localhost;Integrated Security=true;");
1230        assert!(result.is_err());
1231        let err = result.unwrap_err().to_string();
1232        assert!(err.contains("integrated-auth"));
1233    }
1234
1235    // =======================================================================
1236    // ADO.NET conformance tests (quoted values, aliases, boolean validation)
1237    // =======================================================================
1238
1239    #[test]
1240    fn test_parse_conn_bool_all_values() {
1241        assert!(parse_conn_bool("test", "true").unwrap());
1242        assert!(parse_conn_bool("test", "True").unwrap());
1243        assert!(parse_conn_bool("test", "TRUE").unwrap());
1244        assert!(parse_conn_bool("test", "yes").unwrap());
1245        assert!(parse_conn_bool("test", "Yes").unwrap());
1246        assert!(parse_conn_bool("test", "1").unwrap());
1247
1248        assert!(!parse_conn_bool("test", "false").unwrap());
1249        assert!(!parse_conn_bool("test", "False").unwrap());
1250        assert!(!parse_conn_bool("test", "FALSE").unwrap());
1251        assert!(!parse_conn_bool("test", "no").unwrap());
1252        assert!(!parse_conn_bool("test", "No").unwrap());
1253        assert!(!parse_conn_bool("test", "0").unwrap());
1254
1255        // Invalid values should error
1256        assert!(parse_conn_bool("test", "banana").is_err());
1257        assert!(parse_conn_bool("test", "tru").is_err());
1258        assert!(parse_conn_bool("test", "").is_err());
1259    }
1260
1261    #[test]
1262    fn test_boolean_validation_trust_server_certificate() {
1263        // Valid boolean → ok
1264        let config =
1265            Config::from_connection_string("Server=localhost;TrustServerCertificate=true;")
1266                .unwrap();
1267        assert!(config.trust_server_certificate);
1268
1269        let config =
1270            Config::from_connection_string("Server=localhost;TrustServerCertificate=no;").unwrap();
1271        assert!(!config.trust_server_certificate);
1272
1273        // Invalid boolean → error (previously silently set to false!)
1274        let result =
1275            Config::from_connection_string("Server=localhost;TrustServerCertificate=banana;");
1276        assert!(result.is_err());
1277        assert!(result.unwrap_err().to_string().contains("invalid boolean"));
1278    }
1279
1280    #[test]
1281    fn test_boolean_validation_mars() {
1282        let config = Config::from_connection_string("Server=localhost;MARS=true;").unwrap();
1283        assert!(config.mars);
1284
1285        // Typo → error instead of silent false
1286        let result = Config::from_connection_string("Server=localhost;MARS=tru;");
1287        assert!(result.is_err());
1288    }
1289
1290    #[test]
1291    fn test_quoted_value_semicolon() {
1292        // Password with semicolons — must be quoted per ADO.NET spec
1293        let config = Config::from_connection_string(
1294            r#"Server=localhost;User Id=sa;Password="my;complex;pass";"#,
1295        )
1296        .unwrap();
1297        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1298            assert_eq!(password.as_ref(), "my;complex;pass");
1299        } else {
1300            unreachable!("expected SqlServer credentials");
1301        }
1302    }
1303
1304    #[test]
1305    fn test_quoted_value_single_quotes() {
1306        let config =
1307            Config::from_connection_string("Server=localhost;User Id=sa;Password='my;pass';")
1308                .unwrap();
1309        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1310            assert_eq!(password.as_ref(), "my;pass");
1311        } else {
1312            unreachable!("expected SqlServer credentials");
1313        }
1314    }
1315
1316    #[test]
1317    fn test_quoted_value_escaped_double_quotes() {
1318        // Doubled quotes → single quote per ADO.NET spec
1319        let config = Config::from_connection_string(
1320            r#"Server=localhost;User Id=sa;Password="has ""quotes""";"#,
1321        )
1322        .unwrap();
1323        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1324            assert_eq!(password.as_ref(), r#"has "quotes""#);
1325        } else {
1326            unreachable!("expected SqlServer credentials");
1327        }
1328    }
1329
1330    #[test]
1331    fn test_quoted_value_escaped_single_quotes() {
1332        let config =
1333            Config::from_connection_string("Server=localhost;User Id=sa;Password='it''s complex';")
1334                .unwrap();
1335        if let mssql_auth::Credentials::SqlServer { password, .. } = &config.credentials {
1336            assert_eq!(password.as_ref(), "it's complex");
1337        } else {
1338            unreachable!("expected SqlServer credentials");
1339        }
1340    }
1341
1342    #[test]
1343    fn test_quoted_value_unterminated() {
1344        let result = Config::from_connection_string(r#"Server=localhost;Password="unterminated;"#);
1345        assert!(result.is_err());
1346        assert!(result.unwrap_err().to_string().contains("unterminated"));
1347    }
1348
1349    #[test]
1350    fn test_tcp_prefix_stripped() {
1351        // Azure Portal format: tcp:hostname,port
1352        let config = Config::from_connection_string(
1353            "Server=tcp:myserver.database.windows.net,1433;Database=mydb;",
1354        )
1355        .unwrap();
1356        assert_eq!(config.host, "myserver.database.windows.net");
1357        assert_eq!(config.port, 1433);
1358    }
1359
1360    #[test]
1361    fn test_tcp_prefix_mixed_case() {
1362        // Protocol prefixes are case-insensitive per ADO.NET
1363        let config = Config::from_connection_string("Server=Tcp:myhost,1433;").unwrap();
1364        assert_eq!(config.host, "myhost");
1365
1366        let config = Config::from_connection_string("Server=TCP:myhost,1433;").unwrap();
1367        assert_eq!(config.host, "myhost");
1368    }
1369
1370    #[test]
1371    fn test_tcp_prefix_with_instance() {
1372        let config =
1373            Config::from_connection_string("Server=tcp:myhost\\INST;Database=test;").unwrap();
1374        assert_eq!(config.host, "myhost");
1375        assert_eq!(config.instance, Some("INST".to_string()));
1376    }
1377
1378    #[test]
1379    fn test_np_prefix_rejected() {
1380        let result =
1381            Config::from_connection_string(r"Server=np:\\myhost\pipe\sql\query;Database=test;");
1382        assert!(result.is_err());
1383        assert!(result.unwrap_err().to_string().contains("Named Pipes"));
1384
1385        // Case-insensitive rejection
1386        let result =
1387            Config::from_connection_string(r"Server=NP:\\myhost\pipe\sql\query;Database=test;");
1388        assert!(result.is_err());
1389    }
1390
1391    #[test]
1392    fn test_lpc_prefix_rejected() {
1393        let result = Config::from_connection_string("Server=lpc:myhost;Database=test;");
1394        assert!(result.is_err());
1395        assert!(result.unwrap_err().to_string().contains("Shared Memory"));
1396    }
1397
1398    #[test]
1399    fn test_server_alias_addr() {
1400        let config = Config::from_connection_string("Addr=myhost;").unwrap();
1401        assert_eq!(config.host, "myhost");
1402    }
1403
1404    #[test]
1405    fn test_server_alias_address() {
1406        let config = Config::from_connection_string("Address=myhost,1434;").unwrap();
1407        assert_eq!(config.host, "myhost");
1408        assert_eq!(config.port, 1434);
1409    }
1410
1411    #[test]
1412    fn test_server_alias_network_address() {
1413        let config = Config::from_connection_string("Network Address=myhost;").unwrap();
1414        assert_eq!(config.host, "myhost");
1415    }
1416
1417    #[test]
1418    fn test_timeout_alias() {
1419        let config = Config::from_connection_string("Server=localhost;Timeout=30;").unwrap();
1420        assert_eq!(config.connect_timeout, Duration::from_secs(30));
1421    }
1422
1423    #[test]
1424    fn test_application_intent_readonly() {
1425        let config =
1426            Config::from_connection_string("Server=localhost;ApplicationIntent=ReadOnly;").unwrap();
1427        assert_eq!(config.application_intent, ApplicationIntent::ReadOnly);
1428    }
1429
1430    #[test]
1431    fn test_application_intent_readwrite() {
1432        let config =
1433            Config::from_connection_string("Server=localhost;Application Intent=ReadWrite;")
1434                .unwrap();
1435        assert_eq!(config.application_intent, ApplicationIntent::ReadWrite);
1436    }
1437
1438    #[test]
1439    fn test_application_intent_invalid() {
1440        let result = Config::from_connection_string("Server=localhost;ApplicationIntent=banana;");
1441        assert!(result.is_err());
1442        assert!(
1443            result
1444                .unwrap_err()
1445                .to_string()
1446                .contains("ApplicationIntent")
1447        );
1448    }
1449
1450    #[test]
1451    fn test_workstation_id() {
1452        let config =
1453            Config::from_connection_string("Server=localhost;Workstation ID=MYPC;").unwrap();
1454        assert_eq!(config.workstation_id, Some("MYPC".to_string()));
1455    }
1456
1457    #[test]
1458    fn test_wsid_alias() {
1459        let config =
1460            Config::from_connection_string("Server=localhost;WSID=MYWORKSTATION;").unwrap();
1461        assert_eq!(config.workstation_id, Some("MYWORKSTATION".to_string()));
1462    }
1463
1464    #[test]
1465    fn test_language() {
1466        let config =
1467            Config::from_connection_string("Server=localhost;Language=us_english;").unwrap();
1468        assert_eq!(config.language, Some("us_english".to_string()));
1469    }
1470
1471    #[test]
1472    fn test_current_language_alias() {
1473        let config =
1474            Config::from_connection_string("Server=localhost;Current Language=Deutsch;").unwrap();
1475        assert_eq!(config.language, Some("Deutsch".to_string()));
1476    }
1477
1478    #[test]
1479    fn test_connect_retry_count() {
1480        let config =
1481            Config::from_connection_string("Server=localhost;ConnectRetryCount=5;").unwrap();
1482        assert_eq!(config.retry.max_retries, 5);
1483    }
1484
1485    #[test]
1486    fn test_connect_retry_interval() {
1487        let config =
1488            Config::from_connection_string("Server=localhost;ConnectRetryInterval=15;").unwrap();
1489        assert_eq!(config.retry.initial_backoff, Duration::from_secs(15));
1490    }
1491
1492    #[test]
1493    fn test_pool_keywords_accepted_without_error() {
1494        // Pool keywords should be recognized (not error) but not affect Config
1495        let result = Config::from_connection_string(
1496            "Server=localhost;Max Pool Size=10;Min Pool Size=2;Pooling=true;",
1497        );
1498        assert!(result.is_ok());
1499    }
1500
1501    #[test]
1502    fn test_known_unsupported_keywords_accepted() {
1503        // Known ADO.NET keywords we don't support should not error
1504        let result = Config::from_connection_string(
1505            "Server=localhost;Failover Partner=backup;MultiSubnetFailover=true;",
1506        );
1507        assert!(result.is_ok());
1508    }
1509
1510    #[test]
1511    fn test_application_intent_builder() {
1512        let config = Config::new().application_intent(ApplicationIntent::ReadOnly);
1513        assert_eq!(config.application_intent, ApplicationIntent::ReadOnly);
1514    }
1515
1516    #[test]
1517    fn test_workstation_id_builder() {
1518        let config = Config::new().workstation_id("MY-PC");
1519        assert_eq!(config.workstation_id, Some("MY-PC".to_string()));
1520    }
1521
1522    #[test]
1523    fn test_language_builder() {
1524        let config = Config::new().language("us_english");
1525        assert_eq!(config.language, Some("us_english".to_string()));
1526    }
1527
1528    #[test]
1529    fn test_empty_values_become_none() {
1530        // Per ADO.NET, empty values reset optional fields to default (None)
1531        let config =
1532            Config::from_connection_string("Server=localhost;Database=;Language=;").unwrap();
1533        assert_eq!(config.database, None);
1534        assert_eq!(config.language, None);
1535    }
1536}