Files
larksuite-cli/internal/meta/normalize.go

203 lines
5.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package meta
import (
"fmt"
"sort"
"strconv"
)
// CanonicalType maps meta_data's non-standard type names to the standard
// JSON-Schema/type vocabulary used downstream (envelope render, flag kinds):
// "file" -> "string", "list" -> "array"; other types pass through unchanged.
func (f Field) CanonicalType() string {
switch f.Type {
case "file":
return "string"
case "list":
return "array"
default:
return f.Type
}
}
// coerceLiteral converts a meta_data literal (default/enum/example) to the
// field's canonical type. Literals may arrive as strings (meta_data's usual
// form) OR already typed — a JSON number unmarshals to float64, a JSON bool to
// bool — so both must be normalized to the SAME Go type the canonical type
// implies (int64 for "integer", float64 for "number", bool for "boolean").
// Otherwise enumLess, which type-asserts on that Go type, can't order the
// values. Returns (value, true) on success, (nil, false) when the literal
// cannot be represented in the declared type.
func coerceLiteral(canonicalType string, raw any) (any, bool) {
switch canonicalType {
case "integer":
switch v := raw.(type) {
case string:
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n, true
}
case float64: // JSON number; accept only when it's a whole value
if v == float64(int64(v)) {
return int64(v), true
}
case int64:
return v, true
case int:
return int64(v), true
}
return nil, false
case "number":
switch v := raw.(type) {
case string:
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n, true
}
case float64:
return v, true
case int64:
return float64(v), true
case int:
return float64(v), true
}
return nil, false
case "boolean":
switch v := raw.(type) {
case string:
switch v {
case "true":
return true, true
case "false":
return false, true
}
case bool:
return v, true
}
return nil, false
default: // "string", "array", "" (objects), or unknown — pass through as-is
return raw, true
}
}
// enumLess orders two coerced enum values for the canonical type, so integer
// enums end up [1 2 10] not lexicographic [1 10 2].
func enumLess(canonicalType string, a, b any) bool {
switch canonicalType {
case "integer":
ai, _ := a.(int64)
bi, _ := b.(int64)
return ai < bi
case "number":
af, _ := a.(float64)
bf, _ := b.(float64)
return af < bf
case "boolean":
ab, _ := a.(bool)
bb, _ := b.(bool)
return !ab && bb
default:
as, _ := a.(string)
bs, _ := b.(string)
return as < bs
}
}
// EnumOption is one allowed value paired with its human description. The
// description comes from options[].description and is empty for the bare `enum`
// form (which carries no descriptions).
type EnumOption struct {
Value any
Description string
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum, or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
func (f Field) EnumOptions() []EnumOption {
ct := f.CanonicalType()
var out []EnumOption
switch {
case len(f.Enum) > 0:
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v})
}
}
case len(f.Options) > 0:
seen := make(map[string]bool)
for _, o := range f.Options {
key := fmt.Sprintf("%v", o.Value)
if seen[key] {
continue
}
seen[key] = true
if v, ok := coerceLiteral(ct, o.Value); ok {
out = append(out, EnumOption{Value: v, Description: o.Description})
}
}
}
if len(out) > 0 && ct != "string" && ct != "" {
sort.SliceStable(out, func(i, j int) bool { return enumLess(ct, out[i].Value, out[j].Value) })
}
return out
}
// EnumValues returns the field's allowed values — the value projection of
// EnumOptions, in the same order. nil when the field declares no enum
// constraint. (Kept as the values-only accessor for the envelope and flag
// completion, which don't need descriptions.)
func (f Field) EnumValues() []any {
opts := f.EnumOptions()
if len(opts) == 0 {
return nil
}
out := make([]any, len(opts))
for i, o := range opts {
out[i] = o.Value
}
return out
}
// CoercedDefault returns Default coerced to the canonical type, or nil when the
// field has no default or the literal cannot be coerced.
func (f Field) CoercedDefault() any { return f.coerce(f.Default) }
// CoercedExample returns Example coerced to the canonical type, or nil when the
// field has no example or the literal cannot be coerced.
func (f Field) CoercedExample() any { return f.coerce(f.Example) }
func (f Field) coerce(raw any) any {
if raw == nil {
return nil
}
if v, ok := coerceLiteral(f.CanonicalType(), raw); ok {
return v
}
return nil
}
// MinBound returns the field's min constraint as a number, or nil when absent
// or unparseable. meta_data carries min/max as strings and does not say
// whether they bound a value or a string's length; the accessors stay equally
// agnostic, so every renderer (envelope minimum/maximum, flag help) presents
// the same numbers without inventing a semantic the source doesn't declare.
func (f Field) MinBound() *float64 { return parseBound(f.Min) }
// MaxBound returns the field's max constraint as a number, or nil when absent
// or unparseable. See MinBound.
func (f Field) MaxBound() *float64 { return parseBound(f.Max) }
func parseBound(s string) *float64 {
if s == "" {
return nil
}
if v, err := strconv.ParseFloat(s, 64); err == nil {
return &v
}
return nil
}