├── .gitignore ├── internal ├── cli │ ├── auth_handler_test.go │ ├── city_handler.go │ ├── auth_handler.go │ ├── commands_test.go │ ├── runner.go │ ├── setup_handler.go │ ├── setup_handler_test.go │ ├── city_handler_test.go │ ├── config_handler.go │ ├── commands.go │ ├── config_handler_test.go │ ├── cli_test_utils.go │ ├── weather_handler.go │ ├── weather_handler_test.go │ ├── runner_test.go │ └── cli_integration_test.go ├── ui │ ├── renderer │ │ ├── full.go │ │ ├── utils.go │ │ ├── interface.go │ │ ├── alerts.go │ │ ├── current.go │ │ ├── compact.go │ │ ├── terminal.go │ │ ├── hourly.go │ │ ├── daily.go │ │ └── renderer_test.go │ ├── styles │ │ ├── styles_test.go │ │ └── styles.go │ ├── components │ │ ├── spinner.go │ │ └── spinner_runner.go │ ├── setup │ │ ├── runner.go │ │ ├── runner_test.go │ │ ├── model_test.go │ │ ├── model.go │ │ ├── view.go │ │ ├── update.go │ │ ├── view_test.go │ │ └── update_test.go │ └── output │ │ └── output.go ├── config │ ├── config.go │ ├── config_test.go │ ├── auth_test.go │ └── auth.go ├── api │ ├── client_test.go │ └── client.go ├── templates │ └── templates.go └── models │ ├── weather_test.go │ └── weather.go ├── .github └── workflows │ ├── build-test.yml │ └── publish-brew.yml ├── cmd └── gust │ └── main.go ├── Makefile ├── go.mod ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /internal/cli/auth_handler_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestHandleMissingAuth(t *testing.T) { 10 | err := handleMissingAuth() 11 | 12 | assert.Error(t, err) 13 | assert.Equal(t, "authentication required", err.Error()) 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: build & test 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Go 14 | uses: actions/setup-go@v4 15 | with: 16 | go-version: "1.24" 17 | 18 | - name: Build 19 | run: go build -v ./... 20 | 21 | - name: Test 22 | run: go test -v ./... 23 | -------------------------------------------------------------------------------- /internal/ui/renderer/full.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/josephburgess/gust/internal/models" 8 | ) 9 | 10 | func (r *TerminalRenderer) RenderFullWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 11 | r.RenderCurrentWeather(city, weather, cfg) 12 | fmt.Println() 13 | 14 | if len(weather.Alerts) > 0 { 15 | r.RenderAlerts(city, weather, cfg) 16 | fmt.Println() 17 | } 18 | 19 | r.RenderDailyForecast(city, weather, cfg) 20 | fmt.Println() 21 | } 22 | -------------------------------------------------------------------------------- /cmd/gust/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/joho/godotenv" 8 | "github.com/josephburgess/gust/internal/cli" 9 | "github.com/josephburgess/gust/internal/ui/styles" 10 | ) 11 | 12 | func main() { 13 | _ = godotenv.Load() 14 | app, cliInstance := cli.NewApp() 15 | ctx, err := app.Parse(os.Args[1:]) 16 | if err != nil { 17 | styles.ExitWithError("Failed to parse command line arguments", err) 18 | } 19 | 20 | if err := cli.Run(ctx, cliInstance); err != nil { 21 | styles.ExitWithError(fmt.Sprintf("Command failed: %s", ctx.Command()), err) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/cli/city_handler.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func determineCityName(cityFlag string, args []string, defaultCity string) string { 9 | if cityFlag != "" { 10 | return cityFlag 11 | } 12 | if len(args) > 0 { 13 | return strings.Join(args, " ") // might be multi word city 14 | } 15 | return defaultCity 16 | } 17 | 18 | func handleMissingCity() error { 19 | fmt.Println("No city specified and no default city set.") 20 | fmt.Println("Specify a city: gust [city name]") 21 | fmt.Println("Or set a default city: gust --default \"London\"") 22 | fmt.Println("Or run the setup wizard: gust --setup") 23 | return fmt.Errorf("no city provided") 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/publish-brew.yml: -------------------------------------------------------------------------------- 1 | name: publish brew formula 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | homebrew-releaser: 9 | runs-on: ubuntu-latest 10 | name: homebrew-releaser 11 | steps: 12 | - name: Release project to Homebrew tap 13 | uses: Justintime50/homebrew-releaser@v2 14 | with: 15 | homebrew_owner: josephburgess 16 | homebrew_tap: homebrew-formulae 17 | formula_folder: . 18 | github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 19 | commit_owner: josephburgess 20 | commit_email: hello@joeburgess.dev 21 | install: 'system "go", "build", *std_go_args(ldflags: "-s -w"), "./cmd/gust"' 22 | test: 'assert_match "Gust Weather", shell_output("#{bin}/gust -h", 2)' 23 | depends_on: '"go" => :build' 24 | -------------------------------------------------------------------------------- /internal/ui/renderer/utils.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type BaseRenderer struct { 8 | Units string 9 | } 10 | 11 | func (r *BaseRenderer) GetTemperatureUnit() string { 12 | switch r.Units { 13 | case "imperial": 14 | return "°F" 15 | case "metric": 16 | return "°C" 17 | default: 18 | return "K" 19 | } 20 | } 21 | 22 | func (r *BaseRenderer) FormatWindSpeed(speed float64) float64 { 23 | switch r.Units { 24 | case "imperial": 25 | return speed 26 | default: 27 | return speed * 3.6 28 | } 29 | } 30 | 31 | func (r *BaseRenderer) GetWindSpeedUnit() string { 32 | switch r.Units { 33 | case "imperial": 34 | return "mph" 35 | default: 36 | return "km/h" 37 | } 38 | } 39 | 40 | func FormatDateTime(timestamp int64, format string) string { 41 | return time.Unix(timestamp, 0).Format(format) 42 | } 43 | -------------------------------------------------------------------------------- /internal/ui/renderer/interface.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "github.com/josephburgess/gust/internal/config" 5 | "github.com/josephburgess/gust/internal/models" 6 | ) 7 | 8 | type WeatherRenderer interface { 9 | RenderCurrentWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 10 | RenderDailyForecast(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 11 | RenderHourlyForecast(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 12 | RenderAlerts(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 13 | RenderFullWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 14 | RenderCompactWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) 15 | } 16 | 17 | func NewWeatherRenderer(rendererType string, units string) WeatherRenderer { 18 | return NewTerminalRenderer(units) 19 | } 20 | -------------------------------------------------------------------------------- /internal/cli/auth_handler.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/josephburgess/gust/internal/ui/output" 8 | ) 9 | 10 | func handleLogin(apiURL string) error { 11 | output.PrintInfo("Starting GitHub authentication...") 12 | authConfig, err := config.Authenticate(apiURL) 13 | if err != nil { 14 | return fmt.Errorf("authentication failed: %w", err) 15 | } 16 | 17 | if err := config.SaveAuthConfig(authConfig); err != nil { 18 | return fmt.Errorf("failed to save authentication: %w", err) 19 | } 20 | 21 | output.PrintSuccess(fmt.Sprintf("Successfully authenticated as %s\n", authConfig.GithubUser)) 22 | return nil 23 | } 24 | 25 | func handleMissingAuth() error { 26 | output.PrintError("You need to authenticate with GitHub before using Gust.") 27 | output.PrintInfo("Run 'gust --login' to authenticate or 'gust --setup' to run the setup wizard.") 28 | return fmt.Errorf("authentication required") 29 | } 30 | -------------------------------------------------------------------------------- /internal/cli/commands_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewApp(t *testing.T) { 10 | app, cli := NewApp() 11 | 12 | assert.NotNil(t, app) 13 | assert.NotNil(t, cli) 14 | 15 | fields := []struct { 16 | name string 17 | value any 18 | }{ 19 | {"City", cli.City}, 20 | {"Default", cli.Default}, 21 | {"ApiUrl", cli.ApiUrl}, 22 | {"Login", cli.Login}, 23 | {"Setup", cli.Setup}, 24 | {"Compact", cli.Compact}, 25 | {"Detailed", cli.Detailed}, 26 | {"Full", cli.Full}, 27 | {"Daily", cli.Daily}, 28 | {"Hourly", cli.Hourly}, 29 | {"Alerts", cli.Alerts}, 30 | {"Units", cli.Units}, 31 | {"Pretty", cli.Pretty}, 32 | } 33 | 34 | for _, field := range fields { 35 | switch v := field.value.(type) { 36 | case string: 37 | assert.Empty(t, v, "%s should be empty", field.name) 38 | case bool: 39 | assert.False(t, v, "%s should be false", field.name) 40 | } 41 | } 42 | 43 | assert.Empty(t, cli.Args) 44 | } 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test test-unit test-integration test-cover lint clean install all 2 | 3 | BINARY_NAME=gust 4 | GO=go 5 | GOTEST=$(GO) test 6 | GOLINT=golangci-lint 7 | INSTALL_DIR=/usr/local/bin 8 | 9 | build: 10 | $(GO) build -o $(BINARY_NAME) ./cmd/gust 11 | 12 | test: test-unit test-integration 13 | 14 | test-v: test-unit-v test-integration-v 15 | 16 | test-unit: 17 | $(GOTEST) ./... -short 18 | 19 | test-integration: 20 | $(GOTEST) ./... -run Integration 21 | 22 | test-unit-v: 23 | $(GOTEST) -v ./... -short 24 | 25 | test-integration-v: 26 | $(GOTEST) -v ./... -run Integration 27 | 28 | test-cover: 29 | $(GOTEST) -coverprofile=coverage.out ./... 30 | $(GO) tool cover -html=coverage.out 31 | 32 | lint: 33 | $(GOLINT) run ./... 34 | 35 | install: build 36 | mkdir -p $(INSTALL_DIR) 37 | cp $(BINARY_NAME) $(INSTALL_DIR)/ 38 | 39 | uninstall: 40 | rm -f $(INSTALL_DIR)/$(BINARY_NAME) 41 | 42 | clean: 43 | rm -f $(BINARY_NAME) 44 | rm -f coverage.out 45 | 46 | build-test: build test 47 | 48 | all: clean lint test build 49 | -------------------------------------------------------------------------------- /internal/ui/renderer/alerts.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/josephburgess/gust/internal/ui/styles" 11 | ) 12 | 13 | func (r *TerminalRenderer) RenderAlerts(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 14 | fmt.Print(styles.FormatHeader(fmt.Sprintf("WEATHER ALERTS FOR %s", strings.ToUpper(city.Name)))) 15 | 16 | if len(weather.Alerts) == 0 { 17 | fmt.Println("No weather alerts for this area.") 18 | return 19 | } 20 | 21 | for i, alert := range weather.Alerts { 22 | if i > 0 { 23 | fmt.Println(styles.Divider(30)) 24 | } 25 | 26 | fmt.Printf("%s\n", styles.AlertStyle(fmt.Sprintf("⚠️ %s", alert.Event))) 27 | fmt.Printf("Issued by: %s\n", alert.SenderName) 28 | fmt.Printf("Valid: %s to %s\n\n", 29 | styles.TimeStyle(time.Unix(alert.Start, 0).Format("Mon Jan 2 15:04")), 30 | styles.TimeStyle(time.Unix(alert.End, 0).Format("Mon Jan 2 15:04"))) 31 | 32 | fmt.Println(alert.Description) 33 | fmt.Println() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/cli/runner.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/alecthomas/kong" 7 | "github.com/josephburgess/gust/internal/config" 8 | "github.com/josephburgess/gust/internal/ui/output" 9 | ) 10 | 11 | func Run(ctx *kong.Context, cli *CLI) error { 12 | cfg, err := config.Load() 13 | if err != nil { 14 | return fmt.Errorf("failed to load configuration: %w", err) 15 | } 16 | 17 | if updated, err := handleConfigUpdates(cli, cfg); updated || err != nil { 18 | return err 19 | } 20 | 21 | if cli.Login { 22 | return handleLogin(cfg.ApiUrl) 23 | } 24 | 25 | authConfig, _ := config.LoadAuthConfig() 26 | needsAuth := authConfig == nil 27 | 28 | if needsSetup(cli, cfg) { 29 | output.PrintInfo("Defaults not set, running setup...") 30 | needsAuth, err = handleSetup(cfg) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | authConfig, _ = config.LoadAuthConfig() 36 | } 37 | 38 | if needsAuth { 39 | return handleMissingAuth() 40 | } 41 | 42 | city := determineCityName(cli.City, cli.Args, cfg.DefaultCity) 43 | if city == "" { 44 | return handleMissingCity() 45 | } 46 | 47 | return fetchAndRenderWeather(city, cfg, authConfig, cli) 48 | } 49 | -------------------------------------------------------------------------------- /internal/cli/setup_handler.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/josephburgess/gust/internal/ui/output" 8 | "github.com/josephburgess/gust/internal/ui/setup" 9 | ) 10 | 11 | func needsSetup(cli *CLI, cfg *config.Config) bool { 12 | return cfg.DefaultCity == "" || cli.Setup 13 | } 14 | 15 | func handleSetup(cfg *config.Config) (bool, error) { 16 | output.PrintInfo("Running setup wizard...") 17 | authConfig, _ := config.LoadAuthConfig() 18 | needsAuth := authConfig == nil 19 | 20 | if err := setup.RunSetup(cfg, needsAuth); err != nil { 21 | return needsAuth, fmt.Errorf("setup failed: %w", err) 22 | } 23 | 24 | newCfg, err := config.Load() 25 | if err != nil { 26 | return needsAuth, fmt.Errorf("failed to reload configuration after setup: %w", err) 27 | } 28 | 29 | cfg.DefaultCity = newCfg.DefaultCity 30 | 31 | authConfig, err = config.LoadAuthConfig() 32 | if err != nil { 33 | return true, fmt.Errorf("failed to load auth config after setup: %w", err) 34 | } 35 | 36 | needsAuth = authConfig == nil 37 | output.PrintSuccess("Setup complete! Run 'gust' to check the weather for your default city.") 38 | 39 | return needsAuth, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/cli/setup_handler_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNeedsSetup(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | cli *CLI 14 | cfg *config.Config 15 | expected bool 16 | }{ 17 | { 18 | name: "empty default city", 19 | cli: &CLI{}, 20 | cfg: &config.Config{DefaultCity: ""}, 21 | expected: true, 22 | }, 23 | { 24 | name: "setup flag set", 25 | cli: &CLI{Setup: true}, 26 | cfg: &config.Config{DefaultCity: "London"}, 27 | expected: true, 28 | }, 29 | { 30 | name: "city set w/ no setup flag", 31 | cli: &CLI{}, 32 | cfg: &config.Config{DefaultCity: "London"}, 33 | expected: false, 34 | }, 35 | { 36 | name: "empty default and setup flag", 37 | cli: &CLI{Setup: true}, 38 | cfg: &config.Config{DefaultCity: ""}, 39 | expected: true, 40 | }, 41 | } 42 | 43 | for _, tc := range testCases { 44 | t.Run(tc.name, func(t *testing.T) { 45 | result := needsSetup(tc.cli, tc.cfg) 46 | assert.Equal(t, tc.expected, result) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/ui/styles/styles_test.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestDivider(t *testing.T) { 9 | divider := Divider(35) 10 | expected := strings.Repeat("─", 35) 11 | 12 | if divider != expected { 13 | t.Errorf("Divider didn't return the expected string") 14 | } 15 | } 16 | 17 | func TestFormatHeaderFunction(t *testing.T) { 18 | title := "TEST TITLE" 19 | result := FormatHeader(title) 20 | 21 | if strings.Count(result, "\n") != 3 { 22 | t.Errorf("Expected header to have 3 newlines, got %d", strings.Count(result, "\n")) 23 | } 24 | 25 | if !strings.Contains(result, title) { 26 | t.Errorf("Expected header to contain the title, got %s", result) 27 | } 28 | 29 | if !strings.Contains(result, Divider(len(title)*2)) { 30 | t.Errorf("Expected header to contain a divider, got %s", result) 31 | } 32 | } 33 | 34 | func TestStyleFunctions(t *testing.T) { 35 | testText := "Test Text" 36 | 37 | styles := []struct { 38 | name string 39 | function func(a ...any) string 40 | }{ 41 | {"HeaderStyle", HeaderStyle}, 42 | {"TempStyle", TempStyle}, 43 | {"HighlightStyle", HighlightStyleF}, 44 | {"InfoStyle", InfoStyle}, 45 | {"TimeStyle", TimeStyle}, 46 | {"AlertStyle", AlertStyle}, 47 | } 48 | 49 | for _, style := range styles { 50 | result := style.function(testText) 51 | if result == "" { 52 | t.Errorf("%s returned empty string", style.name) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/ui/components/spinner.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/josephburgess/gust/internal/ui/styles" 10 | ) 11 | 12 | type SpinnerModel struct { 13 | Spinner spinner.Model 14 | } 15 | 16 | // default dot spinner 17 | func NewSpinner() SpinnerModel { 18 | s := spinner.New() 19 | s.Spinner = spinner.MiniDot 20 | s.Style = lipgloss.NewStyle().Foreground(styles.Foam) 21 | return SpinnerModel{ 22 | Spinner: s, 23 | } 24 | } 25 | 26 | // custom spinner type/colour 27 | func NewCustomSpinner(spinnerType spinner.Spinner, color lipgloss.Color) SpinnerModel { 28 | s := spinner.New() 29 | s.Spinner = spinnerType 30 | s.Style = lipgloss.NewStyle().Foreground(color) 31 | return SpinnerModel{ 32 | Spinner: s, 33 | } 34 | } 35 | 36 | // tick 37 | func (s SpinnerModel) Tick() tea.Cmd { 38 | return s.Spinner.Tick 39 | } 40 | 41 | // handles messages and updates state 42 | func (s SpinnerModel) Update(msg tea.Msg) (SpinnerModel, tea.Cmd) { 43 | var cmd tea.Cmd 44 | spinner, cmd := s.Spinner.Update(msg) 45 | s.Spinner = spinner 46 | return s, cmd 47 | } 48 | 49 | // render 50 | func (s SpinnerModel) View() string { 51 | return s.Spinner.View() 52 | } 53 | 54 | // custom weather spinner 55 | var WeatherEmojis = spinner.Spinner{ 56 | Frames: []string{"☀️ ", "⛅️ ", "☁️ ", "🌧️ ", "⛈️ ", "❄️ ", "🌪️ ", "🌈 "}, 57 | FPS: time.Second / 5, 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/josephburgess/gust 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/alecthomas/kong v1.8.1 7 | github.com/charmbracelet/bubbles v0.20.0 8 | github.com/charmbracelet/bubbletea v1.1.0 9 | github.com/charmbracelet/lipgloss v0.13.0 10 | github.com/fatih/color v1.16.0 11 | github.com/joho/godotenv v1.5.1 12 | github.com/stretchr/testify v1.10.0 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/x/ansi v0.2.3 // indirect 19 | github.com/charmbracelet/x/term v0.2.0 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-colorable v0.1.13 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/mattn/go-localereader v0.0.1 // indirect 26 | github.com/mattn/go-runewidth v0.0.16 // indirect 27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 28 | github.com/muesli/cancelreader v0.2.2 // indirect 29 | github.com/muesli/termenv v0.15.2 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/stretchr/objx v0.5.2 // indirect 33 | golang.org/x/sync v0.8.0 // indirect 34 | golang.org/x/sys v0.24.0 // indirect 35 | golang.org/x/text v0.3.8 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /internal/cli/city_handler_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDetermineCityName(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | cityFlag string 13 | args []string 14 | defaultCity string 15 | expected string 16 | }{ 17 | { 18 | name: "Use city flag when provided", 19 | cityFlag: "London", 20 | args: []string{"Paris"}, 21 | defaultCity: "Berlin", 22 | expected: "London", 23 | }, 24 | { 25 | name: "Use args when no flag but args provided", 26 | cityFlag: "", 27 | args: []string{"New", "York"}, 28 | defaultCity: "Berlin", 29 | expected: "New York", 30 | }, 31 | { 32 | name: "Use default city when no flag or args", 33 | cityFlag: "", 34 | args: []string{}, 35 | defaultCity: "Berlin", 36 | expected: "Berlin", 37 | }, 38 | { 39 | name: "Return empty when no values provided", 40 | cityFlag: "", 41 | args: []string{}, 42 | defaultCity: "", 43 | expected: "", 44 | }, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | result := determineCityName(tc.cityFlag, tc.args, tc.defaultCity) 50 | assert.Equal(t, tc.expected, result) 51 | }) 52 | } 53 | } 54 | 55 | func TestHandleMissingCity(t *testing.T) { 56 | err := handleMissingCity() 57 | 58 | assert.Error(t, err) 59 | assert.Equal(t, "no city provided", err.Error()) 60 | } 61 | -------------------------------------------------------------------------------- /internal/cli/config_handler.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/config" 9 | ) 10 | 11 | func handleConfigUpdates(cli *CLI, cfg *config.Config) (bool, error) { 12 | updated := false 13 | 14 | if cli.ApiUrl != "" { 15 | cfg.ApiUrl = cli.ApiUrl 16 | updated = true 17 | } 18 | 19 | if cli.ApiKey != "" { 20 | authConfig, _ := config.LoadAuthConfig() 21 | 22 | newAuthConfig := &config.AuthConfig{ 23 | APIKey: cli.ApiKey, 24 | ServerURL: cfg.ApiUrl, 25 | LastAuth: time.Now(), 26 | GithubUser: "", 27 | } 28 | 29 | if authConfig != nil { 30 | newAuthConfig.GithubUser = authConfig.GithubUser 31 | } 32 | 33 | if err := config.SaveAuthConfig(newAuthConfig); err != nil { 34 | return false, fmt.Errorf("failed to save API key: %w", err) 35 | } 36 | 37 | fmt.Println("API key updated.") 38 | updated = true 39 | } 40 | 41 | if cli.Units != "" { 42 | if !isValidUnit(cli.Units) { 43 | fmt.Println("Invalid units. Must be one of: metric, imperial, standard") 44 | os.Exit(1) 45 | } 46 | cfg.Units = cli.Units 47 | updated = true 48 | } 49 | 50 | if cli.Default != "" { 51 | cfg.DefaultCity = cli.Default 52 | updated = true 53 | } 54 | 55 | if updated { 56 | if err := cfg.Save(); err != nil { 57 | return false, fmt.Errorf("failed to save config: %w", err) 58 | } 59 | fmt.Println("Configuration updated.") 60 | } 61 | 62 | return updated, nil 63 | } 64 | 65 | func isValidUnit(unit string) bool { 66 | validUnits := map[string]bool{ 67 | "metric": true, 68 | "imperial": true, 69 | "standard": true, 70 | } 71 | 72 | return validUnits[unit] 73 | } 74 | -------------------------------------------------------------------------------- /internal/cli/commands.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/alecthomas/kong" 5 | ) 6 | 7 | type CLI struct { 8 | // config flags 9 | City string `name:"city" short:"C" help:"City name"` 10 | Default string `name:"default" short:"D" help:"Set a new default city"` 11 | ApiUrl string `name:"api" short:"A" help:"Set custom API server URL"` 12 | Login bool `name:"login" short:"L" help:"Authenticate with GitHub"` 13 | Setup bool `name:"setup" short:"S" help:"Run the setup wizard"` 14 | Units string `name:"units" short:"U" help:"Temperature units (metric, imperial, standard)"` 15 | ApiKey string `name:"api-key" short:"K" help:"Set your api key (either gust or openweathermap)"` 16 | 17 | // display flags 18 | Compact bool `name:"compact" short:"c" help:"Show today's compact weather view"` 19 | Detailed bool `name:"detailed" short:"d" help:"Show today's detailed weather view"` 20 | Full bool `name:"full" short:"f" help:"Show today, 5-day and weather alert forecasts"` 21 | Daily bool `name:"daily" short:"y" help:"Show 5-day forecast"` 22 | Hourly bool `name:"hourly" short:"r" help:"Show 24-hour (hourly) forecast"` 23 | Alerts bool `name:"alerts" short:"a" help:"Show weather alerts"` 24 | Pretty bool `name:"pretty" short:"p" hidden:"" help:"Use the pretty UI - tbc"` // TODO: not implemented yet but including it here to keep me motivated 25 | 26 | // args (city name) 27 | Args []string `arg:"" optional:"" help:"City name (can be multiple words)"` 28 | } 29 | 30 | func NewApp() (*kong.Kong, *CLI) { 31 | cli := &CLI{} 32 | parser := kong.Must(cli, 33 | kong.Name("gust"), 34 | kong.Description("Simple terminal weather 🌤️"), 35 | kong.UsageOnError(), 36 | kong.ConfigureHelp(kong.HelpOptions{ 37 | Compact: true, 38 | Summary: true, 39 | }), 40 | ) 41 | return parser, cli 42 | } 43 | -------------------------------------------------------------------------------- /internal/ui/renderer/current.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/josephburgess/gust/internal/ui/styles" 11 | ) 12 | 13 | func (r *TerminalRenderer) RenderCurrentWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 14 | current := weather.Current 15 | 16 | fmt.Print(styles.FormatHeader(fmt.Sprintf("WEATHER FOR %s", strings.ToUpper(city.Name)))) 17 | 18 | if len(current.Weather) > 0 { 19 | weatherCond := current.Weather[0] 20 | 21 | fmt.Printf("Current Conditions: %s %s\n\n", 22 | styles.HighlightStyleF(weatherCond.Description), 23 | models.GetWeatherEmoji(weatherCond.ID, ¤t)) 24 | 25 | tempUnit := r.GetTemperatureUnit() 26 | 27 | fmt.Printf("Temperature: %s %s (F/L: %.1f%s)\n", 28 | styles.TempStyle(fmt.Sprintf("%.1f%s", current.Temp, tempUnit)), 29 | "🌡️", 30 | current.FeelsLike, tempUnit) 31 | 32 | fmt.Printf("Humidity: %d%% %s\n", current.Humidity, "💧") 33 | if current.UVI > 0 { 34 | fmt.Printf("UV Index: %.1f ☀️\n", current.UVI) 35 | } 36 | 37 | r.displayWindInfo(current.WindSpeed, current.WindDeg, current.WindGust) 38 | 39 | if current.Clouds > 0 { 40 | fmt.Printf("Cloud coverage: %d%% ☁️\n", current.Clouds) 41 | } 42 | 43 | r.displayPrecipitation(current.Rain, current.Snow) 44 | fmt.Printf("Visibility: %s\n", models.VisibilityToString(current.Visibility)) 45 | 46 | fmt.Printf("Sunrise: %s %s Sunset: %s %s\n", 47 | time.Unix(current.Sunrise, 0).Format("15:04"), 48 | "🌅", 49 | time.Unix(current.Sunset, 0).Format("15:04"), 50 | "🌇") 51 | r.displayWeatherTip(weather, cfg) 52 | fmt.Printf("\n") 53 | } 54 | r.displayAlertSummary(weather.Alerts, city.Name) 55 | } 56 | -------------------------------------------------------------------------------- /internal/ui/renderer/compact.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/josephburgess/gust/internal/ui/styles" 11 | ) 12 | 13 | func (r *TerminalRenderer) RenderCompactWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 14 | current := weather.Current 15 | fmt.Print(styles.FormatHeader(fmt.Sprintf("%s WEATHER", strings.ToUpper(city.Name)))) 16 | if len(current.Weather) > 0 { 17 | weatherCond := current.Weather[0] 18 | tempUnit := r.GetTemperatureUnit() 19 | emoji := models.GetWeatherEmoji(weatherCond.ID, ¤t) 20 | temp := styles.TempStyle(fmt.Sprintf("%.1f%s", current.Temp, tempUnit)) 21 | 22 | extraSpace := "" 23 | if current.Temp < 10 { 24 | extraSpace = " " 25 | } 26 | fmt.Printf("🌡️ %-16s%s %s %-s\n", 27 | temp, 28 | extraSpace, 29 | emoji, 30 | styles.HighlightStyleF(weatherCond.Description)) 31 | 32 | windUnit := r.GetWindSpeedUnit() 33 | windSpeed := r.FormatWindSpeed(current.WindSpeed) 34 | windDir := models.GetWindDirection(current.WindDeg) 35 | fmt.Printf("💧 %-3d%% 💨 %-4.1f %-3s %-2s", 36 | current.Humidity, 37 | windSpeed, 38 | windUnit, 39 | windDir) 40 | if current.Rain != nil && current.Rain.OneHour > 0 { 41 | fmt.Printf(" 🌧️ %.1f mm", current.Rain.OneHour) 42 | } 43 | if current.Snow != nil && current.Snow.OneHour > 0 { 44 | fmt.Printf(" ❄️ %.1f mm", current.Snow.OneHour) 45 | } 46 | fmt.Println() 47 | sunrise := time.Unix(current.Sunrise, 0).Format("15:04") 48 | sunset := time.Unix(current.Sunset, 0).Format("15:04") 49 | fmt.Printf("🌅 %-8s 🌇 %-8s", sunrise, sunset) 50 | if len(weather.Alerts) > 0 { 51 | fmt.Printf(" %s", 52 | styles.AlertStyle(fmt.Sprintf("⚠️ %d alerts", len(weather.Alerts)))) 53 | } 54 | fmt.Println() 55 | r.displayWeatherTip(weather, cfg) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/ui/renderer/terminal.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/josephburgess/gust/internal/models" 8 | "github.com/josephburgess/gust/internal/ui/styles" 9 | ) 10 | 11 | type TerminalRenderer struct { 12 | BaseRenderer 13 | } 14 | 15 | func NewTerminalRenderer(units string) *TerminalRenderer { 16 | return &TerminalRenderer{ 17 | BaseRenderer: BaseRenderer{ 18 | Units: units, 19 | }, 20 | } 21 | } 22 | 23 | func (r *TerminalRenderer) displayWindInfo(speed float64, deg int, gust float64) { 24 | windUnit := r.GetWindSpeedUnit() 25 | windSpeed := r.FormatWindSpeed(speed) 26 | 27 | if gust > 0 { 28 | gustSpeed := r.FormatWindSpeed(gust) 29 | fmt.Printf("Wind: %.1f %s %s %s (Gusts: %.1f %s)\n", 30 | windSpeed, 31 | windUnit, 32 | models.GetWindDirection(deg), 33 | "💨", 34 | gustSpeed, 35 | windUnit) 36 | } else { 37 | fmt.Printf("Wind: %.1f %s %s %s\n", 38 | windSpeed, 39 | windUnit, 40 | models.GetWindDirection(deg), 41 | "💨") 42 | } 43 | } 44 | 45 | func (r *TerminalRenderer) displayPrecipitation(rain *models.RainData, snow *models.SnowData) { 46 | if rain != nil && rain.OneHour > 0 { 47 | fmt.Printf("Rain: %.1f mm (last hour) 🌧️\n", rain.OneHour) 48 | } 49 | 50 | if snow != nil && snow.OneHour > 0 { 51 | fmt.Printf("Snow: %.1f mm (last hour) ❄️\n", snow.OneHour) 52 | } 53 | } 54 | 55 | func (r *TerminalRenderer) displayAlertSummary(alerts []models.Alert, cityName string) { 56 | if len(alerts) > 0 { 57 | fmt.Printf("%s Use 'gust --alerts %s' to view them.\n", 58 | styles.AlertStyle(fmt.Sprintf("⚠️ There are %d weather alerts for this area.", len(alerts))), 59 | cityName) 60 | } 61 | } 62 | 63 | func (r *TerminalRenderer) displayWeatherTip(weather *models.OneCallResponse, cfg *config.Config) { 64 | if !cfg.ShowTips { 65 | return 66 | } 67 | tip := models.GetWeatherTip(weather, r.Units) 68 | fmt.Printf("\n%s\n", styles.TipStyle(fmt.Sprintf("💡 %s", tip))) 69 | } 70 | -------------------------------------------------------------------------------- /internal/ui/renderer/hourly.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | 9 | "github.com/josephburgess/gust/internal/config" 10 | "github.com/josephburgess/gust/internal/models" 11 | "github.com/josephburgess/gust/internal/ui/styles" 12 | ) 13 | 14 | func (r *TerminalRenderer) RenderHourlyForecast(city *models.City, weather *models.OneCallResponse, config *config.Config) { 15 | fmt.Print(styles.FormatHeader(fmt.Sprintf("24H FORECAST FOR %s", strings.ToUpper(city.Name)))) 16 | 17 | if len(weather.Hourly) > 0 { 18 | hourLimit := int(math.Min(24, float64(len(weather.Hourly)))) 19 | currentDay := "" 20 | 21 | tempUnit := r.GetTemperatureUnit() 22 | 23 | for i := 0; i < hourLimit; i++ { 24 | hour := weather.Hourly[i] 25 | if len(hour.Weather) == 0 { 26 | continue 27 | } 28 | 29 | t := time.Unix(hour.Dt, 0) 30 | day := t.Format("Mon Jan 2") 31 | hourStr := t.Format("15:04") 32 | 33 | if day != currentDay { 34 | if currentDay != "" { 35 | fmt.Println() 36 | } 37 | fmt.Printf("%s:\n", styles.HighlightStyleF(day)) 38 | currentDay = day 39 | } 40 | 41 | weatherCond := hour.Weather[0] 42 | temp := styles.TempStyle(fmt.Sprintf("%.1f%s", hour.Temp, tempUnit)) 43 | 44 | popStr := "" 45 | if hour.Pop > 0 { 46 | popStr = fmt.Sprintf(" (%.0f%% chance of precipitation)", hour.Pop*100) 47 | } 48 | 49 | extraSpace := "" 50 | if hour.Temp < 10 { 51 | extraSpace = " " 52 | } 53 | fmt.Printf(" %s: %s %s%s %s%s\n", 54 | hourStr, 55 | temp, 56 | extraSpace, 57 | models.GetWeatherEmoji(weatherCond.ID, nil), 58 | styles.InfoStyle(weatherCond.Description), 59 | popStr) 60 | 61 | if hour.Rain != nil && hour.Rain.OneHour > 0 { 62 | fmt.Printf(" Rain: %.1f mm/h\n", hour.Rain.OneHour) 63 | } 64 | 65 | if hour.Snow != nil && hour.Snow.OneHour > 0 { 66 | fmt.Printf(" Snow: %.1f mm/h\n", hour.Snow.OneHour) 67 | } 68 | } 69 | fmt.Println() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/ui/setup/runner.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/josephburgess/gust/internal/api" 8 | "github.com/josephburgess/gust/internal/config" 9 | ) 10 | 11 | // entry point for setup wizard 12 | func RunSetup(cfg *config.Config, needsAuth bool) error { 13 | var apiClient *api.Client 14 | 15 | if cfg.ApiUrl == "" { 16 | cfg.ApiUrl = "https://breeze.joeburgess.dev" 17 | } 18 | 19 | authConfig, err := config.LoadAuthConfig() 20 | if err == nil && authConfig != nil { 21 | apiClient = api.NewClient(cfg.ApiUrl, authConfig.APIKey, cfg.Units) 22 | } else { 23 | // empty api key - dont need for setup 24 | apiClient = api.NewClient(cfg.ApiUrl, "", cfg.Units) 25 | } 26 | 27 | model := NewModel(cfg, needsAuth, apiClient) 28 | p := tea.NewProgram(model, tea.WithAltScreen()) 29 | 30 | finalModel, err := p.Run() 31 | if err != nil { 32 | return fmt.Errorf("error running setup UI: %w", err) 33 | } 34 | 35 | // protection against nil model 36 | if finalModel == nil { 37 | return fmt.Errorf("unexpected nil model after running UI") 38 | } 39 | 40 | // get the final model 41 | finalSetupModel, ok := finalModel.(Model) 42 | if !ok { 43 | return fmt.Errorf("unexpected model type: %T", finalModel) 44 | } 45 | 46 | // dont save if quit with ctrl+c 47 | if finalSetupModel.Quitting { 48 | return nil 49 | } 50 | 51 | // save config if not saved earlier 52 | if err := finalSetupModel.Config.Save(); err != nil { 53 | return fmt.Errorf("failed to save configuration: %w", err) 54 | } 55 | 56 | // handle auth if chosen 57 | if finalSetupModel.State == StateAuth && finalSetupModel.AuthCursor == 0 { 58 | fmt.Println("Starting GitHub authentication...") 59 | auth, err := config.Authenticate(cfg.ApiUrl) 60 | if err != nil { 61 | return fmt.Errorf("authentication failed: %w", err) 62 | } 63 | if err := config.SaveAuthConfig(auth); err != nil { 64 | return fmt.Errorf("failed to save authentication: %w", err) 65 | } 66 | fmt.Printf("Successfully authenticated as %s\n", auth.GithubUser) 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/ui/renderer/daily.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/josephburgess/gust/internal/ui/styles" 11 | ) 12 | 13 | func (r *TerminalRenderer) RenderDailyForecast(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 14 | fmt.Print(styles.FormatHeader(fmt.Sprintf("5-DAY FORECAST FOR %s", strings.ToUpper(city.Name)))) 15 | 16 | if len(weather.Daily) > 0 { 17 | tempUnit := r.GetTemperatureUnit() 18 | 19 | for i, day := range weather.Daily { 20 | if i >= 5 { 21 | break 22 | } 23 | 24 | date := time.Unix(day.Dt, 0).Format("Mon Jan 2") 25 | 26 | if i > 0 { 27 | fmt.Println() 28 | } 29 | 30 | fmt.Printf("%s: %s\n", 31 | styles.HighlightStyleF(date), 32 | day.Summary) 33 | 34 | fmt.Printf(" High/Low: %s/%s %s\n", 35 | styles.TempStyle(fmt.Sprintf("%.1f%s", day.Temp.Max, tempUnit)), 36 | styles.TempStyle(fmt.Sprintf("%.1f%s", day.Temp.Min, tempUnit)), 37 | "🌡️") 38 | 39 | fmt.Printf(" Morning: %.1f%s Day: %.1f%s Evening: %.1f%s Night: %.1f%s\n", 40 | day.Temp.Morn, tempUnit, 41 | day.Temp.Day, tempUnit, 42 | day.Temp.Eve, tempUnit, 43 | day.Temp.Night, tempUnit) 44 | 45 | if len(day.Weather) > 0 { 46 | weather := day.Weather[0] 47 | condition := fmt.Sprintf("%s %s", weather.Description, models.GetWeatherEmoji(weather.ID, nil)) 48 | fmt.Printf(" Conditions: %s\n", styles.InfoStyle(condition)) 49 | } 50 | 51 | if day.Pop > 0 { 52 | fmt.Printf(" Precipitation: %d%% chance\n", int(day.Pop*100)) 53 | } 54 | 55 | if day.Rain > 0 { 56 | fmt.Printf(" Rain: %.1f mm 🌧️\n", day.Rain) 57 | } 58 | 59 | if day.Snow > 0 { 60 | fmt.Printf(" Snow: %.1f mm ❄️\n", day.Snow) 61 | } 62 | 63 | windUnit := r.GetWindSpeedUnit() 64 | windSpeed := r.FormatWindSpeed(day.WindSpeed) 65 | 66 | fmt.Printf(" Wind: %.1f %s %s\n", 67 | windSpeed, 68 | windUnit, 69 | models.GetWindDirection(day.WindDeg)) 70 | 71 | fmt.Printf(" UV Index: %.1f\n", day.UVI) 72 | } 73 | fmt.Println() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | type Config struct { 11 | DefaultCity string `json:"default_city"` 12 | ApiUrl string `json:"api_url"` 13 | Units string `json:"units"` 14 | DefaultView string `json:"default_view"` 15 | ShowTips bool `json:"show_tips"` 16 | } 17 | 18 | type GetConfigPathFunc func() (string, error) 19 | 20 | // for tests 21 | var GetConfigPath GetConfigPathFunc = defaultGetConfigPath 22 | 23 | func defaultGetConfigPath() (string, error) { 24 | homeDir, err := os.UserHomeDir() 25 | if err != nil { 26 | return "", fmt.Errorf("could not get user home directory: %w", err) 27 | } 28 | 29 | configDir := filepath.Join(homeDir, ".config", "gust") 30 | if err := os.MkdirAll(configDir, 0755); err != nil { 31 | return "", fmt.Errorf("could not create config directory: %w", err) 32 | } 33 | 34 | return filepath.Join(configDir, "config.json"), nil 35 | } 36 | 37 | func Load() (*Config, error) { 38 | configPath, err := GetConfigPath() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 44 | return &Config{Units: "metric", DefaultView: "default"}, nil 45 | } 46 | 47 | file, err := os.Open(configPath) 48 | if err != nil { 49 | return nil, fmt.Errorf("could not open config file: %w", err) 50 | } 51 | defer file.Close() 52 | 53 | var config Config 54 | if err := json.NewDecoder(file).Decode(&config); err != nil { 55 | return nil, fmt.Errorf("could not decode config file: %w", err) 56 | } 57 | 58 | if config.Units == "" { 59 | config.Units = "metric" 60 | } 61 | 62 | if config.DefaultView == "" { 63 | config.DefaultView = "default" 64 | } 65 | 66 | return &config, nil 67 | } 68 | 69 | func (c *Config) Save() error { 70 | configPath, err := GetConfigPath() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | file, err := os.Create(configPath) 76 | if err != nil { 77 | return fmt.Errorf("could not create config file: %w", err) 78 | } 79 | defer file.Close() 80 | 81 | encoder := json.NewEncoder(file) 82 | encoder.SetIndent("", " ") 83 | if err := encoder.Encode(c); err != nil { 84 | return fmt.Errorf("could not encode config: %w", err) 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /internal/ui/styles/styles.go: -------------------------------------------------------------------------------- 1 | package styles 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/fatih/color" 11 | ) 12 | 13 | var ( 14 | Base = lipgloss.Color("#191724") 15 | Surface = lipgloss.Color("#1f1d2e") 16 | Overlay = lipgloss.Color("#26233a") 17 | Muted = lipgloss.Color("#6e6a86") 18 | Subtle = lipgloss.Color("#908caa") 19 | Text = lipgloss.Color("#e0def4") 20 | Love = lipgloss.Color("#eb6f92") 21 | Gold = lipgloss.Color("#f6c177") 22 | Rose = lipgloss.Color("#ebbcba") 23 | Pine = lipgloss.Color("#31748f") 24 | Foam = lipgloss.Color("#9ccfd8") 25 | Iris = lipgloss.Color("#c4a7e7") 26 | ) 27 | 28 | var ( 29 | TitleStyle = lipgloss.NewStyle(). 30 | Bold(true). 31 | Foreground(Rose) 32 | 33 | SubtitleStyle = lipgloss.NewStyle(). 34 | Foreground(Gold) 35 | 36 | HighlightStyle = lipgloss.NewStyle(). 37 | Bold(true). 38 | Foreground(Text) 39 | 40 | CursorStyle = lipgloss.NewStyle(). 41 | Foreground(Love) 42 | 43 | SelectedItemStyle = lipgloss.NewStyle(). 44 | Foreground(Foam) 45 | 46 | HintStyle = lipgloss.NewStyle(). 47 | Foreground(Subtle). 48 | Italic(true) 49 | 50 | BoxStyle = lipgloss.NewStyle(). 51 | Border(lipgloss.RoundedBorder()). 52 | BorderForeground(Iris). 53 | Padding(1, 2) 54 | 55 | ProgressMessageStyle = lipgloss.NewStyle(). 56 | Foreground(Foam). 57 | Italic(true) 58 | ) 59 | 60 | var ( 61 | HeaderStyle = color.New(color.FgHiCyan, color.Bold).SprintFunc() 62 | TipStyle = color.New(color.FgHiBlue, color.Italic).SprintFunc() 63 | TempStyle = color.New(color.FgHiYellow, color.Bold).SprintFunc() 64 | HighlightStyleF = color.New(color.FgHiWhite).SprintFunc() 65 | InfoStyle = color.New(color.FgHiBlue).SprintFunc() 66 | TimeStyle = color.New(color.FgHiYellow).SprintFunc() 67 | AlertStyle = color.New(color.FgHiRed, color.Bold).SprintFunc() 68 | ErrorStyle = color.New(color.FgHiRed, color.Bold).SprintFunc() 69 | SuccessStyle = color.New(color.FgHiGreen, color.Bold).SprintFunc() 70 | WarningStyle = color.New(color.FgHiYellow).SprintFunc() 71 | ) 72 | 73 | func Divider(len int) string { 74 | return strings.Repeat("─", len) 75 | } 76 | 77 | func ExitWithError(message string, err error) { 78 | log.Printf("%s: %v", message, err) 79 | os.Exit(1) 80 | } 81 | 82 | func FormatHeader(title string) string { 83 | return fmt.Sprintf("\n%s\n%s\n", HeaderStyle(title), Divider(len(title)*2)) 84 | } 85 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestLoadAndSave(t *testing.T) { 10 | tempDir, err := os.MkdirTemp("", "gust-config-test-") 11 | if err != nil { 12 | t.Fatalf("Failed to create temp directory: %v", err) 13 | } 14 | defer os.RemoveAll(tempDir) 15 | 16 | originalGetConfigPath := GetConfigPath 17 | defer func() { GetConfigPath = originalGetConfigPath }() 18 | 19 | GetConfigPath = func() (string, error) { 20 | return filepath.Join(tempDir, "config.json"), nil 21 | } 22 | 23 | testConfig := &Config{ 24 | DefaultCity: "Tokyo", 25 | ApiUrl: "https://test-api.example.com", 26 | } 27 | 28 | err = testConfig.Save() 29 | if err != nil { 30 | t.Fatalf("Failed to save config: %v", err) 31 | } 32 | 33 | loadedConfig, err := Load() 34 | if err != nil { 35 | t.Fatalf("Failed to load config: %v", err) 36 | } 37 | 38 | if loadedConfig.DefaultCity != testConfig.DefaultCity { 39 | t.Errorf("Expected DefaultCity to be %s, got %s", testConfig.DefaultCity, loadedConfig.DefaultCity) 40 | } 41 | 42 | if loadedConfig.ApiUrl != testConfig.ApiUrl { 43 | t.Errorf("Expected ApiUrl to be %s, got %s", testConfig.ApiUrl, loadedConfig.ApiUrl) 44 | } 45 | } 46 | 47 | func TestLoadNonExistentConfig(t *testing.T) { 48 | tempDir, err := os.MkdirTemp("", "gust-config-test-") 49 | if err != nil { 50 | t.Fatalf("Failed to create temp directory: %v", err) 51 | } 52 | defer os.RemoveAll(tempDir) 53 | 54 | originalGetConfigPath := GetConfigPath 55 | defer func() { GetConfigPath = originalGetConfigPath }() 56 | 57 | GetConfigPath = func() (string, error) { 58 | return filepath.Join(tempDir, "nonexistent-config.json"), nil 59 | } 60 | 61 | config, err := Load() 62 | if err != nil { 63 | t.Fatalf("Expected no error loading non-existent config, got: %v", err) 64 | } 65 | 66 | if config == nil { 67 | t.Fatal("Expected empty config object, got nil") 68 | } 69 | 70 | if config.DefaultCity != "" { 71 | t.Errorf("Expected empty DefaultCity, got %s", config.DefaultCity) 72 | } 73 | 74 | if config.ApiUrl != "" { 75 | t.Errorf("Expected empty ApiUrl, got %s", config.ApiUrl) 76 | } 77 | } 78 | 79 | func TestDefaultGetConfigPath(t *testing.T) { 80 | path, err := defaultGetConfigPath() 81 | if err != nil { 82 | t.Fatalf("Failed to get default config path: %v", err) 83 | } 84 | 85 | if filepath.Base(path) != "config.json" { 86 | t.Errorf("Expected config filename to be config.json, got %s", filepath.Base(path)) 87 | } 88 | 89 | if !filepath.IsAbs(path) { 90 | t.Errorf("Expected absolute path, got %s", path) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/ui/components/spinner_runner.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/josephburgess/gust/internal/ui/styles" 10 | ) 11 | 12 | // message types 13 | type successMsg[T any] struct { 14 | result T 15 | } 16 | type errorMsg struct { 17 | err error 18 | } 19 | 20 | // represents a spinner that runs a function and returns a result 21 | type SpinnerRunnerModel[T any] struct { 22 | spinner SpinnerModel 23 | message string 24 | function func() (T, error) 25 | result T 26 | err error 27 | done bool 28 | } 29 | 30 | // creates a new runner - displays a spinner while executing a func 31 | func NewSpinnerRunner[T any]( 32 | message string, 33 | spinnerType spinner.Spinner, 34 | color lipgloss.Color, 35 | fn func() (T, error), 36 | ) SpinnerRunnerModel[T] { 37 | return SpinnerRunnerModel[T]{ 38 | spinner: NewCustomSpinner(spinnerType, color), 39 | message: message, 40 | function: fn, 41 | } 42 | } 43 | 44 | // initialises the spinner runner and starts func 45 | func (m SpinnerRunnerModel[T]) Init() tea.Cmd { 46 | return tea.Batch( 47 | m.spinner.Tick(), 48 | func() tea.Msg { 49 | result, err := m.function() 50 | if err != nil { 51 | return errorMsg{err: err} 52 | } 53 | return successMsg[T]{result: result} 54 | }, 55 | ) 56 | } 57 | 58 | // handles messages and updates state 59 | func (m SpinnerRunnerModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 60 | switch msg := msg.(type) { 61 | case tea.KeyMsg: 62 | if msg.Type == tea.KeyCtrlC { 63 | return m, tea.Quit 64 | } 65 | case spinner.TickMsg: 66 | var cmd tea.Cmd 67 | m.spinner, cmd = m.spinner.Update(msg) 68 | return m, cmd 69 | case errorMsg: 70 | m.err = msg.err 71 | m.done = true 72 | return m, tea.Quit 73 | case successMsg[T]: 74 | m.result = msg.result 75 | m.done = true 76 | return m, tea.Quit 77 | } 78 | return m, nil 79 | } 80 | 81 | // render 82 | func (m SpinnerRunnerModel[T]) View() string { 83 | if m.done { 84 | return "" 85 | } 86 | return fmt.Sprintf("%s %s", m.spinner.View(), styles.ProgressMessageStyle.Render(m.message)) 87 | } 88 | 89 | // custom func that creates and runs a SpinnerRunnerModel 90 | func RunWithSpinner[T any](message string, spinnerType spinner.Spinner, color lipgloss.Color, fn func() (T, error)) (T, error) { 91 | model := NewSpinnerRunner(message, spinnerType, color, fn) 92 | p := tea.NewProgram(model) 93 | finalModel, err := p.Run() 94 | if err != nil { 95 | var zero T 96 | return zero, fmt.Errorf("error running spinner: %w", err) 97 | } 98 | 99 | if m, ok := finalModel.(SpinnerRunnerModel[T]); ok { 100 | if m.err != nil { 101 | var zero T 102 | return zero, m.err 103 | } 104 | return m.result, nil 105 | } 106 | 107 | var zero T 108 | return zero, fmt.Errorf("unexpected error in spinner") 109 | } 110 | -------------------------------------------------------------------------------- /internal/api/client_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestNewClient(t *testing.T) { 10 | baseURL := "https://example.com" 11 | apiKey := "test-api-key" 12 | units := "metric" 13 | 14 | client := NewClient(baseURL, apiKey, units) 15 | 16 | if client.baseURL != baseURL { 17 | t.Errorf("Expected baseURL to be %s, got %s", baseURL, client.baseURL) 18 | } 19 | 20 | if client.apiKey != apiKey { 21 | t.Errorf("Expected apiKey to be %s, got %s", apiKey, client.apiKey) 22 | } 23 | 24 | if client.client == nil { 25 | t.Error("HTTP client should not be nil") 26 | } 27 | } 28 | 29 | func TestGetWeather(t *testing.T) { 30 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | if r.URL.Path != "/api/weather/London" { 32 | t.Errorf("Expected path /api/weather/London, got %s", r.URL.Path) 33 | } 34 | 35 | if apiKey := r.URL.Query().Get("api_key"); apiKey != "test-api-key" { 36 | t.Errorf("Expected api_key=test-api-key, got %s", apiKey) 37 | } 38 | 39 | w.Header().Set("Content-Type", "application/json") 40 | w.WriteHeader(http.StatusOK) 41 | w.Write([]byte(`{ 42 | "city": { 43 | "name": "London", 44 | "lat": 51.5074, 45 | "lon": -0.1278 46 | }, 47 | "weather": { 48 | "lat": 51.5074, 49 | "lon": -0.1278, 50 | "timezone": "Europe/London", 51 | "timezone_offset": 0, 52 | "current": { 53 | "dt": 1613896743, 54 | "temp": 283.15, 55 | "weather": [{"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"}] 56 | } 57 | } 58 | }`)) 59 | })) 60 | defer server.Close() 61 | 62 | client := NewClient(server.URL, "test-api-key", "metric") 63 | 64 | resp, err := client.GetWeather("London") 65 | if err != nil { 66 | t.Fatalf("Expected no error, got %v", err) 67 | } 68 | 69 | if resp == nil { 70 | t.Fatal("Expected response, got nil") 71 | } 72 | 73 | if resp.City.Name != "London" { 74 | t.Errorf("Expected city name London, got %s", resp.City.Name) 75 | } 76 | 77 | if resp.Weather.Current.Temp != 283.15 { 78 | t.Errorf("Expected temp 283.15, got %f", resp.Weather.Current.Temp) 79 | } 80 | 81 | if len(resp.Weather.Current.Weather) == 0 || resp.Weather.Current.Weather[0].Description != "clear sky" { 82 | t.Error("Weather conditions not parsed correctly") 83 | } 84 | } 85 | 86 | func TestGetWeatherError(t *testing.T) { 87 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | w.WriteHeader(http.StatusNotFound) 89 | w.Write([]byte(`{"error": "City not found"}`)) 90 | })) 91 | defer server.Close() 92 | 93 | client := NewClient(server.URL, "test-api-key", "metric") 94 | 95 | resp, err := client.GetWeather("NonExistentCity") 96 | 97 | if err == nil { 98 | t.Error("Expected error, got nil") 99 | } 100 | 101 | if resp != nil { 102 | t.Errorf("Expected nil response, got %+v", resp) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/cli/config_handler_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/josephburgess/gust/internal/config" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestIsValidUnit(t *testing.T) { 12 | validUnits := []string{"metric", "imperial", "standard"} 13 | invalidUnits := []string{"celsius", "", "Metric"} 14 | 15 | for _, unit := range validUnits { 16 | t.Run("Valid: "+unit, func(t *testing.T) { 17 | assert.True(t, isValidUnit(unit)) 18 | }) 19 | } 20 | 21 | for _, unit := range invalidUnits { 22 | t.Run("Invalid: "+unit, func(t *testing.T) { 23 | assert.False(t, isValidUnit(unit)) 24 | }) 25 | } 26 | } 27 | 28 | func TestHandleConfigUpdates(t *testing.T) { 29 | createConfig := func() *config.Config { 30 | return &config.Config{ 31 | ApiUrl: "https://api.example.com", 32 | Units: "metric", 33 | DefaultCity: "London", 34 | } 35 | } 36 | 37 | testCases := []struct { 38 | name string 39 | cli *CLI 40 | expectedUpdated bool 41 | configMutator func(*config.Config) 42 | }{ 43 | { 44 | name: "update api url", 45 | cli: &CLI{ 46 | ApiUrl: "https://test-api.example.com", 47 | }, 48 | expectedUpdated: true, 49 | configMutator: func(c *config.Config) { 50 | c.ApiUrl = "https://test-api.example.com" 51 | }, 52 | }, 53 | { 54 | name: "update units", 55 | cli: &CLI{ 56 | Units: "imperial", 57 | }, 58 | expectedUpdated: true, 59 | configMutator: func(c *config.Config) { 60 | c.Units = "imperial" 61 | }, 62 | }, 63 | { 64 | name: "update default city", 65 | cli: &CLI{ 66 | Default: "Paris", 67 | }, 68 | expectedUpdated: true, 69 | configMutator: func(c *config.Config) { 70 | c.DefaultCity = "Paris" 71 | }, 72 | }, 73 | { 74 | name: "no updates", 75 | cli: &CLI{}, 76 | expectedUpdated: false, 77 | configMutator: func(c *config.Config) {}, 78 | }, 79 | } 80 | 81 | for _, tc := range testCases { 82 | t.Run(tc.name, func(t *testing.T) { 83 | initialConfig := createConfig() 84 | expectedConfig := createConfig() 85 | tc.configMutator(expectedConfig) 86 | 87 | testHandleConfigUpdates := func(cli *CLI, cfg *config.Config) (bool, error) { 88 | updated := false 89 | 90 | if cli.ApiUrl != "" { 91 | cfg.ApiUrl = cli.ApiUrl 92 | updated = true 93 | } 94 | 95 | if cli.Units != "" { 96 | if !isValidUnit(cli.Units) { 97 | return false, fmt.Errorf("invalid units") 98 | } 99 | cfg.Units = cli.Units 100 | updated = true 101 | } 102 | 103 | if cli.Default != "" { 104 | cfg.DefaultCity = cli.Default 105 | updated = true 106 | } 107 | 108 | return updated, nil 109 | } 110 | 111 | updated, err := testHandleConfigUpdates(tc.cli, initialConfig) 112 | 113 | assert.NoError(t, err) 114 | assert.Equal(t, tc.expectedUpdated, updated) 115 | assert.Equal(t, expectedConfig, initialConfig) 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /internal/ui/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/josephburgess/gust/internal/ui/styles" 8 | ) 9 | 10 | func PrintError(message string) { 11 | fmt.Println(styles.ErrorStyle("❌ " + message)) 12 | } 13 | 14 | func PrintSuccess(message string) { 15 | fmt.Println(styles.SuccessStyle("✅ " + message)) 16 | } 17 | 18 | func PrintInfo(message string) { 19 | fmt.Println(styles.InfoStyle(message)) 20 | } 21 | 22 | func PrintWarning(message string) { 23 | fmt.Println(styles.WarningStyle("⚠️ " + message)) 24 | } 25 | 26 | func PrintHeader(title string) { 27 | fmt.Printf("\n%s\n%s\n", styles.HeaderStyle(title), styles.Divider(len(title)*2)) 28 | } 29 | 30 | func PrintBoxedMessage(message string) { 31 | fmt.Println(styles.BoxStyle.Render(message)) 32 | } 33 | 34 | func PrintRateLimitWarning(remaining, limit int, resetTime time.Time) { 35 | timeUntilReset := time.Until(resetTime) 36 | minutesUntilReset := int(timeUntilReset.Minutes()) 37 | resetFormatted := resetTime.Format("15:04") 38 | 39 | fmt.Println() 40 | fmt.Println(styles.BoxStyle.Render(fmt.Sprintf( 41 | "⚠️ API Rate Limit Warning\n\n"+ 42 | "You have %s requests remaining out of %d.\n"+ 43 | "Your rate limit will reset at %s (%d minutes from now).", 44 | styles.HighlightStyleF(fmt.Sprintf("%d", remaining)), 45 | limit, 46 | styles.TimeStyle(resetFormatted), 47 | minutesUntilReset, 48 | ))) 49 | fmt.Println() 50 | } 51 | 52 | func PrintRateLimitError(limit int, resetTime time.Time) { 53 | timeUntilReset := time.Until(resetTime) 54 | minutesUntilReset := int(timeUntilReset.Minutes()) 55 | resetFormatted := resetTime.Format("15:04") 56 | 57 | fmt.Println() 58 | fmt.Println(styles.BoxStyle.BorderForeground(styles.Love).Render(fmt.Sprintf( 59 | "❌ API Rate Limit Reached\n\n"+ 60 | "Sorry - you have used all %d available requests.\n"+ 61 | "You must really like checking the weather!!\n"+ 62 | "Your rate limit will reset at %s (%d minutes from now).\n\n"+ 63 | "💡 If you think the limits are too low please get in touch :)", 64 | limit, 65 | styles.TimeStyle(resetFormatted), 66 | minutesUntilReset, 67 | ))) 68 | fmt.Println() 69 | } 70 | 71 | // going to implement this later - will create an api key status check endpoint 72 | /* 73 | func PrintRateLimitStatus(remaining, limit int) { 74 | if limit <= 0 { 75 | return 76 | } 77 | 78 | const barWidth = 20 79 | used := limit - remaining 80 | 81 | filledCount := min(int(float64(used) / float64(limit) * barWidth), barWidth) 82 | emptyCount := barWidth - filledCount 83 | 84 | filled := styles.HighlightStyleF(strings.Repeat("█", filledCount)) 85 | empty := strings.Repeat("░", emptyCount) 86 | 87 | percentage := float64(used) / float64(limit) * 100 88 | 89 | var usageText string 90 | if percentage >= 90 { 91 | usageText = styles.ErrorStyle(fmt.Sprintf("%.0f%% used", percentage)) 92 | } else if percentage >= 75 { 93 | usageText = styles.WarningStyle(fmt.Sprintf("%.0f%% used", percentage)) 94 | } else { 95 | usageText = styles.InfoStyle(fmt.Sprintf("%.0f%% used", percentage)) 96 | } 97 | 98 | fmt.Printf("API Usage: [%s%s] %s (%d/%d)\n", filled, empty, usageText, used, limit) 99 | } 100 | */ 101 | -------------------------------------------------------------------------------- /internal/cli/cli_test_utils.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/josephburgess/gust/internal/api" 5 | "github.com/josephburgess/gust/internal/config" 6 | "github.com/josephburgess/gust/internal/models" 7 | "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // for renderWeatjerView 11 | type MockWeatherRenderer struct { 12 | mock.Mock 13 | } 14 | 15 | func (m *MockWeatherRenderer) RenderCurrentWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 16 | m.Called(city, weather, cfg) 17 | } 18 | 19 | func (m *MockWeatherRenderer) RenderDailyForecast(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 20 | m.Called(city, weather, cfg) 21 | } 22 | 23 | func (m *MockWeatherRenderer) RenderHourlyForecast(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 24 | m.Called(city, weather, cfg) 25 | } 26 | 27 | func (m *MockWeatherRenderer) RenderAlerts(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 28 | m.Called(city, weather, cfg) 29 | } 30 | 31 | func (m *MockWeatherRenderer) RenderFullWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 32 | m.Called(city, weather, cfg) 33 | } 34 | 35 | func (m *MockWeatherRenderer) RenderCompactWeather(city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 36 | m.Called(city, weather, cfg) 37 | } 38 | 39 | // auth config handling 40 | type MockAuthConfig struct { 41 | mock.Mock 42 | APIKey string 43 | GithubUser string 44 | } 45 | 46 | func (m *MockAuthConfig) AuthFunc(apiURL string) (*config.AuthConfig, error) { 47 | args := m.Called(apiURL) 48 | if args.Get(0) == nil { 49 | return nil, args.Error(1) 50 | } 51 | return args.Get(0).(*config.AuthConfig), args.Error(1) 52 | } 53 | 54 | func (m *MockAuthConfig) SaveAuthFunc(config *config.AuthConfig) error { 55 | args := m.Called(config) 56 | return args.Error(0) 57 | } 58 | 59 | func (m *MockAuthConfig) LoadAuthFunc() (*config.AuthConfig, error) { 60 | args := m.Called() 61 | if args.Get(0) == nil { 62 | return nil, args.Error(1) 63 | } 64 | return args.Get(0).(*config.AuthConfig), args.Error(1) 65 | } 66 | 67 | // mocks setup wizard 68 | type MockSetup struct { 69 | mock.Mock 70 | } 71 | 72 | func (m *MockSetup) RunSetupFunc(cfg *config.Config, needsAuth bool) error { 73 | args := m.Called(cfg, needsAuth) 74 | return args.Error(0) 75 | } 76 | 77 | type MockWeatherClient struct { 78 | mock.Mock 79 | } 80 | 81 | func (m *MockWeatherClient) GetWeather(cityName string) (*api.WeatherResponse, error) { 82 | args := m.Called(cityName) 83 | if args.Get(0) == nil { 84 | return nil, args.Error(1) 85 | } 86 | return args.Get(0).(*api.WeatherResponse), args.Error(1) 87 | } 88 | 89 | func (m *MockWeatherClient) SearchCities(query string) ([]models.City, error) { 90 | args := m.Called(query) 91 | cities, _ := args.Get(0).([]models.City) 92 | return cities, args.Error(1) 93 | } 94 | 95 | // create a test models.City 96 | func createTestCity() *models.City { 97 | return &models.City{ 98 | Name: "TestCity", 99 | Lat: 51, 100 | Lon: 0, 101 | Country: "GB", 102 | } 103 | } 104 | 105 | // create a test models.OneCallResponse 106 | func createTestWeather() *models.OneCallResponse { 107 | return &models.OneCallResponse{ 108 | Current: models.CurrentWeather{ 109 | Temp: 20.5, 110 | FeelsLike: 21.0, 111 | Humidity: 65, 112 | }, 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/config/auth_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestSaveAndLoadAuthConfig(t *testing.T) { 11 | tempDir, err := os.MkdirTemp("", "gust-auth-test-") 12 | if err != nil { 13 | t.Fatalf("Failed to create temp directory: %v", err) 14 | } 15 | defer os.RemoveAll(tempDir) 16 | 17 | originalGetAuthConfigPath := GetAuthConfigPath 18 | defer func() { GetAuthConfigPath = originalGetAuthConfigPath }() 19 | 20 | GetAuthConfigPath = func() (string, error) { 21 | return filepath.Join(tempDir, "auth.json"), nil 22 | } 23 | 24 | testAuth := &AuthConfig{ 25 | APIKey: "test-api-key-123", 26 | ServerURL: "https://test-server.example.com", 27 | LastAuth: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), 28 | GithubUser: "testuser", 29 | } 30 | 31 | err = SaveAuthConfig(testAuth) 32 | if err != nil { 33 | t.Fatalf("Failed to save auth config: %v", err) 34 | } 35 | 36 | loadedAuth, err := LoadAuthConfig() 37 | if err != nil { 38 | t.Fatalf("Failed to load auth config: %v", err) 39 | } 40 | 41 | if loadedAuth.APIKey != testAuth.APIKey { 42 | t.Errorf("Expected APIKey to be %s, got %s", testAuth.APIKey, loadedAuth.APIKey) 43 | } 44 | 45 | if loadedAuth.ServerURL != testAuth.ServerURL { 46 | t.Errorf("Expected ServerURL to be %s, got %s", testAuth.ServerURL, loadedAuth.ServerURL) 47 | } 48 | 49 | if !loadedAuth.LastAuth.Equal(testAuth.LastAuth) { 50 | t.Errorf("Expected LastAuth to be %v, got %v", testAuth.LastAuth, loadedAuth.LastAuth) 51 | } 52 | 53 | if loadedAuth.GithubUser != testAuth.GithubUser { 54 | t.Errorf("Expected GithubUser to be %s, got %s", testAuth.GithubUser, loadedAuth.GithubUser) 55 | } 56 | } 57 | 58 | func TestLoadNonExistentAuthConfig(t *testing.T) { 59 | tempDir, err := os.MkdirTemp("", "gust-auth-test-") 60 | if err != nil { 61 | t.Fatalf("Failed to create temp directory: %v", err) 62 | } 63 | defer os.RemoveAll(tempDir) 64 | 65 | originalGetAuthConfigPath := GetAuthConfigPath 66 | defer func() { GetAuthConfigPath = originalGetAuthConfigPath }() 67 | 68 | GetAuthConfigPath = func() (string, error) { 69 | return filepath.Join(tempDir, "nonexistent-auth.json"), nil 70 | } 71 | 72 | authConfig, err := LoadAuthConfig() 73 | if err != nil { 74 | t.Fatalf("Expected no error loading non-existent auth config, got: %v", err) 75 | } 76 | 77 | if authConfig != nil { 78 | t.Fatalf("Expected nil auth config when file doesn't exist, got: %+v", authConfig) 79 | } 80 | } 81 | 82 | func TestDefaultGetAuthConfigPath(t *testing.T) { 83 | path, err := defaultGetAuthConfigPath() 84 | if err != nil { 85 | t.Fatalf("Failed to get auth config path: %v", err) 86 | } 87 | 88 | if filepath.Base(path) != "auth.json" { 89 | t.Errorf("Expected auth filename to be auth.json, got %s", filepath.Base(path)) 90 | } 91 | 92 | if !filepath.IsAbs(path) { 93 | t.Errorf("Expected absolute path, got %s", path) 94 | } 95 | 96 | if !filepath.IsAbs(path) || !contains(path, filepath.Join(".config", "gust")) { 97 | t.Errorf("Expected path to contain .config/gust, got %s", path) 98 | } 99 | } 100 | 101 | func contains(path, substr string) bool { 102 | return filepath.ToSlash(path) == filepath.ToSlash(substr) || 103 | contains2(filepath.ToSlash(path), filepath.ToSlash(substr)) 104 | } 105 | 106 | func contains2(path, substr string) bool { 107 | for i := 0; i <= len(path)-len(substr); i++ { 108 | if path[i:i+len(substr)] == substr { 109 | return true 110 | } 111 | } 112 | return false 113 | } 114 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/josephburgess/gust/internal/models" 13 | ) 14 | 15 | type WeatherResponse struct { 16 | City *models.City `json:"city"` 17 | Weather *models.OneCallResponse `json:"weather"` 18 | } 19 | 20 | type RateLimitInfo struct { 21 | Limit int 22 | Remaining int 23 | ResetTime time.Time 24 | } 25 | 26 | type Client struct { 27 | baseURL string 28 | apiKey string 29 | units string 30 | client *http.Client 31 | RateLimitInfo *RateLimitInfo 32 | } 33 | 34 | func NewClient(baseURL, apiKey string, units string) *Client { 35 | return &Client{ 36 | baseURL: baseURL, 37 | apiKey: apiKey, 38 | units: units, 39 | client: &http.Client{}, 40 | RateLimitInfo: &RateLimitInfo{}, 41 | } 42 | } 43 | 44 | func (c *Client) extractRateLimitInfo(resp *http.Response) { 45 | if c.RateLimitInfo == nil { 46 | c.RateLimitInfo = &RateLimitInfo{} 47 | } 48 | 49 | if limit := resp.Header.Get("X-RateLimit-Limit"); limit != "" { 50 | if val, err := strconv.Atoi(limit); err == nil { 51 | c.RateLimitInfo.Limit = val 52 | } 53 | } 54 | 55 | if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" { 56 | if val, err := strconv.Atoi(remaining); err == nil { 57 | c.RateLimitInfo.Remaining = val 58 | } else { 59 | c.RateLimitInfo.Remaining = 0 60 | } 61 | } 62 | 63 | if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { 64 | resetTime, err := time.Parse(time.RFC3339, reset) 65 | if err == nil { 66 | c.RateLimitInfo.ResetTime = resetTime 67 | } else { 68 | c.RateLimitInfo.ResetTime = time.Now().Add(time.Hour) 69 | } 70 | } 71 | } 72 | 73 | func (c *Client) GetWeather(cityName string) (*WeatherResponse, error) { 74 | endpoint := fmt.Sprintf( 75 | "%s/api/weather/%s?api_key=%s", 76 | c.baseURL, 77 | url.QueryEscape(cityName), 78 | c.apiKey, 79 | ) 80 | 81 | if c.units != "" { 82 | endpoint = fmt.Sprintf("%s&units=%s", endpoint, c.units) 83 | } 84 | 85 | resp, err := c.client.Get(endpoint) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to connect to API: %w", err) 88 | } 89 | defer resp.Body.Close() 90 | 91 | c.extractRateLimitInfo(resp) 92 | 93 | if resp.StatusCode == http.StatusTooManyRequests { 94 | body, _ := io.ReadAll(resp.Body) 95 | return nil, fmt.Errorf("rate limit exceeded: %s", string(body)) 96 | } 97 | 98 | if resp.StatusCode != http.StatusOK { 99 | body, _ := io.ReadAll(resp.Body) 100 | return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) 101 | } 102 | 103 | var response WeatherResponse 104 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 105 | return nil, fmt.Errorf("failed to decode API response: %w", err) 106 | } 107 | 108 | return &response, nil 109 | } 110 | 111 | func (c *Client) SearchCities(query string) ([]models.City, error) { 112 | endpoint := fmt.Sprintf( 113 | "%s/api/cities/search?q=%s", 114 | c.baseURL, 115 | url.QueryEscape(query), 116 | ) 117 | 118 | resp, err := c.client.Get(endpoint) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to connect to API: %w", err) 121 | } 122 | defer resp.Body.Close() 123 | 124 | if resp.StatusCode != http.StatusOK { 125 | body, _ := io.ReadAll(resp.Body) 126 | return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) 127 | } 128 | 129 | var cities []models.City 130 | if err := json.NewDecoder(resp.Body).Decode(&cities); err != nil { 131 | return nil, fmt.Errorf("failed to decode API response: %w", err) 132 | } 133 | 134 | return cities, nil 135 | } 136 | -------------------------------------------------------------------------------- /internal/cli/weather_handler.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/josephburgess/gust/internal/api" 9 | "github.com/josephburgess/gust/internal/config" 10 | "github.com/josephburgess/gust/internal/models" 11 | "github.com/josephburgess/gust/internal/ui/components" 12 | "github.com/josephburgess/gust/internal/ui/output" 13 | "github.com/josephburgess/gust/internal/ui/renderer" 14 | "github.com/josephburgess/gust/internal/ui/styles" 15 | ) 16 | 17 | func fetchAndRenderWeather(city string, cfg *config.Config, authConfig *config.AuthConfig, cli *CLI) error { 18 | client := api.NewClient(cfg.ApiUrl, authConfig.APIKey, cfg.Units) 19 | 20 | fetchFunc := func() (*api.WeatherResponse, error) { 21 | weather, err := client.GetWeather(city) 22 | if err != nil { 23 | if strings.Contains(strings.ToLower(err.Error()), "rate limit") { 24 | return nil, fmt.Errorf("rate limit reached: %w", err) 25 | } 26 | return nil, fmt.Errorf("failed to get weather data: %w", err) 27 | } 28 | return weather, nil 29 | } 30 | 31 | message := fmt.Sprintf("Fetching weather for %s...", city) 32 | weather, err := components.RunWithSpinner(message, components.WeatherEmojis, styles.Foam, fetchFunc) 33 | 34 | if client.RateLimitInfo != nil && client.RateLimitInfo.Limit > 0 { 35 | if err != nil && strings.Contains(strings.ToLower(err.Error()), "rate limit") { 36 | output.PrintRateLimitError(client.RateLimitInfo.Limit, client.RateLimitInfo.ResetTime) 37 | 38 | timeUntilReset := time.Until(client.RateLimitInfo.ResetTime) 39 | if timeUntilReset > 0 { 40 | minutesRemaining := int(timeUntilReset.Minutes()) + 1 41 | hoursRemaining := minutesRemaining / 60 42 | 43 | if hoursRemaining > 0 { 44 | remainingMinutes := minutesRemaining % 60 45 | return fmt.Errorf("please try again in about %d hour(s) and %d minute(s) when your rate limit resets", 46 | hoursRemaining, remainingMinutes) 47 | } else { 48 | return fmt.Errorf("please try again in about %d minute(s) when your rate limit resets", 49 | minutesRemaining) 50 | } 51 | } 52 | return fmt.Errorf("rate limit reached, please try again later") 53 | } 54 | 55 | if client.RateLimitInfo.Remaining <= 5 && client.RateLimitInfo.Remaining > 0 { 56 | output.PrintRateLimitWarning( 57 | client.RateLimitInfo.Remaining, 58 | client.RateLimitInfo.Limit, 59 | client.RateLimitInfo.ResetTime, 60 | ) 61 | } 62 | } 63 | 64 | if err != nil { 65 | return err 66 | } 67 | 68 | weatherRenderer := renderer.NewWeatherRenderer("terminal", cfg.Units) 69 | renderWeatherView(cli, weatherRenderer, weather.City, weather.Weather, cfg) 70 | 71 | return nil 72 | } 73 | 74 | func renderWeatherView(cli *CLI, weatherRenderer renderer.WeatherRenderer, city *models.City, weather *models.OneCallResponse, cfg *config.Config) { 75 | switch { 76 | case cli.Alerts: 77 | weatherRenderer.RenderAlerts(city, weather, cfg) 78 | case cli.Hourly: 79 | weatherRenderer.RenderHourlyForecast(city, weather, cfg) 80 | case cli.Daily: 81 | weatherRenderer.RenderDailyForecast(city, weather, cfg) 82 | case cli.Full: 83 | weatherRenderer.RenderFullWeather(city, weather, cfg) 84 | case cli.Compact: 85 | weatherRenderer.RenderCompactWeather(city, weather, cfg) 86 | case cli.Detailed: 87 | weatherRenderer.RenderCurrentWeather(city, weather, cfg) 88 | default: 89 | switch cfg.DefaultView { 90 | case "compact": 91 | weatherRenderer.RenderCompactWeather(city, weather, cfg) 92 | case "daily": 93 | weatherRenderer.RenderDailyForecast(city, weather, cfg) 94 | case "hourly": 95 | weatherRenderer.RenderHourlyForecast(city, weather, cfg) 96 | case "full": 97 | weatherRenderer.RenderFullWeather(city, weather, cfg) 98 | default: 99 | weatherRenderer.RenderCurrentWeather(city, weather, cfg) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gust 2 | 3 | A simple cli weather tool built with Go that provides weather forecasts in the terminal. 4 | 5 |

6 | 7 |

8 | 9 | ## Installation 10 | 11 | ### Option 1: Homebrew (macOS) 12 | 13 | The easiest way to install gust at the moment is with Homebrew: 14 | 15 | ```sh 16 | brew tap josephburgess/formulae 17 | brew install gust 18 | ``` 19 | 20 | ### Option 2: Manual Installation 21 | 22 | #### 1. Clone the repository 23 | 24 | ```sh 25 | git clone https://github.com/josephburgess/gust.git 26 | cd gust 27 | ``` 28 | 29 | #### 2. Install dependencies 30 | 31 | ```sh 32 | go mod tidy 33 | ``` 34 | 35 | #### 3. Build the binary 36 | 37 | ```sh 38 | go build -o gust ./cmd/gust 39 | ``` 40 | 41 | #### 4. Install 42 | 43 | Move the binary to a directory in your `$PATH`: 44 | 45 | ```sh 46 | mv gust /usr/local/bin/ 47 | ``` 48 | 49 | ## Usage 50 | 51 | The first time you run gust a first-time configuration wizard will get you set up in no time. 52 | 53 | ### Basic Commands 54 | 55 | ```bash 56 | # Get weather for your default city 57 | gust 58 | 59 | # Get weather for a specific city 60 | gust london 61 | ``` 62 | 63 |

64 | 65 |

66 | 67 | ## Configuration Flags 68 | 69 | _These flags modify user config / settings and don't display weather by themselves_ 70 | 71 | | Short | Long | Description | 72 | | ----- | ------------------ | ---------------------------------------------------------- | 73 | | `-h` | `--help` | Show help | 74 | | `-S` | `--setup` | Run the setup wizard | 75 | | `-A` | `--api=STRING` | Set custom API server URL (mostly for development) | 76 | | `-C` | `--city=STRING` | Specify city name | 77 | | `-D` | `--default=STRING` | Set a new default city | 78 | | `-U` | `--units=STRING` | Set default temperature units (metric, imperial, standard) | 79 | | `-L` | `--login` | Authenticate with GitHub | 80 | | `-K` | `--api-key` | Set your api key (either gust, or openweathermap) | 81 | 82 | ## Display Flags 83 | 84 | _These flags control how weather information is displayed_ 85 | 86 | | Short | Long | Description | 87 | | ----- | ------------ | --------------------------------------------- | 88 | | `-a` | `--alerts` | Show weather alerts | 89 | | `-c` | `--compact` | Show today's compact weather view | 90 | | `-d` | `--detailed` | Show today's detailed weather view | 91 | | `-f` | `--full` | Show today, 5-day and weather alert forecasts | 92 | | `-r` | `--hourly` | Show 24-hour (hourly) forecast | 93 | | `-y` | `--daily` | Show 5-day forecast | 94 | 95 | ## Authentication 96 | 97 | gust uses a proxy api I set up and host privately, [breeze](http://github.com/josephburgess/breeze), to fetch weather data. This keeps the setup flow pretty frictionless for new users. 98 | 99 | When you first run gust: 100 | 101 | 1. A setup wizard will guide you through the initial configuration 102 | 2. You'll be prompted to choose an authentication method: 103 | - GitHub OAuth (one click sign up/in) 104 | - Your own OpenWeather Map API key if you prefer not to use Oauth or need much higher rate limits 105 | - User submitted keys will need to be eligible for the [One Call API 3.0](https://openweathermap.org/api/one-call-3#how) 106 | - The first 1000 calls every day are free but they ask for CC info to get a key 107 | 3. If you choose GitHub OAuth: 108 | - Your default browser will open to complete authentication 109 | - No need to manually obtain or manage API keys 110 | 4. Your credentials will be securely stored locally for future use in `~/.config/gust/auth.json` 111 | 112 | After this one-time setup, authentication happens automatically whenever you use the app. 113 | 114 | ## Troubleshooting 115 | 116 | If you encounter any auth issues, you can re-run the setup wizard or use the `-L / --login` (Oauth) `-K / --api-key` (api key) flags to re-set your key or check the local config files. 117 | -------------------------------------------------------------------------------- /internal/cli/weather_handler_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/josephburgess/gust/internal/config" 7 | "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | func TestRenderWeatherView_CLIFlags(t *testing.T) { 11 | mockCity := createTestCity() 12 | mockWeather := createTestWeather() 13 | mockConfig := &config.Config{DefaultView: "compact", ShowTips: false} 14 | 15 | testCases := []struct { 16 | name string 17 | cli *CLI 18 | expectedMethod string 19 | }{ 20 | { 21 | name: "alerts flag", 22 | cli: &CLI{Alerts: true}, 23 | expectedMethod: "RenderAlerts", 24 | }, 25 | { 26 | name: "hourly flag", 27 | cli: &CLI{Hourly: true}, 28 | expectedMethod: "RenderHourlyForecast", 29 | }, 30 | { 31 | name: "daily flag", 32 | cli: &CLI{Daily: true}, 33 | expectedMethod: "RenderDailyForecast", 34 | }, 35 | { 36 | name: "full flag", 37 | cli: &CLI{Full: true}, 38 | expectedMethod: "RenderFullWeather", 39 | }, 40 | { 41 | name: "compact flag", 42 | cli: &CLI{Compact: true}, 43 | expectedMethod: "RenderCompactWeather", 44 | }, 45 | { 46 | name: "detailed flag", 47 | cli: &CLI{Detailed: true}, 48 | expectedMethod: "RenderCurrentWeather", 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | mockRenderer := new(MockWeatherRenderer) 55 | mockRenderer.On(tc.expectedMethod, mockCity, mockWeather, mockConfig).Return() 56 | 57 | renderWeatherView(tc.cli, mockRenderer, mockCity, mockWeather, mockConfig) 58 | 59 | mockRenderer.AssertExpectations(t) 60 | }) 61 | } 62 | } 63 | 64 | func TestRenderWeatherView_DefaultView(t *testing.T) { 65 | mockCity := createTestCity() 66 | mockWeather := createTestWeather() 67 | cli := &CLI{} 68 | 69 | testCases := []struct { 70 | name string 71 | defaultView string 72 | expectedFn string 73 | }{ 74 | { 75 | name: "default compact", 76 | defaultView: "compact", 77 | expectedFn: "RenderCompactWeather", 78 | }, 79 | { 80 | name: "default view daily", 81 | defaultView: "daily", 82 | expectedFn: "RenderDailyForecast", 83 | }, 84 | { 85 | name: "default view hourly", 86 | defaultView: "hourly", 87 | expectedFn: "RenderHourlyForecast", 88 | }, 89 | { 90 | name: "default view full", 91 | defaultView: "full", 92 | expectedFn: "RenderFullWeather", 93 | }, 94 | { 95 | name: "default view unknown", 96 | defaultView: "unknown", 97 | expectedFn: "RenderCurrentWeather", 98 | }, 99 | { 100 | name: "default view empty", 101 | defaultView: "", 102 | expectedFn: "RenderCurrentWeather", 103 | }, 104 | } 105 | 106 | for _, tc := range testCases { 107 | t.Run(tc.name, func(t *testing.T) { 108 | mockRenderer := new(MockWeatherRenderer) 109 | mockRenderer.On(tc.expectedFn, mockCity, mockWeather, mock.Anything).Return() 110 | 111 | config := &config.Config{DefaultView: tc.defaultView} 112 | renderWeatherView(cli, mockRenderer, mockCity, mockWeather, config) 113 | 114 | mockRenderer.AssertExpectations(t) 115 | }) 116 | } 117 | } 118 | 119 | func TestRenderWeatherView_FlagsAndPriority(t *testing.T) { 120 | mockCity := createTestCity() 121 | mockWeather := createTestWeather() 122 | mockConfig := &config.Config{DefaultView: "compact"} 123 | 124 | testCases := []struct { 125 | name string 126 | cli *CLI 127 | expectedMethod string 128 | }{ 129 | { 130 | name: "default falls back", 131 | cli: &CLI{}, 132 | expectedMethod: "RenderCompactWeather", 133 | }, 134 | { 135 | name: "flag overrides default", 136 | cli: &CLI{Daily: true}, 137 | expectedMethod: "RenderDailyForecast", 138 | }, 139 | { 140 | name: "multiple flags respect prio", 141 | cli: &CLI{ 142 | Alerts: true, 143 | Hourly: true, 144 | Daily: true, 145 | Full: true, 146 | Compact: true, 147 | Detailed: true, 148 | }, 149 | expectedMethod: "RenderAlerts", 150 | }, 151 | } 152 | 153 | for _, tc := range testCases { 154 | t.Run(tc.name, func(t *testing.T) { 155 | mockRenderer := new(MockWeatherRenderer) 156 | mockRenderer.On(tc.expectedMethod, mockCity, mockWeather, mock.Anything).Return() 157 | 158 | renderWeatherView(tc.cli, mockRenderer, mockCity, mockWeather, mockConfig) 159 | 160 | mockRenderer.AssertExpectations(t) 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/ui/setup/runner_test.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/josephburgess/gust/internal/api" 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // Mock tea.Program 13 | type mockProgram struct { 14 | model tea.Model 15 | err error 16 | } 17 | 18 | func (m *mockProgram) Start() error { 19 | return m.err 20 | } 21 | 22 | // TestRunner wraps RunSetup for testing (prevents tea.Program from running) 23 | type testRunner struct { 24 | program *mockProgram 25 | // Track whether RunSetup was called 26 | runCalled bool 27 | } 28 | 29 | func newTestRunner(mockErr error) *testRunner { 30 | return &testRunner{ 31 | program: &mockProgram{err: mockErr}, 32 | } 33 | } 34 | 35 | // Implementation to mimic RunSetup 36 | func (tr *testRunner) runSetup(cfg *config.Config, needsAuth bool) error { 37 | tr.runCalled = true 38 | 39 | // Default API URL 40 | if cfg.ApiUrl == "" { 41 | cfg.ApiUrl = "https://breeze.joeburgess.dev" 42 | } 43 | 44 | // Initialize model 45 | model := NewModel(cfg, needsAuth, &api.Client{}) 46 | 47 | // Set model in our mock program 48 | if tr.program == nil { 49 | tr.program = &mockProgram{ 50 | model: model, 51 | err: nil, 52 | } 53 | } else { 54 | tr.program.model = model 55 | } 56 | 57 | // Return the error from our mock program 58 | return tr.program.Start() 59 | } 60 | 61 | func TestRunSetup(t *testing.T) { 62 | tests := []struct { 63 | name string 64 | cfg *config.Config 65 | needsAuth bool 66 | mockErr error 67 | wantErr bool 68 | }{ 69 | { 70 | name: "successful setup without auth", 71 | cfg: &config.Config{ 72 | ApiUrl: "https://test.api", 73 | Units: "metric", 74 | }, 75 | needsAuth: false, 76 | mockErr: nil, 77 | wantErr: false, 78 | }, 79 | { 80 | name: "successful setup with auth", 81 | cfg: &config.Config{ 82 | ApiUrl: "https://test.api", 83 | Units: "metric", 84 | }, 85 | needsAuth: true, 86 | mockErr: nil, 87 | wantErr: false, 88 | }, 89 | { 90 | name: "setup with empty API URL uses default", 91 | cfg: &config.Config{ 92 | Units: "metric", 93 | }, 94 | needsAuth: false, 95 | mockErr: nil, 96 | wantErr: false, 97 | }, 98 | { 99 | name: "program error propagates", 100 | cfg: &config.Config{ 101 | Units: "metric", 102 | }, 103 | needsAuth: false, 104 | mockErr: assert.AnError, 105 | wantErr: true, 106 | }, 107 | } 108 | 109 | for _, tt := range tests { 110 | t.Run(tt.name, func(t *testing.T) { 111 | runner := newTestRunner(tt.mockErr) 112 | err := runner.runSetup(tt.cfg, tt.needsAuth) 113 | 114 | // Verify runner was called 115 | assert.True(t, runner.runCalled) 116 | 117 | // Check error state 118 | if tt.wantErr { 119 | assert.Error(t, err) 120 | assert.Equal(t, tt.mockErr, err) 121 | } else { 122 | assert.NoError(t, err) 123 | } 124 | 125 | // Verify default API URL is set 126 | if tt.cfg.ApiUrl == "" { 127 | assert.Equal(t, "https://breeze.joeburgess.dev", tt.cfg.ApiUrl) 128 | } 129 | 130 | // Verify model was created with correct properties 131 | model, ok := runner.program.model.(Model) 132 | assert.True(t, ok) 133 | assert.Equal(t, tt.needsAuth, model.NeedsAuth) 134 | assert.Equal(t, tt.cfg, model.Config) 135 | }) 136 | } 137 | } 138 | 139 | func TestRunSetupEdgeCases(t *testing.T) { 140 | t.Run("nil config should be handled with default config", func(t *testing.T) { 141 | // In the real implementation, we should check for nil config 142 | // But for our test runner, we'll provide a default config 143 | runner := newTestRunner(nil) 144 | 145 | // Create a default config instead of passing nil 146 | defaultCfg := &config.Config{} 147 | err := runner.runSetup(defaultCfg, false) 148 | assert.NoError(t, err) 149 | 150 | // Verify default API URL is set 151 | assert.Equal(t, "https://breeze.joeburgess.dev", defaultCfg.ApiUrl) 152 | }) 153 | 154 | t.Run("various configuration values", func(t *testing.T) { 155 | configs := []*config.Config{ 156 | {Units: "imperial", DefaultView: "daily"}, 157 | {DefaultCity: "London", Units: "standard"}, 158 | {ApiUrl: "https://custom.api", Units: "metric", DefaultView: "full"}, 159 | } 160 | 161 | for _, cfg := range configs { 162 | runner := newTestRunner(nil) 163 | origApiUrl := cfg.ApiUrl 164 | err := runner.runSetup(cfg, false) 165 | 166 | assert.NoError(t, err) 167 | if origApiUrl == "" { 168 | assert.Equal(t, "https://breeze.joeburgess.dev", cfg.ApiUrl) 169 | } else { 170 | assert.Equal(t, origApiUrl, cfg.ApiUrl) 171 | } 172 | } 173 | }) 174 | } 175 | -------------------------------------------------------------------------------- /internal/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "time" 7 | ) 8 | 9 | const authSuccessTemplateContent = ` 10 | 11 | 12 | Gust Authentication Success 13 | 101 | 102 | 103 |

Authentication Successful!

104 |

Welcome, {{.Login}}!

105 |

Your Gust API key has been generated:

106 |
{{.ApiKey}}
107 |

This key will allow you to access weather data through the breeze API.

108 |
109 |

You can now return to your terminal. The CLI application should automatically continue.

110 |

If it doesn't, you can close this window and add the below to your ~/.config/gust/auth.json

111 |
{ 112 | "api_key": "{{.ApiKey}}", 113 | "server_url": "{{.ServerURL}}", 114 | "github_user": "{{.Login}}", 115 | "last_auth": "{{.LastAuth}}" 116 | }
117 |
118 | 119 | ` 120 | 121 | var templates *template.Template 122 | 123 | func init() { 124 | templates = template.Must(template.New("auth_success").Parse(authSuccessTemplateContent)) 125 | } 126 | 127 | func RenderSuccessTemplate(w io.Writer, login, apiKey, serverURL string) error { 128 | lastAuth := time.Now().Format(time.RFC3339) 129 | 130 | data := struct { 131 | Login string 132 | ApiKey string 133 | ServerURL string 134 | LastAuth string 135 | }{ 136 | Login: login, 137 | ApiKey: apiKey, 138 | ServerURL: serverURL, 139 | LastAuth: lastAuth, 140 | } 141 | 142 | return templates.ExecuteTemplate(w, "auth_success", data) 143 | } 144 | -------------------------------------------------------------------------------- /internal/ui/setup/model_test.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/josephburgess/gust/internal/api" 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestNewModel(t *testing.T) { 13 | cfg := &config.Config{ 14 | Units: "metric", 15 | DefaultView: "default", 16 | } 17 | client := &api.Client{} 18 | 19 | tests := []struct { 20 | name string 21 | cfg *config.Config 22 | needsAuth bool 23 | want Model 24 | }{ 25 | { 26 | name: "initializes with default values", 27 | cfg: cfg, 28 | needsAuth: false, 29 | want: Model{ 30 | Config: cfg, 31 | State: StateCity, 32 | Client: client, 33 | NeedsAuth: false, 34 | Quitting: false, 35 | }, 36 | }, 37 | { 38 | name: "initializes with auth required", 39 | cfg: cfg, 40 | needsAuth: true, 41 | want: Model{ 42 | Config: cfg, 43 | State: StateCity, 44 | Client: client, 45 | NeedsAuth: true, 46 | Quitting: false, 47 | }, 48 | }, 49 | } 50 | 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | got := NewModel(tt.cfg, tt.needsAuth, client) 54 | 55 | // Check basic properties 56 | assert.Equal(t, tt.want.Config, got.Config) 57 | assert.Equal(t, tt.want.State, got.State) 58 | assert.Equal(t, tt.want.NeedsAuth, got.NeedsAuth) 59 | assert.Equal(t, tt.want.Quitting, got.Quitting) 60 | 61 | // Check initialized components 62 | assert.NotNil(t, got.CityInput) 63 | assert.Len(t, got.UnitOptions, 3) 64 | assert.Len(t, got.ViewOptions, 5) 65 | assert.Len(t, got.TipOptions, 2) 66 | assert.Len(t, got.AuthOptions, 2) 67 | 68 | // Verify textinput is properly configured 69 | assert.True(t, got.CityInput.Focused()) 70 | assert.Equal(t, "Wherever the wind blows...", got.CityInput.Placeholder) 71 | }) 72 | } 73 | } 74 | 75 | func TestNewModelWithDifferentConfigs(t *testing.T) { 76 | tests := []struct { 77 | name string 78 | units string 79 | view string 80 | unitCursor int 81 | viewCursor int 82 | needsAuth bool 83 | }{ 84 | {"metric", "metric", "default", 0, 0, false}, 85 | {"imperial", "imperial", "compact", 1, 1, false}, 86 | {"standard", "standard", "daily", 2, 2, false}, 87 | {"unknown units", "unknown", "hourly", 0, 3, true}, 88 | {"unknown view", "metric", "unknown", 0, 0, true}, 89 | {"full view", "imperial", "full", 1, 4, false}, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | cfg := &config.Config{ 95 | Units: tt.units, 96 | DefaultView: tt.view, 97 | } 98 | model := NewModel(cfg, tt.needsAuth, nil) 99 | 100 | assert.Equal(t, tt.unitCursor, model.UnitCursor, "Unit cursor should match expected value") 101 | assert.Equal(t, tt.viewCursor, model.ViewCursor, "View cursor should match expected value") 102 | assert.Equal(t, tt.needsAuth, model.NeedsAuth, "Auth flag should match expected value") 103 | }) 104 | } 105 | } 106 | 107 | func TestModelInit(t *testing.T) { 108 | model := NewModel(&config.Config{}, false, nil) 109 | cmd := model.Init() 110 | 111 | // We can't directly test the returned commands, but we can ensure one is returned 112 | assert.NotNil(t, cmd, "Init should return a command") 113 | 114 | // Try initializing with different configurations 115 | configs := []*config.Config{ 116 | {Units: "metric", DefaultView: "default"}, 117 | {Units: "imperial", DefaultView: "compact"}, 118 | {DefaultCity: "London"}, 119 | } 120 | 121 | for _, cfg := range configs { 122 | model = NewModel(cfg, true, nil) 123 | cmd = model.Init() 124 | assert.NotNil(t, cmd, "Init should return a command for all configurations") 125 | } 126 | } 127 | 128 | func TestModelMessageTypes(t *testing.T) { 129 | // Test message types have the correct structure 130 | authMsg := AuthenticateMsg{} 131 | setupCompleteMsg := SetupCompleteMsg{} 132 | 133 | // These are empty structs, so we can only verify they exist 134 | assert.IsType(t, AuthenticateMsg{}, authMsg) 135 | assert.IsType(t, SetupCompleteMsg{}, setupCompleteMsg) 136 | } 137 | 138 | func TestWindowSizeHandling(t *testing.T) { 139 | model := NewModel(&config.Config{}, false, nil) 140 | 141 | // Initial size should be 0,0 142 | assert.Equal(t, 0, model.Width) 143 | assert.Equal(t, 0, model.Height) 144 | 145 | // Test various window sizes 146 | sizes := []struct{ width, height int }{ 147 | {80, 24}, // Standard terminal 148 | {120, 40}, // Large terminal 149 | {40, 10}, // Small terminal 150 | {0, 0}, // Zero dimensions 151 | } 152 | 153 | for _, size := range sizes { 154 | updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: size.width, Height: size.height}) 155 | updated := updatedModel.(Model) 156 | assert.Equal(t, size.width, updated.Width) 157 | assert.Equal(t, size.height, updated.Height) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /internal/models/weather_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestGetWeatherEmoji(t *testing.T) { 9 | tests := []struct { 10 | id int 11 | expected string 12 | }{ 13 | {200, "⚡"}, // storm 14 | {300, "🌦"}, // drizzle 15 | {500, "☔"}, // rain 16 | {600, "⛄"}, // snow 17 | {700, "🌫"}, // fog 18 | {800, "🔆"}, // clear 19 | {801, "🌥️"}, // cloudy 20 | {900, "🌡"}, // default 21 | } 22 | 23 | for _, test := range tests { 24 | result := GetWeatherEmoji(test.id, nil) 25 | if result != test.expected { 26 | t.Errorf("GetWeatherEmoji(%d) = %s, expected %s", test.id, result, test.expected) 27 | } 28 | } 29 | 30 | dayCurrent := &CurrentWeather{ 31 | Dt: 1615900000, // between sunrise/sunset 32 | Sunrise: 1615890000, 33 | Sunset: 1615940000, 34 | } 35 | 36 | dayResult := GetWeatherEmoji(800, dayCurrent) 37 | if dayResult != "🔆" { 38 | t.Errorf("GetWeatherEmoji(800, dayCurrent) = %s, expected 🔆", dayResult) 39 | } 40 | 41 | eveningCurrent := &CurrentWeather{ 42 | Dt: 1615950000, // post-sunset 43 | Sunrise: 1615890000, 44 | Sunset: 1615940000, 45 | } 46 | 47 | eveningResult := GetWeatherEmoji(800, eveningCurrent) 48 | if eveningResult != "🌙" { 49 | t.Errorf("GetWeatherEmoji(800, eveningCurrent) = %s, expected 🌙", eveningResult) 50 | } 51 | 52 | morningCurrent := &CurrentWeather{ 53 | Dt: 1615880000, // Before sunrise 54 | Sunrise: 1615890000, 55 | Sunset: 1615940000, 56 | } 57 | 58 | morningResult := GetWeatherEmoji(800, morningCurrent) 59 | if morningResult != "🌙" { 60 | t.Errorf("GetWeatherEmoji(800, morningCurrent) = %s, expected 🌙", morningResult) 61 | } 62 | } 63 | 64 | func TestGetWindDirection(t *testing.T) { 65 | tests := []struct { 66 | degrees int 67 | expected string 68 | }{ 69 | {0, "N"}, 70 | {45, "NE"}, 71 | {90, "E"}, 72 | {135, "SE"}, 73 | {180, "S"}, 74 | {225, "SW"}, 75 | {270, "W"}, 76 | {315, "NW"}, 77 | {360, "N"}, // full circle 78 | {400, "NE"}, // > 360 79 | {-45, "NW"}, // neg 80 | } 81 | 82 | for _, test := range tests { 83 | result := GetWindDirection(test.degrees) 84 | if result != test.expected { 85 | t.Errorf("GetWindDirection(%d) = %s, expected %s", test.degrees, result, test.expected) 86 | } 87 | } 88 | } 89 | 90 | func TestVisibilityToString(t *testing.T) { 91 | tests := []struct { 92 | meters int 93 | contains string 94 | }{ 95 | {12000, "Excellent"}, 96 | {10000, "Excellent"}, 97 | {7500, "Good"}, 98 | {5000, "Good"}, 99 | {3000, "Moderate"}, 100 | {2000, "Moderate"}, 101 | {1000, "Poor"}, 102 | {500, "Poor"}, 103 | } 104 | 105 | for _, test := range tests { 106 | result := VisibilityToString(test.meters) 107 | if result == "" || !contains(result, test.contains) { 108 | t.Errorf("VisibilityToString(%d) = %s, expected to contain %s", test.meters, result, test.contains) 109 | } 110 | } 111 | } 112 | 113 | func TestCityJSON(t *testing.T) { 114 | city := City{ 115 | Name: "London", 116 | Lat: 51.5074, 117 | Lon: -0.1278, 118 | } 119 | 120 | jsonData, err := json.Marshal(city) 121 | if err != nil { 122 | t.Fatalf("Failed to marshal City to JSON: %v", err) 123 | } 124 | 125 | var parsedCity City 126 | err = json.Unmarshal(jsonData, &parsedCity) 127 | if err != nil { 128 | t.Fatalf("Failed to unmarshal City from JSON: %v", err) 129 | } 130 | 131 | if parsedCity.Name != city.Name { 132 | t.Errorf("Expected Name to be %s, got %s", city.Name, parsedCity.Name) 133 | } 134 | 135 | if parsedCity.Lat != city.Lat { 136 | t.Errorf("Expected Lat to be %f, got %f", city.Lat, parsedCity.Lat) 137 | } 138 | 139 | if parsedCity.Lon != city.Lon { 140 | t.Errorf("Expected Lon to be %f, got %f", city.Lon, parsedCity.Lon) 141 | } 142 | } 143 | 144 | func TestWeatherConditionJSON(t *testing.T) { 145 | weatherCond := WeatherCondition{ 146 | ID: 800, 147 | Main: "Clear", 148 | Description: "clear sky", 149 | Icon: "01d", 150 | } 151 | 152 | jsonData, err := json.Marshal(weatherCond) 153 | if err != nil { 154 | t.Fatalf("Failed to marshal WeatherCondition to JSON: %v", err) 155 | } 156 | 157 | var parsedWeather WeatherCondition 158 | err = json.Unmarshal(jsonData, &parsedWeather) 159 | if err != nil { 160 | t.Fatalf("Failed to unmarshal WeatherCondition from JSON: %v", err) 161 | } 162 | 163 | if parsedWeather.ID != weatherCond.ID { 164 | t.Errorf("Expected ID to be %d, got %d", weatherCond.ID, parsedWeather.ID) 165 | } 166 | 167 | if parsedWeather.Main != weatherCond.Main { 168 | t.Errorf("Expected Main to be %s, got %s", weatherCond.Main, parsedWeather.Main) 169 | } 170 | 171 | if parsedWeather.Description != weatherCond.Description { 172 | t.Errorf("Expected Description to be %s, got %s", 173 | weatherCond.Description, parsedWeather.Description) 174 | } 175 | } 176 | 177 | func contains(s, substr string) bool { 178 | for i := 0; i <= len(s)-len(substr); i++ { 179 | if s[i:i+len(substr)] == substr { 180 | return true 181 | } 182 | } 183 | return false 184 | } 185 | -------------------------------------------------------------------------------- /internal/ui/setup/model.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textinput" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/josephburgess/gust/internal/api" 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/josephburgess/gust/internal/ui/components" 11 | "github.com/josephburgess/gust/internal/ui/styles" 12 | ) 13 | 14 | // rep's the current step of the wizard 15 | type SetupState int 16 | 17 | const ( 18 | StateCity SetupState = iota 19 | StateCitySearch 20 | StateCitySelect 21 | StateUnits 22 | StateView 23 | StateTips 24 | StateAuth 25 | StateApiKeyOption 26 | StateApiKeyInput 27 | StateComplete 28 | ) 29 | 30 | const asciiLogo = ` 31 | __ 32 | ____ ___ _______/ /_ 33 | / ** '/ / / / **_/ __/ 34 | / /_/ / /_/ (__ ) /_ 35 | \__, /\__,_/____/\__/ 💨🍃 36 | /____/ ` 37 | 38 | // setup ui styles 39 | var ( 40 | titleStyle = lipgloss.NewStyle().Bold(true).Foreground(styles.Rose) 41 | boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(styles.Iris).Padding(0, 1, 0, 1) 42 | logoBoxStyle = lipgloss.NewStyle().Border(lipgloss.DoubleBorder()).BorderForeground(styles.Subtle).Padding(0, 2, 2, 1).Foreground(styles.Foam) 43 | subtitleStyle = lipgloss.NewStyle().Foreground(styles.Gold) 44 | highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(styles.Text) 45 | cursorStyle = lipgloss.NewStyle().Foreground(styles.Love) 46 | selectedItemStyle = lipgloss.NewStyle().Foreground(styles.Foam) 47 | hintStyle = lipgloss.NewStyle().Foreground(styles.Subtle).Italic(true) 48 | ) 49 | 50 | // current state of wizard 51 | type Model struct { 52 | Config *config.Config 53 | State SetupState 54 | CityInput textinput.Model 55 | CitySearchQuery string 56 | CityOptions []models.City 57 | CityCursor int 58 | Client *api.Client 59 | UnitOptions []string 60 | UnitCursor int 61 | ViewOptions []string 62 | ViewCursor int 63 | AuthOptions []string 64 | AuthCursor int 65 | TipOptions []string 66 | TipCursor int 67 | NeedsAuth bool 68 | Width, Height int 69 | Quitting bool 70 | Spinner components.SpinnerModel 71 | ApiKeyOptions []string 72 | ApiKeyCursor int 73 | ApiKeyInput textinput.Model 74 | } 75 | 76 | // creates a new setup model 77 | func NewModel(cfg *config.Config, needsAuth bool, client *api.Client) Model { 78 | ti := textinput.New() 79 | ti.Placeholder = "Wherever the wind blows..." 80 | ti.Focus() 81 | ti.CharLimit = 50 82 | ti.Width = len(ti.Placeholder) 83 | ti.PromptStyle = lipgloss.NewStyle().Foreground(styles.Love) 84 | ti.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) 85 | ti.Cursor.Style = lipgloss.NewStyle().Foreground(styles.Gold) 86 | 87 | apiKeyInput := textinput.New() 88 | apiKeyInput.Placeholder = "Paste your OpenWeather API key here..." 89 | apiKeyInput.CharLimit = 64 90 | apiKeyInput.Width = len(apiKeyInput.Placeholder) 91 | apiKeyInput.PromptStyle = lipgloss.NewStyle().Foreground(styles.Love) 92 | apiKeyInput.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) 93 | apiKeyInput.Cursor.Style = lipgloss.NewStyle().Foreground(styles.Gold) 94 | 95 | unitCursor := 0 96 | switch cfg.Units { 97 | case "imperial": 98 | unitCursor = 1 99 | case "standard": 100 | unitCursor = 2 101 | } 102 | 103 | viewCursor := 0 104 | switch cfg.DefaultView { 105 | case "compact": 106 | viewCursor = 1 107 | case "daily": 108 | viewCursor = 2 109 | case "hourly": 110 | viewCursor = 3 111 | case "full": 112 | viewCursor = 4 113 | } 114 | 115 | return Model{ 116 | Config: cfg, 117 | State: StateCity, 118 | CityInput: ti, 119 | CitySearchQuery: "", 120 | CityOptions: []models.City{}, 121 | CityCursor: 0, 122 | Client: client, 123 | UnitOptions: []string{"metric (°C, km/h) 🌡️", "imperial (°F, mph) 🌡️", "standard (K, m/s) 🌡️"}, 124 | UnitCursor: unitCursor, 125 | ViewOptions: []string{ 126 | "detailed 🌤️", 127 | "compact 📊", 128 | "5-day 📆", 129 | "24-hour 🕒", 130 | "full (current + 5-day + alerts) 📋", 131 | }, 132 | ViewCursor: viewCursor, 133 | TipOptions: []string{"Yes, show weather tips", "No, don't show tips"}, 134 | TipCursor: 0, 135 | AuthOptions: []string{"Yes, authenticate with GitHub 🔑", "No, I'll do it later ⏱️"}, 136 | AuthCursor: 0, 137 | NeedsAuth: needsAuth, 138 | Quitting: false, 139 | Spinner: components.NewSpinner(), 140 | ApiKeyOptions: []string{ 141 | "Use gust's authentication (recommended)", 142 | "Use my own OpenWeatherMap API key", 143 | }, 144 | ApiKeyCursor: 0, 145 | ApiKeyInput: apiKeyInput, 146 | } 147 | } 148 | 149 | func (m Model) Init() tea.Cmd { 150 | return tea.Batch( 151 | textinput.Blink, 152 | m.Spinner.Tick(), 153 | ) 154 | } 155 | 156 | type ( 157 | AuthenticateMsg struct{} 158 | SetupCompleteMsg struct{} 159 | ) 160 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v1.8.1 h1:6aamvWBE/REnR/BCq10EcozmcpUPc5aGI1lPAWdB0EE= 4 | github.com/alecthomas/kong v1.8.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 12 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 13 | github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= 14 | github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= 15 | github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= 16 | github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= 17 | github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= 18 | github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 19 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= 20 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 25 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 26 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 27 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 28 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 29 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 30 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 31 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 32 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 33 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 34 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 35 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 36 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 37 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 38 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 39 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 40 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 41 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 43 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 44 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 45 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 46 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 47 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 51 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 52 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 53 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 54 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 55 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 56 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 58 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 59 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 63 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 64 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 65 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /internal/cli/runner_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/alecthomas/kong" 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRunMissingCity(t *testing.T) { 13 | testFunc := func(cli *CLI, cfg *config.Config) error { 14 | city := determineCityName(cli.City, cli.Args, cfg.DefaultCity) 15 | if city == "" { 16 | return handleMissingCity() 17 | } 18 | return nil 19 | } 20 | 21 | testCases := []struct { 22 | name string 23 | cli *CLI 24 | defaultCity string 25 | expectError bool 26 | }{ 27 | { 28 | name: "no city provided", 29 | cli: &CLI{}, 30 | defaultCity: "", 31 | expectError: true, 32 | }, 33 | { 34 | name: "city provided through flag", 35 | cli: &CLI{City: "London"}, 36 | defaultCity: "", 37 | expectError: false, 38 | }, 39 | { 40 | name: "city provided through args", 41 | cli: &CLI{Args: []string{"New", "York"}}, 42 | defaultCity: "", 43 | expectError: false, 44 | }, 45 | { 46 | name: "default city used", 47 | cli: &CLI{}, 48 | defaultCity: "Paris", 49 | expectError: false, 50 | }, 51 | } 52 | 53 | for _, tc := range testCases { 54 | t.Run(tc.name, func(t *testing.T) { 55 | cfg := &config.Config{DefaultCity: tc.defaultCity} 56 | err := testFunc(tc.cli, cfg) 57 | 58 | if tc.expectError { 59 | assert.Error(t, err) 60 | assert.Contains(t, err.Error(), "no city provided") 61 | } else { 62 | assert.NoError(t, err) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestRunAuthRequired(t *testing.T) { 69 | testFunc := func(needsAuth bool) error { 70 | if needsAuth { 71 | return handleMissingAuth() 72 | } 73 | return nil 74 | } 75 | 76 | testCases := []struct { 77 | name string 78 | needsAuth bool 79 | expectError bool 80 | }{ 81 | { 82 | name: "auth required", 83 | needsAuth: true, 84 | expectError: true, 85 | }, 86 | { 87 | name: "auth not required", 88 | needsAuth: false, 89 | expectError: false, 90 | }, 91 | } 92 | 93 | for _, tc := range testCases { 94 | t.Run(tc.name, func(t *testing.T) { 95 | err := testFunc(tc.needsAuth) 96 | 97 | if tc.expectError { 98 | assert.Error(t, err) 99 | assert.Contains(t, err.Error(), "authentication required") 100 | } else { 101 | assert.NoError(t, err) 102 | } 103 | }) 104 | } 105 | } 106 | 107 | // testable version of run with deps injected 108 | func createRunWithDeps() func( 109 | ctx *kong.Context, 110 | cli *CLI, 111 | loadConfig func() (*config.Config, error), 112 | authenticate func(string) (*config.AuthConfig, error), 113 | saveAuthConfig func(*config.AuthConfig) error, 114 | loadAuthConfig func() (*config.AuthConfig, error), 115 | runSetup func(*config.Config, bool) error, 116 | fetchWeather func(string, *config.Config, *config.AuthConfig, *CLI) error, 117 | ) error { 118 | return func( 119 | ctx *kong.Context, 120 | cli *CLI, 121 | loadConfig func() (*config.Config, error), 122 | authenticate func(string) (*config.AuthConfig, error), 123 | saveAuthConfig func(*config.AuthConfig) error, 124 | loadAuthConfig func() (*config.AuthConfig, error), 125 | runSetup func(*config.Config, bool) error, 126 | fetchWeather func(string, *config.Config, *config.AuthConfig, *CLI) error, 127 | ) error { 128 | cfg, err := loadConfig() 129 | if err != nil { 130 | return fmt.Errorf("failed to load configuration: %w", err) 131 | } 132 | 133 | if cli.Login { 134 | authConfig, err := authenticate(cfg.ApiUrl) 135 | if err != nil { 136 | return fmt.Errorf("authentication failed: %w", err) 137 | } 138 | 139 | if err := saveAuthConfig(authConfig); err != nil { 140 | return fmt.Errorf("failed to save auth: %w", err) 141 | } 142 | 143 | fmt.Printf("authed as %s\n", authConfig.GithubUser) 144 | return nil 145 | } 146 | 147 | authConfig, _ := loadAuthConfig() 148 | needsAuth := authConfig == nil 149 | 150 | if needsSetup(cli, cfg) { 151 | err = runSetup(cfg, needsAuth) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | authConfig, _ = loadAuthConfig() 157 | } 158 | 159 | if needsAuth { 160 | return handleMissingAuth() 161 | } 162 | 163 | city := determineCityName(cli.City, cli.Args, cfg.DefaultCity) 164 | if city == "" { 165 | return handleMissingCity() 166 | } 167 | 168 | return fetchWeather(city, cfg, authConfig, cli) 169 | } 170 | } 171 | 172 | func TestRunLogin(t *testing.T) { 173 | runWithDeps := createRunWithDeps() 174 | ctx := &kong.Context{} 175 | cli := &CLI{Login: true} 176 | mockAuthConfig := &config.AuthConfig{ 177 | APIKey: "testkey", 178 | GithubUser: "testuser", 179 | } 180 | 181 | t.Run("Successful login", func(t *testing.T) { 182 | var loadConfigCalled, authenticateCalled, saveAuthCalled bool 183 | 184 | err := runWithDeps( 185 | ctx, 186 | cli, 187 | func() (*config.Config, error) { 188 | loadConfigCalled = true 189 | return &config.Config{ApiUrl: "https://api.example.com"}, nil 190 | }, 191 | func(apiURL string) (*config.AuthConfig, error) { 192 | authenticateCalled = true 193 | assert.Equal(t, "https://api.example.com", apiURL) 194 | return mockAuthConfig, nil 195 | }, 196 | func(config *config.AuthConfig) error { 197 | saveAuthCalled = true 198 | assert.Equal(t, mockAuthConfig, config) 199 | return nil 200 | }, 201 | nil, nil, nil, 202 | ) 203 | 204 | assert.NoError(t, err) 205 | assert.True(t, loadConfigCalled) 206 | assert.True(t, authenticateCalled) 207 | assert.True(t, saveAuthCalled) 208 | }) 209 | 210 | t.Run("Authentication failure", func(t *testing.T) { 211 | err := runWithDeps( 212 | ctx, 213 | cli, 214 | func() (*config.Config, error) { 215 | return &config.Config{ApiUrl: "https://api.example.com"}, nil 216 | }, 217 | func(apiURL string) (*config.AuthConfig, error) { 218 | return nil, fmt.Errorf("authentication failed") 219 | }, 220 | nil, nil, nil, nil, 221 | ) 222 | 223 | assert.Error(t, err) 224 | assert.Contains(t, err.Error(), "authentication failed") 225 | }) 226 | } 227 | -------------------------------------------------------------------------------- /internal/ui/renderer/renderer_test.go: -------------------------------------------------------------------------------- 1 | package renderer 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/josephburgess/gust/internal/config" 12 | "github.com/josephburgess/gust/internal/models" 13 | "github.com/josephburgess/gust/internal/ui/styles" 14 | ) 15 | 16 | func TestNewWeatherRenderer(t *testing.T) { 17 | renderer := NewWeatherRenderer("terminal", "") 18 | if renderer == nil { 19 | t.Fatal("NewWeatherRenderer() should return a non-nil renderer") 20 | } 21 | } 22 | 23 | func TestNewTerminalRenderer(t *testing.T) { 24 | renderer := NewTerminalRenderer("") 25 | if renderer == nil { 26 | t.Fatal("NewTerminalRenderer() should return a non-nil renderer") 27 | } 28 | } 29 | 30 | func TestRenderCurrentWeather(t *testing.T) { 31 | oldStdout := os.Stdout 32 | r, w, _ := os.Pipe() 33 | os.Stdout = w 34 | defer func() { 35 | os.Stdout = oldStdout 36 | }() 37 | 38 | city := &models.City{ 39 | Name: "Test City", 40 | Lat: 51.5074, 41 | Lon: -0.1278, 42 | } 43 | 44 | weather := &models.OneCallResponse{ 45 | Current: models.CurrentWeather{ 46 | Dt: time.Now().Unix(), 47 | Temp: 10, 48 | FeelsLike: 8, 49 | Humidity: 65, 50 | UVI: 2.5, 51 | WindSpeed: 5.1, 52 | WindDeg: 180, 53 | Visibility: 10000, 54 | Sunrise: time.Now().Add(-6 * time.Hour).Unix(), 55 | Sunset: time.Now().Add(6 * time.Hour).Unix(), 56 | Weather: []models.WeatherCondition{ 57 | { 58 | ID: 800, 59 | Main: "Clear", 60 | Description: "clear sky", 61 | Icon: "01d", 62 | }, 63 | }, 64 | }, 65 | } 66 | 67 | cfg := &config.Config{ 68 | Units: "metric", 69 | ShowTips: true, 70 | } 71 | 72 | renderer := NewTerminalRenderer("metric") 73 | renderer.RenderCurrentWeather(city, weather, cfg) 74 | 75 | w.Close() 76 | var buf bytes.Buffer 77 | io.Copy(&buf, r) 78 | output := buf.String() 79 | 80 | expectedPhrases := []string{ 81 | "WEATHER FOR TEST CITY", 82 | "clear sky", 83 | "10.0°C", 84 | "Humidity: 65%", 85 | "UV Index: 2.5", 86 | } 87 | 88 | for _, phrase := range expectedPhrases { 89 | if !strings.Contains(output, phrase) { 90 | t.Errorf("Expected output to contain '%s', but it didn't", phrase) 91 | } 92 | } 93 | } 94 | 95 | func TestRenderAlerts(t *testing.T) { 96 | oldStdout := os.Stdout 97 | r, w, _ := os.Pipe() 98 | os.Stdout = w 99 | defer func() { 100 | os.Stdout = oldStdout 101 | }() 102 | 103 | city := &models.City{Name: "Alert City"} 104 | weather := &models.OneCallResponse{ 105 | Alerts: []models.Alert{ 106 | { 107 | SenderName: "Weather Service", 108 | Event: "Severe Thunderstorm Warning", 109 | Start: time.Now().Unix(), 110 | End: time.Now().Add(3 * time.Hour).Unix(), 111 | Description: "A severe thunderstorm is expected in the area.", 112 | }, 113 | }, 114 | } 115 | 116 | cfg := &config.Config{ 117 | Units: "metric", 118 | ShowTips: true, 119 | } 120 | 121 | renderer := NewTerminalRenderer("") 122 | renderer.RenderAlerts(city, weather, cfg) 123 | 124 | w.Close() 125 | var buf bytes.Buffer 126 | io.Copy(&buf, r) 127 | output := buf.String() 128 | 129 | expectedPhrases := []string{ 130 | "WEATHER ALERTS FOR ALERT CITY", 131 | "Severe Thunderstorm Warning", 132 | "Issued by: Weather Service", 133 | "A severe thunderstorm is expected in the area.", 134 | } 135 | 136 | for _, phrase := range expectedPhrases { 137 | if !strings.Contains(output, phrase) { 138 | t.Errorf("Expected output to contain '%s', but it didn't", phrase) 139 | } 140 | } 141 | } 142 | 143 | func TestRenderAlertsNoAlerts(t *testing.T) { 144 | oldStdout := os.Stdout 145 | r, w, _ := os.Pipe() 146 | os.Stdout = w 147 | defer func() { 148 | os.Stdout = oldStdout 149 | }() 150 | 151 | city := &models.City{Name: "Calm City"} 152 | weather := &models.OneCallResponse{ 153 | Alerts: []models.Alert{}, 154 | } 155 | 156 | cfg := &config.Config{ 157 | Units: "metric", 158 | ShowTips: true, 159 | } 160 | 161 | renderer := NewTerminalRenderer("") 162 | renderer.RenderAlerts(city, weather, cfg) 163 | 164 | w.Close() 165 | var buf bytes.Buffer 166 | io.Copy(&buf, r) 167 | output := buf.String() 168 | 169 | if !strings.Contains(output, "No weather alerts for this area") { 170 | t.Error("Expected output to indicate no alerts") 171 | } 172 | } 173 | 174 | func TestBaseRendererHelpers(t *testing.T) { 175 | testCases := []struct { 176 | units string 177 | expectedTempUnit string 178 | expectedWindSpeedUnit string 179 | }{ 180 | {"metric", "°C", "km/h"}, 181 | {"imperial", "°F", "mph"}, 182 | {"standard", "K", "km/h"}, 183 | } 184 | 185 | for _, tc := range testCases { 186 | t.Run("Units: "+tc.units, func(t *testing.T) { 187 | renderer := BaseRenderer{Units: tc.units} 188 | 189 | tempUnit := renderer.GetTemperatureUnit() 190 | if tempUnit != tc.expectedTempUnit { 191 | t.Errorf("GetTemperatureUnit() = %v, want %v", tempUnit, tc.expectedTempUnit) 192 | } 193 | 194 | windUnit := renderer.GetWindSpeedUnit() 195 | if windUnit != tc.expectedWindSpeedUnit { 196 | t.Errorf("GetWindSpeedUnit() = %v, want %v", windUnit, tc.expectedWindSpeedUnit) 197 | } 198 | 199 | // Test wind speed conversion 200 | windSpeed := 10.0 201 | convertedSpeed := renderer.FormatWindSpeed(windSpeed) 202 | 203 | if tc.units == "imperial" { 204 | if convertedSpeed != windSpeed { 205 | t.Errorf("FormatWindSpeed() = %v, want %v", convertedSpeed, windSpeed) 206 | } 207 | } else { 208 | if convertedSpeed != windSpeed*3.6 { 209 | t.Errorf("FormatWindSpeed() = %v, want %v", convertedSpeed, windSpeed*3.6) 210 | } 211 | } 212 | }) 213 | } 214 | } 215 | 216 | func TestFormatDateTime(t *testing.T) { 217 | timestamp := int64(1609459200) // 2021-01-01 00:00:00 UTC 218 | format := "2006-01-02 15:04:05" 219 | 220 | result := FormatDateTime(timestamp, format) 221 | expected := "2021-01-01 00:00:00" 222 | 223 | if result != expected { 224 | t.Errorf("FormatDateTime() = %v, want %v", result, expected) 225 | } 226 | } 227 | 228 | func TestFormatHeader(t *testing.T) { 229 | title := "TEST HEADER" 230 | header := styles.FormatHeader(title) 231 | 232 | if !strings.Contains(header, title) { 233 | t.Errorf("Header doesn't contain title: %s", header) 234 | } 235 | 236 | if !strings.Contains(header, styles.Divider(len(title)*2)) { 237 | t.Errorf("Header doesn't contain divider: %s", header) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /internal/config/auth.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "runtime" 14 | "time" 15 | 16 | "github.com/josephburgess/gust/internal/templates" 17 | "github.com/josephburgess/gust/internal/ui/output" 18 | ) 19 | 20 | type AuthConfig struct { 21 | APIKey string `json:"api_key"` 22 | ServerURL string `json:"server_url"` 23 | LastAuth time.Time `json:"last_auth"` 24 | GithubUser string `json:"github_user"` 25 | } 26 | 27 | type GetAuthConfigPathFunc func() (string, error) 28 | 29 | var GetAuthConfigPath GetAuthConfigPathFunc = defaultGetAuthConfigPath 30 | 31 | func defaultGetAuthConfigPath() (string, error) { 32 | homeDir, err := os.UserHomeDir() 33 | if err != nil { 34 | return "", fmt.Errorf("could not get user home directory: %w", err) 35 | } 36 | return filepath.Join(homeDir, ".config", "gust", "auth.json"), nil 37 | } 38 | 39 | func Authenticate(apiUrl string) (*AuthConfig, error) { 40 | if apiUrl == "" { 41 | apiUrl = "https://breeze.joeburgess.dev" 42 | } 43 | 44 | authDone := make(chan *AuthConfig) 45 | errorChan := make(chan error) 46 | port := 9876 47 | server := startCallbackServer(port, apiUrl, authDone, errorChan) 48 | 49 | defer func() { 50 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 51 | defer cancel() 52 | server.Shutdown(ctx) 53 | }() 54 | 55 | authURL, err := getAuthURL(apiUrl, port) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to get auth URL: %w", err) 58 | } 59 | 60 | output.PrintInfo("Opening browser for GitHub authentication...") 61 | if err := openBrowser(authURL); err != nil { 62 | output.PrintError(fmt.Sprintf("Could not open browser automatically. Please open this URL manually:\n%s", authURL)) 63 | } 64 | 65 | select { 66 | case config := <-authDone: 67 | return config, nil 68 | case err := <-errorChan: 69 | return nil, err 70 | case <-time.After(5 * time.Minute): 71 | return nil, fmt.Errorf("authentication timed out") 72 | } 73 | } 74 | 75 | // simple server to handle the callback post auth 76 | func startCallbackServer(port int, apiUrl string, authDone chan<- *AuthConfig, errorChan chan<- error) *http.Server { 77 | server := &http.Server{Addr: fmt.Sprintf(":%d", port)} 78 | 79 | http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 80 | code := r.URL.Query().Get("code") 81 | if code == "" { 82 | errorChan <- fmt.Errorf("no auth code received") 83 | w.WriteHeader(http.StatusBadRequest) 84 | w.Write([]byte("Authentication failed: No code provided")) 85 | return 86 | } 87 | 88 | apiConfig, err := exchangeCodeForAPIKey(apiUrl, code, port) 89 | if err != nil { 90 | errorChan <- err 91 | w.WriteHeader(http.StatusInternalServerError) 92 | fmt.Printf("Authentication failed: %v", err) 93 | return 94 | } 95 | 96 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 97 | w.WriteHeader(http.StatusOK) 98 | 99 | err = templates.RenderSuccessTemplate(w, apiConfig.GithubUser, apiConfig.APIKey, apiUrl) 100 | if err != nil { 101 | errorChan <- fmt.Errorf("failed to render success template: %w", err) 102 | return 103 | } 104 | 105 | authDone <- apiConfig 106 | 107 | go func() { 108 | time.Sleep(100 * time.Millisecond) 109 | server.Shutdown(context.Background()) 110 | }() 111 | }) 112 | 113 | go func() { 114 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 115 | errorChan <- err 116 | } 117 | }() 118 | 119 | return server 120 | } 121 | 122 | func getAuthURL(serverURL string, port int) (string, error) { 123 | url := fmt.Sprintf("%s/api/auth/request?callback_port=%d", serverURL, port) 124 | 125 | resp, err := http.Get(url) 126 | if err != nil { 127 | return "", fmt.Errorf("failed to contact auth server: %w", err) 128 | } 129 | defer resp.Body.Close() 130 | 131 | if resp.StatusCode != http.StatusOK { 132 | return "", fmt.Errorf("server returned status code %d", resp.StatusCode) 133 | } 134 | 135 | var response struct { 136 | URL string `json:"url"` 137 | } 138 | 139 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 140 | return "", fmt.Errorf("failed to decode response: %w", err) 141 | } 142 | 143 | return response.URL, nil 144 | } 145 | 146 | func exchangeCodeForAPIKey(serverURL, code string, port int) (*AuthConfig, error) { 147 | url := fmt.Sprintf("%s/api/auth/exchange", serverURL) 148 | 149 | reqBody, err := json.Marshal(map[string]any{ 150 | "code": code, 151 | "callback_port": port, 152 | }) 153 | if err != nil { 154 | return nil, fmt.Errorf("failed to create request body: %w", err) 155 | } 156 | 157 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(reqBody)) 158 | if err != nil { 159 | return nil, fmt.Errorf("failed to exchange code: %w", err) 160 | } 161 | defer resp.Body.Close() 162 | 163 | if resp.StatusCode != http.StatusOK { 164 | body, _ := io.ReadAll(resp.Body) 165 | return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body)) 166 | } 167 | 168 | var response struct { 169 | APIKey string `json:"api_key"` 170 | GithubUser string `json:"github_user"` 171 | } 172 | 173 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 174 | return nil, fmt.Errorf("failed to decode response: %w", err) 175 | } 176 | 177 | return &AuthConfig{ 178 | APIKey: response.APIKey, 179 | ServerURL: serverURL, 180 | LastAuth: time.Now(), 181 | GithubUser: response.GithubUser, 182 | }, nil 183 | } 184 | 185 | func openBrowser(url string) error { 186 | var cmd *exec.Cmd 187 | 188 | switch runtime.GOOS { 189 | case "darwin": 190 | cmd = exec.Command("open", url) 191 | case "windows": 192 | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 193 | default: 194 | cmd = exec.Command("xdg-open", url) 195 | } 196 | 197 | return cmd.Start() 198 | } 199 | 200 | func SaveAuthConfig(config *AuthConfig) error { 201 | configPath, err := GetAuthConfigPath() 202 | if err != nil { 203 | return err 204 | } 205 | 206 | dir := filepath.Dir(configPath) 207 | if err := os.MkdirAll(dir, 0755); err != nil { 208 | return fmt.Errorf("failed to create config directory: %w", err) 209 | } 210 | 211 | file, err := os.Create(configPath) 212 | if err != nil { 213 | return fmt.Errorf("failed to create auth config file: %w", err) 214 | } 215 | defer file.Close() 216 | 217 | encoder := json.NewEncoder(file) 218 | encoder.SetIndent("", " ") 219 | if err := encoder.Encode(config); err != nil { 220 | return fmt.Errorf("failed to encode auth config: %w", err) 221 | } 222 | 223 | return nil 224 | } 225 | 226 | func LoadAuthConfig() (*AuthConfig, error) { 227 | configPath, err := GetAuthConfigPath() 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 233 | return nil, nil 234 | } 235 | 236 | file, err := os.Open(configPath) 237 | if err != nil { 238 | return nil, fmt.Errorf("failed to open auth config file: %w", err) 239 | } 240 | defer file.Close() 241 | 242 | var config AuthConfig 243 | if err := json.NewDecoder(file).Decode(&config); err != nil { 244 | return nil, fmt.Errorf("failed to decode auth config: %w", err) 245 | } 246 | 247 | return &config, nil 248 | } 249 | -------------------------------------------------------------------------------- /internal/ui/setup/view.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | func ClearViewport(width, height int) string { 11 | var sb strings.Builder 12 | 13 | blankLine := strings.Repeat(" ", width) 14 | for i := 0; i < height; i++ { 15 | sb.WriteString(blankLine + "\n") 16 | } 17 | 18 | sb.WriteString("\033[H") 19 | 20 | return sb.String() 21 | } 22 | 23 | // rendr current state of setup wizard 24 | func (m Model) View() string { 25 | var result strings.Builder 26 | 27 | if m.Width > 0 && m.Height > 0 { 28 | result.WriteString(ClearViewport(m.Width, m.Height)) 29 | } 30 | 31 | content := m.buildContent() 32 | result.WriteString(m.centerContent(content)) 33 | 34 | return result.String() 35 | } 36 | 37 | // create correct content for currnet state 38 | func (m Model) buildContent() string { 39 | var sb strings.Builder 40 | 41 | sb.WriteString(logoBoxStyle.Render(asciiLogo) + "\n\n") 42 | sb.WriteString(subtitleStyle.Render("Simple terminal weather 🌤️") + "\n\n") 43 | 44 | switch m.State { 45 | case StateCity: 46 | sb.WriteString(highlightStyle.Render("Enter a default city 🏙️") + "\n\n") 47 | sb.WriteString(m.CityInput.View() + "\n\n") 48 | sb.WriteString(hintStyle.Render("You can enter a country code too, but use a comma! (e.g. London,GB)")) 49 | 50 | case StateCitySearch: 51 | sb.WriteString(highlightStyle.Render("Searching for cities...") + "\n\n") 52 | sb.WriteString(fmt.Sprintf("%s Looking for \"%s\"", m.Spinner.View(), m.CitySearchQuery)) 53 | sb.WriteString("\n\n") 54 | 55 | case StateCitySelect: 56 | sb.WriteString(highlightStyle.Render("Select your town or city: 🏙️") + "\n\n") 57 | 58 | if len(m.CityOptions) == 0 { 59 | sb.WriteString("No cities found. Please try a different search term.\n\n") 60 | } else { 61 | for i, city := range m.CityOptions { 62 | var line string 63 | var locationInfo string 64 | 65 | if city.State != "" && city.Country != "" { 66 | flag := getCountryEmoji(city.Country) 67 | locationInfo = fmt.Sprintf("%s, %s %s", city.State, city.Country, flag) 68 | } else if city.Country != "" { 69 | flag := getCountryEmoji(city.Country) 70 | locationInfo = fmt.Sprintf("%s %s", flag, locationInfo) 71 | } else { 72 | locationInfo = fmt.Sprintf("(%.4f, %.4f)", city.Lat, city.Lon) 73 | } 74 | 75 | displayName := fmt.Sprintf("%s - %s", city.Name, locationInfo) 76 | 77 | if m.CityCursor == i { 78 | line = fmt.Sprintf("%s %s", cursorStyle.Render("→"), selectedItemStyle.Render(displayName)) 79 | } else { 80 | line = fmt.Sprintf(" %s", displayName) 81 | } 82 | sb.WriteString(line + "\n") 83 | } 84 | sb.WriteString("\n") 85 | } 86 | 87 | sb.WriteString(hintStyle.Render("Press Enter to select or Esc to search again")) 88 | 89 | case StateUnits: 90 | sb.WriteString(highlightStyle.Render("Choose your preferred units: 🌡️") + "\n\n") 91 | sb.WriteString(m.renderOptions(m.UnitOptions, m.UnitCursor)) 92 | sb.WriteString("\n" + hintStyle.Render("Press Enter to confirm")) 93 | 94 | case StateView: 95 | sb.WriteString(highlightStyle.Render("Choose your preferred view: 📊") + "\n\n") 96 | sb.WriteString(m.renderOptions(m.ViewOptions, m.ViewCursor)) 97 | sb.WriteString("\n" + hintStyle.Render("Press Enter to confirm")) 98 | 99 | case StateAuth: 100 | sb.WriteString(highlightStyle.Render("GitHub Auth 🔒") + "\n\n") 101 | sb.WriteString("To get weather data you need to authenticate with GitHub (don't worry, no permissions requested!).\n\n") 102 | sb.WriteString(m.renderOptions(m.AuthOptions, m.AuthCursor)) 103 | sb.WriteString("\n" + hintStyle.Render("Press Enter to confirm your selection")) 104 | 105 | case StateComplete: 106 | sb.WriteString(highlightStyle.Render("✓ Setup complete! 🎉") + "\n\n") 107 | sb.WriteString(fmt.Sprintf("Default city: %s 🏙️\n", m.Config.DefaultCity)) 108 | sb.WriteString(fmt.Sprintf("Units: %s 🌡️\n", m.Config.Units)) 109 | sb.WriteString(fmt.Sprintf("Default view: %s 📊\n", m.Config.DefaultView)) 110 | if m.Config.ShowTips { 111 | sb.WriteString("Tips enabled 💡\n") 112 | } else { 113 | sb.WriteString("Tips disabled 💡\n") 114 | } 115 | 116 | case StateTips: 117 | sb.WriteString(highlightStyle.Render("Would you like tips shown on daily forecasts? 💡") + "\n\n") 118 | sb.WriteString(m.renderOptions(m.TipOptions, m.TipCursor)) 119 | sb.WriteString("\n" + hintStyle.Render("Press Enter to confirm")) 120 | 121 | if m.NeedsAuth { 122 | authStatus := "Authenticated ✅" 123 | if m.AuthCursor == 1 { 124 | authStatus = "Not authenticated ❌" 125 | } 126 | sb.WriteString(fmt.Sprintf("GitHub: %s\n", authStatus)) 127 | } 128 | 129 | case StateApiKeyOption: 130 | sb.WriteString(highlightStyle.Render("Choose auth method: 🔑") + "\n\n") 131 | sb.WriteString(m.renderOptions(m.ApiKeyOptions, m.ApiKeyCursor)) 132 | sb.WriteString("\n" + hintStyle.Render("Press Enter to confirm")) 133 | 134 | case StateApiKeyInput: 135 | sb.WriteString(highlightStyle.Render("Enter your OpenWeatherMap API key: 🔑") + "\n\n") 136 | sb.WriteString(m.ApiKeyInput.View() + "\n\n") 137 | sb.WriteString(hintStyle.Render("Get your API key from https://home.openweathermap.org/subscriptions/unauth_subscribe/onecall_30/base")) 138 | 139 | } 140 | 141 | // footer 142 | sb.WriteString("\n" + hintStyle.Render("↓j/↑k Navigate • Enter: Select • Ctrl + C: Quit")) 143 | 144 | return sb.String() 145 | } 146 | 147 | // renders a list of opts with current selection highlighted 148 | func (m Model) renderOptions(options []string, cursor int) string { 149 | var sb strings.Builder 150 | 151 | for i, option := range options { 152 | var line string 153 | if cursor == i { 154 | line = fmt.Sprintf("%s %s", cursorStyle.Render("→"), selectedItemStyle.Render(option)) 155 | } else { 156 | line = fmt.Sprintf(" %s", option) 157 | } 158 | sb.WriteString(line + "\n") 159 | } 160 | 161 | return sb.String() 162 | } 163 | 164 | // center in viewport 165 | func (m Model) centerContent(content string) string { 166 | var sb strings.Builder 167 | lines := strings.Split(content, "\n") 168 | 169 | // only try to center if we have dimensions 170 | if m.Width > 0 && m.Height > 0 { 171 | // vert 172 | contentHeight := len(lines) 173 | verticalPadding := (m.Height - contentHeight) / 2 174 | 175 | if verticalPadding > 0 { 176 | sb.WriteString(strings.Repeat("\n", verticalPadding)) 177 | } 178 | 179 | // horiz 180 | for _, line := range lines { 181 | visibleLen := lipgloss.Width(line) 182 | padding := (m.Width - visibleLen) / 2 183 | 184 | if padding > 0 { 185 | sb.WriteString(strings.Repeat(" ", padding)) 186 | } 187 | sb.WriteString(line + "\n") 188 | } 189 | } else { 190 | // else just render 191 | sb.WriteString(content) 192 | } 193 | 194 | return sb.String() 195 | } 196 | 197 | func getCountryEmoji(countryCode string) string { 198 | if countryCode == "" { 199 | return "🌍" 200 | } 201 | 202 | if len(countryCode) != 2 { 203 | return "🌍" 204 | } 205 | 206 | cc := strings.ToUpper(countryCode) 207 | const offset = 127397 208 | firstLetter := rune(cc[0]) + offset 209 | secondLetter := rune(cc[1]) + offset 210 | flag := string(firstLetter) + string(secondLetter) 211 | 212 | return flag 213 | } 214 | 215 | // Add helper functions to work with country names and emojis 216 | 217 | // GetCountryEmojiByName returns the flag emoji for a given country name 218 | // It converts the name to lowercase for case-insensitive matching 219 | -------------------------------------------------------------------------------- /internal/ui/setup/update.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/spinner" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/josephburgess/gust/internal/config" 10 | "github.com/josephburgess/gust/internal/models" 11 | ) 12 | 13 | type CitiesSearchResult struct { 14 | cities []models.City 15 | err error 16 | } 17 | 18 | func (m Model) searchCities() tea.Cmd { 19 | return func() tea.Msg { 20 | if m.Client == nil { 21 | return CitiesSearchResult{[]models.City{}, fmt.Errorf("API client not initialized")} 22 | } 23 | 24 | cities, err := m.Client.SearchCities(m.CitySearchQuery) 25 | if err != nil { 26 | return CitiesSearchResult{[]models.City{}, err} 27 | } 28 | return CitiesSearchResult{cities, nil} 29 | } 30 | } 31 | 32 | func (m Model) handleApiKeyInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 33 | switch msg.String() { 34 | case "ctrl+c": 35 | m.Quitting = true 36 | return m, tea.Quit 37 | case "enter": 38 | if m.ApiKeyInput.Value() != "" { 39 | authConfig := &config.AuthConfig{ 40 | APIKey: m.ApiKeyInput.Value(), 41 | ServerURL: m.Config.ApiUrl, 42 | LastAuth: time.Now(), 43 | GithubUser: "OpenWeather API User", 44 | } 45 | 46 | if err := config.SaveAuthConfig(authConfig); err != nil { 47 | fmt.Printf("Error: %v\n", err) 48 | } else { 49 | m.NeedsAuth = false 50 | } 51 | } 52 | m.State = StateComplete 53 | return m, nil 54 | case "esc": 55 | m.State = StateApiKeyOption 56 | return m, nil 57 | } 58 | 59 | var cmd tea.Cmd 60 | m.ApiKeyInput, cmd = m.ApiKeyInput.Update(msg) 61 | return m, cmd 62 | } 63 | 64 | // updates the model based on messages 65 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 66 | var cmds []tea.Cmd 67 | 68 | switch msg := msg.(type) { 69 | case tea.KeyMsg: 70 | switch m.State { 71 | case StateCity: 72 | return m.handleTextInput(msg) 73 | case StateApiKeyInput: 74 | return m.handleApiKeyInput(msg) 75 | } 76 | return m.handleKeyPress(msg) 77 | case tea.WindowSizeMsg: 78 | m.Width = msg.Width 79 | m.Height = msg.Height 80 | case SetupCompleteMsg: 81 | m.State = StateComplete 82 | return m, nil 83 | case CitiesSearchResult: 84 | if msg.err != nil { 85 | fmt.Printf("Error searching cities: %v\n", msg.err) 86 | m.State = StateCity 87 | return m, nil 88 | } 89 | 90 | m.CityOptions = msg.cities 91 | m.CityCursor = 0 92 | 93 | if len(m.CityOptions) == 0 { 94 | fmt.Println("No cities found. Please try a different search.") 95 | m.State = StateCity 96 | return m, nil 97 | } 98 | 99 | m.State = StateCitySelect 100 | return m, nil 101 | case spinner.TickMsg: 102 | var spinnerCmd tea.Cmd 103 | m.Spinner, spinnerCmd = m.Spinner.Update(msg) 104 | cmds = append(cmds, spinnerCmd) 105 | default: 106 | switch m.State { 107 | case StateCity: 108 | var tiCmd tea.Cmd 109 | m.CityInput, tiCmd = m.CityInput.Update(msg) 110 | if tiCmd != nil { 111 | cmds = append(cmds, tiCmd) 112 | } 113 | case StateApiKeyInput: 114 | var tiCmd tea.Cmd 115 | m.ApiKeyInput, tiCmd = m.ApiKeyInput.Update(msg) 116 | if tiCmd != nil { 117 | cmds = append(cmds, tiCmd) 118 | } 119 | } 120 | } 121 | 122 | return m, tea.Batch(cmds...) 123 | } 124 | 125 | func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 126 | switch msg.String() { 127 | case "ctrl+c": 128 | m.Quitting = true 129 | return m, tea.Quit 130 | case "esc": 131 | if m.State == StateCitySelect { 132 | m.State = StateCity 133 | } 134 | return m, nil 135 | case "enter": 136 | return m.handleEnterKey() 137 | case "up", "k": 138 | return m.handleUpKey() 139 | case "down", "j": 140 | return m.handleDownKey() 141 | } 142 | return m, nil 143 | } 144 | 145 | func (m Model) handleEnterKey() (tea.Model, tea.Cmd) { 146 | switch m.State { 147 | case StateCity: 148 | if m.CityInput.Value() != "" { 149 | m.CitySearchQuery = m.CityInput.Value() 150 | m.State = StateCitySearch 151 | return m, m.searchCities() 152 | } 153 | 154 | case StateCitySelect: 155 | if len(m.CityOptions) > 0 { 156 | selectedCity := m.CityOptions[m.CityCursor] 157 | m.Config.DefaultCity = selectedCity.Name 158 | m.State = StateUnits 159 | } 160 | 161 | case StateUnits: 162 | unitValues := []string{"metric", "imperial", "standard"} 163 | m.Config.Units = unitValues[m.UnitCursor] 164 | m.State = StateView 165 | 166 | case StateView: 167 | viewValues := []string{"default", "compact", "daily", "hourly", "full"} 168 | m.Config.DefaultView = viewValues[m.ViewCursor] 169 | m.State = StateTips 170 | 171 | case StateTips: 172 | m.Config.ShowTips = (m.TipCursor == 0) 173 | m.State = StateApiKeyOption 174 | 175 | case StateApiKeyOption: 176 | if m.ApiKeyCursor == 0 { 177 | if m.NeedsAuth { 178 | m.State = StateAuth 179 | } else { 180 | m.State = StateComplete 181 | } 182 | } else { 183 | m.State = StateApiKeyInput 184 | m.ApiKeyInput.Focus() 185 | } 186 | 187 | case StateApiKeyInput: 188 | if m.ApiKeyInput.Value() != "" { 189 | authConfig := &config.AuthConfig{ 190 | APIKey: m.ApiKeyInput.Value(), 191 | ServerURL: m.Config.ApiUrl, 192 | LastAuth: time.Now(), 193 | GithubUser: "", 194 | } 195 | 196 | if err := config.SaveAuthConfig(authConfig); err != nil { 197 | fmt.Printf("Error: %v\n", err) 198 | } else { 199 | m.NeedsAuth = false 200 | } 201 | } 202 | m.State = StateComplete 203 | 204 | case StateAuth: 205 | if err := m.Config.Save(); err != nil { 206 | fmt.Printf("Error: %v\n", err) 207 | } 208 | 209 | if m.AuthCursor == 0 { 210 | // user chose to auth 211 | return m, tea.Quit 212 | } else { 213 | // user skipped auth 214 | m.State = StateComplete 215 | } 216 | 217 | case StateComplete: 218 | return m, tea.Quit 219 | } 220 | 221 | return m, nil 222 | } 223 | 224 | // process up n down keypresses 225 | func (m Model) handleUpKey() (tea.Model, tea.Cmd) { 226 | switch m.State { 227 | case StateUnits: 228 | if m.UnitCursor > 0 { 229 | m.UnitCursor-- 230 | } 231 | case StateView: 232 | if m.ViewCursor > 0 { 233 | m.ViewCursor-- 234 | } 235 | case StateAuth: 236 | if m.AuthCursor > 0 { 237 | m.AuthCursor-- 238 | } 239 | case StateCitySelect: 240 | if m.CityCursor > 0 { 241 | m.CityCursor-- 242 | } 243 | case StateTips: 244 | if m.TipCursor > 0 { 245 | m.TipCursor-- 246 | } 247 | case StateApiKeyOption: 248 | if m.ApiKeyCursor > 0 { 249 | m.ApiKeyCursor-- 250 | } 251 | } 252 | return m, nil 253 | } 254 | 255 | func (m Model) handleDownKey() (tea.Model, tea.Cmd) { 256 | switch m.State { 257 | case StateUnits: 258 | if m.UnitCursor < len(m.UnitOptions)-1 { 259 | m.UnitCursor++ 260 | } 261 | case StateView: 262 | if m.ViewCursor < len(m.ViewOptions)-1 { 263 | m.ViewCursor++ 264 | } 265 | case StateAuth: 266 | if m.AuthCursor < len(m.AuthOptions)-1 { 267 | m.AuthCursor++ 268 | } 269 | case StateCitySelect: 270 | if m.CityCursor < len(m.CityOptions)-1 { 271 | m.CityCursor++ 272 | } 273 | case StateTips: 274 | if m.TipCursor < len(m.TipOptions)-1 { 275 | m.TipCursor++ 276 | } 277 | case StateApiKeyOption: 278 | if m.ApiKeyCursor < len(m.ApiKeyOptions)-1 { 279 | m.ApiKeyCursor++ 280 | } 281 | } 282 | return m, nil 283 | } 284 | 285 | func (m Model) handleTextInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 286 | switch msg.String() { 287 | case "ctrl+c": 288 | m.Quitting = true 289 | return m, tea.Quit 290 | case "enter": 291 | if m.CityInput.Value() != "" { 292 | m.CitySearchQuery = m.CityInput.Value() 293 | m.State = StateCitySearch 294 | return m, m.searchCities() 295 | } 296 | return m, nil 297 | } 298 | 299 | var cmd tea.Cmd 300 | m.CityInput, cmd = m.CityInput.Update(msg) 301 | return m, cmd 302 | } 303 | -------------------------------------------------------------------------------- /internal/models/weather.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type City struct { 9 | Name string `json:"name"` 10 | Lat float64 `json:"lat"` 11 | Lon float64 `json:"lon"` 12 | Country string `json:"country"` 13 | State string `json:"state"` 14 | } 15 | 16 | type OneCallResponse struct { 17 | Lat float64 `json:"lat"` 18 | Lon float64 `json:"lon"` 19 | Timezone string `json:"timezone"` 20 | TimezoneOffset int `json:"timezone_offset"` 21 | Current CurrentWeather `json:"current"` 22 | Minutely []MinuteData `json:"minutely"` 23 | Hourly []HourData `json:"hourly"` 24 | Daily []DayData `json:"daily"` 25 | Alerts []Alert `json:"alerts"` 26 | } 27 | 28 | type WeatherCondition struct { 29 | ID int `json:"id"` 30 | Main string `json:"main"` 31 | Description string `json:"description"` 32 | Icon string `json:"icon"` 33 | } 34 | 35 | type RainData struct { 36 | OneHour float64 `json:"1h"` 37 | } 38 | 39 | type SnowData struct { 40 | OneHour float64 `json:"1h"` 41 | } 42 | 43 | type CurrentWeather struct { 44 | Dt int64 `json:"dt"` 45 | Sunrise int64 `json:"sunrise"` 46 | Sunset int64 `json:"sunset"` 47 | Temp float64 `json:"temp"` 48 | FeelsLike float64 `json:"feels_like"` 49 | Pressure int `json:"pressure"` 50 | Humidity int `json:"humidity"` 51 | DewPoint float64 `json:"dew_point"` 52 | UVI float64 `json:"uvi"` 53 | Clouds int `json:"clouds"` 54 | Visibility int `json:"visibility"` 55 | WindSpeed float64 `json:"wind_speed"` 56 | WindGust float64 `json:"wind_gust"` 57 | WindDeg int `json:"wind_deg"` 58 | Rain *RainData `json:"rain,omitempty"` 59 | Snow *SnowData `json:"snow,omitempty"` 60 | Weather []WeatherCondition `json:"weather"` 61 | } 62 | 63 | type MinuteData struct { 64 | Dt int64 `json:"dt"` 65 | Precipitation float64 `json:"precipitation"` 66 | } 67 | 68 | type HourData struct { 69 | Dt int64 `json:"dt"` 70 | Temp float64 `json:"temp"` 71 | FeelsLike float64 `json:"feels_like"` 72 | Pressure int `json:"pressure"` 73 | Humidity int `json:"humidity"` 74 | DewPoint float64 `json:"dew_point"` 75 | UVI float64 `json:"uvi"` 76 | Clouds int `json:"clouds"` 77 | Visibility int `json:"visibility"` 78 | WindSpeed float64 `json:"wind_speed"` 79 | WindGust float64 `json:"wind_gust"` 80 | WindDeg int `json:"wind_deg"` 81 | Pop float64 `json:"pop"` 82 | Rain *RainData `json:"rain,omitempty"` 83 | Snow *SnowData `json:"snow,omitempty"` 84 | Weather []WeatherCondition `json:"weather"` 85 | } 86 | 87 | type TempData struct { 88 | Day float64 `json:"day"` 89 | Min float64 `json:"min"` 90 | Max float64 `json:"max"` 91 | Night float64 `json:"night"` 92 | Eve float64 `json:"eve"` 93 | Morn float64 `json:"morn"` 94 | } 95 | 96 | type FeelsLikeData struct { 97 | Day float64 `json:"day"` 98 | Night float64 `json:"night"` 99 | Eve float64 `json:"eve"` 100 | Morn float64 `json:"morn"` 101 | } 102 | 103 | type DayData struct { 104 | Dt int64 `json:"dt"` 105 | Sunrise int64 `json:"sunrise"` 106 | Sunset int64 `json:"sunset"` 107 | Moonrise int64 `json:"moonrise"` 108 | Moonset int64 `json:"moonset"` 109 | MoonPhase float64 `json:"moon_phase"` 110 | Summary string `json:"summary"` 111 | Temp TempData `json:"temp"` 112 | FeelsLike FeelsLikeData `json:"feels_like"` 113 | Pressure int `json:"pressure"` 114 | Humidity int `json:"humidity"` 115 | DewPoint float64 `json:"dew_point"` 116 | WindSpeed float64 `json:"wind_speed"` 117 | WindGust float64 `json:"wind_gust"` 118 | WindDeg int `json:"wind_deg"` 119 | Clouds int `json:"clouds"` 120 | UVI float64 `json:"uvi"` 121 | Pop float64 `json:"pop"` 122 | Rain float64 `json:"rain,omitempty"` 123 | Snow float64 `json:"snow,omitempty"` 124 | Weather []WeatherCondition `json:"weather"` 125 | } 126 | 127 | type Alert struct { 128 | SenderName string `json:"sender_name"` 129 | Event string `json:"event"` 130 | Start int64 `json:"start"` 131 | End int64 `json:"end"` 132 | Description string `json:"description"` 133 | Tags []string `json:"tags"` 134 | } 135 | 136 | func GetWeatherEmoji(id int, current *CurrentWeather) string { 137 | if id == 800 && current != nil && (current.Dt > current.Sunset || current.Dt < current.Sunrise) { 138 | return "🌙" // clear night 139 | } 140 | 141 | switch { 142 | case id >= 200 && id <= 232: 143 | return "⚡" // storm 144 | case id >= 300 && id <= 321: 145 | return "🌦" // drizzle 146 | case id >= 500 && id <= 531: 147 | return "☔" // rain 148 | case id >= 600 && id <= 622: 149 | return "⛄" // snow 150 | case id >= 700 && id <= 781: 151 | return "🌫" // fog 152 | case id == 800: 153 | return "🔆" // clear (daytime) 154 | case id >= 801 && id <= 804: 155 | return "🌥️" // cloudy 156 | default: 157 | return "🌡" // the rest 158 | } 159 | } 160 | 161 | func GetWindDirection(degrees int) string { 162 | normalizedDegrees := ((degrees % 360) + 360) % 360 163 | directions := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"} 164 | index := int((float64(normalizedDegrees)+22.5)/45) % 8 165 | return directions[index] 166 | } 167 | 168 | func VisibilityToString(meters int) string { 169 | if meters >= 10000 { 170 | return "Excellent (10+ km)" 171 | } else if meters >= 5000 { 172 | return fmt.Sprintf("Good (%.1f km)", float64(meters)/1000) 173 | } else if meters >= 2000 { 174 | return fmt.Sprintf("Moderate (%.1f km)", float64(meters)/1000) 175 | } else { 176 | return fmt.Sprintf("Poor (%.1f km)", float64(meters)/1000) 177 | } 178 | } 179 | 180 | func GetWeatherTip(weather *OneCallResponse, units string) string { 181 | current := weather.Current 182 | hourly := weather.Hourly 183 | 184 | if current.Snow != nil && current.Snow.OneHour > 0 { 185 | return "It might be snowing right now! Stay warm and take care on slippery surfaces! ⛄" 186 | } 187 | 188 | if current.Rain != nil && current.Rain.OneHour > 0 { 189 | return "It might be raining right now - don't go out without an umbrella! ☔" 190 | } 191 | 192 | for i, hour := range hourly { 193 | if i > 0 && i < 12 { // next 12hrs 194 | precipTime := time.Unix(hour.Dt, 0).Format("15:04") 195 | 196 | if hour.Snow != nil && hour.Snow.OneHour > 0.1 { 197 | return fmt.Sprintf("Snow expected around %s - dress warmly and wear appropriate footwear! ❄️", precipTime) 198 | } 199 | 200 | if (hour.Rain != nil && hour.Rain.OneHour > 0.5) || hour.Pop > 0.4 { 201 | return fmt.Sprintf("Rain expected around %s - don't forget your umbrella! ☔", precipTime) 202 | } 203 | } 204 | } 205 | 206 | var coldThreshold, coolThreshold, warmThreshold float64 207 | 208 | switch units { 209 | case "imperial": 210 | coldThreshold = 40 // 5C 211 | coolThreshold = 55 // 12C 212 | warmThreshold = 82 // 28C 213 | case "standard": 214 | coldThreshold = 278 // 5C 215 | coolThreshold = 285 // 12C 216 | warmThreshold = 301 // 28C 217 | default: 218 | coldThreshold = 5 219 | coolThreshold = 12 220 | warmThreshold = 28 221 | } 222 | 223 | if current.Temp < coldThreshold { 224 | return "It's quite cold - wear a heavy coat and maybe a scarf! 🧣" 225 | } else if current.Temp < coolThreshold { 226 | return "It's cool today - a jacket would be a good idea. 🧥" 227 | } else if current.Temp > warmThreshold { 228 | return "It's hot today - stay hydrated and wear sunscreen! 🧴" 229 | } 230 | 231 | if current.UVI > 6 { 232 | return "UV index is high - wear sunscreen and maybe a hat! 🧢" 233 | } 234 | 235 | var windThreshold float64 236 | 237 | switch units { 238 | case "imperial": 239 | windThreshold = 12 // mph 240 | case "standard": 241 | windThreshold = 5.5 // m/s 242 | default: 243 | windThreshold = 20 // km/h 244 | } 245 | 246 | if current.WindSpeed > windThreshold { 247 | return "It's quite windy today - secure any loose items outdoors! 💨" 248 | } 249 | 250 | return "Conditions look fine, enjoy your day! 🌤️" 251 | } 252 | -------------------------------------------------------------------------------- /internal/cli/cli_integration_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/josephburgess/gust/internal/api" 7 | "github.com/josephburgess/gust/internal/config" 8 | "github.com/josephburgess/gust/internal/models" 9 | "github.com/josephburgess/gust/internal/ui/renderer" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestWeatherFlowIntegration(t *testing.T) { 14 | if testing.Short() { 15 | t.Skip("short mode - skipping int tests") 16 | } 17 | 18 | testCity := "London" 19 | 20 | cityData := &models.City{ 21 | Name: testCity, 22 | Country: "GB", 23 | Lat: 51, 24 | Lon: 0, 25 | } 26 | 27 | weatherData := &models.OneCallResponse{ 28 | Current: models.CurrentWeather{ 29 | Temp: 20.5, 30 | FeelsLike: 21.0, 31 | Humidity: 65, 32 | }, 33 | } 34 | 35 | weatherResponse := &api.WeatherResponse{ 36 | City: cityData, 37 | Weather: weatherData, 38 | } 39 | 40 | testCases := []struct { 41 | name string 42 | cityFlag string 43 | args []string 44 | defaultCity string 45 | expectedCity string 46 | }{ 47 | { 48 | name: "Using city flag", 49 | cityFlag: testCity, 50 | args: []string{}, 51 | defaultCity: "Berlin", 52 | expectedCity: testCity, 53 | }, 54 | { 55 | name: "Using positional args", 56 | cityFlag: "", 57 | args: []string{testCity}, 58 | defaultCity: "Berlin", 59 | expectedCity: testCity, 60 | }, 61 | { 62 | name: "Using default city", 63 | cityFlag: "", 64 | args: []string{}, 65 | defaultCity: "Berlin", 66 | expectedCity: "Berlin", 67 | }, 68 | } 69 | 70 | for _, tc := range testCases { 71 | t.Run(tc.name, func(t *testing.T) { 72 | mockClient := new(MockWeatherClient) 73 | mockRenderer := new(MockWeatherRenderer) 74 | mockConfig := &config.Config{ShowTips: true} 75 | 76 | mockClient.On("GetWeather", tc.expectedCity).Return(weatherResponse, nil) 77 | mockRenderer.On("RenderCompactWeather", cityData, weatherData, mockConfig).Return() 78 | 79 | cli := &CLI{ 80 | City: tc.cityFlag, 81 | Args: tc.args, 82 | } 83 | 84 | city := determineCityName(cli.City, cli.Args, tc.defaultCity) 85 | 86 | assert.Equal(t, tc.expectedCity, city) 87 | 88 | weather, err := mockClient.GetWeather(city) 89 | assert.NoError(t, err) 90 | assert.Equal(t, weatherResponse, weather) 91 | 92 | testRenderWeatherView := func(cli *CLI, renderer renderer.WeatherRenderer, city *models.City, weather *models.OneCallResponse, defaultView string, cfg *config.Config) { 93 | switch { 94 | case cli.Alerts: 95 | renderer.RenderAlerts(city, weather, cfg) 96 | case cli.Hourly: 97 | renderer.RenderHourlyForecast(city, weather, cfg) 98 | case cli.Daily: 99 | renderer.RenderDailyForecast(city, weather, cfg) 100 | case cli.Full: 101 | renderer.RenderFullWeather(city, weather, cfg) 102 | case cli.Compact: 103 | renderer.RenderCompactWeather(city, weather, cfg) 104 | case cli.Detailed: 105 | renderer.RenderCurrentWeather(city, weather, cfg) 106 | default: 107 | switch defaultView { 108 | case "compact": 109 | renderer.RenderCompactWeather(city, weather, cfg) 110 | case "daily": 111 | renderer.RenderDailyForecast(city, weather, cfg) 112 | case "hourly": 113 | renderer.RenderHourlyForecast(city, weather, cfg) 114 | case "full": 115 | renderer.RenderFullWeather(city, weather, cfg) 116 | default: 117 | renderer.RenderCurrentWeather(city, weather, cfg) 118 | } 119 | } 120 | } 121 | 122 | testRenderWeatherView(cli, mockRenderer, weather.City, weather.Weather, "compact", mockConfig) 123 | 124 | mockClient.AssertExpectations(t) 125 | mockRenderer.AssertExpectations(t) 126 | }) 127 | } 128 | } 129 | 130 | func TestViewSelectionIntegration(t *testing.T) { 131 | mockCity := &models.City{Name: "TestCity"} 132 | mockWeather := &models.OneCallResponse{} 133 | mockConfig := &config.Config{ShowTips: true} 134 | 135 | testCases := []struct { 136 | name string 137 | cli *CLI 138 | defaultView string 139 | expectedMethod string 140 | }{ 141 | { 142 | name: "cli flag overrides defaults", 143 | cli: &CLI{Hourly: true}, 144 | defaultView: "compact", 145 | expectedMethod: "RenderHourlyForecast", 146 | }, 147 | { 148 | name: "multiple flags follow prio", 149 | cli: &CLI{Hourly: true, Daily: true, Compact: true}, 150 | defaultView: "full", 151 | expectedMethod: "RenderHourlyForecast", 152 | }, 153 | { 154 | name: "default used when no flags", 155 | cli: &CLI{}, 156 | defaultView: "daily", 157 | expectedMethod: "RenderDailyForecast", 158 | }, 159 | { 160 | name: "fallback to current when no flag or valid config", 161 | cli: &CLI{}, 162 | defaultView: "invalid", 163 | expectedMethod: "RenderCurrentWeather", 164 | }, 165 | } 166 | 167 | for _, tc := range testCases { 168 | t.Run(tc.name, func(t *testing.T) { 169 | mockRenderer := new(MockWeatherRenderer) 170 | fakeConfig := &struct { 171 | DefaultView string 172 | ShowTips bool 173 | }{ 174 | DefaultView: tc.defaultView, 175 | ShowTips: true, 176 | } 177 | 178 | switch tc.expectedMethod { 179 | case "RenderHourlyForecast": 180 | mockRenderer.On("RenderHourlyForecast", mockCity, mockWeather, mockConfig).Return() 181 | case "RenderDailyForecast": 182 | mockRenderer.On("RenderDailyForecast", mockCity, mockWeather, mockConfig).Return() 183 | case "RenderFullWeather": 184 | mockRenderer.On("RenderFullWeather", mockCity, mockWeather, mockConfig).Return() 185 | case "RenderCompactWeather": 186 | mockRenderer.On("RenderCompactWeather", mockCity, mockWeather, mockConfig).Return() 187 | case "RenderCurrentWeather": 188 | mockRenderer.On("RenderCurrentWeather", mockCity, mockWeather, mockConfig).Return() 189 | case "RenderAlerts": 190 | mockRenderer.On("RenderAlerts", mockCity, mockWeather, mockConfig).Return() 191 | } 192 | 193 | testRenderWeatherView := func(cli *CLI, renderer renderer.WeatherRenderer, city *models.City, weather *models.OneCallResponse, cfg any) { 194 | configObj, _ := cfg.(*struct { 195 | DefaultView string 196 | ShowTips bool 197 | }) 198 | var defaultView string 199 | if configObj != nil { 200 | defaultView = configObj.DefaultView 201 | } 202 | 203 | realConfig := &config.Config{ShowTips: true} 204 | 205 | switch { 206 | case cli.Alerts: 207 | renderer.RenderAlerts(city, weather, realConfig) 208 | case cli.Hourly: 209 | renderer.RenderHourlyForecast(city, weather, realConfig) 210 | case cli.Daily: 211 | renderer.RenderDailyForecast(city, weather, realConfig) 212 | case cli.Full: 213 | renderer.RenderFullWeather(city, weather, realConfig) 214 | case cli.Compact: 215 | renderer.RenderCompactWeather(city, weather, realConfig) 216 | case cli.Detailed: 217 | renderer.RenderCurrentWeather(city, weather, realConfig) 218 | default: 219 | switch defaultView { 220 | case "compact": 221 | renderer.RenderCompactWeather(city, weather, realConfig) 222 | case "daily": 223 | renderer.RenderDailyForecast(city, weather, realConfig) 224 | case "hourly": 225 | renderer.RenderHourlyForecast(city, weather, realConfig) 226 | case "full": 227 | renderer.RenderFullWeather(city, weather, realConfig) 228 | default: 229 | renderer.RenderCurrentWeather(city, weather, realConfig) 230 | } 231 | } 232 | } 233 | 234 | testRenderWeatherView(tc.cli, mockRenderer, mockCity, mockWeather, fakeConfig) 235 | 236 | mockRenderer.AssertExpectations(t) 237 | }) 238 | } 239 | } 240 | 241 | func TestCityDeterminationIntegration(t *testing.T) { 242 | testCases := []struct { 243 | name string 244 | cityFlag string 245 | args []string 246 | defaultCity string 247 | expectedCity string 248 | }{ 249 | { 250 | name: "city flag takes prio", 251 | cityFlag: "London", 252 | args: []string{"Paris"}, 253 | defaultCity: "Berlin", 254 | expectedCity: "London", 255 | }, 256 | { 257 | name: "args used when no flag", 258 | cityFlag: "", 259 | args: []string{"Paris"}, 260 | defaultCity: "Berlin", 261 | expectedCity: "Paris", 262 | }, 263 | { 264 | name: "multi-word city from args", 265 | cityFlag: "", 266 | args: []string{"New", "York"}, 267 | defaultCity: "Berlin", 268 | expectedCity: "New York", 269 | }, 270 | { 271 | name: "default city when no flag or args", 272 | cityFlag: "", 273 | args: []string{}, 274 | defaultCity: "Berlin", 275 | expectedCity: "Berlin", 276 | }, 277 | { 278 | name: "empty when no sources available", 279 | cityFlag: "", 280 | args: []string{}, 281 | defaultCity: "", 282 | expectedCity: "", 283 | }, 284 | } 285 | 286 | for _, tc := range testCases { 287 | t.Run(tc.name, func(t *testing.T) { 288 | cli := &CLI{ 289 | City: tc.cityFlag, 290 | Args: tc.args, 291 | } 292 | 293 | city := determineCityName(cli.City, cli.Args, tc.defaultCity) 294 | 295 | assert.Equal(t, tc.expectedCity, city) 296 | }) 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /internal/ui/setup/view_test.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/josephburgess/gust/internal/api" 8 | "github.com/josephburgess/gust/internal/config" 9 | "github.com/josephburgess/gust/internal/models" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestView(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | setupModel func() Model 17 | expectedParts []string 18 | unexpectedParts []string 19 | }{ 20 | { 21 | name: "city input state", 22 | setupModel: func() Model { 23 | m := NewModel(&config.Config{}, false, &api.Client{}) 24 | m.State = StateCity 25 | return m 26 | }, 27 | expectedParts: []string{ 28 | "Enter a default city", 29 | "You can enter a country code", 30 | "Simple terminal weather", 31 | }, 32 | }, 33 | { 34 | name: "city search state", 35 | setupModel: func() Model { 36 | m := NewModel(&config.Config{}, false, &api.Client{}) 37 | m.State = StateCitySearch 38 | m.CitySearchQuery = "London" 39 | return m 40 | }, 41 | expectedParts: []string{ 42 | "Searching for cities", 43 | "Looking for \"London\"", 44 | }, 45 | }, 46 | { 47 | name: "city select state with results", 48 | setupModel: func() Model { 49 | m := NewModel(&config.Config{}, false, &api.Client{}) 50 | m.State = StateCitySelect 51 | m.CityOptions = []models.City{ 52 | {Name: "London", Country: "GB"}, 53 | {Name: "Paris", Country: "FR"}, 54 | } 55 | return m 56 | }, 57 | expectedParts: []string{ 58 | "Select your town or city", 59 | "London", 60 | "Paris", 61 | "🇬🇧", 62 | "🇫🇷", 63 | "Press Enter to select or Esc to search again", 64 | }, 65 | }, 66 | { 67 | name: "city select state with empty results", 68 | setupModel: func() Model { 69 | m := NewModel(&config.Config{}, false, &api.Client{}) 70 | m.State = StateCitySelect 71 | m.CityOptions = []models.City{} 72 | return m 73 | }, 74 | expectedParts: []string{ 75 | "No cities found", 76 | "Please try a different search term", 77 | }, 78 | }, 79 | { 80 | name: "units select state", 81 | setupModel: func() Model { 82 | m := NewModel(&config.Config{}, false, &api.Client{}) 83 | m.State = StateUnits 84 | return m 85 | }, 86 | expectedParts: []string{ 87 | "Choose your preferred units", 88 | "metric", 89 | "imperial", 90 | "standard", 91 | }, 92 | }, 93 | { 94 | name: "view select state", 95 | setupModel: func() Model { 96 | m := NewModel(&config.Config{}, false, &api.Client{}) 97 | m.State = StateView 98 | return m 99 | }, 100 | expectedParts: []string{ 101 | "Choose your preferred view", 102 | "detailed", 103 | "compact", 104 | "5-day", 105 | "24-hour", 106 | "full", 107 | }, 108 | }, 109 | { 110 | name: "tips select state", 111 | setupModel: func() Model { 112 | m := NewModel(&config.Config{}, false, &api.Client{}) 113 | m.State = StateTips 114 | return m 115 | }, 116 | expectedParts: []string{ 117 | "Would you like tips shown on daily forecasts", 118 | "Yes, show weather tips", 119 | "No, don't show tips", 120 | }, 121 | }, 122 | { 123 | name: "auth state", 124 | setupModel: func() Model { 125 | m := NewModel(&config.Config{}, true, &api.Client{}) 126 | m.State = StateAuth 127 | return m 128 | }, 129 | expectedParts: []string{ 130 | "GitHub Auth", 131 | "authenticate with GitHub", 132 | "no permissions requested", 133 | }, 134 | }, 135 | { 136 | name: "complete state", 137 | setupModel: func() Model { 138 | m := NewModel(&config.Config{ 139 | DefaultCity: "London", 140 | Units: "metric", 141 | DefaultView: "detailed", 142 | ShowTips: true, 143 | }, false, &api.Client{}) 144 | m.State = StateComplete 145 | return m 146 | }, 147 | expectedParts: []string{ 148 | "Setup complete", 149 | "Default city: London", 150 | "Units: metric", 151 | "Default view: detailed", 152 | "Tips enabled", 153 | }, 154 | unexpectedParts: []string{ 155 | "Tips disabled", 156 | }, 157 | }, 158 | { 159 | name: "complete state with tips disabled", 160 | setupModel: func() Model { 161 | m := NewModel(&config.Config{ 162 | DefaultCity: "London", 163 | Units: "metric", 164 | DefaultView: "detailed", 165 | ShowTips: false, 166 | }, false, &api.Client{}) 167 | m.State = StateComplete 168 | return m 169 | }, 170 | expectedParts: []string{ 171 | "Tips disabled", 172 | }, 173 | unexpectedParts: []string{ 174 | "Tips enabled", 175 | }, 176 | }, 177 | } 178 | 179 | for _, tt := range tests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | model := tt.setupModel() 182 | view := model.View() 183 | 184 | for _, expectedPart := range tt.expectedParts { 185 | assert.Contains(t, view, expectedPart) 186 | } 187 | 188 | if tt.unexpectedParts != nil { 189 | for _, unexpectedPart := range tt.unexpectedParts { 190 | assert.NotContains(t, view, unexpectedPart) 191 | } 192 | } 193 | }) 194 | } 195 | } 196 | 197 | func TestCountryEmoji(t *testing.T) { 198 | tests := []struct { 199 | name string 200 | countryCode string 201 | want string 202 | }{ 203 | { 204 | name: "valid country code", 205 | countryCode: "GB", 206 | want: "🇬🇧", 207 | }, 208 | { 209 | name: "empty country code", 210 | countryCode: "", 211 | want: "🌍", 212 | }, 213 | { 214 | name: "invalid length", 215 | countryCode: "GBR", 216 | want: "🌍", 217 | }, 218 | { 219 | name: "lowercase code", 220 | countryCode: "fr", 221 | want: "🇫🇷", 222 | }, 223 | { 224 | name: "single letter code", 225 | countryCode: "X", 226 | want: "🌍", 227 | }, 228 | } 229 | 230 | for _, tt := range tests { 231 | t.Run(tt.name, func(t *testing.T) { 232 | got := getCountryEmoji(tt.countryCode) 233 | assert.Equal(t, tt.want, got) 234 | }) 235 | } 236 | } 237 | 238 | func TestRenderOptions(t *testing.T) { 239 | model := NewModel(&config.Config{}, false, nil) 240 | options := []string{"Option 1", "Option 2", "Option 3"} 241 | 242 | for cursor := 0; cursor < len(options); cursor++ { 243 | t.Run("cursor at position "+string(rune('0'+cursor)), func(t *testing.T) { 244 | result := model.renderOptions(options, cursor) 245 | 246 | for i, option := range options { 247 | if i == cursor { 248 | assert.Contains(t, result, "→ "+option) 249 | } else { 250 | assert.Contains(t, result, " "+option) 251 | } 252 | } 253 | 254 | for _, option := range options { 255 | assert.Contains(t, result, option) 256 | } 257 | }) 258 | } 259 | 260 | t.Run("empty options", func(t *testing.T) { 261 | result := model.renderOptions([]string{}, 0) 262 | assert.Empty(t, result) 263 | }) 264 | } 265 | 266 | func TestCenterContent(t *testing.T) { 267 | testCases := []struct { 268 | name string 269 | content string 270 | width int 271 | height int 272 | checkFunc func(*testing.T, string, string) 273 | }{ 274 | { 275 | name: "zero dimensions", 276 | content: "test content\nmultiple lines\ndiff lengths", 277 | width: 0, 278 | height: 0, 279 | checkFunc: func(t *testing.T, original, result string) { 280 | assert.Equal(t, original, result, "should with zero dimensions") 281 | }, 282 | }, 283 | { 284 | name: "normal dimensions", 285 | content: "test content\nmultiple lines\ndiff lengths", 286 | width: 80, 287 | height: 24, 288 | checkFunc: func(t *testing.T, original, result string) { 289 | assert.NotEqual(t, original, result, "should be padded/centered") 290 | 291 | for _, line := range strings.Split(original, "\n") { 292 | assert.Contains(t, result, line) 293 | } 294 | 295 | assert.Greater(t, 296 | strings.Count(result, "\n"), 297 | strings.Count(original, "\n"), 298 | "Result should have vertical padding") 299 | }, 300 | }, 301 | { 302 | name: "single line content", 303 | content: "Just one line", 304 | width: 50, 305 | height: 10, 306 | checkFunc: func(t *testing.T, original, result string) { 307 | assert.Contains(t, result, original) 308 | assert.Greater(t, len(result), len(original), "Result should be padded") 309 | }, 310 | }, 311 | } 312 | 313 | for _, tc := range testCases { 314 | t.Run(tc.name, func(t *testing.T) { 315 | model := NewModel(&config.Config{}, false, nil) 316 | model.Width = tc.width 317 | model.Height = tc.height 318 | 319 | result := model.centerContent(tc.content) 320 | tc.checkFunc(t, tc.content, result) 321 | }) 322 | } 323 | } 324 | 325 | func TestViewRendering(t *testing.T) { 326 | testCases := []struct { 327 | width int 328 | height int 329 | }{ 330 | {0, 0}, 331 | {40, 10}, 332 | {80, 24}, 333 | {120, 40}, 334 | {200, 100}, 335 | } 336 | 337 | for _, tc := range testCases { 338 | t.Run("screen size "+string(rune('0'+tc.width))+"x"+string(rune('0'+tc.height)), func(t *testing.T) { 339 | model := NewModel(&config.Config{}, false, &api.Client{}) 340 | model.Width = tc.width 341 | model.Height = tc.height 342 | 343 | view := model.View() 344 | 345 | assert.Contains(t, view, "____ ___ _______/ /_") 346 | assert.Contains(t, view, "Simple terminal weather") 347 | 348 | assert.NotEmpty(t, view) 349 | }) 350 | } 351 | } 352 | 353 | func TestBuildContent(t *testing.T) { 354 | states := []SetupState{ 355 | StateCity, 356 | StateCitySearch, 357 | StateCitySelect, 358 | StateUnits, 359 | StateView, 360 | StateTips, 361 | StateAuth, 362 | StateComplete, 363 | } 364 | 365 | seenContents := make(map[string]bool) 366 | 367 | for _, state := range states { 368 | model := NewModel(&config.Config{}, false, &api.Client{}) 369 | model.State = state 370 | 371 | if state == StateCitySelect { 372 | model.CityOptions = []models.City{{Name: "London"}} 373 | } 374 | 375 | content := model.buildContent() 376 | 377 | _, exists := seenContents[content] 378 | assert.False(t, exists, "%v should be unique", state) 379 | 380 | seenContents[content] = true 381 | 382 | assert.NotEmpty(t, content) 383 | 384 | assert.Contains(t, content, "____/") 385 | assert.Contains(t, content, "Navigate") 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /internal/ui/setup/update_test.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/josephburgess/gust/internal/api" 9 | "github.com/josephburgess/gust/internal/config" 10 | "github.com/josephburgess/gust/internal/models" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestStateTransitions(t *testing.T) { 15 | m := NewModel(&config.Config{}, false, &api.Client{}) 16 | 17 | tests := []struct { 18 | name string 19 | initialState SetupState 20 | msg tea.Msg 21 | expectedState SetupState 22 | expectedCmd bool // Whether we expect a non-nil command 23 | }{ 24 | { 25 | name: "city search to city select", 26 | initialState: StateCitySearch, 27 | msg: CitiesSearchResult{ 28 | cities: []models.City{{Name: "London"}}, 29 | err: nil, 30 | }, 31 | expectedState: StateCitySelect, 32 | expectedCmd: false, 33 | }, 34 | { 35 | name: "empty city search results return to city input", 36 | initialState: StateCitySearch, 37 | msg: CitiesSearchResult{ 38 | cities: []models.City{}, 39 | err: nil, 40 | }, 41 | expectedState: StateCity, 42 | expectedCmd: false, 43 | }, 44 | { 45 | name: "error in city search returns to city input", 46 | initialState: StateCitySearch, 47 | msg: CitiesSearchResult{ 48 | cities: nil, 49 | err: assert.AnError, 50 | }, 51 | expectedState: StateCity, 52 | expectedCmd: false, 53 | }, 54 | { 55 | name: "setup complete transitions to complete state", 56 | initialState: StateAuth, 57 | msg: SetupCompleteMsg{}, 58 | expectedState: StateComplete, 59 | expectedCmd: false, 60 | }, 61 | { 62 | name: "window size msg updates dimensions", 63 | initialState: StateCity, 64 | msg: tea.WindowSizeMsg{Width: 100, Height: 50}, 65 | expectedState: StateCity, 66 | expectedCmd: false, 67 | }, 68 | { 69 | name: "spinner tick updates spinner", 70 | initialState: StateCitySearch, 71 | msg: spinner.TickMsg{}, 72 | expectedState: StateCitySearch, 73 | expectedCmd: true, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | m.State = tt.initialState 80 | updatedModel, cmd := m.Update(tt.msg) 81 | updated := updatedModel.(Model) 82 | 83 | assert.Equal(t, tt.expectedState, updated.State) 84 | if tt.expectedCmd { 85 | assert.NotNil(t, cmd) 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestKeyHandling(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | state SetupState 95 | key string 96 | keyType tea.KeyType 97 | expectedState SetupState 98 | expectedQuit bool 99 | }{ 100 | { 101 | name: "ctrl+c quits from any state", 102 | state: StateCity, 103 | key: "ctrl+c", 104 | keyType: tea.KeyCtrlC, 105 | expectedState: StateCity, 106 | expectedQuit: true, 107 | }, 108 | { 109 | name: "esc from city select returns to city input", 110 | state: StateCitySelect, 111 | key: "esc", 112 | keyType: tea.KeyEsc, 113 | expectedState: StateCity, 114 | expectedQuit: false, 115 | }, 116 | { 117 | name: "enter in city select transitions to units state", 118 | state: StateCitySelect, 119 | key: "enter", 120 | keyType: tea.KeyEnter, 121 | expectedState: StateUnits, 122 | expectedQuit: false, 123 | }, 124 | { 125 | name: "down key in units state increments cursor", 126 | state: StateUnits, 127 | key: "down", 128 | keyType: tea.KeyDown, 129 | expectedState: StateUnits, 130 | expectedQuit: false, 131 | }, 132 | { 133 | name: "up key in view state decrements cursor", 134 | state: StateView, 135 | key: "up", 136 | keyType: tea.KeyUp, 137 | expectedState: StateView, 138 | expectedQuit: false, 139 | }, 140 | { 141 | name: "j key acts as down key", 142 | state: StateTips, 143 | key: "j", 144 | keyType: tea.KeyRunes, 145 | expectedState: StateTips, 146 | expectedQuit: false, 147 | }, 148 | { 149 | name: "k key acts as up key", 150 | state: StateAuth, 151 | key: "k", 152 | keyType: tea.KeyRunes, 153 | expectedState: StateAuth, 154 | expectedQuit: false, 155 | }, 156 | } 157 | 158 | for _, tt := range tests { 159 | t.Run(tt.name, func(t *testing.T) { 160 | // Setup model for test 161 | m := NewModel(&config.Config{}, false, &api.Client{}) 162 | m.State = tt.state 163 | 164 | // Add required test data 165 | if tt.state == StateCitySelect { 166 | m.CityOptions = []models.City{{Name: "London"}} 167 | } 168 | 169 | // Track initial cursor value for cursor movement tests 170 | var initialCursor int 171 | switch tt.state { 172 | case StateUnits: 173 | initialCursor = m.UnitCursor 174 | case StateView: 175 | initialCursor = m.ViewCursor 176 | case StateTips: 177 | initialCursor = m.TipCursor 178 | case StateAuth: 179 | initialCursor = m.AuthCursor 180 | } 181 | 182 | // Create key message 183 | var keyMsg tea.KeyMsg 184 | if tt.keyType == tea.KeyRunes { 185 | keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)} 186 | } else { 187 | keyMsg = tea.KeyMsg{Type: tt.keyType} 188 | } 189 | 190 | // Call Update 191 | updatedModel, cmd := m.Update(keyMsg) 192 | updated := updatedModel.(Model) 193 | 194 | // Verify expected state 195 | assert.Equal(t, tt.expectedState, updated.State) 196 | assert.Equal(t, tt.expectedQuit, updated.Quitting) 197 | 198 | // Verify cursor movement for movement tests 199 | if tt.key == "down" || tt.key == "j" { 200 | switch tt.state { 201 | case StateUnits: 202 | assert.Equal(t, initialCursor+1, updated.UnitCursor) 203 | case StateView: 204 | assert.Equal(t, initialCursor+1, updated.ViewCursor) 205 | case StateTips: 206 | assert.Equal(t, initialCursor+1, updated.TipCursor) 207 | case StateAuth: 208 | assert.Equal(t, initialCursor+1, updated.AuthCursor) 209 | } 210 | } else if tt.key == "up" || tt.key == "k" { 211 | switch tt.state { 212 | case StateUnits: 213 | // If initialCursor is already 0, it should remain 0 214 | if initialCursor > 0 { 215 | assert.Equal(t, initialCursor-1, updated.UnitCursor) 216 | } else { 217 | assert.Equal(t, 0, updated.UnitCursor) 218 | } 219 | case StateView: 220 | // If initialCursor is already 0, it should remain 0 221 | if initialCursor > 0 { 222 | assert.Equal(t, initialCursor-1, updated.ViewCursor) 223 | } else { 224 | assert.Equal(t, 0, updated.ViewCursor) 225 | } 226 | case StateTips: 227 | // If initialCursor is already 0, it should remain 0 228 | if initialCursor > 0 { 229 | assert.Equal(t, initialCursor-1, updated.TipCursor) 230 | } else { 231 | assert.Equal(t, 0, updated.TipCursor) 232 | } 233 | case StateAuth: 234 | // If initialCursor is already 0, it should remain 0 235 | if initialCursor > 0 { 236 | assert.Equal(t, initialCursor-1, updated.AuthCursor) 237 | } else { 238 | assert.Equal(t, 0, updated.AuthCursor) 239 | } 240 | } 241 | } 242 | 243 | // Verify quit command 244 | if tt.expectedQuit { 245 | assert.NotNil(t, cmd) 246 | } 247 | }) 248 | } 249 | } 250 | 251 | func TestEnterKeyHandling(t *testing.T) { 252 | tests := []struct { 253 | name string 254 | setupModel func() Model 255 | expectedState SetupState 256 | }{ 257 | { 258 | name: "city select to units", 259 | setupModel: func() Model { 260 | m := NewModel(&config.Config{}, false, &api.Client{}) 261 | m.State = StateCitySelect 262 | m.CityOptions = []models.City{{Name: "London"}} 263 | return m 264 | }, 265 | expectedState: StateUnits, 266 | }, 267 | { 268 | name: "units to view", 269 | setupModel: func() Model { 270 | m := NewModel(&config.Config{}, false, &api.Client{}) 271 | m.State = StateUnits 272 | return m 273 | }, 274 | expectedState: StateView, 275 | }, 276 | { 277 | name: "view to tips", 278 | setupModel: func() Model { 279 | m := NewModel(&config.Config{}, false, &api.Client{}) 280 | m.State = StateView 281 | return m 282 | }, 283 | expectedState: StateTips, 284 | }, 285 | { 286 | name: "tips to auth choices", 287 | setupModel: func() Model { 288 | m := NewModel(&config.Config{}, true, &api.Client{}) 289 | m.State = StateTips 290 | return m 291 | }, 292 | expectedState: StateApiKeyOption, 293 | }, 294 | { 295 | name: "auth to complete (no auth selected)", 296 | setupModel: func() Model { 297 | m := NewModel(&config.Config{}, true, &api.Client{}) 298 | m.State = StateAuth 299 | m.AuthCursor = 1 300 | return m 301 | }, 302 | expectedState: StateComplete, 303 | }, 304 | { 305 | name: "empty city input doesn't advance", 306 | setupModel: func() Model { 307 | m := NewModel(&config.Config{}, false, &api.Client{}) 308 | m.State = StateCity 309 | m.CityInput.SetValue("") 310 | return m 311 | }, 312 | expectedState: StateCity, 313 | }, 314 | } 315 | 316 | for _, tt := range tests { 317 | t.Run(tt.name, func(t *testing.T) { 318 | model := tt.setupModel() 319 | updatedModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) 320 | updated := updatedModel.(Model) 321 | assert.Equal(t, tt.expectedState, updated.State) 322 | }) 323 | } 324 | } 325 | 326 | func TestCitySearch(t *testing.T) { 327 | t.Run("successful search creates command", func(t *testing.T) { 328 | m := NewModel(&config.Config{}, false, &api.Client{}) 329 | m.CitySearchQuery = "London" 330 | 331 | cmd := m.searchCities() 332 | assert.NotNil(t, cmd) 333 | }) 334 | 335 | t.Run("nil client returns error result", func(t *testing.T) { 336 | m := NewModel(&config.Config{}, false, nil) 337 | m.CitySearchQuery = "London" 338 | 339 | cmd := m.searchCities() 340 | result := cmd().(CitiesSearchResult) 341 | assert.Error(t, result.err) 342 | assert.Contains(t, result.err.Error(), "API client not initialized") 343 | assert.Len(t, result.cities, 0) 344 | }) 345 | 346 | t.Run("empty search query", func(t *testing.T) { 347 | m := NewModel(&config.Config{}, false, &api.Client{}) 348 | m.CitySearchQuery = "" 349 | 350 | // Even with empty query, we should get a valid command 351 | cmd := m.searchCities() 352 | assert.NotNil(t, cmd) 353 | }) 354 | } 355 | 356 | func TestCursorMovement(t *testing.T) { 357 | tests := []struct { 358 | name string 359 | state SetupState 360 | setupModel func() Model 361 | key tea.KeyMsg 362 | checkFunc func(*testing.T, Model) 363 | }{ 364 | { 365 | name: "units cursor up", 366 | state: StateUnits, 367 | setupModel: func() Model { 368 | m := NewModel(&config.Config{}, false, &api.Client{}) 369 | m.UnitCursor = 1 370 | return m 371 | }, 372 | key: tea.KeyMsg{Type: tea.KeyUp}, 373 | checkFunc: func(t *testing.T, m Model) { 374 | assert.Equal(t, 0, m.UnitCursor) 375 | }, 376 | }, 377 | { 378 | name: "units cursor down", 379 | state: StateUnits, 380 | setupModel: func() Model { 381 | m := NewModel(&config.Config{}, false, &api.Client{}) 382 | m.UnitCursor = 0 383 | return m 384 | }, 385 | key: tea.KeyMsg{Type: tea.KeyDown}, 386 | checkFunc: func(t *testing.T, m Model) { 387 | assert.Equal(t, 1, m.UnitCursor) 388 | }, 389 | }, 390 | { 391 | name: "units cursor upper bound", 392 | state: StateUnits, 393 | setupModel: func() Model { 394 | m := NewModel(&config.Config{}, false, &api.Client{}) 395 | m.UnitCursor = 0 396 | return m 397 | }, 398 | key: tea.KeyMsg{Type: tea.KeyUp}, 399 | checkFunc: func(t *testing.T, m Model) { 400 | assert.Equal(t, 0, m.UnitCursor, "cursor shouldn't go below 0") 401 | }, 402 | }, 403 | { 404 | name: "units cursor lower bound", 405 | state: StateUnits, 406 | setupModel: func() Model { 407 | m := NewModel(&config.Config{}, false, &api.Client{}) 408 | m.UnitCursor = 2 // Last option 409 | return m 410 | }, 411 | key: tea.KeyMsg{Type: tea.KeyDown}, 412 | checkFunc: func(t *testing.T, m Model) { 413 | assert.Equal(t, 2, m.UnitCursor, "cursor shouldn't exceed options length") 414 | }, 415 | }, 416 | { 417 | name: "view cursor movement", 418 | state: StateView, 419 | setupModel: func() Model { 420 | m := NewModel(&config.Config{}, false, &api.Client{}) 421 | m.ViewCursor = 1 422 | return m 423 | }, 424 | key: tea.KeyMsg{Type: tea.KeyDown}, 425 | checkFunc: func(t *testing.T, m Model) { 426 | assert.Equal(t, 2, m.ViewCursor) 427 | }, 428 | }, 429 | { 430 | name: "city cursor movement", 431 | state: StateCitySelect, 432 | setupModel: func() Model { 433 | m := NewModel(&config.Config{}, false, &api.Client{}) 434 | m.CityOptions = []models.City{ 435 | {Name: "London"}, 436 | {Name: "Paris"}, 437 | {Name: "Berlin"}, 438 | } 439 | m.CityCursor = 0 440 | return m 441 | }, 442 | key: tea.KeyMsg{Type: tea.KeyDown}, 443 | checkFunc: func(t *testing.T, m Model) { 444 | assert.Equal(t, 1, m.CityCursor) 445 | }, 446 | }, 447 | } 448 | 449 | for _, tt := range tests { 450 | t.Run(tt.name, func(t *testing.T) { 451 | model := tt.setupModel() 452 | model.State = tt.state 453 | updatedModel, _ := model.Update(tt.key) 454 | updated := updatedModel.(Model) 455 | tt.checkFunc(t, updated) 456 | }) 457 | } 458 | } 459 | 460 | func TestTextInputHandling(t *testing.T) { 461 | t.Run("enter with value transitions to search", func(t *testing.T) { 462 | m := NewModel(&config.Config{}, false, &api.Client{}) 463 | m.State = StateCity 464 | m.CityInput.SetValue("London") 465 | 466 | updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) 467 | updated := updatedModel.(Model) 468 | 469 | assert.Equal(t, StateCitySearch, updated.State) 470 | assert.Equal(t, "London", updated.CitySearchQuery) 471 | assert.NotNil(t, cmd) 472 | }) 473 | 474 | t.Run("enter with empty value stays in city state", func(t *testing.T) { 475 | m := NewModel(&config.Config{}, false, &api.Client{}) 476 | m.State = StateCity 477 | m.CityInput.SetValue("") 478 | 479 | updatedModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) 480 | updated := updatedModel.(Model) 481 | 482 | assert.Equal(t, StateCity, updated.State) 483 | }) 484 | 485 | t.Run("text input updates correctly", func(t *testing.T) { 486 | m := NewModel(&config.Config{}, false, &api.Client{}) 487 | m.State = StateCity 488 | 489 | // Type a character 490 | updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("L")}) 491 | updated := updatedModel.(Model) 492 | 493 | assert.Equal(t, StateCity, updated.State) 494 | assert.Equal(t, "L", updated.CityInput.Value()) 495 | assert.NotNil(t, cmd) 496 | }) 497 | 498 | t.Run("ctrl+c quits", func(t *testing.T) { 499 | m := NewModel(&config.Config{}, false, &api.Client{}) 500 | m.State = StateCity 501 | 502 | updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 503 | updated := updatedModel.(Model) 504 | 505 | assert.True(t, updated.Quitting) 506 | assert.NotNil(t, cmd) 507 | }) 508 | } 509 | 510 | func TestMiscUpdateHandling(t *testing.T) { 511 | t.Run("unknown message passes through", func(t *testing.T) { 512 | m := NewModel(&config.Config{}, false, &api.Client{}) 513 | 514 | // Create a custom message type 515 | type customMsg struct{} 516 | 517 | updatedModel, cmd := m.Update(customMsg{}) 518 | updated := updatedModel.(Model) 519 | 520 | // Model should remain unchanged 521 | assert.Equal(t, m.State, updated.State) 522 | assert.Nil(t, cmd) 523 | }) 524 | } 525 | --------------------------------------------------------------------------------