diff --git a/go.mod b/go.mod index 49231626..6de25129 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/cli/safeexec v1.0.1 github.com/fatih/color v1.18.0 + github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/jsonschema-go v0.2.3 github.com/jackc/pgx/v5 v5.7.5 github.com/modelcontextprotocol/go-sdk v0.5.0 @@ -80,7 +81,6 @@ require ( github.com/go-openapi/swag/stringutils v0.24.0 // indirect github.com/go-openapi/swag/typeutils v0.24.0 // indirect github.com/go-openapi/swag/yamlutils v0.24.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gofrs/flock v0.12.1 // indirect diff --git a/internal/tiger/cmd/auth.go b/internal/tiger/cmd/auth.go index 886e8351..85bcb6a4 100644 --- a/internal/tiger/cmd/auth.go +++ b/internal/tiger/cmd/auth.go @@ -247,7 +247,7 @@ func outputAuthInfo(cmd *cobra.Command, authInfo api.AuthInfo, format string) er case "json": return util.SerializeToJSON(outputWriter, authInfo) case "yaml": - return util.SerializeToYAML(outputWriter, authInfo, true) + return util.SerializeToYAML(outputWriter, authInfo) default: // table format (default) return outputAuthInfoTable(authInfo, outputWriter) } diff --git a/internal/tiger/cmd/config.go b/internal/tiger/cmd/config.go index 1ea60f77..33cc123a 100644 --- a/internal/tiger/cmd/config.go +++ b/internal/tiger/cmd/config.go @@ -68,7 +68,7 @@ func buildConfigShowCmd() *cobra.Command { case "json": return util.SerializeToJSON(output, cfgOut) case "yaml": - return util.SerializeToYAML(output, cfgOut, false) + return util.SerializeToYAML(output, cfgOut) default: return outputTable(output, cfgOut) } diff --git a/internal/tiger/cmd/config_test.go b/internal/tiger/cmd/config_test.go index e80b269f..01407d3e 100644 --- a/internal/tiger/cmd/config_test.go +++ b/internal/tiger/cmd/config_test.go @@ -121,9 +121,8 @@ func TestConfigShow_JSONOutput(t *testing.T) { // Create config file with JSON output format configContent := `api_url: https://json.api.com/v1 output: json -analytics: true -password_storage: none -version_check_interval: 1h +analytics: false +password_storage: keyring version_check_last_time: ` + now.Format(time.RFC3339) + "\n" configFile := config.GetConfigFile(tmpDir) @@ -152,12 +151,12 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n" "service_id": "", "color": true, "output": "json", - "analytics": true, - "password_storage": "none", + "analytics": false, + "password_storage": "keyring", "debug": false, "config_dir": tmpDir, "releases_url": "https://cli.tigerdata.com", - "version_check_interval": float64(3600000000000), // JSON unmarshals time.Duration as nanoseconds (1 hour = 3600000000000ns) + "version_check_interval": "24h0m0s", "version_check_last_time": now.Format(time.RFC3339), } @@ -216,22 +215,13 @@ version_check_last_time: ` + now.Format(time.RFC3339) + "\n" "debug": false, "config_dir": tmpDir, "releases_url": "https://cli.tigerdata.com", - "version_check_interval": "24h0m0s", // YAML serializes time.Duration as string - "version_check_last_time": now, + "version_check_interval": "24h0m0s", + "version_check_last_time": now.Format(time.RFC3339), } for key, expectedValue := range expectedValues { - switch expectedValue.(type) { - case time.Time: - // YAML unmarshals time.Time as time.Time type, so we need to compare differently - if expectedValue.(time.Time).Format(time.RFC3339) != result[key].(time.Time).Format(time.RFC3339) { - t.Errorf("foo Expected %s '%v', got %v", key, expectedValue, result[key]) - } - default: - // Other types can be compared directly - if result[key] != expectedValue { - t.Errorf("Expected %s '%v', got %v", key, expectedValue, result[key]) - } + if result[key] != expectedValue { + t.Errorf("Expected %s '%v', got %v", key, expectedValue, result[key]) } } diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index 940f196c..5e042d15 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -530,10 +530,10 @@ func getPasswordForRole(passwordFlag string) (string, error) { // CreateRoleResult represents the output of a create role operation type CreateRoleResult struct { - RoleName string `json:"role_name" yaml:"role_name"` - ReadOnly bool `json:"read_only,omitempty" yaml:"read_only,omitempty"` - StatementTimeout string `json:"statement_timeout,omitempty" yaml:"statement_timeout,omitempty"` - FromRoles []string `json:"from_roles,omitempty" yaml:"from_roles,omitempty"` + RoleName string `json:"role_name"` + ReadOnly bool `json:"read_only,omitempty"` + StatementTimeout string `json:"statement_timeout,omitempty"` + FromRoles []string `json:"from_roles,omitempty"` } // outputCreateRoleResult formats and outputs the create role result @@ -557,7 +557,7 @@ func outputCreateRoleResult(cmd *cobra.Command, roleName string, readOnly bool, case "json": return util.SerializeToJSON(outputWriter, result) case "yaml": - return util.SerializeToYAML(outputWriter, result, false) + return util.SerializeToYAML(outputWriter, result) default: // table format fmt.Fprintf(outputWriter, "✓ Role '%s' created successfully\n", roleName) if readOnly { diff --git a/internal/tiger/cmd/mcp.go b/internal/tiger/cmd/mcp.go index bc2b1504..9ceeb232 100644 --- a/internal/tiger/cmd/mcp.go +++ b/internal/tiger/cmd/mcp.go @@ -267,7 +267,7 @@ Examples: case "json": return util.SerializeToJSON(output, capabilities) case "yaml": - return util.SerializeToYAML(output, capabilities, true) + return util.SerializeToYAML(output, capabilities) default: return outputCapabilitiesTable(output, capabilities) } diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 4146da00..6f077093 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -549,8 +549,8 @@ Examples: type OutputService struct { api.Service common.ConnectionDetails - ConnectionString string `json:"connection_string,omitempty" yaml:"connection_string,omitempty"` - ConsoleURL string `json:"console_url,omitempty" yaml:"console_url,omitempty"` + ConnectionString string `json:"connection_string,omitempty"` + ConsoleURL string `json:"console_url,omitempty"` } // outputService formats and outputs a single service based on the specified format @@ -566,7 +566,7 @@ func outputService(cmd *cobra.Command, service api.Service, format string, withP case "json": return util.SerializeToJSON(outputWriter, outputSvc) case "yaml": - return util.SerializeToYAML(outputWriter, outputSvc, true) + return util.SerializeToYAML(outputWriter, outputSvc) case "env": return outputServiceEnv(outputSvc, outputWriter) default: // table format (default) @@ -583,7 +583,7 @@ func outputServices(cmd *cobra.Command, services []api.Service, format string) e case "json": return util.SerializeToJSON(outputWriter, outputServices) case "yaml": - return util.SerializeToYAML(outputWriter, outputServices, true) + return util.SerializeToYAML(outputWriter, outputServices) case "env": return fmt.Errorf("environment variable output is not supported for multiple services") default: // table format (default) diff --git a/internal/tiger/cmd/version.go b/internal/tiger/cmd/version.go index 0bab9cbf..4727e779 100644 --- a/internal/tiger/cmd/version.go +++ b/internal/tiger/cmd/version.go @@ -14,13 +14,13 @@ import ( ) type VersionOutput struct { - Version string `json:"version" yaml:"version"` - BuildTime string `json:"build_time" yaml:"build_time"` - GitCommit string `json:"git_commit" yaml:"git_commit"` - GoVersion string `json:"go_version" yaml:"go_version"` - Platform string `json:"platform" yaml:"platform"` - LatestVersion string `json:"latest_version,omitempty" yaml:"latest_version,omitempty"` - UpdateAvailable *bool `json:"update_available,omitempty" yaml:"update_available,omitempty"` + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitCommit string `json:"git_commit"` + GoVersion string `json:"go_version"` + Platform string `json:"platform"` + LatestVersion string `json:"latest_version,omitempty"` + UpdateAvailable *bool `json:"update_available,omitempty"` } func buildVersionCmd() *cobra.Command { @@ -64,7 +64,7 @@ func buildVersionCmd() *cobra.Command { return err } case "yaml": - if err := util.SerializeToYAML(output, versionOutput, true); err != nil { + if err := util.SerializeToYAML(output, versionOutput); err != nil { return err } case "bare": diff --git a/internal/tiger/common/connection.go b/internal/tiger/common/connection.go index aef2576d..8b55ac40 100644 --- a/internal/tiger/common/connection.go +++ b/internal/tiger/common/connection.go @@ -24,12 +24,12 @@ type ConnectionDetailsOptions struct { } type ConnectionDetails struct { - Role string `json:"role,omitempty" yaml:"role,omitempty"` - Password string `json:"password,omitempty" yaml:"password,omitempty"` - Host string `json:"host,omitempty" yaml:"host,omitempty"` - Port int `json:"port,omitempty" yaml:"port,omitempty"` - Database string `json:"database,omitempty" yaml:"database,omitempty"` - IsPooler bool `json:"is_pooler,omitempty" yaml:"is_pooler,omitempty"` + Role string `json:"role,omitempty"` + Password string `json:"password,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Database string `json:"database,omitempty"` + IsPooler bool `json:"is_pooler,omitempty"` } func GetConnectionDetails(service api.Service, opts ConnectionDetailsOptions) (*ConnectionDetails, error) { diff --git a/internal/tiger/config/config.go b/internal/tiger/config/config.go index 83855e6b..cf364f04 100644 --- a/internal/tiger/config/config.go +++ b/internal/tiger/config/config.go @@ -11,6 +11,7 @@ import ( "strconv" "time" + "github.com/go-viper/mapstructure/v2" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -18,40 +19,40 @@ import ( ) type Config struct { - APIURL string `mapstructure:"api_url" yaml:"api_url"` - Analytics bool `mapstructure:"analytics" yaml:"analytics"` - Color bool `mapstructure:"color" yaml:"color"` - ConfigDir string `mapstructure:"config_dir" yaml:"-"` - ConsoleURL string `mapstructure:"console_url" yaml:"console_url"` - Debug bool `mapstructure:"debug" yaml:"debug"` - DocsMCP bool `mapstructure:"docs_mcp" yaml:"docs_mcp"` - DocsMCPURL string `mapstructure:"docs_mcp_url" yaml:"docs_mcp_url"` - GatewayURL string `mapstructure:"gateway_url" yaml:"gateway_url"` - Output string `mapstructure:"output" yaml:"output"` - PasswordStorage string `mapstructure:"password_storage" yaml:"password_storage"` - ReleasesURL string `mapstructure:"releases_url" yaml:"releases_url"` - ServiceID string `mapstructure:"service_id" yaml:"service_id"` - VersionCheckInterval time.Duration `mapstructure:"version_check_interval" yaml:"version_check_interval"` - VersionCheckLastTime time.Time `mapstructure:"version_check_last_time" yaml:"version_check_last_time"` - viper *viper.Viper `mapstructure:"-" yaml:"-"` + APIURL string `mapstructure:"api_url"` + Analytics bool `mapstructure:"analytics"` + Color bool `mapstructure:"color"` + ConfigDir string `mapstructure:"config_dir"` + ConsoleURL string `mapstructure:"console_url"` + Debug bool `mapstructure:"debug"` + DocsMCP bool `mapstructure:"docs_mcp"` + DocsMCPURL string `mapstructure:"docs_mcp_url"` + GatewayURL string `mapstructure:"gateway_url"` + Output string `mapstructure:"output"` + PasswordStorage string `mapstructure:"password_storage"` + ReleasesURL string `mapstructure:"releases_url"` + ServiceID string `mapstructure:"service_id"` + VersionCheckInterval time.Duration `mapstructure:"version_check_interval"` + VersionCheckLastTime time.Time `mapstructure:"version_check_last_time"` + viper *viper.Viper `mapstructure:"-"` } type ConfigOutput struct { - APIURL *string `mapstructure:"api_url" json:"api_url,omitempty" yaml:"api_url,omitempty"` - Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty" yaml:"analytics,omitempty"` - Color *bool `mapstructure:"color" json:"color,omitempty" yaml:"color,omitempty"` - ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty" yaml:"config_dir,omitempty"` - ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty" yaml:"console_url,omitempty"` - Debug *bool `mapstructure:"debug" json:"debug,omitempty" yaml:"debug,omitempty"` - DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty" yaml:"docs_mcp,omitempty"` - DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty" yaml:"docs_mcp_url,omitempty"` - GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty" yaml:"gateway_url,omitempty"` - Output *string `mapstructure:"output" json:"output,omitempty" yaml:"output,omitempty"` - PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty" yaml:"password_storage,omitempty"` - ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty" yaml:"releases_url,omitempty"` - ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty" yaml:"service_id,omitempty"` - VersionCheckInterval *time.Duration `mapstructure:"version_check_interval" json:"version_check_interval,omitempty" yaml:"version_check_interval,omitempty"` - VersionCheckLastTime *time.Time `mapstructure:"version_check_last_time" json:"version_check_last_time,omitempty" yaml:"version_check_last_time,omitempty"` + APIURL *string `mapstructure:"api_url" json:"api_url,omitempty"` + Analytics *bool `mapstructure:"analytics" json:"analytics,omitempty"` + Color *bool `mapstructure:"color" json:"color,omitempty"` + ConfigDir *string `mapstructure:"config_dir" json:"config_dir,omitempty"` + ConsoleURL *string `mapstructure:"console_url" json:"console_url,omitempty"` + Debug *bool `mapstructure:"debug" json:"debug,omitempty"` + DocsMCP *bool `mapstructure:"docs_mcp" json:"docs_mcp,omitempty"` + DocsMCPURL *string `mapstructure:"docs_mcp_url" json:"docs_mcp_url,omitempty"` + GatewayURL *string `mapstructure:"gateway_url" json:"gateway_url,omitempty"` + Output *string `mapstructure:"output" json:"output,omitempty"` + PasswordStorage *string `mapstructure:"password_storage" json:"password_storage,omitempty"` + ReleasesURL *string `mapstructure:"releases_url" json:"releases_url,omitempty"` + ServiceID *string `mapstructure:"service_id" json:"service_id,omitempty"` + VersionCheckInterval *util.Duration `mapstructure:"version_check_interval" json:"version_check_interval,omitempty"` // [util.Duration] ensures value is marshaled in [time.Duration.String] format when output + VersionCheckLastTime *time.Time `mapstructure:"version_check_last_time" json:"version_check_last_time,omitempty"` } const ( @@ -83,7 +84,7 @@ var defaultValues = map[string]any{ "password_storage": DefaultPasswordStorage, "releases_url": DefaultReleasesURL, "service_id": "", - "version_check_interval": DefaultVersionCheckInterval, + "version_check_interval": DefaultVersionCheckInterval.String(), // String can be interpreted as either [time.Duration] (for [Config]) or [util.Duration] (for [ConfigOutput]) "version_check_last_time": time.Time{}, } @@ -149,7 +150,10 @@ func ForOutputFromViper(v *viper.Viper) (*ConfigOutput, error) { ConfigDir: &configDir, } - if err := v.Unmarshal(cfg); err != nil { + if err := v.Unmarshal(cfg, + // Decode hook allows us to unmarshal a string into a [util.Duration] for the sake of VersionCheckInterval + viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()), + ); err != nil { return nil, fmt.Errorf("error unmarshaling config for output: %w", err) } diff --git a/internal/tiger/util/duration.go b/internal/tiger/util/duration.go new file mode 100644 index 00000000..c5fdf0aa --- /dev/null +++ b/internal/tiger/util/duration.go @@ -0,0 +1,35 @@ +package util + +import ( + "encoding" + "time" +) + +var ( + _ encoding.TextMarshaler = Duration{} + _ encoding.TextUnmarshaler = (*Duration)(nil) +) + +// Duration is a wrapper around time.Duration that allows it to be +// marshalled to/from JSON via the standard duration string format. +type Duration struct { + time.Duration +} + +// Implements the [encoding.TextUnmarshaler] interface. +func (d *Duration) UnmarshalText(b []byte) error { + duration, err := time.ParseDuration(string(b)) + if err != nil { + return err + } + *d = Duration{ + Duration: duration, + } + return nil +} + +// Implements the [encoding.TextMarshaler] interface. +func (d Duration) MarshalText() ([]byte, error) { + str := d.String() + return []byte(str), nil +} diff --git a/internal/tiger/util/serialize.go b/internal/tiger/util/serialize.go index a12caca2..74a940bd 100644 --- a/internal/tiger/util/serialize.go +++ b/internal/tiger/util/serialize.go @@ -27,18 +27,22 @@ func SerializeToJSON(w io.Writer, v any) error { return encoder.Encode(v) } -func SerializeToYAML(w io.Writer, v any, omitNull bool) error { +// SerializeToYAML serializes data to YAML format. +// +// This function first marshals to JSON, then unmarshals and encodes to YAML. +// This approach ensures that: +// 1. Structs from third-party libraries or generated code that only include +// json: struct tags (without yaml: tags) are correctly marshaled +// 2. JSON and YAML marshaling produce consistent output, both respecting +// the same json: tags and omitempty directives +func SerializeToYAML(w io.Writer, v any) error { encoder := yaml.NewEncoder(w) defer encoder.Close() encoder.SetIndent(2) - if omitNull { - if toOutput, err := toJSON(v); err != nil { - return err - } else { - return encoder.Encode(toOutput) - } + toOutput, err := toJSON(v) + if err != nil { + return err } - - return encoder.Encode(v) + return encoder.Encode(toOutput) }