// Package partnum handles part number generation from schemas. package partnum import ( "context" "fmt" "regexp" "strings" "time" "github.com/kindredsystems/silo/internal/schema" ) // SequenceStore provides atomic sequence number generation. type SequenceStore interface { NextValue(ctx context.Context, schemaID string, scope string) (int, error) } // Generator creates part numbers from schemas. type Generator struct { schemas map[string]*schema.Schema seqStore SequenceStore } // NewGenerator creates a new part number generator. func NewGenerator(schemas map[string]*schema.Schema, seqStore SequenceStore) *Generator { return &Generator{ schemas: schemas, seqStore: seqStore, } } // Input provides values for part number generation. type Input struct { SchemaName string Values map[string]string // segment name -> value } // Generate creates a new part number. func (g *Generator) Generate(ctx context.Context, input Input) (string, error) { s, ok := g.schemas[input.SchemaName] if !ok { return "", fmt.Errorf("unknown schema: %s", input.SchemaName) } segments := make([]string, len(s.Segments)) resolvedValues := make(map[string]string) for i, seg := range s.Segments { val, err := g.resolveSegment(ctx, s, &seg, input.Values, resolvedValues) if err != nil { return "", fmt.Errorf("segment %s: %w", seg.Name, err) } segments[i] = val resolvedValues[seg.Name] = val } // Use format template if provided, otherwise join with separator if s.Format != "" { return g.applyFormat(s.Format, resolvedValues), nil } return strings.Join(segments, s.Separator), nil } func (g *Generator) resolveSegment( ctx context.Context, s *schema.Schema, seg *schema.Segment, input map[string]string, resolved map[string]string, ) (string, error) { switch seg.Type { case "constant": return seg.Value, nil case "string": val, ok := input[seg.Name] if !ok && seg.Required { return "", fmt.Errorf("required value not provided") } return g.formatString(seg, val) case "enum": val, ok := input[seg.Name] if !ok && seg.Required { return "", fmt.Errorf("required value not provided") } if _, valid := seg.Values[val]; !valid { return "", fmt.Errorf("invalid enum value: %s", val) } return val, nil case "serial": scope := g.resolveScope(seg.Scope, resolved) next, err := g.seqStore.NextValue(ctx, s.Name, scope) if err != nil { return "", fmt.Errorf("getting sequence: %w", err) } return g.formatSerial(seg, next), nil case "date": layout := seg.Value if layout == "" { layout = "20060102" } return time.Now().UTC().Format(layout), nil default: return "", fmt.Errorf("unknown segment type: %s", seg.Type) } } func (g *Generator) formatString(seg *schema.Segment, val string) (string, error) { // Apply case transformation switch seg.Case { case "upper": val = strings.ToUpper(val) case "lower": val = strings.ToLower(val) } // Validate length if seg.Length > 0 && len(val) != seg.Length { return "", fmt.Errorf("value must be exactly %d characters", seg.Length) } if seg.MinLength > 0 && len(val) < seg.MinLength { return "", fmt.Errorf("value must be at least %d characters", seg.MinLength) } if seg.MaxLength > 0 && len(val) > seg.MaxLength { return "", fmt.Errorf("value must be at most %d characters", seg.MaxLength) } // Validate pattern if seg.Validation.Pattern != "" { re := regexp.MustCompile(seg.Validation.Pattern) if !re.MatchString(val) { msg := seg.Validation.Message if msg == "" { msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern) } return "", fmt.Errorf("%s", msg) } } return val, nil } func (g *Generator) formatSerial(seg *schema.Segment, val int) string { format := fmt.Sprintf("%%0%dd", seg.Length) return fmt.Sprintf(format, val) } func (g *Generator) resolveScope(scopeTemplate string, resolved map[string]string) string { if scopeTemplate == "" { return "_global_" } result := scopeTemplate for name, val := range resolved { result = strings.ReplaceAll(result, "{"+name+"}", val) } return result } func (g *Generator) applyFormat(format string, resolved map[string]string) string { result := format for name, val := range resolved { result = strings.ReplaceAll(result, "{"+name+"}", val) } return result } // Validate checks if a part number matches a schema. func (g *Generator) Validate(partNumber string, schemaName string) error { s, ok := g.schemas[schemaName] if !ok { return fmt.Errorf("unknown schema: %s", schemaName) } parts := strings.Split(partNumber, s.Separator) if len(parts) != len(s.Segments) { return fmt.Errorf("expected %d segments, got %d", len(s.Segments), len(parts)) } for i, seg := range s.Segments { val := parts[i] if err := g.validateSegment(&seg, val); err != nil { return fmt.Errorf("segment %s: %w", seg.Name, err) } } return nil } // validateSegment checks that a single segment value is valid. func (g *Generator) validateSegment(seg *schema.Segment, val string) error { switch seg.Type { case "constant": if val != seg.Value { return fmt.Errorf("expected %q, got %q", seg.Value, val) } case "enum": if _, ok := seg.Values[val]; !ok { return fmt.Errorf("invalid enum value: %s", val) } case "string": if seg.Length > 0 && len(val) != seg.Length { return fmt.Errorf("value must be exactly %d characters", seg.Length) } if seg.MinLength > 0 && len(val) < seg.MinLength { return fmt.Errorf("value must be at least %d characters", seg.MinLength) } if seg.MaxLength > 0 && len(val) > seg.MaxLength { return fmt.Errorf("value must be at most %d characters", seg.MaxLength) } if seg.Case == "upper" && val != strings.ToUpper(val) { return fmt.Errorf("value must be uppercase") } if seg.Case == "lower" && val != strings.ToLower(val) { return fmt.Errorf("value must be lowercase") } if seg.Validation.Pattern != "" { re := regexp.MustCompile(seg.Validation.Pattern) if !re.MatchString(val) { msg := seg.Validation.Message if msg == "" { msg = fmt.Sprintf("value does not match pattern %s", seg.Validation.Pattern) } return fmt.Errorf("%s", msg) } } case "serial": if seg.Length > 0 && len(val) != seg.Length { return fmt.Errorf("value must be exactly %d characters", seg.Length) } for _, ch := range val { if ch < '0' || ch > '9' { return fmt.Errorf("serial must be numeric") } } case "date": layout := seg.Value if layout == "" { layout = "20060102" } expected := time.Now().UTC().Format(layout) if len(val) != len(expected) { return fmt.Errorf("date segment length mismatch: expected %d, got %d", len(expected), len(val)) } default: return fmt.Errorf("unknown segment type: %s", seg.Type) } return nil }