github-actions[bot] commented on code in PR #4497:
URL: https://github.com/apache/iggy/pull/4497#discussion_r4248247566


##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<duration>
+//     nodelay=true|false
+//
+// The Go client takes its reconnect, heartbeat, and nodelay behavior from
+// its own defaults, so those spellings are validated for compatibility and
+// then ignored; a value the other SDKs would reject is still rejected here,
+// so one connection string is either valid everywhere or nowhere.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",

Review Comment:
   warning: The unsupported-transport error echoes whatever follows `iggy+`, so 
a malformed scheme like `iggy+user:password@​host:8090:​//host:8090` puts the 
credentials into the message. Echo only an ASCII-letter transport name, as 
`foreign/csharp/Iggy_SDK/Configuration/ConnectionString.cs:92` does.



##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<duration>
+//     nodelay=true|false
+//
+// The Go client takes its reconnect, heartbeat, and nodelay behavior from
+// its own defaults, so those spellings are validated for compatibility and
+// then ignored; a value the other SDKs would reject is still rejected here,
+// so one connection string is either valid everywhere or nowhere.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",
+                       ierror.InvalidConnectionString{}, protocol)
+       }
+
+       parts := strings.Split(protocolParts[1], "@")
+       if len(parts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       credentials, err := parseCredentials(parts[0])
+       if err != nil {
+               return nil, err
+       }
+
+       serverAndOptions := strings.Split(parts[1], "?")
+       if len(serverAndOptions) > 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       var tlsConfig parsedTLS
+       if len(serverAndOptions) == 2 {
+               tlsConfig, err = parseConnectionOptions(serverAndOptions[1])
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       serverAddress := serverAndOptions[0]
+       if serverAddress == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       host, portStr, err := net.SplitHostPort(serverAddress)
+       if err != nil || host == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       // Node and Rust accept any u16 port, 0 included; the dial fails later
+       // instead of the parse.
+       if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
+               return nil, ierror.InvalidNumberValue{}
+       }
+
+       options := []tcp.Option{tcp.WithServerAddress(serverAddress)}
+       options = append(options, tcp.WithAutoLogin(credentials))
+       if tlsConfig.enabled {
+               var tlsOpts []tcp.TLSOption
+               if tlsConfig.domain != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSDomain(tlsConfig.domain))
+               }
+               if tlsConfig.caFile != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSCAFile(tlsConfig.caFile))
+               }
+               options = append(options, tcp.WithTLS(tlsOpts...))
+       }
+       return options, nil
+}
+
+// parseProtocol extracts the transport from the scheme, which is either the
+// bare default protocol or an explicit iggy+<transport> spelling.
+func parseProtocol(scheme string) (string, error) {
+       if scheme == defaultProtocol {
+               return tcpProtocol, nil
+       }
+       if !strings.HasPrefix(scheme, protocolPrefix) {
+               return "", ierror.InvalidConnectionString{}
+       }
+       return strings.TrimPrefix(scheme, protocolPrefix), nil
+}
+
+// parseCredentials turns the userinfo before the @ into auto-login
+// credentials: username:password or a single personal access token.
+func parseCredentials(userinfo string) (tcp.Credentials, error) {
+       username, password, hasSeparator := strings.Cut(userinfo, ":")
+       if !hasSeparator {
+               if username == "" {
+                       return tcp.Credentials{}, 
ierror.InvalidConnectionString{}
+               }
+               return tcp.NewPersonalAccessTokenCredentials(username), nil
+       }
+       if username == "" || password == "" {
+               return tcp.Credentials{}, ierror.InvalidConnectionString{}
+       }
+       return tcp.NewUsernamePasswordCredentials(username, password), nil
+}
+
+type parsedTLS struct {
+       enabled bool
+       domain  string
+       caFile  string
+}
+
+// parseConnectionOptions validates the shared connection-string grammar,
+// keeping the tls options and ignoring the reconnect spellings the Go
+// client does not take from a connection string.
+func parseConnectionOptions(optionsString string) (parsedTLS, error) {
+       parsed := parsedTLS{}
+       if optionsString == "" {
+               return parsed, ierror.InvalidConnectionString{}
+       }
+       for _, option := range strings.Split(optionsString, "&") {
+               name, value, found := strings.Cut(option, "=")
+               if !found {
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+               switch name {
+               case "tls":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+                       parsed.enabled = value == "true"
+               case "tls_domain":
+                       parsed.domain = value
+               case "tls_ca_file":
+                       parsed.caFile = value
+               case "reconnection_retries":
+                       if value != "unlimited" {
+                               if _, err := strconv.ParseUint(value, 10, 32); 
err != nil {
+                                       return parsed, 
ierror.InvalidNumberValue{}
+                               }
+                       }
+               case "reconnection_interval", "reestablish_after", 
"heartbeat_interval":
+                       if !isValidIggyDuration(value) {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               case "nodelay":

Review Comment:
   warning: `parseConnectionOptions` validates `nodelay` and drops it, so 
`?nodelay=false` still leaves Nagle disabled, the opposite of what the caller 
asked for. `tcp.WithNoDelay` already exists, so apply the parsed value when the 
key is present.



##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<duration>
+//     nodelay=true|false
+//
+// The Go client takes its reconnect, heartbeat, and nodelay behavior from
+// its own defaults, so those spellings are validated for compatibility and
+// then ignored; a value the other SDKs would reject is still rejected here,
+// so one connection string is either valid everywhere or nowhere.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",
+                       ierror.InvalidConnectionString{}, protocol)
+       }
+
+       parts := strings.Split(protocolParts[1], "@")
+       if len(parts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       credentials, err := parseCredentials(parts[0])
+       if err != nil {
+               return nil, err
+       }
+
+       serverAndOptions := strings.Split(parts[1], "?")
+       if len(serverAndOptions) > 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       var tlsConfig parsedTLS
+       if len(serverAndOptions) == 2 {
+               tlsConfig, err = parseConnectionOptions(serverAndOptions[1])
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       serverAddress := serverAndOptions[0]
+       if serverAddress == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       host, portStr, err := net.SplitHostPort(serverAddress)
+       if err != nil || host == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       // Node and Rust accept any u16 port, 0 included; the dial fails later
+       // instead of the parse.
+       if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
+               return nil, ierror.InvalidNumberValue{}
+       }
+
+       options := []tcp.Option{tcp.WithServerAddress(serverAddress)}
+       options = append(options, tcp.WithAutoLogin(credentials))
+       if tlsConfig.enabled {
+               var tlsOpts []tcp.TLSOption
+               if tlsConfig.domain != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSDomain(tlsConfig.domain))
+               }
+               if tlsConfig.caFile != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSCAFile(tlsConfig.caFile))
+               }
+               options = append(options, tcp.WithTLS(tlsOpts...))
+       }
+       return options, nil
+}
+
+// parseProtocol extracts the transport from the scheme, which is either the
+// bare default protocol or an explicit iggy+<transport> spelling.
+func parseProtocol(scheme string) (string, error) {
+       if scheme == defaultProtocol {
+               return tcpProtocol, nil
+       }
+       if !strings.HasPrefix(scheme, protocolPrefix) {
+               return "", ierror.InvalidConnectionString{}
+       }
+       return strings.TrimPrefix(scheme, protocolPrefix), nil
+}
+
+// parseCredentials turns the userinfo before the @ into auto-login
+// credentials: username:password or a single personal access token.
+func parseCredentials(userinfo string) (tcp.Credentials, error) {
+       username, password, hasSeparator := strings.Cut(userinfo, ":")
+       if !hasSeparator {
+               if username == "" {
+                       return tcp.Credentials{}, 
ierror.InvalidConnectionString{}
+               }
+               return tcp.NewPersonalAccessTokenCredentials(username), nil
+       }
+       if username == "" || password == "" {
+               return tcp.Credentials{}, ierror.InvalidConnectionString{}
+       }
+       return tcp.NewUsernamePasswordCredentials(username, password), nil
+}
+
+type parsedTLS struct {
+       enabled bool
+       domain  string
+       caFile  string
+}
+
+// parseConnectionOptions validates the shared connection-string grammar,
+// keeping the tls options and ignoring the reconnect spellings the Go
+// client does not take from a connection string.
+func parseConnectionOptions(optionsString string) (parsedTLS, error) {
+       parsed := parsedTLS{}
+       if optionsString == "" {
+               return parsed, ierror.InvalidConnectionString{}
+       }
+       for _, option := range strings.Split(optionsString, "&") {
+               name, value, found := strings.Cut(option, "=")
+               if !found {
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+               switch name {
+               case "tls":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+                       parsed.enabled = value == "true"
+               case "tls_domain":
+                       parsed.domain = value
+               case "tls_ca_file":
+                       parsed.caFile = value
+               case "reconnection_retries":
+                       if value != "unlimited" {
+                               if _, err := strconv.ParseUint(value, 10, 32); 
err != nil {
+                                       return parsed, 
ierror.InvalidNumberValue{}
+                               }
+                       }
+               case "reconnection_interval", "reestablish_after", 
"heartbeat_interval":
+                       if !isValidIggyDuration(value) {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               case "nodelay":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               default:
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+       }
+       return parsed, nil
+}
+
+// isValidIggyDuration reports whether value is a duration the Rust SDK
+// accepts for its connection-string options, which the Go SDK reuses for
+// its grammar: the zero spellings, or a humantime-style duration such as
+// 1s, 500ms, 1h 30m or 1h30m.
+func isValidIggyDuration(value string) bool {
+       lower := strings.ToLower(value)
+       switch lower {
+       case "0", "unlimited", "disabled", "none":
+               return true
+       }
+       remainder := strings.TrimLeft(lower, " ")
+       for remainder != "" {
+               amount, rest, ok := splitLeadingNumber(remainder)
+               if !ok || amount <= 0 {
+                       return false
+               }
+               unit, rest := cutDurationUnit(rest)
+               if unit == "" {
+                       return false
+               }
+               // Parts may be separated by spaces, as in 1h 30m, or run
+               // together, as in 1h30m.
+               remainder = strings.TrimLeft(rest, " ")
+       }
+       return true
+}
+
+// splitLeadingNumber consumes the leading decimal digits of s, with an
+// optional fractional part as in 0.5s or 1.005s. A leading sign is
+// rejected, because negative durations do not parse. The value is
+// returned as a number, so a fraction-only amount such as 0.5 still
+// counts as positive.
+func splitLeadingNumber(s string) (float64, string, bool) {
+       digits := 0
+       for digits < len(s) && s[digits] >= '0' && s[digits] <= '9' {
+               digits++
+       }
+       // A fraction only counts when a digit follows the dot, so a bogus
+       // 1.s does not parse and 5. does not either.
+       if digits+2 <= len(s) && s[digits] == '.' && s[digits+1] >= '0' && 
s[digits+1] <= '9' {
+               digits++ // the dot
+               for digits < len(s) && s[digits] >= '0' && s[digits] <= '9' {
+                       digits++
+               }
+       }
+       if digits == 0 {
+               return 0, "", false
+       }
+       n, err := strconv.ParseFloat(s[:digits], 64)
+       if err != nil {
+               return 0, "", false
+       }
+       return n, s[digits:], true
+}
+
+// durationUnits are the humantime units, longest spelling first so a
+// prefix match cannot cut a longer unit short.
+var durationUnits = []string{

Review Comment:
   warning: The unit list misses `hr`, `hrs`, `wk`, `wks` and `month`, so 
`reconnection_interval=1hr` fails here and parses in the Node and C# SDKs. It 
also takes `milliseconds`, which they reject. Take their table, at 
`foreign/csharp/Iggy_SDK/Utils/IggyDurationParser.cs:34`.



##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<duration>
+//     nodelay=true|false
+//
+// The Go client takes its reconnect, heartbeat, and nodelay behavior from
+// its own defaults, so those spellings are validated for compatibility and
+// then ignored; a value the other SDKs would reject is still rejected here,
+// so one connection string is either valid everywhere or nowhere.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",
+                       ierror.InvalidConnectionString{}, protocol)
+       }
+
+       parts := strings.Split(protocolParts[1], "@")
+       if len(parts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       credentials, err := parseCredentials(parts[0])
+       if err != nil {
+               return nil, err
+       }
+
+       serverAndOptions := strings.Split(parts[1], "?")
+       if len(serverAndOptions) > 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       var tlsConfig parsedTLS
+       if len(serverAndOptions) == 2 {
+               tlsConfig, err = parseConnectionOptions(serverAndOptions[1])
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       serverAddress := serverAndOptions[0]
+       if serverAddress == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       host, portStr, err := net.SplitHostPort(serverAddress)
+       if err != nil || host == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       // Node and Rust accept any u16 port, 0 included; the dial fails later
+       // instead of the parse.
+       if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
+               return nil, ierror.InvalidNumberValue{}
+       }
+
+       options := []tcp.Option{tcp.WithServerAddress(serverAddress)}
+       options = append(options, tcp.WithAutoLogin(credentials))
+       if tlsConfig.enabled {
+               var tlsOpts []tcp.TLSOption
+               if tlsConfig.domain != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSDomain(tlsConfig.domain))
+               }
+               if tlsConfig.caFile != "" {
+                       tlsOpts = append(tlsOpts, 
tcp.WithTLSCAFile(tlsConfig.caFile))
+               }
+               options = append(options, tcp.WithTLS(tlsOpts...))
+       }
+       return options, nil
+}
+
+// parseProtocol extracts the transport from the scheme, which is either the
+// bare default protocol or an explicit iggy+<transport> spelling.
+func parseProtocol(scheme string) (string, error) {
+       if scheme == defaultProtocol {
+               return tcpProtocol, nil
+       }
+       if !strings.HasPrefix(scheme, protocolPrefix) {
+               return "", ierror.InvalidConnectionString{}
+       }
+       return strings.TrimPrefix(scheme, protocolPrefix), nil
+}
+
+// parseCredentials turns the userinfo before the @ into auto-login
+// credentials: username:password or a single personal access token.
+func parseCredentials(userinfo string) (tcp.Credentials, error) {
+       username, password, hasSeparator := strings.Cut(userinfo, ":")
+       if !hasSeparator {
+               if username == "" {
+                       return tcp.Credentials{}, 
ierror.InvalidConnectionString{}
+               }
+               return tcp.NewPersonalAccessTokenCredentials(username), nil
+       }
+       if username == "" || password == "" {
+               return tcp.Credentials{}, ierror.InvalidConnectionString{}
+       }
+       return tcp.NewUsernamePasswordCredentials(username, password), nil
+}
+
+type parsedTLS struct {
+       enabled bool
+       domain  string
+       caFile  string
+}
+
+// parseConnectionOptions validates the shared connection-string grammar,
+// keeping the tls options and ignoring the reconnect spellings the Go
+// client does not take from a connection string.
+func parseConnectionOptions(optionsString string) (parsedTLS, error) {
+       parsed := parsedTLS{}
+       if optionsString == "" {
+               return parsed, ierror.InvalidConnectionString{}
+       }
+       for _, option := range strings.Split(optionsString, "&") {
+               name, value, found := strings.Cut(option, "=")
+               if !found {
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+               switch name {
+               case "tls":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+                       parsed.enabled = value == "true"
+               case "tls_domain":
+                       parsed.domain = value
+               case "tls_ca_file":
+                       parsed.caFile = value
+               case "reconnection_retries":
+                       if value != "unlimited" {
+                               if _, err := strconv.ParseUint(value, 10, 32); 
err != nil {
+                                       return parsed, 
ierror.InvalidNumberValue{}
+                               }
+                       }
+               case "reconnection_interval", "reestablish_after", 
"heartbeat_interval":
+                       if !isValidIggyDuration(value) {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               case "nodelay":
+                       if value != "true" && value != "false" {
+                               return parsed, ierror.InvalidConnectionString{}
+                       }
+               default:
+                       return parsed, ierror.InvalidConnectionString{}
+               }
+       }
+       return parsed, nil
+}
+
+// isValidIggyDuration reports whether value is a duration the Rust SDK
+// accepts for its connection-string options, which the Go SDK reuses for
+// its grammar: the zero spellings, or a humantime-style duration such as
+// 1s, 500ms, 1h 30m or 1h30m.
+func isValidIggyDuration(value string) bool {
+       lower := strings.ToLower(value)
+       switch lower {
+       case "0", "unlimited", "disabled", "none":

Review Comment:
   warning: Rust types `reconnection_interval` and `heartbeat_interval` as 
non-zero durations, and `isValidIggyDuration` returns true for `0`, `none`, 
`disabled` and `unlimited` on both. Reject the zero spellings for those two 
options, and accept them, with `0s`, only for `reestablish_after`, which Rust 
takes as a plain duration.



##########
foreign/go/client/connection_string_test.go:
##########
@@ -0,0 +1,469 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "context"
+       "crypto/ecdsa"
+       "crypto/elliptic"
+       "crypto/rand"
+       "crypto/tls"
+       "crypto/x509"
+       "crypto/x509/pkix"
+       "encoding/binary"
+       "encoding/pem"
+       "io"
+       "math/big"
+       "net"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       iggcon "github.com/apache/iggy/foreign/go/contracts"
+       "github.com/apache/iggy/foreign/go/internal/command"
+       "github.com/apache/iggy/foreign/go/internal/vsr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// applyOptions runs options the way NewIggyClient does, and returns the
+// resulting TCP client so a test can observe what the parser produced.
+func applyOptions(t *testing.T, options []tcp.Option) *tcp.IggyTcpClient {
+       t.Helper()
+       return tcp.NewIggyTcpClient(nil, options...)
+}
+
+func TestParseConnectionString_ParsesTheDefaultSchemeWithPasswordCredentials(t 
*testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:[email protected]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "127.0.0.1:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func 
TestParseConnectionString_ParsesTheExplicitTcpSchemeWithAPersonalAccessToken(t 
*testing.T) {
+       opts, err := 
ParseConnectionString("iggy+tcp://iggypat-1234567890abcdef@localhost:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "localhost:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func TestParseConnectionString_ParsesIPv6HostWithoutBrackets(t *testing.T) {

Review Comment:
   nit: `TestParseConnectionString_ParsesIPv6HostWithoutBrackets` passes the 
bracketed `[::1]​:8090`, and the unbracketed form is rejected in the table 
below. Rename the test to name the bracketed host.



##########
foreign/go/client/connection_string_test.go:
##########
@@ -0,0 +1,469 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "context"
+       "crypto/ecdsa"
+       "crypto/elliptic"
+       "crypto/rand"
+       "crypto/tls"
+       "crypto/x509"
+       "crypto/x509/pkix"
+       "encoding/binary"
+       "encoding/pem"
+       "io"
+       "math/big"
+       "net"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       iggcon "github.com/apache/iggy/foreign/go/contracts"
+       "github.com/apache/iggy/foreign/go/internal/command"
+       "github.com/apache/iggy/foreign/go/internal/vsr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// applyOptions runs options the way NewIggyClient does, and returns the
+// resulting TCP client so a test can observe what the parser produced.
+func applyOptions(t *testing.T, options []tcp.Option) *tcp.IggyTcpClient {
+       t.Helper()
+       return tcp.NewIggyTcpClient(nil, options...)
+}
+
+func TestParseConnectionString_ParsesTheDefaultSchemeWithPasswordCredentials(t 
*testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:[email protected]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "127.0.0.1:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func 
TestParseConnectionString_ParsesTheExplicitTcpSchemeWithAPersonalAccessToken(t 
*testing.T) {
+       opts, err := 
ParseConnectionString("iggy+tcp://iggypat-1234567890abcdef@localhost:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "localhost:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func TestParseConnectionString_ParsesIPv6HostWithoutBrackets(t *testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:secret@[::1]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "[::1]:8090", client.GetConnectionInfo().ServerAddress)
+}
+
+// Frame offsets the fake server reads and writes. They mirror the
+// unexported tables of the codec package, which this package cannot reach.
+const (
+       frameOffsetSize      = 48
+       frameOffsetCommand   = 60
+       frameOffsetClient    = 128
+       frameOffsetRequest   = 168
+       frameOffsetOperation = 176
+       frameOffsetSession   = 184
+       frameOffsetReserved  = 196
+
+       replyFrameOffsetRequest   = 200
+       replyFrameOffsetOperation = 208
+       replyFrameOffsetStatus    = 216
+)
+
+// wireRequest is one frame the fake server read off the wire.
+type wireRequest struct {
+       header  [vsr.HeaderSize]byte
+       payload []byte
+}
+
+func (r wireRequest) operation() vsr.Operation {
+       return vsr.Operation(r.header[frameOffsetOperation])
+}
+
+func (r wireRequest) code() uint32 {
+       return binary.LittleEndian.Uint32(r.header[frameOffsetReserved:])
+}
+
+// readWireRequest reads one complete frame from conn.
+func readWireRequest(conn net.Conn) (wireRequest, error) {
+       var read wireRequest
+       if _, err := io.ReadFull(conn, read.header[:]); err != nil {
+               return read, err
+       }
+       size := binary.LittleEndian.Uint32(read.header[frameOffsetSize:])
+       if size > vsr.HeaderSize {
+               read.payload = make([]byte, int(size)-vsr.HeaderSize)
+               if _, err := io.ReadFull(conn, read.payload); err != nil {
+                       return read, err
+               }
+       }
+       return read, nil
+}
+
+// replyFrameFor builds a reply whose header carries the given operation.
+func replyFrameFor(operation vsr.Operation, body []byte) []byte {
+       frame := make([]byte, vsr.HeaderSize+len(body))
+       binary.LittleEndian.PutUint32(frame[frameOffsetSize:], 
uint32(len(frame)))
+       frame[frameOffsetCommand] = 8
+       frame[replyFrameOffsetOperation] = byte(operation)
+       binary.LittleEndian.PutUint32(frame[replyFrameOffsetStatus:], 0)
+       copy(frame[vsr.HeaderSize:], body)
+       return frame
+}
+
+// resultSection builds the committed result prefix of a metadata reply.
+func resultSection(codes ...uint32) []byte {
+       section := binary.LittleEndian.AppendUint32(nil, uint32(len(codes)))
+       for index, code := range codes {
+               section = binary.LittleEndian.AppendUint32(section, 
uint32(index))
+               section = binary.LittleEndian.AppendUint32(section, code)
+       }
+       return section
+}
+
+// registerReply builds the reply of a successful sign-in.
+func registerReply(userID uint32, session uint64) []byte {
+       body := binary.LittleEndian.AppendUint32(resultSection(), userID)
+       body = binary.LittleEndian.AppendUint64(body, session)
+       body = binary.LittleEndian.AppendUint32(body, vsr.ProtocolVersion)
+       body = append(body, byte(len("0.11.0-edge.1")))
+       body = append(body, "0.11.0-edge.1"...)
+       return replyFrameFor(vsr.OperationRegister, body)
+}
+
+// bindReply builds the reply of a successful session bind.
+func bindReply(userID uint32, session uint64) []byte {
+       registered := registerReply(userID, session)
+       return replyFrameFor(vsr.OperationNonReplicated, 
registered[vsr.HeaderSize+len(resultSection()):])
+}
+
+// clusterMetadataReply builds a roster where the node at the listener
+// address is the healthy leader.
+func clusterMetadataReply(t *testing.T, address string) []byte {
+       t.Helper()
+
+       metadata := iggcon.ClusterMetadata{Name: "test-cluster"}
+       host, port, err := net.SplitHostPort(address)
+       require.NoError(t, err)
+       tcpPort, err := net.LookupPort("tcp", port)
+       require.NoError(t, err)
+       metadata.Nodes = append(metadata.Nodes, iggcon.ClusterNode{
+               Name:      host,
+               IP:        host,
+               Role:      iggcon.RoleLeader,
+               Status:    iggcon.Healthy,
+               Endpoints: iggcon.TransportEndpoints{Tcp: uint16(tcpPort)},
+       })
+
+       body, err := metadata.MarshalBinary()
+       require.NoError(t, err)
+       return replyFrameFor(vsr.OperationNonReplicated, body)
+}
+
+// fakeVSRServer answers frames the way a single-node Iggy server does, so a
+// test can drive a full connect-and-login flow without a real server.
+type fakeVSRServer struct {
+       mtx      sync.Mutex
+       requests []wireRequest
+       listener net.Listener
+}
+
+func listenFakeVSR(t *testing.T, wrap func(net.Conn) net.Conn) *fakeVSRServer {
+       t.Helper()
+
+       listener, err := net.Listen("tcp", "127.0.0.1:0")
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = listener.Close() })
+
+       server := &fakeVSRServer{listener: listener}
+       go func() {
+               for {
+                       conn, err := listener.Accept()
+                       if err != nil {
+                               return
+                       }
+                       if wrap != nil {
+                               conn = wrap(conn)
+                       }
+                       go func(conn net.Conn) {
+                               defer func() { _ = conn.Close() }()
+                               for {
+                                       read, err := readWireRequest(conn)
+                                       if err != nil {
+                                               return
+                                       }
+                                       server.mtx.Lock()
+                                       server.requests = 
append(server.requests, read)
+                                       server.mtx.Unlock()
+
+                                       var answer []byte
+                                       switch {
+                                       case read.code() == 
uint32(command.BindSessionCode):
+                                               answer = bindReply(7, 
read.sessionID())
+                                       case read.code() == 
uint32(command.GetClusterMetadataCode):
+                                               answer = 
clusterMetadataReply(t, server.listener.Addr().String())
+                                       case read.operation() == 
vsr.OperationRegister:
+                                               answer = registerReply(7, 128)
+                                       default:
+                                               answer = 
replyFrameFor(vsr.OperationNonReplicated, nil)
+                                       }
+                                       // Echo the request id the way the 
server correlates replies.
+                                       if len(answer) >= vsr.HeaderSize && 
answer[frameOffsetCommand] == 8 {
+                                               
copy(answer[replyFrameOffsetRequest:replyFrameOffsetRequest+8],
+                                                       
read.header[frameOffsetRequest:frameOffsetRequest+8])
+                                       }
+                                       if _, err := conn.Write(answer); err != 
nil {
+                                               return
+                                       }
+                               }
+                       }(conn)
+               }
+       }()
+       return server
+}
+
+func (s *fakeVSRServer) recorded() []wireRequest {
+       s.mtx.Lock()
+       defer s.mtx.Unlock()
+       return append([]wireRequest(nil), s.requests...)
+}
+
+// sessionID reads the logical session the request carries.
+func (r wireRequest) sessionID() uint64 {
+       return binary.LittleEndian.Uint64(r.header[frameOffsetSession:])
+}
+
+// TestParseConnectionString_ConnectsAndLogsInAgainstAServer drives the
+// full connect-and-login flow from the issue acceptance criteria: opts
+// from ParseConnectionString, wired into NewIggyClient via WithTcp,
+// connecting and signing in against a running (fake) server.
+func TestParseConnectionString_ConnectsAndLogsInAgainstAServer(t *testing.T) {
+       server := listenFakeVSR(t, nil)
+       address := server.listener.Addr().String()
+
+       opts, err := ParseConnectionString("iggy://iggy:iggy@" + address)
+       require.NoError(t, err)
+       cli, err := NewIggyClient(WithTcp(opts...))
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cli.Close() })
+
+       require.NoError(t, cli.Connect(context.Background()))
+
+       recorded := server.recorded()
+       require.Len(t, recorded, 2)
+       // The roster read is auth-gated, so the sign-in comes first.
+       assert.Equal(t, vsr.OperationRegister, recorded[0].operation())
+       assert.Contains(t, string(recorded[0].payload), "iggy")
+       assert.Equal(t, uint32(command.GetClusterMetadataCode), 
recorded[1].code())
+}
+
+// TestParseConnectionString_TLSEnabledByQueryString drives the same flow
+// with tls=true and a TLS-only fake server: a client that ignored the
+// option would send its register frame in plaintext and the TLS handshake
+// would fail, so a successful connect-and-login proves the option landed.
+func TestParseConnectionString_TLSEnabledByQueryString(t *testing.T) {
+       certificate, caPath := selfSignedCert(t)
+
+       server := listenFakeVSR(t, func(conn net.Conn) net.Conn {
+               return tls.Server(conn, &tls.Config{Certificates: 
[]tls.Certificate{certificate}})
+       })
+       address := server.listener.Addr().String()
+       host, port, err := net.SplitHostPort(address)
+       require.NoError(t, err)
+
+       connectionString := "iggy://iggy:iggy@" + host + ":" + port +
+               "?tls=true&tls_domain=localhost&tls_ca_file=" + caPath
+       opts, err := ParseConnectionString(connectionString)
+       require.NoError(t, err)
+       cli, err := NewIggyClient(WithTcp(opts...))
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cli.Close() })
+
+       require.NoError(t, cli.Connect(context.Background()))
+
+       recorded := server.recorded()
+       require.Len(t, recorded, 2)
+       assert.Equal(t, vsr.OperationRegister, recorded[0].operation())
+       assert.Equal(t, uint32(command.GetClusterMetadataCode), 
recorded[1].code())
+}
+
+// selfSignedCert mints a throwaway server certificate for the loopback
+// listener, so the test carries no expiry or checkout-layout dependency.
+func selfSignedCert(t *testing.T) (tls.Certificate, string) {
+       t.Helper()
+
+       key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+       require.NoError(t, err)
+       template := x509.Certificate{
+               SerialNumber:          big.NewInt(1),
+               Subject:               pkix.Name{CommonName: "localhost"},
+               NotBefore:             time.Now().Add(-time.Hour),
+               NotAfter:              time.Now().Add(time.Hour),
+               KeyUsage:              x509.KeyUsageDigitalSignature | 
x509.KeyUsageCertSign,
+               ExtKeyUsage:           
[]x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+               BasicConstraintsValid: true,
+               IsCA:                  true,
+               DNSNames:              []string{"localhost"},
+               IPAddresses:           []net.IP{net.IPv4(127, 0, 0, 1)},
+       }
+       certDER, err := x509.CreateCertificate(rand.Reader, &template, 
&template, &key.PublicKey, key)
+       require.NoError(t, err)
+       keyDER, err := x509.MarshalECPrivateKey(key)
+       require.NoError(t, err)
+
+       certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: 
certDER})
+       keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: 
keyDER})
+       certificate, err := tls.X509KeyPair(certPEM, keyPEM)
+       require.NoError(t, err)
+
+       caPath := filepath.Join(t.TempDir(), "ca.pem")
+       require.NoError(t, os.WriteFile(caPath, certPEM, 0o600))
+       return certificate, caPath
+}
+
+// TestParseConnectionString_RejectsMalformedConnectionStrings pins every
+// malformed shape the other SDKs reject, copied from their test tables.

Review Comment:
   nit: The comment says the table pins shapes the other SDKs reject, but Rust 
accepts `host:8090:9090`, `[]​:8090` and `tls=maybe`. Attribute the table to 
the Node SDK, which is where those shapes fail.



##########
foreign/go/client/connection_string_test.go:
##########
@@ -0,0 +1,469 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "context"
+       "crypto/ecdsa"
+       "crypto/elliptic"
+       "crypto/rand"
+       "crypto/tls"
+       "crypto/x509"
+       "crypto/x509/pkix"
+       "encoding/binary"
+       "encoding/pem"
+       "io"
+       "math/big"
+       "net"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       iggcon "github.com/apache/iggy/foreign/go/contracts"
+       "github.com/apache/iggy/foreign/go/internal/command"
+       "github.com/apache/iggy/foreign/go/internal/vsr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// applyOptions runs options the way NewIggyClient does, and returns the
+// resulting TCP client so a test can observe what the parser produced.
+func applyOptions(t *testing.T, options []tcp.Option) *tcp.IggyTcpClient {
+       t.Helper()
+       return tcp.NewIggyTcpClient(nil, options...)
+}
+
+func TestParseConnectionString_ParsesTheDefaultSchemeWithPasswordCredentials(t 
*testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:[email protected]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "127.0.0.1:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func 
TestParseConnectionString_ParsesTheExplicitTcpSchemeWithAPersonalAccessToken(t 
*testing.T) {
+       opts, err := 
ParseConnectionString("iggy+tcp://iggypat-1234567890abcdef@localhost:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "localhost:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func TestParseConnectionString_ParsesIPv6HostWithoutBrackets(t *testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:secret@[::1]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "[::1]:8090", client.GetConnectionInfo().ServerAddress)
+}
+
+// Frame offsets the fake server reads and writes. They mirror the

Review Comment:
   simplification: The frame offsets, the fake server and the reply builders 
repeat `foreign/go/client/tcp/tcp_testing_test.go:36-250`, and `selfSignedCert` 
repeats `foreign/go/client/tcp/tcp_connect_test.go:49`. Move them into one 
internal test-support package that both test binaries import.



##########
foreign/go/client/connection_string_test.go:
##########
@@ -0,0 +1,469 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "context"
+       "crypto/ecdsa"
+       "crypto/elliptic"
+       "crypto/rand"
+       "crypto/tls"
+       "crypto/x509"
+       "crypto/x509/pkix"
+       "encoding/binary"
+       "encoding/pem"
+       "io"
+       "math/big"
+       "net"
+       "os"
+       "path/filepath"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       iggcon "github.com/apache/iggy/foreign/go/contracts"
+       "github.com/apache/iggy/foreign/go/internal/command"
+       "github.com/apache/iggy/foreign/go/internal/vsr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// applyOptions runs options the way NewIggyClient does, and returns the
+// resulting TCP client so a test can observe what the parser produced.
+func applyOptions(t *testing.T, options []tcp.Option) *tcp.IggyTcpClient {
+       t.Helper()
+       return tcp.NewIggyTcpClient(nil, options...)
+}
+
+func TestParseConnectionString_ParsesTheDefaultSchemeWithPasswordCredentials(t 
*testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:[email protected]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "127.0.0.1:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func 
TestParseConnectionString_ParsesTheExplicitTcpSchemeWithAPersonalAccessToken(t 
*testing.T) {
+       opts, err := 
ParseConnectionString("iggy+tcp://iggypat-1234567890abcdef@localhost:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "localhost:8090", 
client.GetConnectionInfo().ServerAddress)
+}
+
+func TestParseConnectionString_ParsesIPv6HostWithoutBrackets(t *testing.T) {
+       opts, err := ParseConnectionString("iggy://iggy:secret@[::1]:8090")
+       require.NoError(t, err)
+       client := applyOptions(t, opts)
+
+       assert.Equal(t, "[::1]:8090", client.GetConnectionInfo().ServerAddress)
+}
+
+// Frame offsets the fake server reads and writes. They mirror the
+// unexported tables of the codec package, which this package cannot reach.
+const (
+       frameOffsetSize      = 48
+       frameOffsetCommand   = 60
+       frameOffsetClient    = 128

Review Comment:
   simplification: `frameOffsetClient` is declared and never read, so it can 
only drift from the header layout it mirrors. Delete it.



##########
foreign/go/client/connection_string.go:
##########
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import (
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+
+       "github.com/apache/iggy/foreign/go/client/tcp"
+       ierror "github.com/apache/iggy/foreign/go/errors"
+)
+
+const (
+       defaultProtocol = "iggy"
+       protocolPrefix  = "iggy+"
+       tcpProtocol     = "tcp"
+)
+
+// ParseConnectionString parses an Iggy TCP connection string into the TCP
+// client options it describes, so the result can be passed to WithTcp:
+//
+//     client.NewIggyClient(client.WithTcp(opts...))
+//
+// with opts from 
client.ParseConnectionString("iggy://iggy:iggy@localhost:8090").
+//
+// The accepted shape is:
+//
+//     iggy://[username:password@ | personal_access_token@]host:port[?options]
+//     iggy+tcp://[username:password@ | 
personal_access_token@]host:port[?options]
+//
+// The scheme iggy:// means TCP, the same as the explicit iggy+tcp://.
+// Other iggy+<transport> schemes such as iggy+quic:// are rejected,
+// because the Go SDK implements the TCP transport only, which covers both
+// plain TCP and TLS. Credentials are username:password or a single
+// personal access token before the @; either way auto-login is configured,
+// matching what the Rust and Node SDKs do with a connection string. The
+// host may be a bracketed IPv6 literal, as in [::1]:8090.
+//
+// Query options follow the grammar the other SDKs share:
+//
+//     tls=true|false              enable TLS
+//     tls_domain=<name>           TLS server name indication (SNI)
+//     tls_ca_file=<path>          CA certificate file for verification
+//     reconnection_retries=<n|unlimited>
+//     reconnection_interval=<duration>
+//     reestablish_after=<duration>
+//     heartbeat_interval=<duration>
+//     nodelay=true|false
+//
+// The Go client takes its reconnect, heartbeat, and nodelay behavior from
+// its own defaults, so those spellings are validated for compatibility and
+// then ignored; a value the other SDKs would reject is still rejected here,
+// so one connection string is either valid everywhere or nowhere.
+func ParseConnectionString(connectionString string) ([]tcp.Option, error) {
+       protocolParts := strings.Split(connectionString, "://")
+       if len(protocolParts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       protocol, err := parseProtocol(protocolParts[0])
+       if err != nil {
+               return nil, err
+       }
+       if protocol != tcpProtocol {
+               return nil, fmt.Errorf("%w: unsupported transport %q, Go SDK 
supports tcp only",
+                       ierror.InvalidConnectionString{}, protocol)
+       }
+
+       parts := strings.Split(protocolParts[1], "@")
+       if len(parts) != 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       credentials, err := parseCredentials(parts[0])
+       if err != nil {
+               return nil, err
+       }
+
+       serverAndOptions := strings.Split(parts[1], "?")
+       if len(serverAndOptions) > 2 {
+               return nil, ierror.InvalidConnectionString{}
+       }
+
+       var tlsConfig parsedTLS
+       if len(serverAndOptions) == 2 {
+               tlsConfig, err = parseConnectionOptions(serverAndOptions[1])
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       serverAddress := serverAndOptions[0]
+       if serverAddress == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       host, portStr, err := net.SplitHostPort(serverAddress)
+       if err != nil || host == "" {
+               return nil, ierror.InvalidConnectionString{}
+       }
+       // Node and Rust accept any u16 port, 0 included; the dial fails later
+       // instead of the parse.
+       if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
+               return nil, ierror.InvalidNumberValue{}

Review Comment:
   nit: A bad port returns `ierror.InvalidNumberValue` while every other parse 
failure returns `ierror.InvalidConnectionString`, and the Rust SDK returns 
`InvalidConnectionString` here. Return `ierror.InvalidConnectionString` so 
`errors.Is(err, ierror.ErrInvalidConnectionString)` covers the whole parse.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to