diff --git a/.gitignore b/.gitignore index 0e1b7ab..a6cdc2d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ *.dll *.so *.dylib +*.a +*.o # Test binary, built with `go test -c` *.test @@ -41,6 +43,7 @@ go.work.sum .LSOverride # Local build output +goplaces bin/ dist/ tmp/ diff --git a/.golangci.yml b/.golangci.yml index 5bcbaac..8649cbd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,5 @@ +version: "2" + run: timeout: 5m tests: true @@ -5,27 +7,30 @@ run: linters: enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - - typecheck - unused - gocritic - goconst - - gofmt - - gofumpt - - goimports - misspell - prealloc - revive - unparam -linters-settings: +formatters: + enable: + - gofmt + - gofumpt + - goimports + +formatters-settings: gofumpt: extra-rules: true goimports: local-prefixes: github.com/steipete/goplaces + +linters-settings: revive: rules: - name: early-return diff --git a/client.go b/client.go index 9e9c157..a883abf 100644 --- a/client.go +++ b/client.go @@ -1,3 +1,4 @@ +// Package goplaces provides a Go client for the Google Places API (New). package goplaces import ( @@ -12,6 +13,7 @@ import ( "time" ) +// DefaultBaseURL is the default endpoint for the Places API (New). const DefaultBaseURL = "https://places.googleapis.com/v1" const ( @@ -51,12 +53,14 @@ var enumToPriceLevel = map[string]int{ priceLevelVeryExp: 4, } +// Client wraps access to the Google Places API. type Client struct { apiKey string baseURL string httpClient *http.Client } +// Options configures the Places client. type Options struct { APIKey string BaseURL string @@ -64,6 +68,7 @@ type Options struct { Timeout time.Duration } +// NewClient builds a client with sane defaults. func NewClient(opts Options) *Client { baseURL := strings.TrimRight(opts.BaseURL, "/") if baseURL == "" { @@ -86,6 +91,7 @@ func NewClient(opts Options) *Client { } } +// Search performs a text search with optional filters. func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) { req = applySearchDefaults(req) if err := validateSearchRequest(req); err != nil { @@ -114,6 +120,7 @@ func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResponse, }, nil } +// Details fetches details for a specific place ID. func (c *Client) Details(ctx context.Context, placeID string) (PlaceDetails, error) { placeID = strings.TrimSpace(placeID) if placeID == "" { @@ -134,6 +141,7 @@ func (c *Client) Details(ctx context.Context, placeID string) (PlaceDetails, err return mapPlaceDetails(place), nil } +// Resolve converts a free-form location string into candidate places. func (c *Client) Resolve(ctx context.Context, req LocationResolveRequest) (LocationResolveResponse, error) { req = applyResolveDefaults(req) if err := validateResolveRequest(req); err != nil { @@ -197,7 +205,9 @@ func (c *Client) doRequest( if err != nil { return nil, fmt.Errorf("goplaces: request failed: %w", err) } - defer response.Body.Close() + defer func() { + _ = response.Body.Close() + }() payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { diff --git a/client_test.go b/client_test.go index 3134268..408b291 100644 --- a/client_test.go +++ b/client_test.go @@ -111,7 +111,7 @@ func TestSearchSuccess(t *testing.T) { } func TestSearchHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte("bad")) })) @@ -129,7 +129,7 @@ func TestSearchHTTPError(t *testing.T) { } func TestSearchInvalidJSON(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("not-json")) })) defer server.Close() diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d691e5c..df22795 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -43,7 +43,7 @@ func TestRunSearchJSON(t *testing.T) { } func TestRunSearchHuman(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"places": [{"id": "abc", "displayName": {"text": "Cafe"}}]}`)) })) defer server.Close() @@ -95,7 +95,7 @@ func TestRunDetailsJSON(t *testing.T) { } func TestRunDetailsHuman(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"id": "place-2", "displayName": {"text": "Park"}}`)) })) defer server.Close() @@ -149,7 +149,7 @@ func TestRunResolveHuman(t *testing.T) { } func TestRunResolveJSON(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"places": [{"id": "loc-2"}]}`)) })) defer server.Close() diff --git a/internal/cli/color.go b/internal/cli/color.go index d971af3..7abcb70 100644 --- a/internal/cli/color.go +++ b/internal/cli/color.go @@ -5,30 +5,37 @@ import ( "strings" ) +// Color renders ANSI color sequences. type Color struct { enabled bool } +// NewColor returns a color helper, optionally disabled. func NewColor(enabled bool) Color { return Color{enabled: enabled} } +// Bold wraps a string in bold ANSI codes. func (c Color) Bold(value string) string { return c.wrap("1", value) } +// Cyan wraps a string in cyan ANSI codes. func (c Color) Cyan(value string) string { return c.wrap("36", value) } +// Green wraps a string in green ANSI codes. func (c Color) Green(value string) string { return c.wrap("32", value) } +// Yellow wraps a string in yellow ANSI codes. func (c Color) Yellow(value string) string { return c.wrap("33", value) } +// Dim wraps a string in dim ANSI codes. func (c Color) Dim(value string) string { return c.wrap("2", value) } diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..d987eb0 --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,2 @@ +// Package cli implements the goplaces command-line interface. +package cli diff --git a/internal/cli/root.go b/internal/cli/root.go index ecfb6a9..7b0f43c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,6 +4,7 @@ import ( "time" ) +// Root defines the CLI command tree. type Root struct { Global GlobalOptions `embed:""` Search SearchCmd `cmd:"" help:"Search places by text query."` @@ -11,6 +12,7 @@ type Root struct { Resolve ResolveCmd `cmd:"" help:"Resolve a location string to candidate places."` } +// GlobalOptions are flags shared by all commands. type GlobalOptions struct { APIKey string `help:"Google Places API key." env:"GOOGLE_PLACES_API_KEY"` BaseURL string `help:"Places API base URL." env:"GOOGLE_PLACES_BASE_URL" default:"https://places.googleapis.com/v1"` @@ -21,6 +23,7 @@ type GlobalOptions struct { Version VersionFlag `name:"version" help:"Print version and exit."` } +// SearchCmd runs text search queries. type SearchCmd struct { Query string `arg:"" name:"query" help:"Search text."` Limit int `help:"Max results (1-20)." default:"10"` @@ -35,10 +38,12 @@ type SearchCmd struct { RadiusM *float64 `help:"Radius in meters for location bias."` } +// DetailsCmd fetches place details. type DetailsCmd struct { PlaceID string `arg:"" name:"place_id" help:"Place ID."` } +// ResolveCmd resolves a location string into candidates. type ResolveCmd struct { LocationText string `arg:"" name:"location" help:"Location text to resolve."` Limit int `help:"Max results (1-10)." default:"5"` diff --git a/internal/cli/run.go b/internal/cli/run.go index d42b174..52fdb28 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -12,6 +12,7 @@ import ( "github.com/steipete/goplaces" ) +// App wires CLI output and API access. type App struct { client *goplaces.Client out io.Writer @@ -20,6 +21,7 @@ type App struct { color Color } +// Run executes the CLI with the provided arguments. func Run(args []string, stdout io.Writer, stderr io.Writer) int { if stdout == nil { stdout = os.Stdout @@ -44,7 +46,7 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { kong.Vars{"version": Version}, ) if err != nil { - fmt.Fprintln(stderr, err) + _, _ = fmt.Fprintln(stderr, err) return 1 } @@ -55,10 +57,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { if err != nil { if parseErr, ok := err.(*kong.ParseError); ok { _ = parseErr.Context.PrintUsage(true) - fmt.Fprintln(stderr, parseErr.Error()) + _, _ = fmt.Fprintln(stderr, parseErr.Error()) return parseErr.ExitCode() } - fmt.Fprintln(stderr, err) + _, _ = fmt.Fprintln(stderr, err) return 2 } if root.Global.JSON { @@ -110,6 +112,7 @@ func parseWithExit(parser *kong.Kong, args []string, exitCode *int) (ctx *kong.C return ctx, exited, err } +// Run executes the search command. func (c *SearchCmd) Run(app *App) error { request := goplaces.SearchRequest{ Query: c.Query, @@ -167,6 +170,7 @@ func (c *SearchCmd) Run(app *App) error { return err } +// Run executes the details command. func (c *DetailsCmd) Run(app *App) error { response, err := app.client.Details(context.Background(), c.PlaceID) if err != nil { @@ -181,6 +185,7 @@ func (c *DetailsCmd) Run(app *App) error { return err } +// Run executes the resolve command. func (c *ResolveCmd) Run(app *App) error { request := goplaces.LocationResolveRequest{ LocationText: c.LocationText, @@ -215,13 +220,13 @@ func handleError(writer io.Writer, err error) int { } var validation goplaces.ValidationError if errors.As(err, &validation) { - fmt.Fprintln(writer, validation.Error()) + _, _ = fmt.Fprintln(writer, validation.Error()) return 2 } if errors.Is(err, goplaces.ErrMissingAPIKey) { - fmt.Fprintln(writer, err.Error()) + _, _ = fmt.Fprintln(writer, err.Error()) return 2 } - fmt.Fprintln(writer, err.Error()) + _, _ = fmt.Fprintln(writer, err.Error()) return 1 } diff --git a/internal/cli/version.go b/internal/cli/version.go index 3e95671..3dce457 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -6,15 +6,21 @@ import ( "github.com/alecthomas/kong" ) +// Version is the CLI version string. const Version = "0.1.0" +// VersionFlag prints the version and exits. type VersionFlag string +// Decode is a no-op for the boolean version flag. func (v VersionFlag) Decode(_ *kong.DecodeContext) error { return nil } -func (v VersionFlag) IsBool() bool { return true } +// IsBool marks the version flag as boolean. +func (v VersionFlag) IsBool() bool { return true } + +// BeforeApply prints the version and exits. func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { - fmt.Fprintln(app.Stdout, vars["version"]) + _, _ = fmt.Fprintln(app.Stdout, vars["version"]) app.Exit(0) return nil }