| 1 |
|
package core |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"fmt" |
| 5 |
|
"strings" |
| 6 |
|
) |
| 7 |
|
|
| 8 |
|
const ( |
| 9 |
|
// MaxDocIDPrefixLen bounds the alphabetic part of an ID ("SPEC", "RFC"). |
| 10 |
|
MaxDocIDPrefixLen = 16 |
| 11 |
|
|
| 12 |
|
// MaxDocIDSeqLen bounds the numeric part. Eight digits is far past the |
| 13 |
|
// volume this service will ever see; the cap exists so a pathological ID |
| 14 |
|
// cannot become a pathological registry key. |
| 15 |
|
MaxDocIDSeqLen = 8 |
| 16 |
|
) |
| 17 |
|
|
| 18 |
|
// DocID is a parsed document identifier such as "SPEC-0007". |
| 19 |
|
// |
| 20 |
|
// Seq is kept as a string rather than an int on purpose: leading zeros are part |
| 21 |
|
// of how these IDs are written and read, and "SPEC-7" and "SPEC-0007" are |
| 22 |
|
// therefore different IDs in a registry that is globally unique. Normalizing |
| 23 |
|
// them to the same integer would silently merge two documents. |
| 24 |
|
type DocID struct { |
| 25 |
|
Prefix string |
| 26 |
|
Seq string |
| 27 |
|
} |
| 28 |
|
|
| 29 |
|
// String renders the canonical "PREFIX-SEQ" form. |
| 30 |
9 |
func (d DocID) String() string { return d.Prefix + "-" + d.Seq } |
| 31 |
|
|
| 32 |
|
// ParseDocID parses and validates a document ID. The grammar is deliberately |
| 33 |
|
// tiny: |
| 34 |
|
// |
| 35 |
|
// ID = PREFIX "-" SEQ |
| 36 |
|
// PREFIX = [A-Z] [A-Z0-9]* (1..MaxDocIDPrefixLen) |
| 37 |
|
// SEQ = [0-9]+ (1..MaxDocIDSeqLen) |
| 38 |
|
// |
| 39 |
|
// Exactly one '-' separates the two, so the split is unambiguous and IDs sort |
| 40 |
|
// predictably. ASCII-only and uppercase-only is the load-bearing part: IDs are |
| 41 |
|
// the global registry key, so a Cyrillic "ะก" or a lowercase "spec" must be a |
| 42 |
|
// different-looking ID that is rejected outright rather than a homograph that |
| 43 |
|
// quietly registers alongside the real one. |
| 44 |
|
// |
| 45 |
|
// Core validates shape only. Global uniqueness is enforced by the registry |
| 46 |
|
// table, which is the only thing that can know about other spaces. |
| 47 |
51 |
func ParseDocID(s string) (DocID, error) { |
| 48 |
51 |
if s == "" { |
| 49 |
3 |
return DocID{}, fmt.Errorf("%w: empty id", ErrInvalidDocID) |
| 50 |
3 |
} |
| 51 |
48 |
i := strings.IndexByte(s, '-') |
| 52 |
48 |
if i < 0 { |
| 53 |
2 |
return DocID{}, fmt.Errorf("%w: id %q has no '-' separator", ErrInvalidDocID, s) |
| 54 |
2 |
} |
| 55 |
46 |
prefix, seq := s[:i], s[i+1:] |
| 56 |
46 |
if strings.IndexByte(seq, '-') >= 0 { |
| 57 |
2 |
return DocID{}, fmt.Errorf("%w: id %q has more than one '-'", ErrInvalidDocID, s) |
| 58 |
2 |
} |
| 59 |
|
|
| 60 |
44 |
if prefix == "" { |
| 61 |
1 |
return DocID{}, fmt.Errorf("%w: id %q has an empty prefix", ErrInvalidDocID, s) |
| 62 |
1 |
} |
| 63 |
43 |
if len(prefix) > MaxDocIDPrefixLen { |
| 64 |
1 |
return DocID{}, fmt.Errorf("%w: id %q prefix is too long (%d > %d)", |
| 65 |
1 |
ErrInvalidDocID, s, len(prefix), MaxDocIDPrefixLen) |
| 66 |
1 |
} |
| 67 |
42 |
if !isUpperAlpha(prefix[0]) { |
| 68 |
6 |
return DocID{}, fmt.Errorf("%w: id %q prefix must start with A-Z", ErrInvalidDocID, s) |
| 69 |
6 |
} |
| 70 |
151 |
for i := 0; i < len(prefix); i++ { |
| 71 |
151 |
if c := prefix[i]; !isUpperAlpha(c) && !isDigit(c) { |
| 72 |
4 |
return DocID{}, fmt.Errorf("%w: id %q prefix contains disallowed byte %q", ErrInvalidDocID, s, c) |
| 73 |
4 |
} |
| 74 |
|
} |
| 75 |
|
|
| 76 |
32 |
if seq == "" { |
| 77 |
1 |
return DocID{}, fmt.Errorf("%w: id %q has an empty sequence", ErrInvalidDocID, s) |
| 78 |
1 |
} |
| 79 |
31 |
if len(seq) > MaxDocIDSeqLen { |
| 80 |
3 |
return DocID{}, fmt.Errorf("%w: id %q sequence is too long (%d > %d)", |
| 81 |
3 |
ErrInvalidDocID, s, len(seq), MaxDocIDSeqLen) |
| 82 |
3 |
} |
| 83 |
69 |
for i := 0; i < len(seq); i++ { |
| 84 |
69 |
if !isDigit(seq[i]) { |
| 85 |
6 |
return DocID{}, fmt.Errorf("%w: id %q sequence contains disallowed byte %q", ErrInvalidDocID, s, seq[i]) |
| 86 |
6 |
} |
| 87 |
|
} |
| 88 |
|
|
| 89 |
22 |
return DocID{Prefix: prefix, Seq: seq}, nil |
| 90 |
|
} |
| 91 |
|
|
| 92 |
|
// ValidateDocID reports whether s is a well-formed document ID, discarding the |
| 93 |
|
// parse. Use it where only the verdict matters (frontmatter schema checks, push |
| 94 |
|
// validation). |
| 95 |
18 |
func ValidateDocID(s string) error { |
| 96 |
18 |
_, err := ParseDocID(s) |
| 97 |
18 |
return err |
| 98 |
18 |
} |
| 99 |
|
|
| 100 |
193 |
func isUpperAlpha(c byte) bool { return c >= 'A' && c <= 'Z' } |
| 101 |
74 |
func isDigit(c byte) bool { return c >= '0' && c <= '9' } |