Skip to content

Commit 638ef73

Browse files
bmsaadatclaude
andauthored
cli: collapse --tag provided/value signals into one in browsers update (#186)
## Summary `tagsFromFlag` now reports whether the `--tag` flag was *provided* (independent of whether its values parsed), so `runBrowsersUpdate` no longer rebuilds that signal via `cmd.Flags().Changed("tag")` — `BrowsersUpdateInput.SetTags` is renamed to `TagsProvided` and sourced from the helper, with the three other call sites discarding the new bool. This is a behavior-preserving structural cleanup (non-blocking follow-up to #184) that leaves the `--name`/`--clear-name` side untouched, since it has no equivalent redundancy. Two helper tests were added to pin the newly-distinguished "absent" vs. "provided-but-all-malformed" states. ## Test plan - [x] `go build ./...`, `go vet ./cmd/`, `gofmt -l` clean - [x] `go test ./...` green (incl. `TestBrowsersUpdate_*`, `TestTagsFromFlag_*`, `TestParseKeyValueSpecs`) - [x] CLI smoke: `--tag foo` → "No valid --tag KEY=VALUE pairs provided"; `--tag foo --clear-tags` → "Cannot specify both --tag and --clear-tags" 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > CLI-only flag parsing refactor with regression tests; intended to preserve browsers update validation behavior. > > **Overview** > **`tagsFromFlag`** now returns parsed tags plus a **`provided`** bool from `pflag`’s `Changed`, so an empty `--tag=` still counts as the user passing `--tag` (not a silent no-op). > > **`browsers update`** wires that bool into **`BrowsersUpdateInput.TagsProvided`** (replacing **`SetTags`** and duplicate `Changed("tag")` in the runner). Validation for conflicting **`--clear-tags`**, all-malformed tags, and lone empty **`--tag`** still keys off “flag was provided,” not only a non-empty map. > > List/create/acquire call sites adopt the new two-value return and ignore **`provided`**. Tests add **`tagsCmdWithArgs`** and cases for absent flag, all-malformed, and empty **`--tag=`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 282203a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2c06a2c commit 638ef73

3 files changed

Lines changed: 89 additions & 37 deletions

File tree

cmd/browser_pools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error {
742742
client := getKernelClient(cmd)
743743
timeout, _ := cmd.Flags().GetInt64("timeout")
744744
name, _ := cmd.Flags().GetString("name")
745-
tags := tagsFromFlag(cmd, "tag")
745+
tags, _ := tagsFromFlag(cmd, "tag")
746746
output, _ := cmd.Flags().GetString("output")
747747
c := BrowserPoolsCmd{client: &client.BrowserPools}
748748
return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{

cmd/browsers.go

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -216,20 +216,23 @@ func parseKeyValueSpecs(specs []string) (map[string]string, []string) {
216216
}
217217

218218
// tagsFromFlag reads a repeated KEY=VALUE flag and parses it into a map,
219-
// warning about any malformed entries. Returns nil when no valid tags are set.
220-
func tagsFromFlag(cmd *cobra.Command, flagName string) map[string]string {
219+
// warning about any malformed entries. It returns the parsed tags (nil when no
220+
// valid pairs are set) and whether the flag was provided on the command line.
221+
//
222+
// "Provided" keys off Changed, not len(specs): pflag records an empty `--tag=`
223+
// as Changed-but-empty, and that must still count as provided so the update
224+
// path rejects it (or `--tag= --clear-tags`) instead of silently ignoring it.
225+
func tagsFromFlag(cmd *cobra.Command, flagName string) (map[string]string, bool) {
226+
provided := cmd.Flags().Changed(flagName)
221227
specs, _ := cmd.Flags().GetStringArray(flagName)
222-
if len(specs) == 0 {
223-
return nil
224-
}
225228
tags, malformed := parseKeyValueSpecs(specs)
226229
for _, invalid := range malformed {
227230
pterm.Warning.Printf("Ignoring malformed tag: %s\n", invalid)
228231
}
229232
if len(tags) == 0 {
230-
return nil
233+
return nil, provided
231234
}
232-
return tags
235+
return tags, provided
233236
}
234237

235238
// formatTags renders tags as a deterministic "k=v, k2=v2" string with keys
@@ -302,7 +305,7 @@ type BrowsersUpdateInput struct {
302305
SetName bool
303306
ClearName bool
304307
Tags map[string]string
305-
SetTags bool
308+
TagsProvided bool
306309
ClearTags bool
307310
Output string
308311
}
@@ -708,16 +711,16 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
708711
return fmt.Errorf("cannot specify both --name and --clear-name")
709712
}
710713

711-
// Cannot specify both --tag and --clear-tags. Use SetTags (the raw flag
712-
// signal) as well as the parsed map so the check still fires when every
714+
// Cannot specify both --tag and --clear-tags. TagsProvided (whether the flag
715+
// was passed) is the authoritative signal so the check still fires when every
713716
// --tag value was malformed and dropped to an empty map.
714-
if (in.SetTags || len(in.Tags) > 0) && in.ClearTags {
717+
if (in.TagsProvided || len(in.Tags) > 0) && in.ClearTags {
715718
return fmt.Errorf("cannot specify both --tag and --clear-tags")
716719
}
717720

718721
// --tag was provided but parsed to zero valid pairs (every value malformed).
719722
// Treat as a user error rather than silently leaving tags unchanged.
720-
if in.SetTags && len(in.Tags) == 0 {
723+
if in.TagsProvided && len(in.Tags) == 0 {
721724
return fmt.Errorf("no valid --tag KEY=VALUE pairs provided")
722725
}
723726

@@ -2761,7 +2764,7 @@ func runBrowsersList(cmd *cobra.Command, args []string) error {
27612764
limit, _ := cmd.Flags().GetInt("limit")
27622765
offset, _ := cmd.Flags().GetInt("offset")
27632766
query, _ := cmd.Flags().GetString("query")
2764-
tags := tagsFromFlag(cmd, "tag")
2767+
tags, _ := tagsFromFlag(cmd, "tag")
27652768
return b.List(cmd.Context(), BrowsersListInput{
27662769
Output: out,
27672770
IncludeDeleted: includeDeleted,
@@ -2814,7 +2817,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
28142817
poolName, _ := cmd.Flags().GetString("pool-name")
28152818
telemetry, _ := cmd.Flags().GetString("telemetry")
28162819
name, _ := cmd.Flags().GetString("name")
2817-
tags := tagsFromFlag(cmd, "tag")
2820+
tags, _ := tagsFromFlag(cmd, "tag")
28182821
chromePolicy, _ := cmd.Flags().GetString("chrome-policy")
28192822
chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file")
28202823
output, _ := cmd.Flags().GetString("output")
@@ -2986,7 +2989,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
29862989
telemetry, _ := cmd.Flags().GetString("telemetry")
29872990
name, _ := cmd.Flags().GetString("name")
29882991
clearName, _ := cmd.Flags().GetBool("clear-name")
2989-
tags := tagsFromFlag(cmd, "tag")
2992+
tags, tagsProvided := tagsFromFlag(cmd, "tag")
29902993
clearTags, _ := cmd.Flags().GetBool("clear-tags")
29912994

29922995
svc := client.Browsers
@@ -3005,7 +3008,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
30053008
SetName: cmd.Flags().Changed("name"),
30063009
ClearName: clearName,
30073010
Tags: tags,
3008-
SetTags: cmd.Flags().Changed("tag"),
3011+
TagsProvided: tagsProvided,
30093012
ClearTags: clearTags,
30103013
Output: out,
30113014
})

cmd/browsers_test.go

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -693,18 +693,67 @@ func TestParseKeyValueSpecs(t *testing.T) {
693693
assert.Equal(t, []string{"bad", "=novalue"}, malformed)
694694
}
695695

696+
// tagsCmdWithArgs builds a command with a StringArray --tag flag and parses the
697+
// given args through pflag, so cmd.Flags().Changed("tag") reflects real
698+
// command-line usage (the default-value injection pattern would leave Changed
699+
// false and not exercise the provided signal).
700+
func tagsCmdWithArgs(t *testing.T, args ...string) *cobra.Command {
701+
t.Helper()
702+
cmd := &cobra.Command{}
703+
cmd.Flags().StringArray("tag", nil, "")
704+
require.NoError(t, cmd.Flags().Parse(args))
705+
return cmd
706+
}
707+
696708
func TestTagsFromFlag_WarnsOnMalformed(t *testing.T) {
697709
setupStdoutCapture(t)
698710

699-
cmd := &cobra.Command{}
700-
cmd.Flags().StringArray("tag", []string{"team=backend", "oops", "env=staging"}, "")
711+
cmd := tagsCmdWithArgs(t, "--tag=team=backend", "--tag=oops", "--tag=env=staging")
701712

702-
tags := tagsFromFlag(cmd, "tag")
713+
tags, provided := tagsFromFlag(cmd, "tag")
703714

715+
assert.True(t, provided)
704716
assert.Equal(t, map[string]string{"team": "backend", "env": "staging"}, tags)
705717
assert.Contains(t, outBuf.String(), "Ignoring malformed tag: oops")
706718
}
707719

720+
func TestTagsFromFlag_NotProvided_ReportsFalse(t *testing.T) {
721+
cmd := tagsCmdWithArgs(t)
722+
723+
tags, provided := tagsFromFlag(cmd, "tag")
724+
725+
assert.False(t, provided)
726+
assert.Nil(t, tags)
727+
}
728+
729+
func TestTagsFromFlag_AllMalformed_ReportsProvidedWithNoTags(t *testing.T) {
730+
setupStdoutCapture(t)
731+
732+
cmd := tagsCmdWithArgs(t, "--tag=foo")
733+
734+
tags, provided := tagsFromFlag(cmd, "tag")
735+
736+
assert.True(t, provided)
737+
assert.Empty(t, tags)
738+
assert.Contains(t, outBuf.String(), "Ignoring malformed tag: foo")
739+
}
740+
741+
// Regression (PR #186 review): an empty `--tag=` leaves pflag's StringArray
742+
// slice empty while marking the flag Changed. tagsFromFlag must report it as
743+
// provided so the update path rejects a lone `--tag=` and `--tag= --clear-tags`
744+
// instead of silently ignoring them — matching the prior Changed("tag") signal.
745+
func TestTagsFromFlag_EmptyValue_ReportsProvided(t *testing.T) {
746+
setupStdoutCapture(t)
747+
748+
cmd := tagsCmdWithArgs(t, "--tag=")
749+
750+
tags, provided := tagsFromFlag(cmd, "tag")
751+
752+
assert.True(t, provided)
753+
assert.Empty(t, tags)
754+
assert.NotContains(t, outBuf.String(), "Ignoring malformed tag")
755+
}
756+
708757
func TestBrowsersGet_JSONOutput_IncludesNameAndTags(t *testing.T) {
709758
setupStdoutCapture(t)
710759
oldStdout := os.Stdout
@@ -2239,12 +2288,12 @@ func TestBrowsersUpdate_NameAndTagsWithProxy_AllForwarded(t *testing.T) {
22392288
b := BrowsersCmd{browsers: fake}
22402289

22412290
err := b.Update(context.Background(), BrowsersUpdateInput{
2242-
Identifier: "session123",
2243-
ProxyID: "proxy-123",
2244-
Name: "combo",
2245-
SetName: true,
2246-
Tags: map[string]string{"k": "v"},
2247-
SetTags: true,
2291+
Identifier: "session123",
2292+
ProxyID: "proxy-123",
2293+
Name: "combo",
2294+
SetName: true,
2295+
Tags: map[string]string{"k": "v"},
2296+
TagsProvided: true,
22482297
})
22492298

22502299
assert.NoError(t, err)
@@ -2280,10 +2329,10 @@ func TestBrowsersUpdate_ClearNameWithSetTags_BothForwarded(t *testing.T) {
22802329
b := BrowsersCmd{browsers: fake}
22812330

22822331
err := b.Update(context.Background(), BrowsersUpdateInput{
2283-
Identifier: "session123",
2284-
ClearName: true,
2285-
Tags: map[string]string{"env": "prod"},
2286-
SetTags: true,
2332+
Identifier: "session123",
2333+
ClearName: true,
2334+
Tags: map[string]string{"env": "prod"},
2335+
TagsProvided: true,
22872336
})
22882337

22892338
assert.NoError(t, err)
@@ -2320,10 +2369,10 @@ func TestBrowsersUpdate_MalformedTagWithClearTags_Errors(t *testing.T) {
23202369
b := BrowsersCmd{browsers: fake}
23212370

23222371
err := b.Update(context.Background(), BrowsersUpdateInput{
2323-
Identifier: "session123",
2324-
SetTags: true, // --tag was provided...
2325-
Tags: nil, // ...but every value was malformed and dropped
2326-
ClearTags: true,
2372+
Identifier: "session123",
2373+
TagsProvided: true, // --tag was provided...
2374+
Tags: nil, // ...but every value was malformed and dropped
2375+
ClearTags: true,
23272376
})
23282377

23292378
assert.Error(t, err)
@@ -2338,9 +2387,9 @@ func TestBrowsersUpdate_AllMalformedTags_Errors(t *testing.T) {
23382387
b := BrowsersCmd{browsers: fake}
23392388

23402389
err := b.Update(context.Background(), BrowsersUpdateInput{
2341-
Identifier: "session123",
2342-
SetTags: true,
2343-
Tags: nil,
2390+
Identifier: "session123",
2391+
TagsProvided: true,
2392+
Tags: nil,
23442393
})
23452394

23462395
assert.Error(t, err)

0 commit comments

Comments
 (0)