package review import ( "embed" "encoding/json" "fmt" "os" "strings" "unicode/utf8" "github.com/goccy/go-yaml" ) //go:embed personas/*.yaml var embeddedPersonas embed.FS // Persona defines a specialized review role with focused expertise. type Persona struct { Name string `json:"name" yaml:"name"` DisplayName string `json:"display_name" yaml:"display_name"` ModelPref string `json:"model_preference,omitempty" yaml:"model_preference,omitempty"` Identity string `json:"identity" yaml:"identity"` Focus []string `json:"focus" yaml:"focus"` Ignore []string `json:"ignore" yaml:"ignore"` Severity Severity `json:"severity" yaml:"severity"` OutputFormat string `json:"output_format,omitempty" yaml:"output_format,omitempty"` } // Severity defines what constitutes each severity level for this persona. // These are prompt guidance for the LLM, not output format changes. type Severity struct { Major string `json:"major" yaml:"major"` Minor string `json:"minor" yaml:"minor"` Nit string `json:"nit" yaml:"nit"` } // LoadPersona loads a persona from a JSON or YAML file path. // Format is detected by file extension: .yaml/.yml for YAML, .json or other for JSON. func LoadPersona(path string) (*Persona, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read persona file %s: %w", path, err) } return parsePersona(data, path) } // LoadBuiltinPersona loads a built-in persona by name. // Returns an error if the persona doesn't exist. // Built-in personas are stored in YAML format. func LoadBuiltinPersona(name string) (*Persona, error) { // Try YAML first (preferred format) yamlFile := name + ".yaml" data, err := embeddedPersonas.ReadFile("personas/" + yamlFile) if err == nil { return parsePersona(data, "builtin:"+yamlFile) } // Fall back to JSON for backwards compatibility jsonFile := name + ".json" data, err = embeddedPersonas.ReadFile("personas/" + jsonFile) if err != nil { available := ListBuiltinPersonas() return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", ")) } return parsePersona(data, "builtin:"+jsonFile) } // ListBuiltinPersonas returns the names of all built-in personas. // Returns an empty slice if the embedded directory cannot be read. func ListBuiltinPersonas() []string { entries, err := embeddedPersonas.ReadDir("personas") if err != nil { return []string{} } seen := make(map[string]bool) for _, e := range entries { if e.IsDir() { continue } name := e.Name() // Strip extension to get persona name var personaName string switch { case strings.HasSuffix(name, ".yaml"): personaName = strings.TrimSuffix(name, ".yaml") case strings.HasSuffix(name, ".yml"): personaName = strings.TrimSuffix(name, ".yml") case strings.HasSuffix(name, ".json"): personaName = strings.TrimSuffix(name, ".json") default: continue } if !seen[personaName] { seen[personaName] = true } } var names []string for name := range seen { names = append(names, name) } return names } // parsePersona parses persona data from JSON or YAML format. // Format is detected by the source file extension. func parsePersona(data []byte, source string) (*Persona, error) { lowerSource := strings.ToLower(source) isYAML := strings.HasSuffix(lowerSource, ".yaml") || strings.HasSuffix(lowerSource, ".yml") var p Persona var err error if isYAML { // go-yaml v1.16.0+ has built-in protection against deeply nested structures err = yaml.Unmarshal(data, &p) } else { err = json.Unmarshal(data, &p) } if err != nil { return nil, fmt.Errorf("parse persona %s: %w", source, err) } if err := validatePersona(&p, source); err != nil { return nil, err } return &p, nil } func validatePersona(p *Persona, source string) error { if p.Name == "" { return fmt.Errorf("persona %s: name is required", source) } if p.Identity == "" { return fmt.Errorf("persona %s: identity is required", source) } // DisplayName defaults to Name if not set if p.DisplayName == "" { p.DisplayName = p.Name } return nil } // CapitalizeFirst capitalizes the first rune of a string in a Unicode-safe way. // Returns the original string if it's empty. func CapitalizeFirst(s string) string { if s == "" { return s } r, size := utf8.DecodeRuneInString(s) if r == utf8.RuneError { return s } return strings.ToUpper(string(r)) + s[size:] }