Files
relspecgo/pkg/ui/connstring.go
T
SG CommandandClaude Sonnet 5.5 0235a28add feat(ui): file browser and connection string builder dialogs (#44)
Enter on File Path inputs opens a file browser (load/save, extension
filter, hidden toggle, overwrite confirm). Enter on Connection String
inputs opens a builder for PostgreSQL, MSSQL and SQLite with masked
password/preview, parsing and optional connection test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:35:12 +02:00

187 lines
4.4 KiB
Go

package ui
import (
"fmt"
"net"
"net/url"
"strings"
)
// ConnKind identifies the database type a connection string targets.
type ConnKind string
const (
ConnPostgres ConnKind = "postgres"
ConnMSSQL ConnKind = "mssql"
ConnSQLite ConnKind = "sqlite"
)
// connKinds lists the kinds offered by the builder dialog, in display order.
var connKinds = []ConnKind{ConnPostgres, ConnMSSQL, ConnSQLite}
// maskedPassword is substituted for the password in previews.
const maskedPassword = "****"
// ConnFields holds the editable parts of a connection string.
type ConnFields struct {
Kind ConnKind
Host string
Port string
Database string
User string
Password string
SSLMode string
FilePath string // SQLite only
// Extra keeps query parameters the builder has no field for, so that
// parsing and rebuilding an existing string does not drop them.
Extra url.Values
}
// DefaultConnFields returns sensible defaults for the given kind.
func DefaultConnFields(kind ConnKind) ConnFields {
f := ConnFields{Kind: kind}
switch kind {
case ConnPostgres:
f.Host, f.Port, f.User, f.SSLMode = "localhost", "5432", "postgres", "disable"
case ConnMSSQL:
f.Host, f.Port, f.User, f.SSLMode = "localhost", "1433", "sa", "disable"
}
return f
}
// SSLModes returns the valid SSL/encryption options for a kind.
func SSLModes(kind ConnKind) []string {
switch kind {
case ConnPostgres:
return []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
case ConnMSSQL:
return []string{"disable", "false", "true"}
}
return nil
}
func (f ConnFields) sslParam() string {
if f.Kind == ConnMSSQL {
return "encrypt"
}
return "sslmode"
}
// BuildConnString renders the fields as a connection string. With mask set,
// a non-empty password is replaced by asterisks (for previews).
func BuildConnString(f ConnFields, mask bool) string {
if f.Kind == ConnSQLite {
return f.FilePath
}
u := &url.URL{Scheme: "postgres"}
if f.Kind == ConnMSSQL {
u.Scheme = "sqlserver"
}
if f.Port != "" {
u.Host = net.JoinHostPort(f.Host, f.Port)
} else {
u.Host = f.Host
}
if f.User != "" {
if f.Password != "" {
pw := f.Password
if mask {
pw = maskedPassword
}
u.User = url.UserPassword(f.User, pw)
} else {
u.User = url.User(f.User)
}
}
query := url.Values{}
for k, v := range f.Extra {
query[k] = v
}
if f.Kind == ConnMSSQL {
if f.Database != "" {
query.Set("database", f.Database)
}
} else if f.Database != "" {
u.Path = "/" + f.Database
}
if f.SSLMode != "" {
query.Set(f.sslParam(), f.SSLMode)
}
u.RawQuery = query.Encode()
out := u.String()
if mask {
// url escapes '*' in the userinfo; keep the preview readable.
out = strings.Replace(out, url.QueryEscape(maskedPassword), maskedPassword, 1)
}
return out
}
// DetectConnKind guesses the kind from a connection string's scheme. Anything
// that is not a recognised URL is treated as a SQLite file path.
func DetectConnKind(s string) ConnKind {
lower := strings.ToLower(strings.TrimSpace(s))
switch {
case strings.HasPrefix(lower, "postgres://"), strings.HasPrefix(lower, "postgresql://"):
return ConnPostgres
case strings.HasPrefix(lower, "sqlserver://"), strings.HasPrefix(lower, "mssql://"):
return ConnMSSQL
}
return ConnSQLite
}
// ParseConnString splits a connection string into fields. An empty string
// yields the defaults for hint. Missing ports fall back to the kind default.
func ParseConnString(s string, hint ConnKind) (ConnFields, error) {
s = strings.TrimSpace(s)
if s == "" {
return DefaultConnFields(hint), nil
}
kind := DetectConnKind(s)
if kind == ConnSQLite {
path := s
for _, prefix := range []string{"sqlite://", "sqlite3://"} {
path = strings.TrimPrefix(path, prefix)
}
return ConnFields{Kind: ConnSQLite, FilePath: path}, nil
}
u, err := url.Parse(s)
if err != nil {
return DefaultConnFields(kind), fmt.Errorf("invalid connection string: %w", err)
}
f := ConnFields{
Kind: kind,
Host: u.Hostname(),
Port: u.Port(),
}
if f.Port == "" {
f.Port = DefaultConnFields(kind).Port
}
if u.User != nil {
f.User = u.User.Username()
f.Password, _ = u.User.Password()
}
query := u.Query()
if kind == ConnMSSQL {
f.Database = query.Get("database")
query.Del("database")
} else {
f.Database = strings.TrimPrefix(u.Path, "/")
}
f.SSLMode = query.Get(f.sslParam())
query.Del(f.sslParam())
if len(query) > 0 {
f.Extra = query
}
return f, nil
}