// Package helpers — конвертация имён, форматирование, plan-модификаторы. package helpers import ( "fmt" "go/format" "strings" "unicode" "resource-generator/internal/types" ) // ToCamel конвертирует snake_case → CamelCase. func ToCamel(s string) string { parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' }) for i, p := range parts { if len(p) == 0 { continue } parts[i] = strings.ToUpper(p[:1]) + p[1:] } out := strings.Join(parts, "") return EnsureGoIdent(out) } // ToSnake конвертирует CamelCase → snake_case. // Аббревиатуры (CPU, TTL, CA, API, VIP) сохраняются слитно: resourceCPU → resource_cpu. func ToSnake(s string) string { var out []rune runes := []rune(s) for i, r := range runes { if i > 0 && r >= 'A' && r <= 'Z' { // Вставляем _ только если предыдущий символ — строчная буква // (переход из слова в аббревиатуру: resourceCPU → resource_cpu) // ИЛИ если следующий символ — строчная буква // (переход из аббревиатуры в слово: HTTPSProxy → https_proxy) prev := runes[i-1] if prev >= 'a' && prev <= 'z' { out = append(out, '_') } else if i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' { out = append(out, '_') } } out = append(out, rune(strings.ToLower(string(r))[0])) } return string(out) } // ParamType возвращает Go-тип для параметра (types.Bool/Int64/String). func ParamType(p types.Param) string { switch strings.ToLower(p.Type) { case "bool": return "types.Bool" case "int", "int64", "number": return "types.Int64" default: return "types.String" } } // ParamDefaultExpr возвращает выражение значения по умолчанию. func ParamDefaultExpr(p types.Param) string { if p.Default == "" { return "" } switch strings.ToLower(p.Type) { case "bool": val := strings.ToLower(p.Default) return fmt.Sprintf("booldefault.StaticBool(%s)", val) case "int", "int64", "number": return fmt.Sprintf("int64default.StaticInt64(%s)", p.Default) default: return fmt.Sprintf("stringdefault.StaticString(%q)", p.Default) } } // ShouldBeOptionalComputed — обязан ли параметр быть объявлен в схеме как // `Optional: true, Computed: true` (при отсутствии явного Default). // // ОБЩИЙ ИНВАРИАНТ ПРОВАЙДЕРА (не привязан к конкретному сервису или параметру): // каждый скалярный параметр, который провайдер читает ОБРАТНО из state_params // инстанса и записывает в state (см. RefreshResourceState + InputField в шаблоне // instance.go), обязан быть Optional+Computed, если он не Required и без Default. // // ПОЧЕМУ ИНАЧЕ ЛОМАЕТСЯ: // - Пользователь не задал опциональный параметр → в плане он = null. // - Платформа подставляет своё значение (например дефолтный QoS-профиль // "QoS-100Mbit" для vc_nsxt) → провайдер кладёт его в state при apply. // - Terraform видит расхождение plan(null) ≠ state(значение) и падает: // "Provider produced inconsistent result after apply: .qos_profile: // was null, but now cty.StringVal("QoS-100Mbit")". // // ПОЧЕМУ Computed ЭТО ЧИНИТ: // - С `Computed: true` незаданный параметр в плане = unknown (не null), // и провайдер имеет право заполнить его значением из API — это штатная // семантика Optional+Computed, а не обходной путь. // // УСЛОВИЯ, ПРИ КОТОРЫХ Computed НЕ НУЖЕН: // - Required: значение всегда задано пользователем → план уже известен; // - RefSvcId != 0: refSvc-параметры НЕ читаются обратно в модель — они // резолвятся только для API-вызова, а в state остаётся ровно то, что // написал пользователь (иначе получим plan(display name) ≠ state(UUID)); // - Default != "": атрибут уже объявлен Computed+Default, значение известно. func ShouldBeOptionalComputed(p types.Param) bool { return !p.Required && p.RefSvcId == 0 && p.Default == "" } // ShouldUseStateForUnknown решает, нужен ли UseStateForUnknown() plan-modifier // для скалярного параметра. // // Зачем: Optional+Computed параметр БЕЗ Default при незаданном значении в плане // = unknown. Если Update не перезаписывает его (в т.ч. ветка no-op с ранним // return, см. instance.go), unknown протекает в state и Terraform падает с // "Provider produced invalid result object after apply: ... unknown". // UseStateForUnknown схлопывает unknown в предыдущее known-значение из state, // не требуя сетевого вызова. // // НЕ применяем к JSON (у них свой JsonNormalize) и к полям с Default/Required. func ShouldUseStateForUnknown(p types.Param) bool { if p.IsJson { return false } return ShouldBeOptionalComputed(p) } // ParamFormat возвращает вызов Format* для форматирования значения параметра. func ParamFormat(p types.Param, varName string) string { switch strings.ToLower(p.Type) { case "bool": return fmt.Sprintf("resources_core.FormatBool(%s)", varName) case "int", "int64", "number": return fmt.Sprintf("resources_core.FormatInt64(%s)", varName) default: return fmt.Sprintf("resources_core.FormatString(%s)", varName) } } // ParamParse возвращает имя Parse*-функции для параметра. func ParamParse(p types.Param) string { switch strings.ToLower(p.Type) { case "bool": return "resources_core.ParseBool" case "int", "int64", "number": return "resources_core.ParseInt64" default: return "resources_core.ParseString" } } // ParamDescription формирует MarkdownDescription для параметра. func ParamDescription(p types.Param) string { var desc string if strings.TrimSpace(p.Descr) != "" { desc = strings.TrimSpace(p.Descr) } else if strings.TrimSpace(p.Man) != "" { desc = strings.TrimSpace(p.Man) } else if p.RefSvcId > 0 { desc = fmt.Sprintf("UI display_name for service_id=%d (mapped to UUID in core)", p.RefSvcId) } if p.CreateOnly { if desc == "" { desc = "Не изменяется после создания." } else { desc = desc + " Не изменяется после создания." } } if p.ForceNew { if desc == "" { desc = "Изменение требует пересоздания." } else { desc = desc + " Изменение требует пересоздания." } } if desc == "" { return "" } return fmt.Sprintf("%q", desc) } // OutputParamType возвращает Go-тип для выходного параметра (types.Map/List/String). func OutputParamType(p types.OutputParam) string { switch strings.ToLower(p.Type) { case "map": return "types.Map" case "list": return "types.List" default: return "types.String" } } // OutputParamIsMap возвращает true, если выходной параметр — map. func OutputParamIsMap(p types.OutputParam) bool { return strings.EqualFold(strings.TrimSpace(p.Type), "map") } // OutputParamIsList возвращает true, если выходной параметр — list. func OutputParamIsList(p types.OutputParam) bool { return strings.EqualFold(strings.TrimSpace(p.Type), "list") } // OutputParamSensitive возвращает true, если параметр секретный. func OutputParamSensitive(p types.OutputParam) bool { return p.Sensitive } // EnsureGoIdent проверяет, что строка начинается с буквы или _. func EnsureGoIdent(s string) string { if s == "" { return "R" } first := rune(s[0]) if unicode.IsLetter(first) || first == '_' { return s } return "R" + s } // FormatSourceOrWarn форматирует Go-код и возвращает ошибку при сбое gofmt. func FormatSourceOrWarn(filePath string, src []byte) ([]byte, error) { formatted, err := format.Source(src) if err != nil { return nil, fmt.Errorf("gofmt failed for %s: %w", filePath, err) } return formatted, nil } // PlanModifierType возвращает суффикс типа план-модификатора (Bool/Int64/String). func PlanModifierType(p types.Param) string { switch strings.ToLower(strings.TrimSpace(p.Type)) { case "bool": return "Bool" case "int", "int64", "number": return "Int64" default: return "String" } } // PlanModifierExpr возвращает выражение план-модификатора RequiresReplace. func PlanModifierExpr(p types.Param) string { switch strings.ToLower(strings.TrimSpace(p.Type)) { case "bool": return "boolplanmodifier.RequiresReplace()" case "int", "int64", "number": return "int64planmodifier.RequiresReplace()" default: return "stringplanmodifier.RequiresReplace()" } } // ─── Nested (map-fixed) helpers ────────────────────────────────────────────── // IsNested возвращает true для параметров с SubParams (map-fixed). // array-map-fixed исключается — требует отдельной обработки. func IsNested(p types.Param) bool { return p.HasSubParams && !IsNestedList(p) } // IsNestedList возвращает true для array-map-fixed (список объектов). func IsNestedList(p types.Param) bool { return p.HasSubParams && strings.Contains(strings.ToLower(p.Type), "array") } // NestedModelName возвращает имя вложенного Go-struct для ресурса и параметра. // Например: NestedModelName("postgres", "cluster_configuration") → "PostgresClusterConfigurationModel" func NestedModelName(resourceName, paramCode string) string { return ToCamel(resourceName) + ToCamel(paramCode) + "Model" } // NestedTfType возвращает Go-тип для вложенного поля в Model: // *PostgresClusterConfigurationModel (SingleNestedAttribute) или // []PostgresClusterConfigurationModel (ListNestedAttribute — array-map-fixed). func NestedTfType(p types.Param, resourceName string) string { name := NestedModelName(resourceName, p.Code) if IsNestedList(p) { return "[]" + name } return "*" + name } // NestedSchemaType возвращает тип схемы для nested параметра: // "SingleNested" (map-fixed) или "ListNested" (array-map-fixed). func NestedSchemaType(p types.Param) string { if IsNestedList(p) { return "ListNested" } return "SingleNested" } // NestedSchemaBlock генерирует открывающую строку для nested-атрибута в Schema. func NestedSchemaBlock(p types.Param) string { t := NestedSchemaType(p) switch t { case "ListNested": return fmt.Sprintf("schema.%sAttribute{Optional: true,\n\t\t\tNestedObject: schema.NestedAttributeObject{Attributes: map[string]schema.Attribute{", t) default: req := "Required: true" if p.Default != "" { req = "Optional: true" } return fmt.Sprintf("schema.%sAttribute{%s,\n\t\t\tAttributes: map[string]schema.Attribute{", t, req) } } // NestedSchemaEnd возвращает закрывающую строку для nested-атрибута. // Закрывает Attributes map и сам атрибут: `}},` для SingleNested, `}}},` для ListNested. func NestedSchemaEnd(p types.Param) string { t := NestedSchemaType(p) if t == "ListNested" { return "}}}," } return "}}," } // SubSchemaType возвращает тип схемы для подпараметра. func SubSchemaType(p types.Param) string { switch strings.ToLower(p.Type) { case "bool": return "Bool" case "int64", "int", "number": return "Int64" default: return "String" } } // SubDefaultExpr возвращает выражение default для подпараметра. // ⚠️ Только Optional+Computed (Required несовместим с Default во фреймворке). func SubDefaultExpr(p types.Param) string { if p.Default == "" { return "" } switch strings.ToLower(p.Type) { case "bool": return fmt.Sprintf("booldefault.StaticBool(%s)", strings.ToLower(p.Default)) case "int64", "int", "number": return fmt.Sprintf("int64default.StaticInt64(%s)", p.Default) default: return fmt.Sprintf("stringdefault.StaticString(%q)", p.Default) } } // SubParamFormat возвращает JSON-ключ:значение для форматирования подпараметра. func SubParamFormat(p types.Param, varPrefix string) string { varName := varPrefix + "." + ToCamel(p.Code) switch strings.ToLower(p.Type) { case "bool": return fmt.Sprintf(`"%s": `+varName+`.ValueBool()`, p.Code) case "int64", "int", "number": return fmt.Sprintf(`"%s": %s `+varName+`.ValueInt64()`, p.Code, "%s") default: return fmt.Sprintf(`"%s": `+varName+`.ValueString()`, p.Code) } } // NestedJSONExpr возвращает выражение для сборки JSON из nested struct через resources_core.BuildJSON. // varPrefix — префикс переменной (например "data" или "plan"). // Генерирует доступ: data.ClusterConfiguration.Cpu, data.ClusterConfiguration.Memory, ... func NestedJSONExpr(p types.Param, varPrefix string) string { varName := varPrefix + "." + ToCamel(p.Code) pairs := make([]string, 0, len(p.SubParams)) for _, sp := range p.SubParams { field := varName + "." + ToCamel(sp.Code) var valExpr string // IsJson проверяем до Type — для json полей Type="string", но сериализация другая. if sp.IsJson { valExpr = fmt.Sprintf(`%s.ValueString()`, field) } else { switch strings.ToLower(sp.Type) { case "bool": valExpr = fmt.Sprintf(`fmt.Sprintf("%%v", %s.ValueBool())`, field) case "int64", "int", "number": valExpr = fmt.Sprintf(`fmt.Sprintf("%%d", %s.ValueInt64())`, field) default: valExpr = fmt.Sprintf(`fmt.Sprintf("\"%%s\"", %s.ValueString())`, field) } } pairs = append(pairs, fmt.Sprintf(`"%s": %s`, sp.Code, valExpr)) } return fmt.Sprintf("resources_core.BuildJSON(map[string]string{%s})", strings.Join(pairs, ", ")) }