Skip to content

Commit 88f3841

Browse files
authored
Merge branch 'main' into feat/complete-build-version-ldflags-wiring
2 parents 758b62a + dd79d9e commit 88f3841

File tree

2 files changed

+307
-0
lines changed

2 files changed

+307
-0
lines changed

internal/manifest/validate.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package manifest
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57
"sort"
68
"strings"
@@ -41,9 +43,131 @@ func Validate(data []byte) ([]ValidationError, error) {
4143
}
4244
}
4345

46+
// Check for duplicate flag keys
47+
duplicates := findDuplicateFlagKeys(data)
48+
for _, key := range duplicates {
49+
issues = append(issues, ValidationError{
50+
Type: "duplicate_key",
51+
Path: fmt.Sprintf("flags.%s", key),
52+
Message: fmt.Sprintf("flag '%s' is defined multiple times in the manifest", key),
53+
})
54+
}
55+
4456
return issues, nil
4557
}
4658

59+
// findDuplicateFlagKeys parses the raw JSON to detect duplicate keys within the "flags" object.
60+
// Standard JSON unmarshaling silently accepts duplicates (taking the last value), so we use
61+
// a token-based approach to detect them.
62+
func findDuplicateFlagKeys(data []byte) []string {
63+
decoder := json.NewDecoder(bytes.NewReader(data))
64+
65+
// Navigate to the root object
66+
token, err := decoder.Token()
67+
if err != nil || token != json.Delim('{') {
68+
return nil
69+
}
70+
71+
// Look for the "flags" key at the top level
72+
for decoder.More() {
73+
keyToken, err := decoder.Token()
74+
if err != nil {
75+
return nil
76+
}
77+
78+
key, ok := keyToken.(string)
79+
if !ok {
80+
continue
81+
}
82+
83+
if key == "flags" {
84+
return findDuplicatesInObject(decoder)
85+
}
86+
87+
// Skip the value for non-"flags" keys
88+
skipValue(decoder)
89+
}
90+
91+
return nil
92+
}
93+
94+
// findDuplicatesInObject reads an object from the decoder and returns any duplicate keys.
95+
func findDuplicatesInObject(decoder *json.Decoder) []string {
96+
token, err := decoder.Token()
97+
if err != nil || token != json.Delim('{') {
98+
return nil
99+
}
100+
101+
seen := make(map[string]bool)
102+
var duplicates []string
103+
104+
for decoder.More() {
105+
keyToken, err := decoder.Token()
106+
if err != nil {
107+
break
108+
}
109+
110+
key, ok := keyToken.(string)
111+
if !ok {
112+
continue
113+
}
114+
115+
if seen[key] {
116+
duplicates = append(duplicates, key)
117+
} else {
118+
seen[key] = true
119+
}
120+
121+
// Skip the value
122+
skipValue(decoder)
123+
}
124+
125+
// Consume the closing brace
126+
_, err = decoder.Token()
127+
if err != nil {
128+
return duplicates
129+
}
130+
131+
// Sort for consistent output
132+
sort.Strings(duplicates)
133+
134+
return duplicates
135+
}
136+
137+
// skipValue advances the decoder past one complete JSON value (object, array, or primitive).
138+
func skipValue(decoder *json.Decoder) {
139+
token, err := decoder.Token()
140+
if err != nil {
141+
return
142+
}
143+
144+
switch t := token.(type) {
145+
case json.Delim:
146+
switch t {
147+
case '{':
148+
// Skip object contents
149+
for decoder.More() {
150+
if _, err := decoder.Token(); err != nil { // key
151+
return
152+
}
153+
skipValue(decoder)
154+
}
155+
if _, err := decoder.Token(); err != nil { // closing }
156+
return
157+
}
158+
case '[':
159+
// Skip array contents
160+
for decoder.More() {
161+
skipValue(decoder)
162+
}
163+
if _, err := decoder.Token(); err != nil { // closing ]
164+
return
165+
}
166+
}
167+
}
168+
// Primitives (string, number, bool, null) are already consumed by the Token() call
169+
}
170+
47171
func FormatValidationError(issues []ValidationError) string {
48172
var sb strings.Builder
49173
sb.WriteString("flag manifest validation failed:\n\n")

internal/manifest/validate_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,189 @@ import (
55
"testing"
66
)
77

8+
func TestValidate_DuplicateFlagKeys(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
manifest string
12+
wantDuplicates []string
13+
}{
14+
{
15+
name: "no duplicates",
16+
manifest: `{
17+
"flags": {
18+
"flag-a": {"flagType": "boolean", "defaultValue": true},
19+
"flag-b": {"flagType": "string", "defaultValue": "hello"}
20+
}
21+
}`,
22+
wantDuplicates: nil,
23+
},
24+
{
25+
name: "single duplicate",
26+
manifest: `{
27+
"flags": {
28+
"my-flag": {"flagType": "boolean", "defaultValue": true},
29+
"my-flag": {"flagType": "string", "defaultValue": "hello"}
30+
}
31+
}`,
32+
wantDuplicates: []string{"my-flag"},
33+
},
34+
{
35+
name: "multiple duplicates",
36+
manifest: `{
37+
"flags": {
38+
"flag-a": {"flagType": "boolean", "defaultValue": true},
39+
"flag-b": {"flagType": "string", "defaultValue": "hello"},
40+
"flag-a": {"flagType": "integer", "defaultValue": 42},
41+
"flag-b": {"flagType": "float", "defaultValue": 3.14}
42+
}
43+
}`,
44+
wantDuplicates: []string{"flag-a", "flag-b"},
45+
},
46+
{
47+
name: "triple duplicate of same key",
48+
manifest: `{
49+
"flags": {
50+
"repeated": {"flagType": "boolean", "defaultValue": true},
51+
"repeated": {"flagType": "string", "defaultValue": "hello"},
52+
"repeated": {"flagType": "integer", "defaultValue": 42}
53+
}
54+
}`,
55+
wantDuplicates: []string{"repeated", "repeated"},
56+
},
57+
{
58+
name: "empty flags object",
59+
manifest: `{
60+
"flags": {}
61+
}`,
62+
wantDuplicates: nil,
63+
},
64+
{
65+
name: "manifest with schema field",
66+
manifest: `{
67+
"$schema": "https://example.com/schema.json",
68+
"flags": {
69+
"dup": {"flagType": "boolean", "defaultValue": true},
70+
"dup": {"flagType": "boolean", "defaultValue": false}
71+
}
72+
}`,
73+
wantDuplicates: []string{"dup"},
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
issues, err := Validate([]byte(tt.manifest))
80+
if err != nil {
81+
t.Fatalf("Validate() error = %v", err)
82+
}
83+
84+
var gotDuplicates []string
85+
for _, issue := range issues {
86+
if issue.Type == "duplicate_key" {
87+
// Extract the flag key from the path (format: "flags.key")
88+
parts := strings.SplitN(issue.Path, ".", 2)
89+
if len(parts) == 2 {
90+
gotDuplicates = append(gotDuplicates, parts[1])
91+
}
92+
}
93+
}
94+
95+
if len(gotDuplicates) != len(tt.wantDuplicates) {
96+
t.Errorf("got %d duplicates, want %d", len(gotDuplicates), len(tt.wantDuplicates))
97+
t.Errorf("got duplicates: %v", gotDuplicates)
98+
t.Errorf("want duplicates: %v", tt.wantDuplicates)
99+
return
100+
}
101+
102+
for i, want := range tt.wantDuplicates {
103+
if gotDuplicates[i] != want {
104+
t.Errorf("duplicate[%d] = %q, want %q", i, gotDuplicates[i], want)
105+
}
106+
}
107+
})
108+
}
109+
}
110+
111+
func TestValidate_DuplicateKeyErrorMessage(t *testing.T) {
112+
manifest := `{
113+
"flags": {
114+
"my-flag": {"flagType": "boolean", "defaultValue": true},
115+
"my-flag": {"flagType": "string", "defaultValue": "hello"}
116+
}
117+
}`
118+
119+
issues, err := Validate([]byte(manifest))
120+
if err != nil {
121+
t.Fatalf("Validate() error = %v", err)
122+
}
123+
124+
var found bool
125+
for _, issue := range issues {
126+
if issue.Type == "duplicate_key" {
127+
found = true
128+
if issue.Path != "flags.my-flag" {
129+
t.Errorf("expected path 'flags.my-flag', got %q", issue.Path)
130+
}
131+
expectedMsg := "flag 'my-flag' is defined multiple times in the manifest"
132+
if issue.Message != expectedMsg {
133+
t.Errorf("expected message %q, got %q", expectedMsg, issue.Message)
134+
}
135+
}
136+
}
137+
138+
if !found {
139+
t.Error("expected to find a duplicate_key validation error")
140+
}
141+
}
142+
143+
func TestFindDuplicateFlagKeys_EdgeCases(t *testing.T) {
144+
tests := []struct {
145+
name string
146+
input string
147+
expected []string
148+
}{
149+
{
150+
name: "invalid JSON",
151+
input: "not valid json",
152+
expected: nil,
153+
},
154+
{
155+
name: "array instead of object",
156+
input: `["a", "b", "c"]`,
157+
expected: nil,
158+
},
159+
{
160+
name: "no flags key",
161+
input: `{"other": "value"}`,
162+
expected: nil,
163+
},
164+
{
165+
name: "flags is not an object",
166+
input: `{"flags": "string value"}`,
167+
expected: nil,
168+
},
169+
{
170+
name: "flags is an array",
171+
input: `{"flags": [1, 2, 3]}`,
172+
expected: nil,
173+
},
174+
{
175+
name: "nested duplicates not detected in flag values",
176+
input: `{"flags": {"flag1": {"nested": 1, "nested": 2}}}`,
177+
expected: nil,
178+
},
179+
}
180+
181+
for _, tt := range tests {
182+
t.Run(tt.name, func(t *testing.T) {
183+
result := findDuplicateFlagKeys([]byte(tt.input))
184+
if len(result) != len(tt.expected) {
185+
t.Errorf("got %v, want %v", result, tt.expected)
186+
}
187+
})
188+
}
189+
}
190+
8191
// Sample test for FormatValidationError
9192
func TestFormatValidationError_SortsByPath(t *testing.T) {
10193
issues := []ValidationError{

0 commit comments

Comments
 (0)