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' }