~bigbes/sr-ht-spec

ref: 105e0b003b482928921d6894dfcf6d73ed776d73 sr-ht-spec/core/id.go -rw-r--r-- 3.4 KiB
105e0b00 — bigbes chore(beads): track the spec.sr.ht issue backlog 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
package core

import (
	"fmt"
	"strings"
)

const (
	// MaxDocIDPrefixLen bounds the alphabetic part of an ID ("SPEC", "RFC").
	MaxDocIDPrefixLen = 16

	// MaxDocIDSeqLen bounds the numeric part. Eight digits is far past the
	// volume this service will ever see; the cap exists so a pathological ID
	// cannot become a pathological registry key.
	MaxDocIDSeqLen = 8
)

// DocID is a parsed document identifier such as "SPEC-0007".
//
// Seq is kept as a string rather than an int on purpose: leading zeros are part
// of how these IDs are written and read, and "SPEC-7" and "SPEC-0007" are
// therefore different IDs in a registry that is globally unique. Normalizing
// them to the same integer would silently merge two documents.
type DocID struct {
	Prefix string
	Seq    string
}

// String renders the canonical "PREFIX-SEQ" form.
func (d DocID) String() string { return d.Prefix + "-" + d.Seq }

// ParseDocID parses and validates a document ID. The grammar is deliberately
// tiny:
//
//	ID     = PREFIX "-" SEQ
//	PREFIX = [A-Z] [A-Z0-9]*        (1..MaxDocIDPrefixLen)
//	SEQ    = [0-9]+                 (1..MaxDocIDSeqLen)
//
// Exactly one '-' separates the two, so the split is unambiguous and IDs sort
// predictably. ASCII-only and uppercase-only is the load-bearing part: IDs are
// the global registry key, so a Cyrillic "ะก" or a lowercase "spec" must be a
// different-looking ID that is rejected outright rather than a homograph that
// quietly registers alongside the real one.
//
// Core validates shape only. Global uniqueness is enforced by the registry
// table, which is the only thing that can know about other spaces.
func ParseDocID(s string) (DocID, error) {
	if s == "" {
		return DocID{}, fmt.Errorf("%w: empty id", ErrInvalidDocID)
	}
	i := strings.IndexByte(s, '-')
	if i < 0 {
		return DocID{}, fmt.Errorf("%w: id %q has no '-' separator", ErrInvalidDocID, s)
	}
	prefix, seq := s[:i], s[i+1:]
	if strings.IndexByte(seq, '-') >= 0 {
		return DocID{}, fmt.Errorf("%w: id %q has more than one '-'", ErrInvalidDocID, s)
	}

	if prefix == "" {
		return DocID{}, fmt.Errorf("%w: id %q has an empty prefix", ErrInvalidDocID, s)
	}
	if len(prefix) > MaxDocIDPrefixLen {
		return DocID{}, fmt.Errorf("%w: id %q prefix is too long (%d > %d)",
			ErrInvalidDocID, s, len(prefix), MaxDocIDPrefixLen)
	}
	if !isUpperAlpha(prefix[0]) {
		return DocID{}, fmt.Errorf("%w: id %q prefix must start with A-Z", ErrInvalidDocID, s)
	}
	for i := 0; i < len(prefix); i++ {
		if c := prefix[i]; !isUpperAlpha(c) && !isDigit(c) {
			return DocID{}, fmt.Errorf("%w: id %q prefix contains disallowed byte %q", ErrInvalidDocID, s, c)
		}
	}

	if seq == "" {
		return DocID{}, fmt.Errorf("%w: id %q has an empty sequence", ErrInvalidDocID, s)
	}
	if len(seq) > MaxDocIDSeqLen {
		return DocID{}, fmt.Errorf("%w: id %q sequence is too long (%d > %d)",
			ErrInvalidDocID, s, len(seq), MaxDocIDSeqLen)
	}
	for i := 0; i < len(seq); i++ {
		if !isDigit(seq[i]) {
			return DocID{}, fmt.Errorf("%w: id %q sequence contains disallowed byte %q", ErrInvalidDocID, s, seq[i])
		}
	}

	return DocID{Prefix: prefix, Seq: seq}, nil
}

// ValidateDocID reports whether s is a well-formed document ID, discarding the
// parse. Use it where only the verdict matters (frontmatter schema checks, push
// validation).
func ValidateDocID(s string) error {
	_, err := ParseDocID(s)
	return err
}

func isUpperAlpha(c byte) bool { return c >= 'A' && c <= 'Z' }
func isDigit(c byte) bool      { return c >= '0' && c <= '9' }