Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/tracebloc/cli/internal/geo"
"github.com/tracebloc/cli/internal/slug"
"github.com/tracebloc/cli/internal/ui"
"github.com/tracebloc/cli/internal/zones"
)

// newClientCmd wires the `tracebloc client` subtree — provisioning + selecting
Expand Down Expand Up @@ -123,21 +124,27 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, loca
return mapClientErr(err)
}
}
if location == "" {
if location != "" {
// --location given: validate up front so an invalid zone fails here with
// a suggestion, not as an opaque backend 400 at create time.
if verr := validateZone(location); verr != nil {
return &exitError{code: 1, err: verr}
}
} else {
if pr == nil {
return errMissingFlag("--location")
}
// Auto-detect a suggested zone (cloud metadata → IP geolocation) and
// pre-fill it as the prompt default; the user confirms with Enter or
// overrides. Never silent (it's a prompt), never empty (validateNonEmpty).
// overrides. validateZone keeps the input a real zone; never empty/silent.
suggested := ""
help := "electricityMaps zone for the carbon footprint (e.g. DE)"
if z := detectZone(ctx); z != nil {
if z := detectZone(ctx); z != nil && zones.Valid(z.Code) {
suggested = z.Code
help = fmt.Sprintf("detected %s via %s (%s confidence) — Enter to accept, or type your zone",
z.Code, z.Source, z.Confidence)
}
if location, err = pr.Input("Location zone (e.g. DE)", help, suggested, validateNonEmpty); err != nil {
if location, err = pr.Input("Location zone (e.g. DE)", help, suggested, validateZone); err != nil {
return mapClientErr(err)
}
}
Expand Down Expand Up @@ -289,6 +296,21 @@ func validateNonEmpty(s string) error {
return nil
}

// validateZone rejects a location that isn't a known zone (what the backend's
// ChoiceField accepts), with close-match suggestions — so an invalid zone fails
// at the prompt/flag with guidance instead of as an opaque backend 400.
func validateZone(s string) error {
s = strings.TrimSpace(s)
if zones.Valid(s) {
return nil
}
msg := fmt.Sprintf("%q is not a valid location zone", s)
if sugg := zones.Suggest(s, 3); len(sugg) > 0 {
msg += " — did you mean: " + strings.Join(sugg, ", ") + "?"
}
return errors.New(msg)
}

// mapClientErr turns a cancelled interactive prompt into a clean exit.
func mapClientErr(err error) error {
if errors.Is(err, errInteractiveCancelled) {
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,18 @@ func TestClientCreate_AcceptsDetectedZone(t *testing.T) {
t.Errorf("location = %q, want FR (detected zone accepted as the default)", body.Location)
}
}

func TestClientCreate_RejectsInvalidZone(t *testing.T) {
withClientBackend(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("no API call expected for an invalid zone; got %s %s", r.Method, r.URL.Path)
})
// --location given but not a real zone → fails up front with a suggestion,
// before any API call.
err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, "My Client", "Germany", true)
if err == nil || !strings.Contains(err.Error(), "not a valid location zone") {
t.Fatalf("want invalid-zone error, got %v", err)
}
if !strings.Contains(err.Error(), "DE") {
t.Errorf("expected a DE suggestion, got: %v", err)
}
}
95 changes: 95 additions & 0 deletions internal/zones/zones.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Package zones is the CLI's vendored copy of the backend's location zone list
// (ZONE_CHOICES — electricityMaps zones), embedded from zones.json so `client
// create` can validate a location against exactly what the backend's ChoiceField
// accepts, instead of letting an invalid zone fail as a 400 at create time.
// Regenerate with scripts/sync-zones.sh when the backend list changes.
package zones

import (
_ "embed"
"encoding/json"
"sort"
"strings"
)

//go:embed zones.json
var zonesJSON []byte

// names maps a zone code (e.g. "DE", "US-CAL-CISO") to its display name.
var names map[string]string

func init() {
if err := json.Unmarshal(zonesJSON, &names); err != nil {
panic("zones: invalid embedded zones.json: " + err.Error())
}
}

// Valid reports whether code is a known zone. Case-sensitive: zone codes are
// upper-case, matching the backend ZONE_CHOICES the API validates against.
func Valid(code string) bool {
_, ok := names[strings.TrimSpace(code)]
return ok
}

// Name returns the display name for a zone code, or "" if unknown.
func Name(code string) string { return names[strings.TrimSpace(code)] }

// Count returns how many zones are known.
func Count() int { return len(names) }

// Suggest returns up to n plausible zone codes for a (likely invalid) input, to
// help a user who typed "germany", "de", or "Germany" find "DE". Match priority:
// the input as an exact code in the wrong case, then a code prefix, then a
// case-insensitive name substring. De-duplicated; sorted within each tier.
func Suggest(input string, n int) []string {
q := strings.TrimSpace(input)
if q == "" || n <= 0 {
return nil
}
upper := strings.ToUpper(q)
lower := strings.ToLower(q)

var out []string
seen := map[string]bool{}
add := func(code string) bool {
if !seen[code] {
seen[code] = true
out = append(out, code)
}
return len(out) >= n
}

// 1. exact code, wrong case ("de" → "DE")
for code := range names {
if strings.EqualFold(code, q) && add(code) {
return out
}
}
// 2. code prefix ("US" → US, US-CAL-CISO, …)
var prefixed []string
for code := range names {
if !seen[code] && strings.HasPrefix(code, upper) {
prefixed = append(prefixed, code)
}
}
sort.Strings(prefixed)
for _, code := range prefixed {
if add(code) {
return out
}
}
// 3. name substring ("german" → DE)
var named []string
for code, name := range names {
if !seen[code] && strings.Contains(strings.ToLower(name), lower) {
named = append(named, code)
}
}
sort.Strings(named)
for _, code := range named {
if add(code) {
return out
}
}
return out
}
Loading
Loading