|
| 1 | +// Copyright 2026 The Zaparoo Project Contributors. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | +// |
| 4 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +// you may not use this file except in compliance with the License. |
| 6 | +// You may obtain a copy of the License at |
| 7 | +// |
| 8 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +// |
| 10 | +// Unless required by applicable law or agreed to in writing, software |
| 11 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +// See the License for the specific language governing permissions and |
| 14 | +// limitations under the License. |
| 15 | + |
| 16 | +package zapscript |
| 17 | + |
| 18 | +import ( |
| 19 | + "fmt" |
| 20 | + "regexp" |
| 21 | + "strings" |
| 22 | +) |
| 23 | + |
| 24 | +var ( |
| 25 | + reColonSpacing = regexp.MustCompile(`\s*:\s*`) |
| 26 | + reSpecialChars = regexp.MustCompile(`[^a-z0-9:,+\-]`) |
| 27 | +) |
| 28 | + |
| 29 | +// NormalizeTag normalizes a tag string for consistent matching. |
| 30 | +// Applied to BOTH type and value parts separately. |
| 31 | +// Rules: trim whitespace, normalize colon spacing, lowercase, spaces→dashes, |
| 32 | +// periods→dashes, and remove special chars (except colon, dash, and comma). |
| 33 | +func NormalizeTag(s string) string { |
| 34 | + // 1. Trim whitespace |
| 35 | + s = strings.TrimSpace(s) |
| 36 | + |
| 37 | + // 2. Normalize colon spacing - remove spaces around colons first |
| 38 | + s = reColonSpacing.ReplaceAllString(s, ":") |
| 39 | + |
| 40 | + // 3. Convert to lowercase |
| 41 | + s = strings.ToLower(s) |
| 42 | + |
| 43 | + // 4. Replace remaining spaces with dashes |
| 44 | + s = strings.ReplaceAll(s, " ", "-") |
| 45 | + |
| 46 | + // 5. Convert periods to dashes (for version numbers like "1.2.3" → "1-2-3") |
| 47 | + s = strings.ReplaceAll(s, ".", "-") |
| 48 | + |
| 49 | + // 6. Remove other special chars (except colon, dash, and comma) |
| 50 | + // Keep: a-z, 0-9, dash, colon, comma |
| 51 | + s = reSpecialChars.ReplaceAllString(s, "") |
| 52 | + |
| 53 | + return s |
| 54 | +} |
| 55 | + |
| 56 | +// ParseTagFilters parses a comma-separated tag filter string into TagFilter structs. |
| 57 | +// Supports operator prefixes: |
| 58 | +// - "+" or no prefix: AND (default) - must have tag |
| 59 | +// - "-": NOT - must not have tag |
| 60 | +// - "~": OR - at least one OR tag must match |
| 61 | +// |
| 62 | +// Format: "type:value" or "+type:value" (AND), "-type:value" (NOT), "~type:value" (OR) |
| 63 | +// Example: "region:usa,-unfinished:demo,~lang:en,~lang:es" |
| 64 | +// Returns normalized, deduplicated filters. |
| 65 | +func ParseTagFilters(raw string) ([]TagFilter, error) { |
| 66 | + if raw == "" { |
| 67 | + return []TagFilter{}, nil |
| 68 | + } |
| 69 | + |
| 70 | + parts := strings.Split(raw, ",") |
| 71 | + |
| 72 | + // Use map for deduplication while maintaining order |
| 73 | + type filterKey struct { |
| 74 | + typ string |
| 75 | + value string |
| 76 | + operator TagOperator |
| 77 | + } |
| 78 | + seenFilters := make(map[filterKey]bool) |
| 79 | + result := make([]TagFilter, 0, len(parts)) |
| 80 | + |
| 81 | + for _, tagStr := range parts { |
| 82 | + trimmedTag := strings.TrimSpace(tagStr) |
| 83 | + if trimmedTag == "" { |
| 84 | + continue |
| 85 | + } |
| 86 | + |
| 87 | + // Parse operator prefix |
| 88 | + operator := TagOperatorAND // default |
| 89 | + switch trimmedTag[0] { |
| 90 | + case '+': |
| 91 | + operator = TagOperatorAND |
| 92 | + trimmedTag = trimmedTag[1:] |
| 93 | + case '-': |
| 94 | + operator = TagOperatorNOT |
| 95 | + trimmedTag = trimmedTag[1:] |
| 96 | + case '~': |
| 97 | + operator = TagOperatorOR |
| 98 | + trimmedTag = trimmedTag[1:] |
| 99 | + } |
| 100 | + |
| 101 | + // Validate type:value format |
| 102 | + colonIdx := strings.Index(trimmedTag, ":") |
| 103 | + if colonIdx == -1 { |
| 104 | + return nil, fmt.Errorf("invalid tag format for %q: must be in 'type:value' format", tagStr) |
| 105 | + } |
| 106 | + |
| 107 | + tagType := strings.TrimSpace(trimmedTag[:colonIdx]) |
| 108 | + tagValue := strings.TrimSpace(trimmedTag[colonIdx+1:]) |
| 109 | + |
| 110 | + // Apply normalization |
| 111 | + normalizedType := NormalizeTag(tagType) |
| 112 | + normalizedValue := NormalizeTag(tagValue) |
| 113 | + |
| 114 | + // Validate after normalization |
| 115 | + if normalizedType == "" || normalizedValue == "" { |
| 116 | + return nil, fmt.Errorf("invalid tag %q: type and value cannot be empty after normalization", tagStr) |
| 117 | + } |
| 118 | + |
| 119 | + filter := TagFilter{ |
| 120 | + Type: normalizedType, |
| 121 | + Value: normalizedValue, |
| 122 | + Operator: operator, |
| 123 | + } |
| 124 | + |
| 125 | + // Deduplicate by normalized key (including operator), preserving order |
| 126 | + key := filterKey{ |
| 127 | + typ: filter.Type, |
| 128 | + value: filter.Value, |
| 129 | + operator: operator, |
| 130 | + } |
| 131 | + if !seenFilters[key] { |
| 132 | + seenFilters[key] = true |
| 133 | + result = append(result, filter) |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + return result, nil |
| 138 | +} |
0 commit comments