// Package schema handles YAML schema parsing and validation for part numbering. package schema import ( "fmt" "os" "path/filepath" "regexp" "strings" "gopkg.in/yaml.v3" ) // Schema represents a part numbering schema. type Schema struct { Name string `yaml:"name"` Version int `yaml:"version"` Description string `yaml:"description"` Separator string `yaml:"separator"` Uniqueness Uniqueness `yaml:"uniqueness"` Segments []Segment `yaml:"segments"` Format string `yaml:"format"` Examples []string `yaml:"examples"` PropertySchemas *PropertySchemas `yaml:"property_schemas,omitempty"` UI *UIConfig `yaml:"ui,omitempty" json:"ui,omitempty"` } // UIConfig defines form layout for all clients. type UIConfig struct { CategoryPicker *CategoryPickerConfig `yaml:"category_picker,omitempty" json:"category_picker,omitempty"` ItemFields map[string]ItemFieldDef `yaml:"item_fields,omitempty" json:"item_fields,omitempty"` FieldGroups map[string]FieldGroup `yaml:"field_groups,omitempty" json:"field_groups"` CategoryFieldGroups map[string]map[string]FieldGroup `yaml:"category_field_groups,omitempty" json:"category_field_groups,omitempty"` FieldOverrides map[string]FieldOverride `yaml:"field_overrides,omitempty" json:"field_overrides,omitempty"` } // CategoryPickerConfig defines how the category picker is rendered. type CategoryPickerConfig struct { Style string `yaml:"style" json:"style"` Stages []CategoryPickerStage `yaml:"stages" json:"stages"` } // CategoryPickerStage defines one stage of a multi-stage category picker. type CategoryPickerStage struct { Name string `yaml:"name" json:"name"` Label string `yaml:"label" json:"label"` Values map[string]string `yaml:"values,omitempty" json:"values,omitempty"` } // ItemFieldDef defines a field stored on the items table (not in revision properties). type ItemFieldDef struct { Type string `yaml:"type" json:"type"` Widget string `yaml:"widget" json:"widget"` Label string `yaml:"label" json:"label"` Required bool `yaml:"required,omitempty" json:"required,omitempty"` Default any `yaml:"default,omitempty" json:"default,omitempty"` Options []string `yaml:"options,omitempty" json:"options,omitempty"` DerivedFromCategory map[string]string `yaml:"derived_from_category,omitempty" json:"derived_from_category,omitempty"` SearchEndpoint string `yaml:"search_endpoint,omitempty" json:"search_endpoint,omitempty"` } // FieldGroup defines an ordered group of fields for form layout. type FieldGroup struct { Label string `yaml:"label" json:"label"` Order int `yaml:"order" json:"order"` Fields []string `yaml:"fields" json:"fields"` } // FieldOverride overrides display hints for a field. type FieldOverride struct { Widget string `yaml:"widget,omitempty" json:"widget,omitempty"` Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` Options []string `yaml:"options,omitempty" json:"options,omitempty"` } // PropertySchemas defines property schemas per category. type PropertySchemas struct { Version int `yaml:"version" json:"version"` Defaults map[string]PropertyDefinition `yaml:"defaults" json:"defaults"` Categories map[string]map[string]PropertyDefinition `yaml:"categories" json:"categories"` } // PropertyDefinition defines a single property's schema. type PropertyDefinition struct { Type string `yaml:"type" json:"type"` // string, number, boolean, date Default any `yaml:"default" json:"default"` Required bool `yaml:"required,omitempty" json:"required,omitempty"` Unit string `yaml:"unit,omitempty" json:"unit,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` } // GetPropertiesForCategory returns merged properties for a category. func (ps *PropertySchemas) GetPropertiesForCategory(category string) map[string]PropertyDefinition { result := make(map[string]PropertyDefinition) // Start with defaults for k, v := range ps.Defaults { result[k] = v } // Add category-specific (first character of category code) if len(category) > 0 { categoryPrefix := string(category[0]) if catProps, ok := ps.Categories[categoryPrefix]; ok { for k, v := range catProps { result[k] = v } } } return result } // ApplyDefaults fills in missing properties with defaults. func (ps *PropertySchemas) ApplyDefaults(properties map[string]any, category string) map[string]any { if properties == nil { properties = make(map[string]any) } defs := ps.GetPropertiesForCategory(category) for key, def := range defs { if _, exists := properties[key]; !exists && def.Default != nil { properties[key] = def.Default } } return properties } // Uniqueness defines how part number uniqueness is enforced. type Uniqueness struct { Scope string `yaml:"scope"` // global, per-project, per-type, per-schema CaseSensitive bool `yaml:"case_sensitive"` // default false } // Segment represents a part number segment. type Segment struct { Name string `yaml:"name"` Type string `yaml:"type"` // string, enum, serial, date, constant Length int `yaml:"length"` MinLength int `yaml:"min_length"` MaxLength int `yaml:"max_length"` Case string `yaml:"case"` // upper, lower, preserve Padding string `yaml:"padding"` Start int `yaml:"start"` Scope string `yaml:"scope"` // template for serial scope Value string `yaml:"value"` // for constant type Values map[string]string `yaml:"values"` Validation Validation `yaml:"validation"` Required bool `yaml:"required"` Description string `yaml:"description"` } // Validation defines validation rules for a segment. type Validation struct { Pattern string `yaml:"pattern"` Message string `yaml:"message"` } // SchemaFile wraps the schema in a file structure. type SchemaFile struct { Schema Schema `yaml:"schema"` } // Load reads a schema from a YAML file. func Load(path string) (*Schema, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading schema file: %w", err) } var sf SchemaFile if err := yaml.Unmarshal(data, &sf); err != nil { return nil, fmt.Errorf("parsing schema YAML: %w", err) } // Set defaults if sf.Schema.Separator == "" { sf.Schema.Separator = "-" } if sf.Schema.Uniqueness.Scope == "" { sf.Schema.Uniqueness.Scope = "global" } return &sf.Schema, nil } // LoadAll reads all schemas from a directory. func LoadAll(dir string) (map[string]*Schema, error) { schemas := make(map[string]*Schema) entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading schema directory: %w", err) } for _, entry := range entries { if entry.IsDir() { continue } if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") { continue } path := filepath.Join(dir, entry.Name()) schema, err := Load(path) if err != nil { return nil, fmt.Errorf("loading %s: %w", entry.Name(), err) } schemas[schema.Name] = schema } return schemas, nil } // Validate checks that the schema definition is valid. func (s *Schema) Validate() error { if s.Name == "" { return fmt.Errorf("schema name is required") } if len(s.Segments) == 0 { return fmt.Errorf("schema must have at least one segment") } for i, seg := range s.Segments { if err := seg.Validate(); err != nil { return fmt.Errorf("segment %d (%s): %w", i, seg.Name, err) } } if err := s.ValidateUI(); err != nil { return err } return nil } // Validate checks that a segment definition is valid. func (seg *Segment) Validate() error { if seg.Name == "" { return fmt.Errorf("segment name is required") } validTypes := map[string]bool{ "string": true, "enum": true, "serial": true, "date": true, "constant": true, } if !validTypes[seg.Type] { return fmt.Errorf("invalid segment type: %s", seg.Type) } switch seg.Type { case "enum": if len(seg.Values) == 0 { return fmt.Errorf("enum segment requires values") } case "constant": if seg.Value == "" { return fmt.Errorf("constant segment requires value") } case "serial": if seg.Length <= 0 { return fmt.Errorf("serial segment requires positive length") } } if seg.Validation.Pattern != "" { if _, err := regexp.Compile(seg.Validation.Pattern); err != nil { return fmt.Errorf("invalid validation pattern: %w", err) } } return nil } // ValuesByDomain groups category enum values by their first character (domain prefix). func (s *Schema) ValuesByDomain() map[string]map[string]string { catSeg := s.GetSegment("category") if catSeg == nil { return nil } result := make(map[string]map[string]string) for code, desc := range catSeg.Values { if len(code) == 0 { continue } domain := string(code[0]) if result[domain] == nil { result[domain] = make(map[string]string) } result[domain][code] = desc } return result } // ValidateUI validates the UI configuration against property schemas and segments. func (s *Schema) ValidateUI() error { if s.UI == nil { return nil } // Build a set of all known fields (item_fields + property defaults) knownGlobal := make(map[string]bool) if s.UI.ItemFields != nil { for k := range s.UI.ItemFields { knownGlobal[k] = true } } if s.PropertySchemas != nil { for k := range s.PropertySchemas.Defaults { knownGlobal[k] = true } } // Validate field_groups: every field must be a known global field for groupKey, group := range s.UI.FieldGroups { for _, field := range group.Fields { if !knownGlobal[field] { return fmt.Errorf("ui.field_groups.%s: field %q not found in item_fields or property_schemas.defaults", groupKey, field) } } } // Validate category_field_groups: every field must exist in the category's property schema if s.PropertySchemas != nil { for prefix, groups := range s.UI.CategoryFieldGroups { catProps := s.PropertySchemas.Categories[prefix] for groupKey, group := range groups { for _, field := range group.Fields { if catProps == nil { return fmt.Errorf("ui.category_field_groups.%s.%s: category prefix %q has no property schema", prefix, groupKey, prefix) } if _, ok := catProps[field]; !ok { return fmt.Errorf("ui.category_field_groups.%s.%s: field %q not found in property_schemas.categories.%s", prefix, groupKey, field, prefix) } } } } } // Validate field_overrides: keys must be known fields for key := range s.UI.FieldOverrides { if !knownGlobal[key] { // Also check category-level properties found := false if s.PropertySchemas != nil { for _, catProps := range s.PropertySchemas.Categories { if _, ok := catProps[key]; ok { found = true break } } } if !found { return fmt.Errorf("ui.field_overrides: field %q not found in any property schema", key) } } } // Validate category_picker stages: first stage values must be valid domain prefixes if s.UI.CategoryPicker != nil && len(s.UI.CategoryPicker.Stages) > 0 { catSeg := s.GetSegment("category") if catSeg != nil { validPrefixes := make(map[string]bool) for code := range catSeg.Values { if len(code) > 0 { validPrefixes[string(code[0])] = true } } firstStage := s.UI.CategoryPicker.Stages[0] for key := range firstStage.Values { if !validPrefixes[key] { return fmt.Errorf("ui.category_picker.stages[0]: value %q is not a valid category prefix", key) } } } } return nil } // GetSegment returns a segment by name. func (s *Schema) GetSegment(name string) *Segment { for i := range s.Segments { if s.Segments[i].Name == name { return &s.Segments[i] } } return nil }