package racename import ( "fmt" "strings" "galaxy/util" confusables "github.com/disciplinedware/go-confusables" "golang.org/x/text/cases" ) // confusableSkeletoner abstracts the underlying TR39 confusable-skeleton // computer so tests may substitute a deterministic stub. type confusableSkeletoner interface { Skeleton(string) string } // Policy produces canonical uniqueness keys and validates user-supplied race // names under the Race Name Directory rules: Unicode case folding, explicit // ASCII anti-fraud digit-to-letter mappings, and a TR39 confusable skeleton. type Policy struct { caseFolder cases.Caser skeletoner confusableSkeletoner } // antiFraudReplacer collapses the frozen ASCII anti-fraud digit-to-letter // pairs so `P1lot` and `Pilot` canonicalize together. var antiFraudReplacer = strings.NewReplacer( "1", "i", "0", "o", "8", "b", ) // NewPolicy returns the default race-name canonicalization policy. func NewPolicy() (*Policy, error) { policy := &Policy{ caseFolder: cases.Fold(), skeletoner: confusables.Default(), } if policy.skeletoner == nil { return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner") } return policy, nil } // Canonical returns the stable uniqueness key for raceName. // // raceName is expected to be non-empty; surrounding whitespace is trimmed // before canonicalization so callers that preserve original casing pass the // untrimmed display form directly. func (policy *Policy) Canonical(raceName string) (CanonicalKey, error) { switch { case policy == nil: return "", fmt.Errorf("canonicalize race name: nil policy") case policy.skeletoner == nil: return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner") } trimmed := strings.TrimSpace(raceName) if trimmed == "" { return "", fmt.Errorf("canonicalize race name: race name must not be empty") } folded := policy.caseFolder.String(trimmed) antiFraudMapped := antiFraudReplacer.Replace(folded) key := CanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped)) if err := key.Validate(); err != nil { return "", fmt.Errorf("canonicalize race name: %w", err) } return key, nil } // ValidateName reports whether raceName is structurally valid for use in the // Race Name Directory. It delegates to galaxy/util.ValidateTypeName and // returns the trimmed canonical display value on success. func ValidateName(raceName string) (string, error) { trimmed, ok := util.ValidateTypeName(raceName) if !ok { return "", fmt.Errorf("race name is invalid") } return trimmed, nil } // Canonicalize validates raceName as a Race Name Directory display value // and returns its canonical uniqueness key. It composes ValidateName with // the Canonical pipeline so every RND write and lookup shares a single // entry point for both character-set and confusable-pair policy. // // Invalid raceName values surface the error returned by ValidateName; // callers at the RaceNameDirectory port boundary map these to // ports.ErrInvalidName. func (policy *Policy) Canonicalize(raceName string) (CanonicalKey, error) { trimmed, err := ValidateName(raceName) if err != nil { return "", fmt.Errorf("canonicalize race name: %w", err) } return policy.Canonical(trimmed) }