diff --git a/cmd/pgxcli/main.go b/cmd/pgxcli/main.go index e67f4d3..dcceedd 100644 --- a/cmd/pgxcli/main.go +++ b/cmd/pgxcli/main.go @@ -9,7 +9,6 @@ import ( "context" "os" - "github.com/balajz/pgxcli/internal/app/renderer" "github.com/balajz/pgxcli/internal/cli" "github.com/balajz/pgxcli/internal/cliio" ) @@ -27,7 +26,7 @@ func main() { ) if err := rootCmd.ExecuteContext(ctx); err != nil { - _ = renderer.Error(err, os.Stderr) + printer.PrintError(err) os.Exit(1) } } diff --git a/internal/app/app.go b/internal/app/app.go index 8c3751b..31ce1f6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,7 +6,6 @@ package app import ( "context" - "fmt" "log/slog" "os" "time" @@ -17,6 +16,7 @@ import ( "github.com/balajz/pgxcli/internal/cliio" "github.com/balajz/pgxcli/internal/config" "github.com/balajz/pgxcli/internal/database" + "github.com/balajz/pgxcli/internal/perrors" compDB "github.com/balajz/pgxls/pkg/database" ) @@ -94,14 +94,17 @@ func (p *pgxCLI) Start(ctx context.Context) error { p.getCompletions(), ) if err != nil { - return fmt.Errorf("creating UI model: %w", err) + return err } p.model = m p.program = tea.NewProgram(p.model, tea.WithContext(ctx)) if _, err := p.program.Run(); err != nil { - return fmt.Errorf("running UI program: %w", err) + return perrors.Wrap( + err, + perrors.WithMessage("failed to start UI"), + ) } return nil diff --git a/internal/app/renderer/formatter/table.go b/internal/app/renderer/formatter/table.go index 1645669..5c15aaa 100644 --- a/internal/app/renderer/formatter/table.go +++ b/internal/app/renderer/formatter/table.go @@ -4,6 +4,7 @@ import ( "io" "github.com/balajz/pgxcli/internal/config" + "github.com/balajz/pgxcli/internal/perrors" "github.com/fatih/color" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" @@ -38,7 +39,7 @@ func (p *TableFormatter) Iter(_, ew io.Writer, row []string) error { } if err := p.table.Append(row); err != nil { - return err + return perrors.Wrap(err, perrors.WithMessage("failed to append row to table")) } p.rows++ @@ -61,7 +62,10 @@ func (p *TableFormatter) Caption(w io.Writer, caption string) error { } func (p *TableFormatter) Render(_ io.Writer, _ int) error { - return p.table.Render() + if err := p.table.Render(); err != nil { + return perrors.Wrap(err, perrors.WithMessage("failed to render table")) + } + return nil } func (p *TableFormatter) Done(_ io.Writer) error { diff --git a/internal/app/renderer/meta_renderer.go b/internal/app/renderer/meta_renderer.go index 2cc6362..0668a2a 100644 --- a/internal/app/renderer/meta_renderer.go +++ b/internal/app/renderer/meta_renderer.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/balajz/pgxcli/internal/config" + "github.com/balajz/pgxcli/internal/perrors" "github.com/balajz/pgxcli/pgxspecial" "github.com/fatih/color" "github.com/olekukonko/tablewriter" @@ -30,7 +31,7 @@ func Table(data Data, w io.Writer, c *config.Config) error { t.Header(data.Columns()) if err := t.Bulk(rows); err != nil { - return err + return perrors.Wrap(err, perrors.WithMessage("failed to bulk append rows to table")) } if captionText := data.Caption(); captionText != "" { @@ -41,7 +42,10 @@ func Table(data Data, w io.Writer, c *config.Config) error { } t.Caption(caption) } - return t.Render() + if err := t.Render(); err != nil { + return perrors.Wrap(err, perrors.WithMessage("failed to render table")) + } + return nil } type rowsTableResult interface { diff --git a/internal/app/ui/components/input.go b/internal/app/ui/components/input.go index efe24aa..24bb44e 100644 --- a/internal/app/ui/components/input.go +++ b/internal/app/ui/components/input.go @@ -2,7 +2,6 @@ package components import ( "bytes" - "fmt" "os" "path/filepath" "strings" @@ -12,6 +11,7 @@ import ( "github.com/alecthomas/chroma/v2/quick" "github.com/balajz/bubbline/editline" "github.com/balajz/bubbline/history" + "github.com/balajz/pgxcli/internal/perrors" "github.com/muesli/termenv" ) @@ -33,7 +33,7 @@ func NewInputModel(prompt, historyFile string, style string, autoCompleter editl } if err := applyEditlineConfig(el, historyFile, style); err != nil { - return nil, fmt.Errorf("applying input config: %w", err) + return nil, err } el.AutoComplete = autoCompleter @@ -78,7 +78,16 @@ func (m *InputModel) SaveHistory() error { if m.HistoryFile == "" { return nil } - return history.SaveHistory(m.Model.GetHistory(), m.HistoryFile) + if err := history.SaveHistory(m.Model.GetHistory(), m.HistoryFile); err != nil { + return perrors.Wrap( + err, + perrors.WithMessage("failed to save save history"), + perrors.WithDetails( + "path", m.HistoryFile, + ), + ) + } + return nil } func (m *InputModel) SetPrompt(prompt string) { @@ -143,7 +152,13 @@ func applyEditlineConfig(el *editline.Model, historyFile string, style string) e entries, err := history.LoadHistory(historyFile) if err != nil { - return fmt.Errorf("loading history: %w", err) + return perrors.Wrap( + err, + perrors.WithMessage("failed to load history"), + perrors.WithDetails( + "path", historyFile, + ), + ) } el.SetHistory(entries) diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 3efde13..b46d07d 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -17,6 +17,7 @@ import ( "github.com/alecthomas/chroma/v2/quick" "github.com/balajz/bubbline/editline" "github.com/balajz/pgxcli/internal/app/ui/components" + "github.com/balajz/pgxcli/internal/perrors" "github.com/davecgh/go-spew/spew" "github.com/muesli/termenv" ) @@ -77,7 +78,7 @@ type Model struct { func New(initialPrefix string, historyFile string, style string, version string, executeFunc execute, cancelFunc cancel, autocompleter editline.AutoCompleteFn) (*Model, error) { inputModel, err := components.NewInputModel(initialPrefix, historyFile, style, autocompleter) if err != nil { - return nil, fmt.Errorf("creating input model: %w", err) + return nil, err } styles := DefaultStyles() @@ -92,7 +93,10 @@ func New(initialPrefix string, historyFile string, style string, version string, if _, ok := os.LookupEnv("PGXCLI_DEBUG"); ok { dump, err = os.OpenFile("pgxcli_messages.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { - return nil, fmt.Errorf("opening debug log: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to debug UI"), + ) } } @@ -325,18 +329,11 @@ func (m *Model) View() tea.View { return tea.NewView(baseView) } -func (m *Model) saveHistory() error { - return m.input.SaveHistory() -} - func (m *Model) Close() error { if m.dump != nil { _ = m.dump.Close() } - if err := m.saveHistory(); err != nil { - return fmt.Errorf("saving history: %w", err) - } - return nil + return m.input.SaveHistory() } // PrintCmd returns a command that prints formatted text. diff --git a/internal/cli/root.go b/internal/cli/root.go index 60e0daa..7e968eb 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ import ( "github.com/balajz/pgxcli/internal/config" "github.com/balajz/pgxcli/internal/database" "github.com/balajz/pgxcli/internal/logger" + "github.com/balajz/pgxcli/internal/perrors" "github.com/spf13/cobra" ) @@ -81,8 +82,8 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { }, PersistentPostRunE: func(_ *cobra.Command, _ []string) error { - if cliCtx.Logger != nil { - if err := cliCtx.Logger.Close(); err != nil { + if cliCtx.App != nil { + if err := cliCtx.App.Close(); err != nil { return err } } @@ -91,8 +92,8 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { return err } } - if cliCtx.App != nil { - if err := cliCtx.App.Close(); err != nil { + if cliCtx.Logger != nil { + if err := cliCtx.Logger.Close(); err != nil { return err } } @@ -408,7 +409,11 @@ func promptPassword(s string) (string, error) { if !term.IsTerminal(fd) { pwd, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { - return "", err + return "", perrors.Wrap( + err, + perrors.WithMessage("failed to read password"), + perrors.WithDetails("terminal", false), + ) } return strings.TrimRight(pwd, "\r\n"), nil } @@ -416,7 +421,11 @@ func promptPassword(s string) (string, error) { pwd, err := term.ReadPassword(fd) fmt.Println() if err != nil { - return "", err + return "", perrors.Wrap( + err, + perrors.WithMessage("failed to read password"), + perrors.WithDetails("terminal", true), + ) } return string(pwd), nil } @@ -424,7 +433,11 @@ func promptPassword(s string) (string, error) { func mustParsePort(port string) (uint16, error) { portNum, err := strconv.Atoi(port) if err != nil { - return 0, err + return 0, perrors.Wrap( + err, + perrors.WithMessage("failed to parse port"), + perrors.WithDetails("port", port), + ) } return uint16(portNum), nil } diff --git a/internal/cliio/printer.go b/internal/cliio/printer.go index c35bbca..3600710 100644 --- a/internal/cliio/printer.go +++ b/internal/cliio/printer.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "os/exec" "runtime" @@ -13,6 +14,9 @@ import ( "syscall" "time" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/list" + "github.com/balajz/pgxcli/internal/perrors" "github.com/charmbracelet/x/term" "github.com/fatih/color" "github.com/google/shlex" @@ -30,6 +34,7 @@ const ( pagerModeNever = "never" defaultTerminalHeight = 24 + defaultTerminalWidth = 80 autoPagerMinBytes = 4096 ) @@ -50,9 +55,9 @@ type pgxPrinter struct { out io.Writer errOut io.Writer - pagerMode string - isTerminal bool - terminalHeight int + pagerMode string + isTerminal bool + terminalHeight, terminalWidth int pagerPath string pagerArgs []string @@ -61,12 +66,19 @@ type pgxPrinter struct { // NewPgxPrinter creates a printer with pager auto-detection. func NewPgxPrinter(out io.Writer, errOut io.Writer) Printer { + width, height, err := term.GetSize(os.Stderr.Fd()) + if err != nil || height <= 0 { + height = defaultTerminalHeight + width = defaultTerminalWidth + } + p := &pgxPrinter{ out: out, errOut: errOut, pagerMode: pagerModeAuto, isTerminal: term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()), - terminalHeight: detectTerminalHeight(os.Stdout.Fd()), + terminalHeight: height, + terminalWidth: width, pagerPath: "", pagerArgs: nil, pagerSupported: false, @@ -114,7 +126,52 @@ func (p *pgxPrinter) Print(str string) { // PrintError writes an error message to the configured error stream. func (p *pgxPrinter) PrintError(err error) { - printErr(p.errOut, "%v\n", err) + de, ok := errors.AsType[perrors.ErrDetailed](err) + if !ok { + lipgloss.Fprintln(p.errOut, + lipgloss.JoinHorizontal( + lipgloss.Top, + Error.Render("❌ Error"), + err.Error(), + ), + ) + return + } + + // Print main error message + lipgloss.Fprintln(p.errOut, + lipgloss.JoinHorizontal( + lipgloss.Top, + Error.Render("❌ Error"), + err.Error(), + ), + ) + + if msgs := de.Messages(); len(msgs) > 0 { + l := list.New(msgs). + ItemStyle(Detail). + Enumerator(list.Roman). + EnumeratorStyle(Accent.Margin(0, 1, 0, 2)) + + lipgloss.Fprintln(p.errOut, Heading.Render("\nDetails")) + lipgloss.Fprintln(p.errOut, l) + } + + if details := maps.Collect(de.Details()); len(details) > 0 { + lipgloss.Fprintln(os.Stderr, Heading.Render("\nContext")) + for k, v := range details { + fmt.Fprintln(p.errOut, lipgloss.JoinHorizontal( + lipgloss.Left, + Accent.MarginLeft(2).Render(k), + ": ", + Detail.Render(fmt.Sprint(v)), + )) + } + } + + if output := de.Output(); output != "" { + fmt.Fprintf(os.Stderr, "\nOutput:\n%s\n", output) + } } // PrintTime prints execution duration in seconds. @@ -135,7 +192,10 @@ func (p *pgxPrinter) PrintViaPager(str string) { err := p.echoViaPager(func(w io.Writer) error { _, err := io.WriteString(w, output) - return err + if err != nil { + return perrors.Wrap(err, perrors.WithMessage("failed to write to pager")) + } + return nil }) if err != nil { p.PrintError(err) @@ -258,14 +318,6 @@ func waitIgnoringInterrupt(w waiter) error { } } -func detectTerminalHeight(fd uintptr) int { - _, height, err := term.GetSize(fd) - if err != nil || height <= 0 { - return defaultTerminalHeight - } - return height -} - func resolvePagerCommand() (string, []string, bool) { pagerCmd := getPager() if len(pagerCmd) == 0 { diff --git a/internal/cliio/styles.go b/internal/cliio/styles.go new file mode 100644 index 0000000..61ab10f --- /dev/null +++ b/internal/cliio/styles.go @@ -0,0 +1,39 @@ +package cliio + +import "charm.land/lipgloss/v2" + +var ( + // Primary + Heading = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9333EA")). + Bold(true) + + Accent = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C026D3")) + + Error = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F87171")). + MarginRight(2). + Bold(true) + + Warning = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B084F5")). + Bold(true) + + Info = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A855F7")) + + Success = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8BDAA0")). + Bold(true) + + Debug = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C6FA6")) + + // Text + Detail = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")) + + Dim = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B6680")) +) diff --git a/internal/config/config.go b/internal/config/config.go index 3a80af2..f39cafa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" + "github.com/balajz/pgxcli/internal/perrors" "github.com/spf13/viper" ) @@ -69,17 +70,31 @@ func Load() (*Config, error) { userV := viper.New() userV.SetConfigFile(userPath) if err := userV.ReadInConfig(); err != nil { - return nil, fmt.Errorf("read user config: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to read config"), + perrors.WithDetails( + "type", "user", + ), + ) } - // user settings land on top of default settings if err := defaultV.MergeConfigMap(userV.AllSettings()); err != nil { - return nil, fmt.Errorf("merge configs: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to merge cofig"), + ) } var cfg Config if err := defaultV.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("unmarshal config: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to unmarshal config"), + perrors.WithDetails( + "type", "merged", + ), + ) } if err := validate(cfg); err != nil { @@ -93,12 +108,24 @@ func GetDefaultConfig() (*Config, error) { defaultV := viper.New() defaultV.SetConfigType("toml") if err := defaultV.ReadConfig(bytes.NewReader(defaultConfigFile)); err != nil { - return nil, fmt.Errorf("read default config: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to read config"), + perrors.WithDetails( + "type", "default", + ), + ) } var cfg Config if err := defaultV.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("unmarshal config: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to unmarshal config"), + perrors.WithDetails( + "type", "default", + ), + ) } return &cfg, nil } @@ -111,7 +138,10 @@ func UserConfigPath() (string, error) { userdir, err := os.UserConfigDir() if err != nil { - return "", err + return "", perrors.Wrap( + err, + perrors.WithMessage("failed to get the user config directory"), + ) } return filepath.Join(userdir, appName, filename), nil } @@ -122,10 +152,23 @@ func ensureUserConfig(path string) error { return nil } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("create config directory: %w", err) + return perrors.Wrap( + err, + perrors.WithMessage("failed to create config directory"), + perrors.WithDetails( + "path", path, + ), + ) } + if err := os.WriteFile(path, defaultConfigFile, 0o644); err != nil { - return fmt.Errorf("write default config: %w", err) + return perrors.Wrap( + err, + perrors.WithMessage("failed to write default config"), + perrors.WithDetails( + "path", path, + ), + ) } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cce8865..91de0bb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -100,7 +100,7 @@ func TestLoad_InvalidUserConfig(t *testing.T) { _, err = Load() require.Error(t, err) - assert.Contains(t, err.Error(), "read user config") + assert.Contains(t, err.Error(), "parsing config") } func TestUserConfigPath(t *testing.T) { diff --git a/internal/database/client.go b/internal/database/client.go index 9067795..c3e90a3 100644 --- a/internal/database/client.go +++ b/internal/database/client.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/balajz/pgxcli/internal/perrors" "github.com/balajz/pgxcli/pgxspecial" compDB "github.com/balajz/pgxls/pkg/database" ) @@ -218,8 +219,19 @@ func (c *Client) Cache(worker *compDB.Worker) error { // Close closes the current database connection if one exists. func (c *Client) Close(ctx context.Context) error { - if c.executor != nil { - return c.executor.close(ctx) + if c.executor == nil { + return nil + } + + if err := c.executor.close(ctx); err != nil { + return perrors.Wrap( + err, + perrors.WithMessage("failed to close connection"), + perrors.WithDetails( + "conn", c.executor.conn.Config().ConnString(), + ), + ) } + return nil } diff --git a/internal/database/connector.go b/internal/database/connector.go index 5a0eaad..0ae2e5c 100644 --- a/internal/database/connector.go +++ b/internal/database/connector.go @@ -5,6 +5,7 @@ import ( "net" "time" + "github.com/balajz/pgxcli/internal/perrors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -25,7 +26,10 @@ type pgConnector struct { func NewPGConnectorFromConnString(connString string) (Connector, error) { cfg, err := pgx.ParseConfig(connString) if err != nil { - return nil, err + return nil, perrors.Wrap( + err, + perrors.WithMessage("invalid PostgreSQL connection string"), + ) } return &pgConnector{cfg: cfg}, nil @@ -35,7 +39,10 @@ func NewPGConnectorFromConnString(connString string) (Connector, error) { func NewPGConnectorFromFields(host, database, user, password string, port uint16) (Connector, error) { cfg, err := pgx.ParseConfig("") if err != nil { - return nil, err + return nil, perrors.Wrap( + err, + perrors.WithMessage("invalid PostgreSQL connection string"), + ) } checkAndSet := func(field *string, value string) { @@ -54,16 +61,6 @@ func NewPGConnectorFromFields(host, database, user, password string, port uint16 return &pgConnector{cfg: cfg}, nil } -// UpdatePassword updates the password on the underlying connection config. -func (c *pgConnector) UpdatePassword(newPassword string) { - c.cfg.Password = newPassword -} - -// Password returns the password from the underlying connection config. -func (c *pgConnector) Password() string { - return c.cfg.Password -} - // Connect opens a new pgx connection using the connector configuration. func (c *pgConnector) Connect(ctx context.Context) (*pgx.Conn, error) { c.cfg.DefaultQueryExecMode = pgx.QueryExecModeExec @@ -77,7 +74,16 @@ func (c *pgConnector) Connect(ctx context.Context) (*pgx.Conn, error) { conn, err := pgx.ConnectConfig(ctx, c.cfg) if err != nil { - return nil, err + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to connect to PostgreSQL"), + perrors.WithDetails( + "user", c.cfg.User, + "database", c.cfg.Database, + "host", c.cfg.Host, + "port", c.cfg.Port, + ), + ) } conn.TypeMap().RegisterTypes(customTypes()) @@ -85,6 +91,16 @@ func (c *pgConnector) Connect(ctx context.Context) (*pgx.Conn, error) { return conn, nil } +// UpdatePassword updates the password on the underlying connection config. +func (c *pgConnector) UpdatePassword(newPassword string) { + c.cfg.Password = newPassword +} + +// Password returns the password from the underlying connection config. +func (c *pgConnector) Password() string { + return c.cfg.Password +} + func customTypes() []*pgtype.Type { return []*pgtype.Type{ { diff --git a/internal/database/executor.go b/internal/database/executor.go index 0e12977..e8f3685 100644 --- a/internal/database/executor.go +++ b/internal/database/executor.go @@ -6,6 +6,7 @@ import ( "log/slog" "github.com/balajz/pgxcli/internal/database/result" + "github.com/balajz/pgxcli/internal/perrors" "github.com/balajz/pgxcli/pgxspecial" "github.com/jackc/pgx/v5" ) @@ -42,7 +43,10 @@ func newExecutor(ctx context.Context, c Connector, logger *slog.Logger) (*execut logger.Error("Failed to close connection", "error", err) } - return nil, err + return nil, perrors.Wrap( + err, + perrors.WithMessage("db ping failed"), + ) } return &executor{ diff --git a/internal/database/rows_multi.go b/internal/database/rows_multi.go index e7a5777..33f90e5 100644 --- a/internal/database/rows_multi.go +++ b/internal/database/rows_multi.go @@ -6,6 +6,7 @@ import ( "fmt" "io" + "github.com/balajz/pgxcli/internal/perrors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" @@ -39,7 +40,11 @@ func (r *sqlRowsMultiResultSet) Columns() []string { func (r *sqlRowsMultiResultSet) Tag() (CommandTag, error) { if rr := r.rows.ResultReader(); rr != nil { // ResultReader may be nil if an empty query was executed. - return r.rows.ResultReader().Close() + tag, err := r.rows.ResultReader().Close() + if err != nil { + return tag, perrors.Wrap(err, perrors.WithMessage("failed to close result reader")) + } + return tag, nil } return pgconn.CommandTag{}, nil } @@ -47,9 +52,13 @@ func (r *sqlRowsMultiResultSet) Tag() (CommandTag, error) { func (r *sqlRowsMultiResultSet) Close() (retErr error) { if rr := r.rows.ResultReader(); rr != nil { // ResultReader may be nil if an empty query was executed. - _, retErr = r.rows.ResultReader().Close() + if _, err := r.rows.ResultReader().Close(); err != nil { + retErr = perrors.Wrap(err, perrors.WithMessage("failed to close result reader")) + } + } + if err := r.rows.Close(); err != nil { + retErr = errors.Join(retErr, perrors.Wrap(err, perrors.WithMessage("failed to close multi result reader"))) } - retErr = errors.Join(retErr, r.rows.Close()) if r.exec.conn.IsClosed() { return retErr } @@ -68,7 +77,7 @@ func (r *sqlRowsMultiResultSet) Next(values []driver.Value) error { } if !rd.NextRow() { if _, err := rd.Close(); err != nil { - return err + return perrors.Wrap(err, perrors.WithMessage("failed to close result reader")) } return io.EOF } @@ -103,7 +112,7 @@ func (r *sqlRowsMultiResultSet) NextResultSet() (bool, error) { next := r.rows.NextResult() if !next { if err := r.rows.Close(); err != nil { - return false, err + return false, perrors.Wrap(err, perrors.WithMessage("failed to close multi result reader")) } } if r.exec.conn.IsClosed() { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index ff383ab..0a5c6fa 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -2,12 +2,13 @@ package logger import ( - "fmt" "io" "log/slog" "os" "path/filepath" "runtime" + + "github.com/balajz/pgxcli/internal/perrors" ) // Logger wraps slog.Logger and the underlying file for proper cleanup. @@ -36,14 +37,16 @@ func InitLogger(debug bool, filename string) (*Logger, error) { opts.Level = slog.LevelDebug } - if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { - return nil, fmt.Errorf("failed to create log directory: %w", err) - } - file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) + return nil, perrors.Wrap( + err, + perrors.WithMessage("failed to create the log file"), + perrors.WithDetails( + "path", filename, + ), + ) } handler := slog.NewTextHandler(file, opts) @@ -56,7 +59,15 @@ func InitLogger(debug bool, filename string) (*Logger, error) { // Close closes the underlying log file if one exists. func (l *Logger) Close() error { if l.file != nil { - return l.file.Close() + if err := l.file.Close(); err != nil { + return perrors.Wrap( + err, + perrors.WithMessage("failed to close the log file"), + perrors.WithDetails( + "path", l.file.Name(), + ), + ) + } } return nil } @@ -89,7 +100,13 @@ func getDefaultLogPath() (string, error) { logDir := filepath.Join(baseDir, "pgxcli") if err := os.MkdirAll(logDir, 0o755); err != nil { - return "", fmt.Errorf("failed to create log directory: %w", err) + return "", perrors.Wrap( + err, + perrors.WithMessage("failed to create log directory"), + perrors.WithDetails( + "path", logDir, + ), + ) } return filepath.Join(logDir, "pgxcli.log"), nil diff --git a/internal/perrors/errors.go b/internal/perrors/errors.go new file mode 100644 index 0000000..23bb748 --- /dev/null +++ b/internal/perrors/errors.go @@ -0,0 +1,105 @@ +// Package perrors provides error handling for pgxcli. +package perrors + +import ( + "cmp" + "errors" + "fmt" + "iter" +) + +// Option changes things in an [ErrDetailed]. +type Option func(*ErrDetailed) + +// WithExit sets the exit code in an [ErrDetailed]. +func WithExit(exit int) Option { + return func(ed *ErrDetailed) { + ed.exit = exit + } +} + +// WithMessage adds a message to an [ErrDetailed]. +func WithMessage(message string) Option { + return func(ed *ErrDetailed) { + ed.messages = append(ed.messages, message) + } +} + +// WithOutput sets the output in an [ErrDetailed]. +func WithOutput(output string) Option { + return func(ed *ErrDetailed) { + ed.output = output + } +} + +// WithDetails adds details to an [ErrDetailed]. +// +// Details are key-value pairs, so the number of arguments should be even. +func WithDetails(pairs ...any) Option { + return func(ed *ErrDetailed) { + if len(pairs)%2 != 0 { + pairs = append(pairs, "missing value") + } + ed.details = pairs + } +} + +// Wrap makes an error with details, mainly used for logging. +func Wrap(err error, opts ...Option) error { + result := ErrDetailed{ + err: err, + exit: 1, + } + + for _, opt := range opts { + opt(&result) + } + + if de, ok := errors.AsType[ErrDetailed](err); ok { + result.details = append(de.details, result.details...) + result.messages = append(result.messages, de.messages...) + result.output = cmp.Or(result.output, de.output) + } + + return result +} + +// ErrDetailed is an error with details, mainly used for logging. +type ErrDetailed struct { + err error + exit int + output string + messages []string + details []any +} + +// Details returns the details of an [ErrDetailed] as a sequence of key-value +// pairs. +func (e ErrDetailed) Details() iter.Seq2[string, any] { + return func(yield func(string, any) bool) { + for i := 0; i+1 < len(e.details); i += 2 { + key, ok := e.details[i].(string) + if !ok { + key = fmt.Sprintf("%v", e.details[i]) + } + if !yield(key, e.details[i+1]) { + break + } + } + } +} + +// Error implements error. +func (e ErrDetailed) Error() string { return e.err.Error() } + +// Unwrap implements unwrap. +func (e ErrDetailed) Unwrap() error { return e.err } + +// Exit gets the exit code of an error, if available. +func (e ErrDetailed) Exit() int { return e.exit } + +// Messages returns the messages of an [ErrDetailed]. +func (e ErrDetailed) Messages() []string { return e.messages } + +// Output returns the output of an [ErrDetailed]. +func (e ErrDetailed) Output() string { return e.output } diff --git a/internal/perrors/errors_test.go b/internal/perrors/errors_test.go new file mode 100644 index 0000000..fbf04be --- /dev/null +++ b/internal/perrors/errors_test.go @@ -0,0 +1,82 @@ +package perrors + +import ( + "errors" + "maps" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDetails(t *testing.T) { + og := errors.New("fake") + err := Wrap( + og, + WithMessage("message"), + WithDetails( + "foo", "bar", + "hi", 10, + ), + ) + + de, ok := errors.AsType[ErrDetailed](err) + require.True(t, ok) + + require.Equal(t, map[string]any{ + "foo": "bar", + "hi": 10, + }, maps.Collect(de.Details())) + require.Equal(t, 1, de.Exit()) + require.Equal(t, []string{"message"}, de.Messages()) + require.ErrorIs(t, err, og) + require.Equal(t, "fake", err.Error()) +} + +func TestDetailsStacking(t *testing.T) { + og := errors.New("fake") + err := Wrap( + og, + WithMessage("message1"), + WithDetails( + "foo", "bar", + "hi", 10, + ), + ) + err = Wrap( + err, + WithMessage("message2"), + WithExit(2), + WithDetails("stacked", true), + ) + + de, ok := errors.AsType[ErrDetailed](err) + require.True(t, ok) + + require.Equal(t, map[string]any{ + "foo": "bar", + "hi": 10, + "stacked": true, + }, maps.Collect(de.Details())) + require.Equal(t, 2, de.Exit()) + require.Equal(t, []string{"message2", "message1"}, de.Messages()) + require.Equal(t, "fake", err.Error()) +} + +func TestDetailsOdd(t *testing.T) { + og := errors.New("fake") + err := Wrap( + og, + WithMessage("message"), + WithDetails("foo", "bar", "hi"), + ) + + de, ok := errors.AsType[ErrDetailed](err) + require.True(t, ok) + + require.Equal(t, map[string]any{ + "foo": "bar", + "hi": "missing value", + }, maps.Collect(de.Details())) + require.ErrorIs(t, err, og) + require.Equal(t, "fake", err.Error()) +}