From 012a134829bfc0e24c4d49d953a08aca76229e89 Mon Sep 17 00:00:00 2001 From: Ayush Kumar <65535504+roguepikachu@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:55:06 +0530 Subject: [PATCH] Feat: extend fluent builder API validator patterns (#7092) * Feat: Add NotEmpty and NegativePattern constraints to StringParam; implement Closed for MapParam Signed-off-by: Ayush Kumar * feat: add validation support for array and map parameters - Introduced validators for ArrayParam and MapParam, allowing for cross-field validation within structured parameters. - Added NonEmpty validation for ArrayParam to ensure arrays are not empty. - Implemented ConditionalStructOp for conditional struct generation based on specified conditions. - Created a new Validator type for defining validation rules with optional guard conditions. - Added tests for various validation scenarios, including mutual exclusion and conditional parameters. - Enhanced the CUE generation logic to incorporate new validation features and conditional struct handling. Signed-off-by: Ayush Kumar * feat: extend fluent API with new scoped field conditions and improve validation checks Signed-off-by: Ayush Kumar * feat: enhance ArrayParam with NotEmpty constraint and update ScopedField documentation Signed-off-by: Ayush Kumar * feat: rename ScopedField to LocalField for improved clarity in condition building Signed-off-by: Ayush Kumar * feat: refactor local field conditions to use RegexMatch and streamline condition building Signed-off-by: Ayush Kumar * feat: simplify condition handling by removing unused comparison types and refactoring NotCondition usage Signed-off-by: Ayush Kumar * refactor: remove unused raw CUE block handling from baseDefinition and ComponentDefinition Signed-off-by: Ayush Kumar * test: update condition handling in parameter tests to use NotExpr and Cond methods Signed-off-by: Ayush Kumar * refactor: remove negative pattern handling from StringParam and related tests Signed-off-by: Ayush Kumar * feat: add support for emitting raw header blocks in template generation Signed-off-by: Ayush Kumar * refactor: remove non-empty check from ArrayParam and update related tests Signed-off-by: Ayush Kumar * refactor: convert parameter constraint tests to use Ginkgo and Gomega for improved readability and maintainability Signed-off-by: Ayush Kumar * feat: extend fluent APIs for OAM with new CUE generation tests and condition evaluations Signed-off-by: Ayush Kumar * refactor: clean up whitespace in component, cuegen, expr, param, and resource files Signed-off-by: Ayush Kumar * feat: enhance CUE generation by adding support for new expression types and iterator references Signed-off-by: Ayush Kumar Signed-off-by: Ayush Kumar * refactor: remove unnecessary whitespace in cuegen.go Signed-off-by: Ayush Kumar * refactor: rename LenOf to LenOfExpr for clarity in comparison methods Signed-off-by: Ayush Kumar * feat: enhance CUE generation and validation for string arrays in ArrayParam Signed-off-by: Ayush Kumar * ci: retrigger checks Signed-off-by: Ayush Kumar --------- Signed-off-by: Ayush Kumar Signed-off-by: Ayush Kumar Co-authored-by: Ayush Kumar --- pkg/definition/defkit/base.go | 24 + pkg/definition/defkit/component.go | 27 + pkg/definition/defkit/component_test.go | 94 ++ pkg/definition/defkit/conditional_param.go | 87 ++ .../defkit/conditional_param_test.go | 111 +++ pkg/definition/defkit/cuegen.go | 382 ++++++-- pkg/definition/defkit/cuegen_test.go | 386 ++++++++ pkg/definition/defkit/expr.go | 60 +- pkg/definition/defkit/expr_test.go | 61 ++ pkg/definition/defkit/param.go | 124 ++- .../defkit/param_constraints_test.go | 877 +++++++----------- pkg/definition/defkit/param_test.go | 166 +++- pkg/definition/defkit/patch_container.go | 6 +- pkg/definition/defkit/patch_container_test.go | 2 +- pkg/definition/defkit/render.go | 12 +- pkg/definition/defkit/render_test.go | 44 + pkg/definition/defkit/resource.go | 85 ++ pkg/definition/defkit/resource_test.go | 103 ++ pkg/definition/defkit/validator.go | 239 +++++ pkg/definition/defkit/validator_test.go | 627 +++++++++++++ 20 files changed, 2828 insertions(+), 689 deletions(-) create mode 100644 pkg/definition/defkit/conditional_param.go create mode 100644 pkg/definition/defkit/conditional_param_test.go create mode 100644 pkg/definition/defkit/validator.go create mode 100644 pkg/definition/defkit/validator_test.go diff --git a/pkg/definition/defkit/base.go b/pkg/definition/defkit/base.go index f653e7f6e..5137fe4e2 100644 --- a/pkg/definition/defkit/base.go +++ b/pkg/definition/defkit/base.go @@ -47,6 +47,10 @@ type baseDefinition struct { helperDefinitions []HelperDefinition rawCUE string imports []string + // validators holds top-level parameter validators + validators []*Validator + // conditionalParamBlocks holds conditional parameter blocks + conditionalParamBlocks []*ConditionalParamBlock // Placement constraints for cluster-aware definition deployment runOn []placement.Condition notRunOn []placement.Condition @@ -64,6 +68,26 @@ func (b *baseDefinition) addParams(params ...Param) { b.params = append(b.params, params...) } +// addValidators appends validators to the definition. +func (b *baseDefinition) addValidators(validators ...*Validator) { + b.validators = append(b.validators, validators...) +} + +// GetValidators returns the top-level validators. +func (b *baseDefinition) GetValidators() []*Validator { + return b.validators +} + +// addConditionalParamBlock appends a conditional parameter block. +func (b *baseDefinition) addConditionalParamBlock(block *ConditionalParamBlock) { + b.conditionalParamBlocks = append(b.conditionalParamBlocks, block) +} + +// GetConditionalParamBlocks returns the conditional parameter blocks. +func (b *baseDefinition) GetConditionalParamBlocks() []*ConditionalParamBlock { + return b.conditionalParamBlocks +} + // setTemplate sets the template function. func (b *baseDefinition) setTemplate(fn func(tpl *Template)) { b.template = fn diff --git a/pkg/definition/defkit/component.go b/pkg/definition/defkit/component.go index 661796446..88c95fcac 100644 --- a/pkg/definition/defkit/component.go +++ b/pkg/definition/defkit/component.go @@ -30,6 +30,7 @@ import ( type ComponentDefinition struct { baseDefinition // embedded common fields (name, description, params, template, etc.) workload WorkloadType + omitWorkloadType bool // when true, suppresses the auto-generated workload.type field in CUE labels map[string]string // metadata labels for the component definition childResourceKinds []common.ChildResourceKind podSpecPath string @@ -64,6 +65,18 @@ func (c *ComponentDefinition) Workload(apiVersion, kind string) *ComponentDefini return c } +// OmitWorkloadType suppresses the auto-generated workload.type field in the CUE output. +// Use this when the vela source CUE does not include a workload type field. +func (c *ComponentDefinition) OmitWorkloadType() *ComponentDefinition { + c.omitWorkloadType = true + return c +} + +// IsOmitWorkloadType returns whether workload type should be suppressed in CUE output. +func (c *ComponentDefinition) IsOmitWorkloadType() bool { + return c.omitWorkloadType +} + // AutodetectWorkload sets the workload type to "autodetects.core.oam.dev". // This is used for components where the workload type is auto-detected at runtime // rather than being statically defined. @@ -197,6 +210,20 @@ func (c *ComponentDefinition) PodSpecPath(path string) *ComponentDefinition { // GetPodSpecPath returns the pod spec path. func (c *ComponentDefinition) GetPodSpecPath() string { return c.podSpecPath } +// Validators adds top-level parameter validators to the component. +// These validators are emitted inside the parameter: { ... } block as _validate* variables. +func (c *ComponentDefinition) Validators(validators ...*Validator) *ComponentDefinition { + c.addValidators(validators...) + return c +} + +// ConditionalParams adds a conditional parameter block to the component. +// This allows parameters to change shape based on a discriminator value. +func (c *ComponentDefinition) ConditionalParams(block *ConditionalParamBlock) *ComponentDefinition { + c.addConditionalParamBlock(block) + return c +} + // RawCUE sets raw CUE for complex component definitions that don't fit the builder pattern. // When set, this bypasses all other template settings and outputs the raw CUE directly. func (c *ComponentDefinition) RawCUE(cue string) *ComponentDefinition { diff --git a/pkg/definition/defkit/component_test.go b/pkg/definition/defkit/component_test.go index 65ed37c6a..260f43cbd 100644 --- a/pkg/definition/defkit/component_test.go +++ b/pkg/definition/defkit/component_test.go @@ -866,6 +866,100 @@ var _ = Describe("ComponentDefinition", func() { }) }) + Context("OmitWorkloadType", func() { + It("should default to false", func() { + c := defkit.NewComponent("test").Workload("apps/v1", "Deployment") + Expect(c.IsOmitWorkloadType()).To(BeFalse()) + }) + + It("should set omit workload type flag", func() { + c := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + OmitWorkloadType() + Expect(c.IsOmitWorkloadType()).To(BeTrue()) + }) + + It("should suppress workload type in CUE output", func() { + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + OmitWorkloadType(). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("apps/v1", "Deployment"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := comp.ToCue() + Expect(cue).To(ContainSubstring(`kind: "Deployment"`)) + Expect(cue).NotTo(ContainSubstring(`type: "deployments.apps"`)) + Expect(cue).NotTo(ContainSubstring(`type: "autodetects.core.oam.dev"`)) + }) + + It("should include workload type when not omitted", func() { + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("apps/v1", "Deployment"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := comp.ToCue() + Expect(cue).To(ContainSubstring(`type: "deployments.apps"`)) + }) + }) + + Context("Validators on Component", func() { + It("should store validators via Validators method", func() { + v1 := defkit.Validate("a").WithName("_a") + v2 := defkit.Validate("b").WithName("_b") + + c := defkit.NewComponent("test").Validators(v1, v2) + Expect(c.GetValidators()).To(HaveLen(2)) + Expect(c.GetValidators()[0]).To(Equal(v1)) + Expect(c.GetValidators()[1]).To(Equal(v2)) + }) + + It("should accumulate validators across multiple calls", func() { + c := defkit.NewComponent("test"). + Validators(defkit.Validate("a").WithName("_a")). + Validators(defkit.Validate("b").WithName("_b")) + Expect(c.GetValidators()).To(HaveLen(2)) + }) + + It("should return empty when no validators set", func() { + c := defkit.NewComponent("test") + Expect(c.GetValidators()).To(BeEmpty()) + }) + }) + + Context("ConditionalParams on Component", func() { + It("should store conditional param blocks", func() { + block := defkit.ConditionalParams( + defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("a")), + ) + c := defkit.NewComponent("test").ConditionalParams(block) + Expect(c.GetConditionalParamBlocks()).To(HaveLen(1)) + Expect(c.GetConditionalParamBlocks()[0]).To(Equal(block)) + }) + + It("should accumulate blocks across multiple calls", func() { + b1 := defkit.ConditionalParams( + defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("a")), + ) + b2 := defkit.ConditionalParams( + defkit.WhenParam(defkit.Bool("y").Eq(true)).Params(defkit.String("b")), + ) + c := defkit.NewComponent("test"). + ConditionalParams(b1). + ConditionalParams(b2) + Expect(c.GetConditionalParamBlocks()).To(HaveLen(2)) + }) + + It("should return empty when no blocks set", func() { + c := defkit.NewComponent("test") + Expect(c.GetConditionalParamBlocks()).To(BeEmpty()) + }) + }) + Context("Repeated generation stability", func() { It("should produce identical CUE across 20 runs", func() { build := func() string { diff --git a/pkg/definition/defkit/conditional_param.go b/pkg/definition/defkit/conditional_param.go new file mode 100644 index 000000000..e9fc04dba --- /dev/null +++ b/pkg/definition/defkit/conditional_param.go @@ -0,0 +1,87 @@ +/* +Copyright 2025 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defkit + +// ConditionalParamBlock represents a set of conditional parameter branches. +// Each branch has a guard condition and emits different parameters when active. +// +// Example: +// +// ConditionalParams( +// WhenParam(existingResources.Eq(false)).Params( +// defkit.Bool("forceDestroy").Default(false), +// ), +// WhenParam(existingResources.Eq(true)).Params( +// defkit.Bool("forceDestroy").Optional(), +// ), +// ) +// +// Emits: +// +// if existingResources == false { +// forceDestroy: *false | bool +// } +// if existingResources == true { +// forceDestroy?: bool +// } +type ConditionalParamBlock struct { + branches []*ConditionalBranch +} + +// ConditionalParams creates a new conditional parameter block from branches. +func ConditionalParams(branches ...*ConditionalBranch) *ConditionalParamBlock { + return &ConditionalParamBlock{branches: branches} +} + +// Branches returns all conditional branches. +func (b *ConditionalParamBlock) Branches() []*ConditionalBranch { + return b.branches +} + +// ConditionalBranch represents a single branch in a conditional parameter block. +// It contains a guard condition, parameters to emit when active, and optional validators. +type ConditionalBranch struct { + condition Condition + params []Param + validators []*Validator +} + +// WhenParam creates a new conditional branch with the given guard condition. +func WhenParam(cond Condition) *ConditionalBranch { + return &ConditionalBranch{condition: cond} +} + +// Params sets the parameters emitted when this branch's condition is true. +func (b *ConditionalBranch) Params(params ...Param) *ConditionalBranch { + b.params = append(b.params, params...) + return b +} + +// Validators adds validators emitted inside this branch's conditional block. +func (b *ConditionalBranch) Validators(validators ...*Validator) *ConditionalBranch { + b.validators = append(b.validators, validators...) + return b +} + +// Condition returns the branch's guard condition. +func (b *ConditionalBranch) Condition() Condition { return b.condition } + +// GetParams returns the branch's parameters. +func (b *ConditionalBranch) GetParams() []Param { return b.params } + +// GetValidators returns the branch's validators. +func (b *ConditionalBranch) GetValidators() []*Validator { return b.validators } diff --git a/pkg/definition/defkit/conditional_param_test.go b/pkg/definition/defkit/conditional_param_test.go new file mode 100644 index 000000000..a6363c636 --- /dev/null +++ b/pkg/definition/defkit/conditional_param_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2025 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defkit_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/oam-dev/kubevela/pkg/definition/defkit" +) + +var _ = Describe("ConditionalParam", func() { + + Context("ConditionalParamBlock", func() { + It("should create a block with branches", func() { + b1 := defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("a")) + b2 := defkit.WhenParam(defkit.Bool("x").Eq(false)).Params(defkit.String("b")) + + block := defkit.ConditionalParams(b1, b2) + Expect(block.Branches()).To(HaveLen(2)) + Expect(block.Branches()[0]).To(Equal(b1)) + Expect(block.Branches()[1]).To(Equal(b2)) + }) + + It("should create a block with a single branch", func() { + b1 := defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("a")) + block := defkit.ConditionalParams(b1) + Expect(block.Branches()).To(HaveLen(1)) + }) + + It("should create an empty block with no branches", func() { + block := defkit.ConditionalParams() + Expect(block.Branches()).To(BeEmpty()) + }) + }) + + Context("WhenParam / ConditionalBranch", func() { + It("should create a branch with condition", func() { + cond := defkit.Bool("flag").Eq(true) + branch := defkit.WhenParam(cond) + Expect(branch.Condition()).To(Equal(cond)) + Expect(branch.GetParams()).To(BeEmpty()) + Expect(branch.GetValidators()).To(BeEmpty()) + }) + + It("should chain Params and return them via GetParams", func() { + p1 := defkit.String("name") + p2 := defkit.Int("count") + branch := defkit.WhenParam(defkit.Bool("x").Eq(true)). + Params(p1, p2) + + Expect(branch.GetParams()).To(HaveLen(2)) + Expect(branch.GetParams()[0]).To(Equal(p1)) + Expect(branch.GetParams()[1]).To(Equal(p2)) + }) + + It("should accumulate params across multiple Params calls", func() { + branch := defkit.WhenParam(defkit.Bool("x").Eq(true)). + Params(defkit.String("a")). + Params(defkit.String("b")) + + Expect(branch.GetParams()).To(HaveLen(2)) + }) + + It("should chain Validators and return them via GetValidators", func() { + v1 := defkit.Validate("check1").WithName("_v1") + v2 := defkit.Validate("check2").WithName("_v2") + + branch := defkit.WhenParam(defkit.Bool("x").Eq(true)). + Params(defkit.String("name")). + Validators(v1, v2) + + Expect(branch.GetValidators()).To(HaveLen(2)) + Expect(branch.GetValidators()[0]).To(Equal(v1)) + Expect(branch.GetValidators()[1]).To(Equal(v2)) + }) + + It("should accumulate validators across multiple Validators calls", func() { + branch := defkit.WhenParam(defkit.Bool("x").Eq(true)). + Validators(defkit.Validate("a").WithName("_a")). + Validators(defkit.Validate("b").WithName("_b")) + + Expect(branch.GetValidators()).To(HaveLen(2)) + }) + + It("should preserve condition through full chain", func() { + cond := defkit.Bool("enabled").Eq(false) + branch := defkit.WhenParam(cond). + Params(defkit.String("name"), defkit.Int("count")). + Validators(defkit.Validate("check").WithName("_v")) + + Expect(branch.Condition()).To(Equal(cond)) + Expect(branch.GetParams()).To(HaveLen(2)) + Expect(branch.GetValidators()).To(HaveLen(1)) + }) + }) +}) diff --git a/pkg/definition/defkit/cuegen.go b/pkg/definition/defkit/cuegen.go index 91e339d2e..c6bbf9704 100644 --- a/pkg/definition/defkit/cuegen.go +++ b/pkg/definition/defkit/cuegen.go @@ -285,6 +285,16 @@ func (g *CUEGenerator) GenerateParameterSchema(c *ComponentDefinition) string { g.writeParam(&sb, param, 1) } + // Write top-level validators + for _, v := range c.GetValidators() { + g.writeValidator(&sb, v, 1) + } + + // Write conditional parameter blocks + for _, block := range c.GetConditionalParamBlocks() { + g.writeConditionalParamBlock(&sb, block, 1) + } + sb.WriteString("}\n") return sb.String() } @@ -394,6 +404,15 @@ func (g *CUEGenerator) GenerateTemplate(c *ComponentDefinition) string { g.writeHelper(&sb, helper, 1) } + // Emit raw header block (let bindings, helpers like _claimName) + if rawHeader := tpl.GetRawHeaderBlock(); rawHeader != "" { + for _, line := range strings.Split(strings.TrimSpace(rawHeader), "\n") { + sb.WriteString(g.indent) + sb.WriteString(line) + sb.WriteString("\n") + } + } + // Generate output block if output := tpl.GetOutput(); output != nil { g.writeResourceOutput(&sb, "output", output, nil, 1) @@ -443,10 +462,150 @@ func (g *CUEGenerator) generateParameterBlock(c *ComponentDefinition, depth int) g.writeParam(&sb, param, depth+1) } + // Write top-level validators + for _, v := range c.GetValidators() { + g.writeValidator(&sb, v, depth+1) + } + + // Write conditional parameter blocks + for _, block := range c.GetConditionalParamBlocks() { + g.writeConditionalParamBlock(&sb, block, depth+1) + } + sb.WriteString(fmt.Sprintf("%s}\n", indent)) return sb.String() } +// writeValidator writes a CUE _validate* block. +// Example output: +// +// _validateTenantName: { +// "tenantName must not end with a hyphen": true +// if tenantName =~ ".*-$" { +// "tenantName must not end with a hyphen": false +// } +// } +func (g *CUEGenerator) writeValidator(sb *strings.Builder, v *Validator, depth int) { + indent := strings.Repeat(g.indent, depth) + inner := strings.Repeat(g.indent, depth+1) + inner2 := strings.Repeat(g.indent, depth+2) + + // Determine the CUE variable name + name := v.CUEName() + if name == "" { + // Auto-generate from message — use a simple underscore-prefixed name + name = "_validate" + } + + if v.GuardCondition() != nil { + // Guarded validator: wrap in if guard { ... } + guardCUE := g.conditionToCUE(v.GuardCondition()) + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, guardCUE)) + sb.WriteString(fmt.Sprintf("%s%s: {\n", inner, name)) + sb.WriteString(fmt.Sprintf("%s%q: true\n", inner2, v.Message())) + if v.FailCondition() != nil { + failCUE := g.conditionToCUE(v.FailCondition()) + sb.WriteString(fmt.Sprintf("%sif %s {\n", inner2, failCUE)) + sb.WriteString(fmt.Sprintf("%s\t%q: false\n", inner2, v.Message())) + sb.WriteString(fmt.Sprintf("%s}\n", inner2)) + } + sb.WriteString(fmt.Sprintf("%s}\n", inner)) + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } else { + // Unguarded validator + sb.WriteString(fmt.Sprintf("%s%s: {\n", indent, name)) + sb.WriteString(fmt.Sprintf("%s%q: true\n", inner, v.Message())) + if v.FailCondition() != nil { + failCUE := g.conditionToCUE(v.FailCondition()) + sb.WriteString(fmt.Sprintf("%sif %s {\n", inner, failCUE)) + sb.WriteString(fmt.Sprintf("%s\t%q: false\n", inner, v.Message())) + sb.WriteString(fmt.Sprintf("%s}\n", inner)) + } + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } +} + +// writeConditionalParamBlock writes conditional parameter branches. +// Example output: +// +// if existingResources == false { +// forceDestroy: *false | bool +// } +// if existingResources == true { +// forceDestroy?: bool +// } +func (g *CUEGenerator) writeConditionalParamBlock(sb *strings.Builder, block *ConditionalParamBlock, depth int) { + indent := strings.Repeat(g.indent, depth) + + for _, branch := range block.Branches() { + condCUE := g.conditionToCUE(branch.Condition()) + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condCUE)) + + // Write params inside the conditional block + for _, param := range branch.GetParams() { + g.writeParam(sb, param, depth+1) + } + + // Write validators inside the conditional block + for _, v := range branch.GetValidators() { + g.writeValidator(sb, v, depth+1) + } + + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } +} + +// writeConditionalStructOp writes a conditional struct block in the output. +// Example output: +// +// if parameter["replicationConfiguration"] != _|_ { +// replicationConfiguration: { +// role: parameter.replicationConfiguration.role +// } +// } +func (g *CUEGenerator) writeConditionalStructOp(sb *strings.Builder, cs *ConditionalStructOp, depth int) { + indent := strings.Repeat(g.indent, depth) + + // Build the struct + builder := &OutputStructBuilder{} + cs.Builder()(builder) + + condCUE := g.conditionToCUE(cs.Cond()) + sb.WriteString(fmt.Sprintf("%sif %s {\n", indent, condCUE)) + + // Split the path into segments and open nested structs + parts := splitPath(cs.Path()) + currentIndent := indent + g.indent + for _, part := range parts { + sb.WriteString(fmt.Sprintf("%s%s: {\n", currentIndent, cueLabel(part))) + currentIndent += g.indent + } + + // Write builder operations + for _, op := range builder.Ops() { + switch o := op.(type) { + case *structSetOp: + valCUE := g.valueToCUE(o.value) + sb.WriteString(fmt.Sprintf("%s%s: %s\n", currentIndent, cueLabel(o.field), valCUE)) + case *structSetIfOp: + condStr := g.conditionToCUE(o.cond) + sb.WriteString(fmt.Sprintf("%sif %s {\n", currentIndent, condStr)) + valCUE := g.valueToCUE(o.value) + sb.WriteString(fmt.Sprintf("%s\t%s: %s\n", currentIndent, cueLabel(o.field), valCUE)) + sb.WriteString(fmt.Sprintf("%s}\n", currentIndent)) + } + } + + // Close nested structs + for i := len(parts) - 1; i >= 0; i-- { + currentIndent = currentIndent[:len(currentIndent)-len(g.indent)] + sb.WriteString(fmt.Sprintf("%s}\n", currentIndent)) + } + + // Close the if block + sb.WriteString(fmt.Sprintf("%s}\n", indent)) +} + // WriteHelperDefinition writes a CUE helper type definition like #HealthProbe. // This method is exported so it can be used by policy and workflow step generators. func (g *CUEGenerator) WriteHelperDefinition(sb *strings.Builder, def HelperDefinition, depth int) { @@ -1084,12 +1243,28 @@ func (g *CUEGenerator) writeResourceOutput(sb *strings.Builder, name string, res // Write kind sb.WriteString(fmt.Sprintf("%skind: %q\n", innerIndent, res.Kind())) - // Build a tree structure from the operations - tree := g.buildFieldTree(res.Ops()) + // Separate ConditionalStructOps from regular ops + var regularOps []ResourceOp + var conditionalStructs []*ConditionalStructOp + for _, op := range res.Ops() { + if cs, ok := op.(*ConditionalStructOp); ok { + conditionalStructs = append(conditionalStructs, cs) + } else { + regularOps = append(regularOps, op) + } + } + + // Build a tree structure from the regular operations + tree := g.buildFieldTree(regularOps) // Write the tree as CUE g.writeFieldTree(sb, tree, depth+1) + // Write conditional struct blocks + for _, cs := range conditionalStructs { + g.writeConditionalStructOp(sb, cs, depth+1) + } + sb.WriteString(fmt.Sprintf("%s}\n", indent)) // Close conditional block @@ -1887,6 +2062,12 @@ func (g *CUEGenerator) valueToCUE(v Value) string { return val.Path() case *Ref: return val.Path() + case *LocalFieldRef: + // Local field reference — emits the bare field name (no parameter. prefix) + return val.Name() + case *TimeParseExpr: + // time.Parse(layout, field) call + return fmt.Sprintf(`time.Parse(%q, %s)`, val.Layout(), val.FieldName()) case *HelperVar: // Return reference to the helper by name return val.Name() @@ -1929,17 +2110,32 @@ func (g *CUEGenerator) valueToCUE(v Value) string { case *ListComprehension: // Return list comprehension CUE return g.listComprehensionToCUE(val) + case *ParamArithExpr, *ParamConcatExpr, *ParamFieldRef, *InterpolatedString, *PlusExpr: + return g.exprValueToCUE(v) + case *IterVarRef, *IterFieldRef, *IterLetRef: + return g.iterRefToCUE(v) + case *ForEachMapOp: + return g.forEachMapOpToCUE(val) + default: + // Try to get name from Param interface + if p, ok := v.(Param); ok { + return "parameter." + p.Name() + } + return "_" + } +} + +// exprValueToCUE handles expression-type values (arithmetic, concatenation, interpolation, plus). +func (g *CUEGenerator) exprValueToCUE(v Value) string { + switch val := v.(type) { case *ParamArithExpr: - // Arithmetic expression on a parameter: parameter.name op value return fmt.Sprintf("parameter.%s %s %s", val.ParamName(), val.Op(), formatCUEValue(val.ArithValue())) case *ParamConcatExpr: - // String concatenation on a parameter if val.Prefix() != "" { return fmt.Sprintf("%s + parameter.%s", formatCUEValue(val.Prefix()), val.ParamName()) } return fmt.Sprintf("parameter.%s + %s", val.ParamName(), formatCUEValue(val.Suffix())) case *ParamFieldRef: - // Reference to a field within a struct parameter: parameter.name.field.path return fmt.Sprintf("parameter.%s.%s", val.ParamName(), val.FieldPath()) case *InterpolatedString: return g.interpolatedStringToCUE(val) @@ -1949,19 +2145,21 @@ func (g *CUEGenerator) valueToCUE(v Value) string { parts[i] = g.valueToCUE(p) } return strings.Join(parts, " + ") + default: + return "_" + } +} + +// iterRefToCUE handles iterator reference values. +func (g *CUEGenerator) iterRefToCUE(v Value) string { + switch val := v.(type) { case *IterVarRef: return val.VarName() case *IterFieldRef: return fmt.Sprintf("%s.%s", val.VarName(), val.FieldName()) case *IterLetRef: return val.RefName() - case *ForEachMapOp: - return g.forEachMapOpToCUE(val) default: - // Try to get name from Param interface - if p, ok := v.(Param); ok { - return "parameter." + p.Name() - } return "_" } } @@ -2675,8 +2873,6 @@ func (g *CUEGenerator) conditionToCUE(cond Condition) string { return g.inConditionToCUE(c) case *StringContainsCondition: return fmt.Sprintf(`strings.Contains(parameter.%s, %q)`, c.ParamName(), c.Substr()) - case *StringMatchesCondition: - return fmt.Sprintf(`parameter.%s =~ %q`, c.ParamName(), c.Pattern()) case *StringStartsWithCondition: return fmt.Sprintf(`strings.HasPrefix(parameter.%s, %q)`, c.ParamName(), c.Prefix()) case *StringEndsWithCondition: @@ -2694,25 +2890,10 @@ func (g *CUEGenerator) conditionToCUE(cond Condition) string { left := g.exprToCUE(c.Left()) right := g.exprToCUE(c.Right()) return fmt.Sprintf("%s %s %s", left, c.Op(), right) - case *CompareCondition: - left := g.anyToCUE(c.Left()) - right := g.anyToCUE(c.Right()) - return fmt.Sprintf("%s %s %s", left, c.Operator(), right) case *AndCondition: left := g.conditionToCUE(c.left) right := g.conditionToCUE(c.right) return fmt.Sprintf("(%s) && (%s)", left, right) - case *OrCondition: - left := g.conditionToCUE(c.left) - right := g.conditionToCUE(c.right) - return fmt.Sprintf("(%s) || (%s)", left, right) - case *NotCondition: - // Special case: Not(IsSet("x")) -> parameter["x"] == _|_ (cleaner than !(parameter["x"] != _|_)) - if isSet, ok := c.Inner().(*IsSetCondition); ok { - return fmt.Sprintf("parameter[%q] == _|_", isSet.ParamName()) - } - inner := g.conditionToCUE(c.Inner()) - return fmt.Sprintf("!(%s)", inner) case *LogicalExpr: parts := make([]string, len(c.Conditions())) for i, sub := range c.Conditions() { @@ -2724,6 +2905,14 @@ func (g *CUEGenerator) conditionToCUE(cond Condition) string { } return strings.Join(parts, op) case *NotExpr: + // Special case: Not(IsSet("x")) -> parameter["x"] == _|_ + if isSet, ok := c.Cond().(*IsSetCondition); ok { + return fmt.Sprintf("parameter[%q] == _|_", isSet.ParamName()) + } + // Special case: Not(PathExists("x")) -> x == _|_ + if pe, ok := c.Cond().(*PathExistsCondition); ok { + return fmt.Sprintf("%s == _|_", pe.Path()) + } return fmt.Sprintf("!(%s)", g.conditionToCUE(c.Cond())) case *HasExposedPortsCondition: // Check if any port has expose=true @@ -2761,6 +2950,12 @@ func (g *CUEGenerator) conditionToCUE(cond Condition) string { // For CUE, we generate: cond1 && cond2 && cond3 // which will be used in a single if statement return strings.Join(parts, " && ") + case *RegexMatchCondition: + // General-purpose regex match: =~ "pattern" + return fmt.Sprintf(`%s =~ %q`, g.valueToCUE(c.Source()), c.Pattern()) + case *RawCUECondition: + // Raw CUE expression — emit verbatim + return c.Expr() default: return cueBoolTrue } @@ -2784,14 +2979,6 @@ func (g *CUEGenerator) exprToCUE(e Expr) string { return "_" } -// anyToCUE converts any value to CUE syntax. -func (g *CUEGenerator) anyToCUE(v any) string { - if val, ok := v.(Value); ok { - return g.valueToCUE(val) - } - return formatCUEValue(v) -} - // writeWorkload writes the workload definition. func (g *CUEGenerator) writeWorkload(sb *strings.Builder, c *ComponentDefinition, depth int) { indent := strings.Repeat(g.indent, depth) @@ -2809,9 +2996,11 @@ func (g *CUEGenerator) writeWorkload(sb *strings.Builder, c *ComponentDefinition sb.WriteString(fmt.Sprintf("%s%s%skind: %q\n", indent, g.indent, g.indent, workload.Kind())) sb.WriteString(fmt.Sprintf("%s%s}\n", indent, g.indent)) - // Write workload type - workloadType := g.inferWorkloadType(workload) - sb.WriteString(fmt.Sprintf("%s%stype: %q\n", indent, g.indent, workloadType)) + // Write workload type (unless suppressed) + if !c.IsOmitWorkloadType() { + workloadType := g.inferWorkloadType(workload) + sb.WriteString(fmt.Sprintf("%s%stype: %q\n", indent, g.indent, workloadType)) + } sb.WriteString(fmt.Sprintf("%s}\n", indent)) } @@ -2968,6 +3157,11 @@ func (g *CUEGenerator) writeStringParam(sb *strings.Builder, p *StringParam, ind // Build constraint parts var constraints []string + // NotEmpty constraint: !="" + if p.GetNotEmpty() { + constraints = append(constraints, `!=""`) + } + // Pattern constraint: =~"pattern" if pattern := p.GetPattern(); pattern != "" { constraints = append(constraints, fmt.Sprintf(`=~%q`, pattern)) @@ -3093,32 +3287,64 @@ func (g *CUEGenerator) writeArrayParam(sb *strings.Builder, p *ArrayParam, inden // Reference to a helper definition like #HealthProbe // For arrays, output [...#SchemaRef] to indicate an array of the helper type sb.WriteString(fmt.Sprintf("%s%s%s: %s[...#%s]\n", indent, name, optional, constraintPrefix, schemaRef)) - return - } - - if schema := p.GetSchema(); schema != "" { - // Raw CUE schema - output directly - if constraintPrefix != "" { - sb.WriteString(fmt.Sprintf("%s%s%s: %s%s\n", indent, name, optional, constraintPrefix, schema)) + } else if schema := p.GetSchema(); schema != "" { + // Raw CUE schema - output directly, with optional default + if p.HasDefault() { + defaultJSON := g.formatArrayDefault(p.GetDefault()) + if constraintPrefix != "" { + sb.WriteString(fmt.Sprintf("%s%s%s: %s%s | *%s\n", indent, name, optional, constraintPrefix, schema, defaultJSON)) + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: %s | *%s\n", indent, name, optional, schema, defaultJSON)) + } } else { - sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, name, optional, schema)) + if constraintPrefix != "" { + sb.WriteString(fmt.Sprintf("%s%s%s: %s%s\n", indent, name, optional, constraintPrefix, schema)) + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: %s\n", indent, name, optional, schema)) + } + } + } else { + elemType := g.cueTypeForParamType(p.ElementType()) + + // Check if array has structured fields + if fields := p.GetFields(); len(fields) > 0 { + sb.WriteString(fmt.Sprintf("%s%s%s: %s[...{\n", indent, name, optional, constraintPrefix)) + for _, field := range fields { + g.writeParam(sb, field, depth+1) + } + // Write validators inside each array element struct + for _, v := range p.GetValidators() { + g.writeValidator(sb, v, depth+1) + } + sb.WriteString(fmt.Sprintf("%s}]\n", indent)) + } else if elemType != "" { + if p.HasNotEmpty() && elemType == "string" { + sb.WriteString(fmt.Sprintf("%s%s%s: %s[...(%s & !=\"\")]\n", indent, name, optional, constraintPrefix, elemType)) + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: %s[...%s]\n", indent, name, optional, constraintPrefix, elemType)) + } + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: %s[...]\n", indent, name, optional, constraintPrefix)) } - return } - elemType := g.cueTypeForParamType(p.ElementType()) +} - // Check if array has structured fields - if fields := p.GetFields(); len(fields) > 0 { - sb.WriteString(fmt.Sprintf("%s%s%s: %s[...{\n", indent, name, optional, constraintPrefix)) - for _, field := range fields { - g.writeParam(sb, field, depth+1) +// formatArrayDefault formats an array default value as a CUE literal. +// Converts []any{"*"} to `["*"]`, []any{"Observe"} to `["Observe"]`, etc. +func (g *CUEGenerator) formatArrayDefault(val any) string { + if val == nil { + return "[]" + } + switch v := val.(type) { + case []any: + parts := make([]string, 0, len(v)) + for _, elem := range v { + parts = append(parts, fmt.Sprintf("%q", elem)) } - sb.WriteString(fmt.Sprintf("%s}]\n", indent)) - } else if elemType != "" { - sb.WriteString(fmt.Sprintf("%s%s%s: %s[...%s]\n", indent, name, optional, constraintPrefix, elemType)) - } else { - sb.WriteString(fmt.Sprintf("%s%s%s: %s[...]\n", indent, name, optional, constraintPrefix)) + return "[" + strings.Join(parts, ", ") + "]" + default: + return fmt.Sprintf("%v", val) } } @@ -3137,13 +3363,41 @@ func (g *CUEGenerator) writeMapParam(sb *strings.Builder, p *MapParam, indent, n return } - // Check if map has structured fields - if fields := p.GetFields(); len(fields) > 0 { - sb.WriteString(fmt.Sprintf("%s%s%s: {\n", indent, name, optional)) - for _, field := range fields { + // Check if map has structured fields, validators, or conditional fields + hasFields := len(p.GetFields()) > 0 + hasValidators := len(p.GetValidators()) > 0 + hasConditionalFields := len(p.GetConditionalFields()) > 0 + + if hasFields || hasValidators || hasConditionalFields { + if p.IsClosed() { + sb.WriteString(fmt.Sprintf("%s%s%s: close({\n", indent, name, optional)) + } else { + sb.WriteString(fmt.Sprintf("%s%s%s: {\n", indent, name, optional)) + } + for _, field := range p.GetFields() { g.writeParam(sb, field, depth+1) } - sb.WriteString(fmt.Sprintf("%s}\n", indent)) + // Write validators inside the struct + for _, v := range p.GetValidators() { + g.writeValidator(sb, v, depth+1) + } + // Write conditional fields inside the struct + for _, branch := range p.GetConditionalFields() { + condCUE := g.conditionToCUE(branch.Condition()) + sb.WriteString(fmt.Sprintf("%s\tif %s {\n", indent, condCUE)) + for _, param := range branch.GetParams() { + g.writeParam(sb, param, depth+2) + } + for _, v := range branch.GetValidators() { + g.writeValidator(sb, v, depth+2) + } + sb.WriteString(fmt.Sprintf("%s\t}\n", indent)) + } + if p.IsClosed() { + sb.WriteString(fmt.Sprintf("%s})\n", indent)) + } else { + sb.WriteString(fmt.Sprintf("%s}\n", indent)) + } } else if valType := p.ValueType(); valType != "" { // Typed map: [string]: type cueType := g.cueTypeForParamType(valType) diff --git a/pkg/definition/defkit/cuegen_test.go b/pkg/definition/defkit/cuegen_test.go index 60990b125..65289c45c 100644 --- a/pkg/definition/defkit/cuegen_test.go +++ b/pkg/definition/defkit/cuegen_test.go @@ -1463,6 +1463,392 @@ var _ = Describe("CUEGenerator", func() { }) }) + Describe("Fluent Builder API Validator Patterns CUE Generation", func() { + var gen *defkit.CUEGenerator + + BeforeEach(func() { + gen = defkit.NewCUEGenerator() + }) + + Context("OmitWorkloadType CUE Generation", func() { + It("should suppress workload type field when omitted", func() { + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + OmitWorkloadType(). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("apps/v1", "Deployment"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := comp.ToCue() + Expect(cue).To(ContainSubstring(`kind: "Deployment"`)) + Expect(cue).To(ContainSubstring("workload:")) + Expect(cue).NotTo(MatchRegexp(`type:\s+"deployments\.apps"`)) + }) + + It("should include workload type when not omitted", func() { + comp := defkit.NewComponent("test"). + Workload("apps/v1", "Deployment"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("apps/v1", "Deployment"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := comp.ToCue() + Expect(cue).To(ContainSubstring(`type: "deployments.apps"`)) + }) + }) + + Context("RawHeaderBlock in Component Template", func() { + It("should emit raw header block before output", func() { + comp := defkit.NewComponent("test"). + Workload("v1", "PersistentVolumeClaim"). + Template(func(tpl *defkit.Template) { + tpl.SetRawHeaderBlock(`let _claimName = parameter.claimName + "-pvc"`) + tpl.Output(defkit.NewResource("v1", "PersistentVolumeClaim"). + Set("metadata.name", defkit.Reference("_claimName"))) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).To(ContainSubstring(`let _claimName = parameter.claimName + "-pvc"`)) + Expect(cue).To(ContainSubstring("_claimName")) + }) + + It("should emit multiline raw header block", func() { + comp := defkit.NewComponent("test"). + Workload("v1", "ConfigMap"). + Template(func(tpl *defkit.Template) { + tpl.SetRawHeaderBlock("let _a = parameter.a\nlet _b = parameter.b") + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).To(ContainSubstring("let _a = parameter.a")) + Expect(cue).To(ContainSubstring("let _b = parameter.b")) + }) + + It("should not emit header block when empty", func() { + comp := defkit.NewComponent("test"). + Workload("v1", "ConfigMap"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("metadata.name", defkit.Lit("test"))) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).NotTo(ContainSubstring("let _")) + }) + }) + + Context("ArrayParam NotEmpty Elements CUE Generation", func() { + It("should generate [...(string & !=\"\")] for NotEmpty string arrays", func() { + p := defkit.StringList("tags").NotEmpty() + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`[...(string & !="")]`)) + }) + + It("should generate normal [...string] without NotEmpty", func() { + p := defkit.StringList("tags") + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`[...string]`)) + Expect(cue).NotTo(ContainSubstring(`!=""`)) + }) + }) + + Context("ArrayParam with Schema and Default CUE Generation", func() { + It("should generate array with OfEnum and default value", func() { + p := defkit.Array("methods"). + OfEnum("GET", "POST", "DELETE"). + Default([]any{"GET"}) + + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`["GET"]`)) + Expect(cue).To(ContainSubstring(`"GET" | "POST" | "DELETE"`)) + }) + + It("should generate array with OfEnum and nil default as empty array", func() { + p := defkit.Array("methods"). + OfEnum("GET", "POST"). + Default(nil) + + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("*[]")) + }) + + It("should generate array with OfEnum and multi-value default", func() { + p := defkit.Array("methods"). + OfEnum("GET", "POST", "DELETE"). + Default([]any{"GET", "POST"}) + + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`["GET", "POST"]`)) + }) + }) + + Context("MapParam Closed CUE Generation", func() { + It("should emit close({...}) for closed struct", func() { + p := defkit.Object("governance").Closed().WithFields( + defkit.String("tenantName"), + defkit.String("departmentCode"), + ) + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("close({")) + Expect(cue).To(ContainSubstring("})")) + Expect(cue).To(ContainSubstring("tenantName: string")) + }) + + It("should not emit close({}) for non-closed struct", func() { + p := defkit.Object("config").WithFields( + defkit.String("name"), + ) + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).NotTo(ContainSubstring("close({")) + Expect(cue).To(ContainSubstring("config: {")) + }) + }) + + Context("MapParam with Validators CUE Generation", func() { + It("should emit validators inside struct", func() { + v := defkit.Validate("name required"). + WithName("_validateName"). + FailWhen(defkit.LocalField("name").Eq("")) + + p := defkit.Object("governance").WithFields( + defkit.String("name"), + ).Validators(v) + + comp := defkit.NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("governance: {")) + Expect(cue).To(ContainSubstring("_validateName:")) + Expect(cue).To(ContainSubstring(`"name required": true`)) + }) + }) + + Context("MapParam with ConditionalFields CUE Generation", func() { + It("should emit conditional fields inside struct", func() { + flag := defkit.Bool("flag").Default(false) + p := defkit.Object("config").Optional().ConditionalFields( + defkit.WhenParam(flag.Eq(true)).Params( + defkit.String("secret").Required(), + ), + defkit.WhenParam(flag.Eq(false)).Params( + defkit.String("secret").Optional(), + ), + ) + + comp := defkit.NewComponent("test").Params(flag, p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("config?: {")) + Expect(cue).To(ContainSubstring("if parameter.flag == true")) + Expect(cue).To(ContainSubstring("if parameter.flag == false")) + }) + + It("should emit validators inside conditional field branches", func() { + flag := defkit.Bool("flag").Default(false) + v := defkit.Validate("check").WithName("_v"). + FailWhen(defkit.LocalField("secret").Eq("")) + + p := defkit.Object("config").Optional().ConditionalFields( + defkit.WhenParam(flag.Eq(true)). + Params(defkit.String("secret").Required()). + Validators(v), + ) + + comp := defkit.NewComponent("test").Params(flag, p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("_v:")) + }) + }) + + Context("Closed MapParam with Validators and ConditionalFields Combined", func() { + It("should combine all features", func() { + flag := defkit.Bool("flag").Default(false) + v := defkit.Validate("name required"). + WithName("_validateName"). + FailWhen(defkit.LocalField("name").Eq("")) + + p := defkit.Object("governance").Closed(). + WithFields( + defkit.String("name").NotEmpty(), + ). + Validators(v). + ConditionalFields( + defkit.WhenParam(flag.Eq(true)).Params( + defkit.String("extra").Required(), + ), + ) + + comp := defkit.NewComponent("test").Params(flag, p) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("close({")) + Expect(cue).To(ContainSubstring(`!=""`)) + Expect(cue).To(ContainSubstring("_validateName:")) + Expect(cue).To(ContainSubstring("if parameter.flag == true")) + Expect(cue).To(ContainSubstring("})")) + }) + }) + + Context("RegexMatch CUE Generation", func() { + It("should generate regex match for StringParam.Matches", func() { + p := defkit.String("name") + comp := defkit.NewComponent("test"). + Params(p). + Workload("v1", "ConfigMap"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + SetIf(p.Matches("^prod-"), "data.env", defkit.Lit("production"))) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).To(ContainSubstring(`parameter.name =~ "^prod-"`)) + }) + + It("should generate regex match for LocalFieldRef.Matches in validator", func() { + v := defkit.Validate("bad"). + WithName("_v"). + FailWhen(defkit.LocalField("host").Matches(`\.internal$`)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`host =~ "\\.internal$"`)) + }) + }) + + Context("LocalFieldRef NotSet CUE Generation", func() { + It("should generate == _|_ for LocalFieldRef.NotSet", func() { + v := defkit.Validate("role required"). + WithName("_v"). + FailWhen(defkit.LocalField("role").NotSet()) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("role == _|_")) + }) + }) + + Context("LocalFieldRef LenGt CUE Generation", func() { + It("should generate len(field) > n", func() { + v := defkit.Validate("too many"). + WithName("_v"). + FailWhen(defkit.LocalField("items").LenGt(10)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(items) > 10")) + }) + }) + + Context("LenOfExpr CUE Generation", func() { + It("should generate len(expr) > n for Gt", func() { + v := defkit.Validate("name too long"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.LocalField("name")).Gt(63)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(name) > 63")) + }) + + It("should generate len(expr) >= n for Gte", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.LocalField("data")).Gte(100)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(data) >= 100")) + }) + + It("should generate len(expr) == n for Eq", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.LocalField("code")).Eq(3)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(code) == 3")) + }) + }) + + Context("TimeParse CUE Generation", func() { + It("should generate time.Parse expressions", func() { + v := defkit.Validate("start before end"). + WithName("_v"). + FailWhen(defkit.TimeParse("2006-01-02T15:04:05Z", defkit.LocalField("start")). + Gte(defkit.TimeParse("2006-01-02T15:04:05Z", defkit.LocalField("end")))) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02T15:04:05Z", start)`)) + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02T15:04:05Z", end)`)) + }) + }) + + Context("RawCUECondition CUE Generation", func() { + It("should emit raw expression verbatim", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.CUEExpr(`len("prefix-"+parameter.name) > 63`)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`len("prefix-"+parameter.name) > 63`)) + }) + }) + + Context("ConditionalStructOp CUE Generation", func() { + It("should generate conditional struct in template output", func() { + replConfig := defkit.Object("replicationConfiguration").Optional() + + comp := defkit.NewComponent("test"). + Params(replConfig). + Workload("apps/v1", "Deployment"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("metadata.name", defkit.Lit("test")). + ConditionalStruct(replConfig.IsSet(), "spec.replication", func(b *defkit.OutputStructBuilder) { + b.Set("role", defkit.Reference("parameter.replicationConfiguration.role")) + b.SetIf(replConfig.IsSet(), "enabled", defkit.Lit(true)) + })) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).To(ContainSubstring(`if parameter["replicationConfiguration"] != _|_`)) + Expect(cue).To(ContainSubstring("replication:")) + Expect(cue).To(ContainSubstring("role: parameter.replicationConfiguration.role")) + }) + + It("should generate nested path correctly", func() { + config := defkit.Object("config").Optional() + + comp := defkit.NewComponent("test"). + Params(config). + Workload("v1", "ConfigMap"). + Template(func(tpl *defkit.Template) { + tpl.Output(defkit.NewResource("v1", "ConfigMap"). + Set("metadata.name", defkit.Lit("test")). + ConditionalStruct(config.IsSet(), "spec.deep.nested.path", func(b *defkit.OutputStructBuilder) { + b.Set("key", defkit.Reference("parameter.config.key")) + })) + }) + + cue := gen.GenerateTemplate(comp) + Expect(cue).To(ContainSubstring("deep:")) + Expect(cue).To(ContainSubstring("nested:")) + Expect(cue).To(ContainSubstring("path:")) + }) + }) + }) + Describe("Builtin WithDirectFields", func() { It("should render fields directly without $params wrapper", func() { ws := defkit.NewWorkflowStep("test"). diff --git a/pkg/definition/defkit/expr.go b/pkg/definition/defkit/expr.go index f4019b55c..21201cbd1 100644 --- a/pkg/definition/defkit/expr.go +++ b/pkg/definition/defkit/expr.go @@ -169,23 +169,6 @@ type TruthyCondition struct { // ParamName returns the parameter name being checked. func (t *TruthyCondition) ParamName() string { return t.paramName } -// CompareCondition represents a comparison between two values. -type CompareCondition struct { - baseCondition - left any - right any - op string -} - -// Left returns the left operand. -func (c *CompareCondition) Left() any { return c.left } - -// Right returns the right operand. -func (c *CompareCondition) Right() any { return c.right } - -// Operator returns the comparison operator. -func (c *CompareCondition) Operator() string { return c.op } - // AndCondition represents a binary logical AND of two conditions. // This is an internal IR type used by cuegen to combine conditions during code generation. // For the user-facing API, use And() which accepts variadic conditions via LogicalExpr. @@ -195,25 +178,6 @@ type AndCondition struct { right Condition } -// OrCondition represents a binary logical OR of two conditions. -// This is an internal IR type used by cuegen to combine conditions during code generation. -// For the user-facing API, use Or() which accepts variadic conditions via LogicalExpr. -type OrCondition struct { - baseCondition - left Condition - right Condition -} - -// NotCondition represents a logical NOT of a condition. -// Used both internally (e.g., by ParamNotSet) and as part of the user-facing Not() API. -type NotCondition struct { - baseCondition - inner Condition -} - -// Inner returns the negated condition. -func (n *NotCondition) Inner() Condition { return n.inner } - // --- Parameter Runtime Condition Types --- // FalsyCondition represents a falsy check on a boolean parameter. @@ -254,19 +218,25 @@ func (c *StringContainsCondition) ParamName() string { return c.paramName } // Substr returns the substring to check for. func (c *StringContainsCondition) Substr() string { return c.substr } -// StringMatchesCondition checks if a string parameter matches a regex pattern. -// Generates: parameter.name =~ "pattern" -type StringMatchesCondition struct { +// RegexMatchCondition checks if any Value matches a regex pattern. +// Generates: =~ "pattern" +// Used by both LocalFieldRef.Matches() and StringParam.Matches(). +type RegexMatchCondition struct { baseCondition - paramName string - pattern string + source Value + pattern string } -// ParamName returns the parameter name being checked. -func (c *StringMatchesCondition) ParamName() string { return c.paramName } +// Source returns the value being matched. +func (c *RegexMatchCondition) Source() Value { return c.source } -// Pattern returns the regex pattern to match against. -func (c *StringMatchesCondition) Pattern() string { return c.pattern } +// Pattern returns the regex pattern. +func (c *RegexMatchCondition) Pattern() string { return c.pattern } + +// RegexMatch creates a condition that checks if a value matches a regex pattern. +func RegexMatch(source Value, pattern string) *RegexMatchCondition { + return &RegexMatchCondition{source: source, pattern: pattern} +} // StringStartsWithCondition checks if a string parameter starts with a prefix. // Generates: strings.HasPrefix(parameter.name, "prefix") diff --git a/pkg/definition/defkit/expr_test.go b/pkg/definition/defkit/expr_test.go index 9678808ec..295109937 100644 --- a/pkg/definition/defkit/expr_test.go +++ b/pkg/definition/defkit/expr_test.go @@ -354,6 +354,67 @@ var _ = Describe("Expressions", func() { }) }) + Context("RegexMatch", func() { + It("should create a RegexMatchCondition with source and pattern", func() { + ref := defkit.LocalField("name") + rm := defkit.RegexMatch(ref, "^test-") + Expect(rm.Pattern()).To(Equal("^test-")) + Expect(rm.Source()).To(Equal(ref)) + }) + + It("should work with StringParam as source", func() { + p := defkit.String("host") + rm := defkit.RegexMatch(p, `^prod-.*$`) + Expect(rm.Pattern()).To(Equal(`^prod-.*$`)) + Expect(rm.Source()).To(Equal(p)) + }) + + It("should be produced by StringParam.Matches", func() { + p := defkit.String("name") + cond := p.Matches("^prod-") + rm, ok := cond.(*defkit.RegexMatchCondition) + Expect(ok).To(BeTrue()) + Expect(rm.Pattern()).To(Equal("^prod-")) + Expect(rm.Source()).To(Equal(p)) + }) + + It("should be produced by LocalFieldRef.Matches", func() { + ref := defkit.LocalField("tenantName") + cond := ref.Matches(".*-$") + rm, ok := cond.(*defkit.RegexMatchCondition) + Expect(ok).To(BeTrue()) + Expect(rm.Pattern()).To(Equal(".*-$")) + Expect(rm.Source()).To(Equal(ref)) + }) + }) + + Context("NotExpr replaces NotCondition", func() { + It("should be returned by baseParam.NotSet", func() { + p := defkit.Int("replicas") + cond := p.NotSet() + notExpr, ok := cond.(*defkit.NotExpr) + Expect(ok).To(BeTrue(), "expected *NotExpr, got %T", cond) + inner, ok := notExpr.Cond().(*defkit.IsSetCondition) + Expect(ok).To(BeTrue()) + Expect(inner.ParamName()).To(Equal("replicas")) + }) + + It("should be returned by ParamNotSet", func() { + ne := defkit.ParamNotSet("defaults") + inner, ok := ne.Cond().(*defkit.IsSetCondition) + Expect(ok).To(BeTrue()) + Expect(inner.ParamName()).To(Equal("defaults")) + }) + + It("should be returned by LocalFieldRef.NotSet", func() { + cond := defkit.LocalField("role").NotSet() + notExpr, ok := cond.(*defkit.NotExpr) + Expect(ok).To(BeTrue()) + _, ok = notExpr.Cond().(*defkit.PathExistsCondition) + Expect(ok).To(BeTrue()) + }) + }) + Context("InlineArrayValue", func() { It("should create inline array with fields and store correct values", func() { port := defkit.Int("port") diff --git a/pkg/definition/defkit/param.go b/pkg/definition/defkit/param.go index 0e4d6d6d3..a2b2e2f02 100644 --- a/pkg/definition/defkit/param.go +++ b/pkg/definition/defkit/param.go @@ -16,6 +16,11 @@ limitations under the License. package defkit +import ( + "fmt" + "strings" +) + // baseParam provides common parameter functionality. type baseParam struct { name string @@ -50,7 +55,7 @@ func (p *baseParam) IsSet() Condition { // NotSet returns a condition that checks if the parameter is not set. // This generates `if parameter["name"] == _|_` in CUE. func (p *baseParam) NotSet() Condition { - return &NotCondition{inner: &IsSetCondition{paramName: p.name}} + return Not(&IsSetCondition{paramName: p.name}) } // Eq creates a condition that compares this parameter to a literal value. @@ -112,6 +117,7 @@ type StringParam struct { enumValues []string // allowed enum values openEnum bool // when true, appends | string to enum disjunction (open enum) pattern string // regex pattern constraint + notEmpty bool // when true, emits !="" constraint minLen *int // minimum length constraint maxLen *int // maximum length constraint } @@ -227,6 +233,18 @@ func (p *StringParam) GetMaxLen() *int { return p.maxLen } +// NotEmpty adds a non-empty string constraint. +// This generates CUE like: string & !="" +func (p *StringParam) NotEmpty() *StringParam { + p.notEmpty = true + return p +} + +// GetNotEmpty returns whether the non-empty constraint is set. +func (p *StringParam) GetNotEmpty() bool { + return p.notEmpty +} + // Concat creates a string concatenation expression. // Example: name.Concat("-suffix") generates: parameter.name + "-suffix" func (p *StringParam) Concat(suffix string) Value { @@ -250,7 +268,7 @@ func (p *StringParam) Contains(substr string) Condition { // Matches creates a condition that checks if this string parameter matches a regex pattern. // Example: name.Matches("^prod-") generates: parameter.name =~ "^prod-" func (p *StringParam) Matches(pattern string) Condition { - return &StringMatchesCondition{paramName: p.name, pattern: pattern} + return RegexMatch(p, pattern) } // StartsWith creates a condition that checks if this string parameter starts with a prefix. @@ -581,12 +599,14 @@ func (p *FloatParam) In(values ...float64) Condition { // ArrayParam represents an array/list parameter. type ArrayParam struct { baseParam - elementType ParamType - fields []Param // fields for structured array elements - schema string // raw CUE schema for the array elements - schemaRef string // reference to a helper definition (e.g., "HealthProbe") - minItems *int // minimum number of items - maxItems *int // maximum number of items + elementType ParamType + fields []Param // fields for structured array elements + schema string // raw CUE schema for the array elements + schemaRef string // reference to a helper definition (e.g., "HealthProbe") + minItems *int // minimum number of items + maxItems *int // maximum number of items + validators []*Validator // validators emitted inside each array element struct + notEmptyElements bool // when true, adds !="" constraint to string elements: [...(string & !="")] } // Array creates a new array parameter with the given name. @@ -672,6 +692,45 @@ func (p *ArrayParam) GetSchemaRef() string { return p.schemaRef } +// OfEnum is a convenience method for arrays of enum values. +// It sets the schema to [...("val1" | "val2" | ...)]. +// Example: Array("methods").OfEnum("GET", "POST") emits: [...("GET" | "POST")] +func (p *ArrayParam) OfEnum(values ...string) *ArrayParam { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = fmt.Sprintf("%q", v) + } + p.schema = "[...(" + strings.Join(quoted, " | ") + ")]" + return p +} + +// Validators adds validation rules to this array parameter. +// Validators are emitted inside each array element struct as _validate* blocks. +func (p *ArrayParam) Validators(validators ...*Validator) *ArrayParam { + p.validators = append(p.validators, validators...) + return p +} + +// GetValidators returns the validators for this array parameter. +func (p *ArrayParam) GetValidators() []*Validator { + return p.validators +} + +// NotEmpty adds a !="" constraint to each string element of the array. +// Changes [...string] to [...(string & !="")]. +// Only applies to string-typed arrays; silently ignored for other element types. +// Consistent with StringParam.NotEmpty(). +// Example: StringList("x").NotEmpty() produces [...(string & !="")] +func (p *ArrayParam) NotEmpty() *ArrayParam { + p.notEmptyElements = true + return p +} + +// HasNotEmpty returns true if elements must be non-empty. +func (p *ArrayParam) HasNotEmpty() bool { + return p.notEmptyElements +} + // MinItems sets the minimum number of items constraint for the array. // This generates CUE like: list.MinItems(n) func (p *ArrayParam) MinItems(n int) *ArrayParam { @@ -749,11 +808,14 @@ func (p *ArrayParam) IsNotEmpty() Condition { // MapParam represents a map/dictionary parameter. type MapParam struct { baseParam - keyType ParamType - valueType ParamType - fields []Param // fields for structured map values - schema string // raw CUE schema for the map structure - schemaRef string // reference to a helper definition (e.g., "HealthProbe") + keyType ParamType + valueType ParamType + fields []Param // fields for structured map values + schema string // raw CUE schema for the map structure + schemaRef string // reference to a helper definition (e.g., "HealthProbe") + closed bool // when true, wraps struct output in close({...}) + validators []*Validator // validators emitted inside this struct + conditionalFields []*ConditionalBranch // conditional field branches inside this struct } // Map creates a new map parameter with the given name. @@ -840,6 +902,42 @@ func (p *MapParam) GetSchemaRef() string { return p.schemaRef } +// Closed marks the map as a closed struct, preventing extra fields. +// This generates CUE like: close({...}) +func (p *MapParam) Closed() *MapParam { + p.closed = true + return p +} + +// IsClosed returns whether the map is a closed struct. +func (p *MapParam) IsClosed() bool { + return p.closed +} + +// Validators adds validation rules to this map parameter. +// Validators are emitted inside the struct as _validate* blocks. +func (p *MapParam) Validators(validators ...*Validator) *MapParam { + p.validators = append(p.validators, validators...) + return p +} + +// GetValidators returns the validators for this map parameter. +func (p *MapParam) GetValidators() []*Validator { + return p.validators +} + +// ConditionalFields adds conditional field branches inside this struct. +// Fields within each branch are emitted conditionally based on the guard. +func (p *MapParam) ConditionalFields(branches ...*ConditionalBranch) *MapParam { + p.conditionalFields = append(p.conditionalFields, branches...) + return p +} + +// GetConditionalFields returns the conditional field branches. +func (p *MapParam) GetConditionalFields() []*ConditionalBranch { + return p.conditionalFields +} + // Field returns a reference to a nested field within this map parameter. // This allows map parameters to be used as variables with field access. // Example: requests := Map("requests").WithSchema(...); requests.Field("cpu") => parameter.requests.cpu diff --git a/pkg/definition/defkit/param_constraints_test.go b/pkg/definition/defkit/param_constraints_test.go index e714fe699..a61f33e65 100644 --- a/pkg/definition/defkit/param_constraints_test.go +++ b/pkg/definition/defkit/param_constraints_test.go @@ -17,592 +17,369 @@ limitations under the License. package defkit import ( - "strings" - "testing" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" ) -// --- Schema Constraint Tests --- +var _ = ginkgo.Describe("Parameter Constraints", func() { + var gen *CUEGenerator -func TestStringParamPattern(t *testing.T) { - p := String("name").Pattern("^[a-z][a-z0-9-]*$") + ginkgo.BeforeEach(func() { + gen = NewCUEGenerator() + }) - if p.GetPattern() != "^[a-z][a-z0-9-]*$" { - t.Errorf("GetPattern() = %q, want %q", p.GetPattern(), "^[a-z][a-z0-9-]*$") - } + // --- Schema Constraint Tests --- - // Test CUE generation - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + ginkgo.Context("Schema Constraints", func() { + ginkgo.It("should set and generate string pattern constraint", func() { + p := String("name").Pattern("^[a-z][a-z0-9-]*$") + gomega.Expect(p.GetPattern()).To(gomega.Equal("^[a-z][a-z0-9-]*$")) - if !strings.Contains(cue, `=~"^[a-z][a-z0-9-]*$"`) { - t.Errorf("Generated CUE should contain pattern constraint, got:\n%s", cue) - } -} - -func TestStringParamMinMaxLen(t *testing.T) { - p := String("name").MinLen(3).MaxLen(63) - - minLen := p.GetMinLen() - maxLen := p.GetMaxLen() - - if minLen == nil || *minLen != 3 { - t.Errorf("GetMinLen() = %v, want 3", minLen) - } - if maxLen == nil || *maxLen != 63 { - t.Errorf("GetMaxLen() = %v, want 63", maxLen) - } - - // Test CUE generation - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) - - if !strings.Contains(cue, "strings.MinRunes(3)") { - t.Errorf("Generated CUE should contain MinRunes, got:\n%s", cue) - } - if !strings.Contains(cue, "strings.MaxRunes(63)") { - t.Errorf("Generated CUE should contain MaxRunes, got:\n%s", cue) - } -} - -func TestIntParamMinMax(t *testing.T) { - p := Int("replicas").Min(1).Max(100) - - minVal := p.GetMin() - maxVal := p.GetMax() - - if minVal == nil || *minVal != 1 { - t.Errorf("GetMin() = %v, want 1", minVal) - } - if maxVal == nil || *maxVal != 100 { - t.Errorf("GetMax() = %v, want 100", maxVal) - } - - // Test CUE generation - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) - - if !strings.Contains(cue, ">=1") { - t.Errorf("Generated CUE should contain >=1, got:\n%s", cue) - } - if !strings.Contains(cue, "<=100") { - t.Errorf("Generated CUE should contain <=100, got:\n%s", cue) - } -} - -func TestFloatParamMinMax(t *testing.T) { - p := Float("ratio").Min(0.0).Max(1.0) - - minVal := p.GetMin() - maxVal := p.GetMax() - - if minVal == nil || *minVal != 0.0 { - t.Errorf("GetMin() = %v, want 0.0", minVal) - } - if maxVal == nil || *maxVal != 1.0 { - t.Errorf("GetMax() = %v, want 1.0", maxVal) - } - - // Test CUE generation - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) - - if !strings.Contains(cue, ">=0") { - t.Errorf("Generated CUE should contain >=0, got:\n%s", cue) - } - if !strings.Contains(cue, "<=1") { - t.Errorf("Generated CUE should contain <=1, got:\n%s", cue) - } -} - -func TestArrayParamMinMaxItems(t *testing.T) { - p := Array("tags").Of(ParamTypeString).MinItems(1).MaxItems(10) - - minItems := p.GetMinItems() - maxItems := p.GetMaxItems() - - if minItems == nil || *minItems != 1 { - t.Errorf("GetMinItems() = %v, want 1", minItems) - } - if maxItems == nil || *maxItems != 10 { - t.Errorf("GetMaxItems() = %v, want 10", maxItems) - } - - // Test CUE generation - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) - - if !strings.Contains(cue, "list.MinItems(1)") { - t.Errorf("Generated CUE should contain MinItems, got:\n%s", cue) - } - if !strings.Contains(cue, "list.MaxItems(10)") { - t.Errorf("Generated CUE should contain MaxItems, got:\n%s", cue) - } -} - -// --- Runtime Condition Tests --- - -func TestStringParamContains(t *testing.T) { - p := String("name") - cond := p.Contains("prod") - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - expected := `strings.Contains(parameter.name, "prod")` - if cueStr != expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, expected) - } -} - -func TestStringParamMatches(t *testing.T) { - p := String("name") - cond := p.Matches("^prod-") - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - expected := `parameter.name =~ "^prod-"` - if cueStr != expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, expected) - } -} - -func TestStringParamStartsWith(t *testing.T) { - p := String("name") - cond := p.StartsWith("prod-") - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - expected := `strings.HasPrefix(parameter.name, "prod-")` - if cueStr != expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, expected) - } -} - -func TestStringParamEndsWith(t *testing.T) { - p := String("name") - cond := p.EndsWith("-prod") - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - expected := `strings.HasSuffix(parameter.name, "-prod")` - if cueStr != expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, expected) - } -} - -func TestStringParamIn(t *testing.T) { - p := String("name") - cond := p.In("api", "web", "worker") - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - // Should contain all values - if !strings.Contains(cueStr, `parameter.name == "api"`) { - t.Errorf("conditionToCUE() should contain 'api', got: %s", cueStr) - } - if !strings.Contains(cueStr, `parameter.name == "web"`) { - t.Errorf("conditionToCUE() should contain 'web', got: %s", cueStr) - } - if !strings.Contains(cueStr, " || ") { - t.Errorf("conditionToCUE() should contain '||', got: %s", cueStr) - } -} - -func TestIntParamIn(t *testing.T) { - p := Int("port") - cond := p.In(80, 443, 8080) - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - if !strings.Contains(cueStr, "parameter.port == 80") { - t.Errorf("conditionToCUE() should contain '80', got: %s", cueStr) - } - if !strings.Contains(cueStr, "parameter.port == 443") { - t.Errorf("conditionToCUE() should contain '443', got: %s", cueStr) - } -} - -func TestStringParamLenConditions(t *testing.T) { - p := String("name") - - tests := []struct { - name string - cond Condition - expected string - }{ - {"LenEq", p.LenEq(5), "len(parameter.name) == 5"}, - {"LenGt", p.LenGt(5), "len(parameter.name) > 5"}, - {"LenGte", p.LenGte(5), "len(parameter.name) >= 5"}, - {"LenLt", p.LenLt(5), "len(parameter.name) < 5"}, - {"LenLte", p.LenLte(5), "len(parameter.name) <= 5"}, - } - - gen := NewCUEGenerator() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cueStr := gen.conditionToCUE(tt.cond) - if cueStr != tt.expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, tt.expected) - } - }) - } -} - -func TestArrayParamConditions(t *testing.T) { - p := Array("tags").Of(ParamTypeString) - - tests := []struct { - name string - cond Condition - expected string - }{ - {"LenEq", p.LenEq(5), "len(parameter.tags) == 5"}, - {"LenGt", p.LenGt(0), "len(parameter.tags) > 0"}, - {"IsEmpty", p.IsEmpty(), "len(parameter.tags) == 0"}, - {"IsNotEmpty", p.IsNotEmpty(), "len(parameter.tags) > 0"}, - {"Contains", p.Contains("gpu"), `list.Contains(parameter.tags, "gpu")`}, - } - - gen := NewCUEGenerator() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cueStr := gen.conditionToCUE(tt.cond) - if cueStr != tt.expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, tt.expected) - } - }) - } -} - -func TestMapParamConditions(t *testing.T) { - p := Map("config") - - tests := []struct { - name string - cond Condition - expected string - }{ - {"HasKey", p.HasKey("debug"), "parameter.config.debug != _|_"}, - {"LenEq", p.LenEq(5), "len(parameter.config) == 5"}, - {"LenGt", p.LenGt(0), "len(parameter.config) > 0"}, - {"IsEmpty", p.IsEmpty(), "len(parameter.config) == 0"}, - {"IsNotEmpty", p.IsNotEmpty(), "len(parameter.config) > 0"}, - } - - gen := NewCUEGenerator() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cueStr := gen.conditionToCUE(tt.cond) - if cueStr != tt.expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, tt.expected) - } - }) - } -} - -func TestBoolParamIsFalse(t *testing.T) { - p := Bool("enabled") - cond := p.IsFalse() - - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) - - expected := "!parameter.enabled" - if cueStr != expected { - t.Errorf("conditionToCUE() = %q, want %q", cueStr, expected) - } -} - -// --- Chaining Tests --- - -func TestSchemaConstraintChaining(t *testing.T) { - // Test that schema constraint methods can be chained - strP := String("name"). - Pattern("^[a-z]+$"). - MinLen(3). - MaxLen(63). - Description("The name") - - if strP.GetPattern() != "^[a-z]+$" { - t.Error("Pattern not set correctly after chaining") - } - if strP.GetMinLen() == nil || *strP.GetMinLen() != 3 { - t.Error("MinLen not set correctly after chaining") - } - if strP.GetMaxLen() == nil || *strP.GetMaxLen() != 63 { - t.Error("MaxLen not set correctly after chaining") - } - if strP.GetDescription() != "The name" { - t.Error("Description not set correctly after chaining") - } - - intP := Int("replicas"). - Min(1). - Max(100). - Default(3) - - if intP.GetMin() == nil || *intP.GetMin() != 1 { - t.Error("Min not set correctly after chaining") - } - if intP.GetMax() == nil || *intP.GetMax() != 100 { - t.Error("Max not set correctly after chaining") - } - if !intP.HasDefault() || intP.GetDefault() != 3 { - t.Error("Default not set correctly after chaining") - } -} - -// --- Combined Schema + Runtime Test --- - -func TestCombinedSchemaAndRuntimeConditions(t *testing.T) { - // Schema constraints define WHAT values are valid - replicas := Int("replicas").Min(1).Max(100).Default(3) - - // Runtime conditions control WHAT resources are generated - cond := replicas.Gt(5) - - gen := NewCUEGenerator() - comp := NewComponent("test").Params(replicas) - - // Check schema generation - schema := gen.GenerateParameterSchema(comp) - if !strings.Contains(schema, ">=1") { - t.Errorf("Schema should contain >=1, got:\n%s", schema) - } - if !strings.Contains(schema, "<=100") { - t.Errorf("Schema should contain <=100, got:\n%s", schema) - } - if !strings.Contains(schema, "*3") { - t.Errorf("Schema should contain default *3, got:\n%s", schema) - } - - // Check runtime condition - condStr := gen.conditionToCUE(cond) - expected := "parameter.replicas > 5" - if condStr != expected { - t.Errorf("Runtime condition = %q, want %q", condStr, expected) - } -} - -// --- Integration Tests with SetIf --- - -func TestSetIfWithNewConditions(t *testing.T) { - name := String("name") - replicas := Int("replicas").Min(1).Max(100) - tags := Array("tags").Of(ParamTypeString) - - comp := NewComponent("test-app"). - Params(name, replicas, tags). - Template(func(t *Template) { - deployment := NewResource("apps/v1", "Deployment"). - Set("metadata.name", name). - Set("spec.replicas", replicas). - // Test various conditions with SetIf - SetIf(name.StartsWith("prod-"), "metadata.labels.env", Lit("production")). - SetIf(name.Contains("canary"), "metadata.labels.deployment", Lit("canary")). - SetIf(replicas.Gt(5), "spec.strategy.type", Lit("RollingUpdate")). - SetIf(tags.IsNotEmpty(), "metadata.annotations.has-tags", Lit("true")). - SetIf(tags.Contains("gpu"), "spec.template.spec.nodeSelector.accelerator", Lit("nvidia")) - - t.Output(deployment) + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`=~"^[a-z][a-z0-9-]*$"`)) }) - cue := comp.ToCue() + ginkgo.It("should set and generate string min/max length constraints", func() { + p := String("name").MinLen(3).MaxLen(63) + gomega.Expect(p.GetMinLen()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMinLen()).To(gomega.Equal(3)) + gomega.Expect(p.GetMaxLen()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMaxLen()).To(gomega.Equal(63)) - // Verify conditions are in the output - expectedConditions := []string{ - `strings.HasPrefix(parameter.name, "prod-")`, - `strings.Contains(parameter.name, "canary")`, - `parameter.replicas > 5`, - `len(parameter.tags) > 0`, - `list.Contains(parameter.tags, "gpu")`, - } + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring("strings.MinRunes(3)")) + gomega.Expect(cue).To(gomega.ContainSubstring("strings.MaxRunes(63)")) + }) - for _, expected := range expectedConditions { - if !strings.Contains(cue, expected) { - t.Errorf("Generated CUE should contain %q, got:\n%s", expected, cue) - } - } -} + ginkgo.It("should set and generate int min/max constraints", func() { + p := Int("replicas").Min(1).Max(100) + gomega.Expect(p.GetMin()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMin()).To(gomega.Equal(1)) + gomega.Expect(p.GetMax()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMax()).To(gomega.Equal(100)) -// --- Additional Edge Case Tests --- + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(">=1")) + gomega.Expect(cue).To(gomega.ContainSubstring("<=100")) + }) -func TestFloatParamIn(t *testing.T) { - p := Float("ratio") - cond := p.In(0.5, 1.0, 2.0) + ginkgo.It("should set and generate float min/max constraints", func() { + p := Float("ratio").Min(0.0).Max(1.0) + gomega.Expect(p.GetMin()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMin()).To(gomega.Equal(0.0)) + gomega.Expect(p.GetMax()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMax()).To(gomega.Equal(1.0)) - gen := NewCUEGenerator() - cueStr := gen.conditionToCUE(cond) + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(">=0")) + gomega.Expect(cue).To(gomega.ContainSubstring("<=1")) + }) - if !strings.Contains(cueStr, "parameter.ratio == 0.5") { - t.Errorf("conditionToCUE() should contain '0.5', got: %s", cueStr) - } - if !strings.Contains(cueStr, "parameter.ratio == 1") { - t.Errorf("conditionToCUE() should contain '1', got: %s", cueStr) - } - if !strings.Contains(cueStr, " || ") { - t.Errorf("conditionToCUE() should contain '||', got: %s", cueStr) - } -} + ginkgo.It("should set and generate array min/max items constraints", func() { + p := Array("tags").Of(ParamTypeString).MinItems(1).MaxItems(10) + gomega.Expect(p.GetMinItems()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMinItems()).To(gomega.Equal(1)) + gomega.Expect(p.GetMaxItems()).ToNot(gomega.BeNil()) + gomega.Expect(*p.GetMaxItems()).To(gomega.Equal(10)) -func TestArrayContainsWithDifferentTypes(t *testing.T) { - gen := NewCUEGenerator() + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring("list.MinItems(1)")) + gomega.Expect(cue).To(gomega.ContainSubstring("list.MaxItems(10)")) + }) - // String array with string value - strArray := Array("tags").Of(ParamTypeString) - strCond := strArray.Contains("value") - strResult := gen.conditionToCUE(strCond) - if strResult != `list.Contains(parameter.tags, "value")` { - t.Errorf("String contains = %q", strResult) - } + ginkgo.It("should generate string NotEmpty constraint", func() { + p := String("name").NotEmpty() + gomega.Expect(p.GetNotEmpty()).To(gomega.BeTrue()) - // Int array with int value - intArray := Array("ports").Of(ParamTypeInt) - intCond := intArray.Contains(8080) - intResult := gen.conditionToCUE(intCond) - if intResult != `list.Contains(parameter.ports, 8080)` { - t.Errorf("Int contains = %q", intResult) - } + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`!=""`)) + }) - // Bool value - boolArray := Array("flags").Of(ParamTypeBool) - boolCond := boolArray.Contains(true) - boolResult := gen.conditionToCUE(boolCond) - if boolResult != `list.Contains(parameter.flags, true)` { - t.Errorf("Bool contains = %q", boolResult) - } -} + ginkgo.It("should generate string NotEmpty with pattern combined", func() { + p := String("name").NotEmpty().Pattern(`^[a-z0-9.-]{3,63}$`) -func TestCombinedStringConstraints(t *testing.T) { - // Test all string constraints together - p := String("hostname"). - Pattern("^[a-z][a-z0-9-]*$"). - MinLen(3). - MaxLen(63) + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`!=""`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`=~"^[a-z0-9.-]{3,63}$"`)) + }) - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + ginkgo.It("should generate MapParam closed struct", func() { + p := Object("governance").Closed().WithFields( + String("tenantName"), + String("departmentCode"), + ) + gomega.Expect(p.IsClosed()).To(gomega.BeTrue()) - // All constraints should be present - if !strings.Contains(cue, `=~"^[a-z][a-z0-9-]*$"`) { - t.Errorf("Should contain pattern, got:\n%s", cue) - } - if !strings.Contains(cue, "strings.MinRunes(3)") { - t.Errorf("Should contain MinRunes, got:\n%s", cue) - } - if !strings.Contains(cue, "strings.MaxRunes(63)") { - t.Errorf("Should contain MaxRunes, got:\n%s", cue) - } - // They should be combined with & - if !strings.Contains(cue, " & ") { - t.Errorf("Constraints should be combined with &, got:\n%s", cue) - } -} + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring("close({")) + gomega.Expect(cue).To(gomega.ContainSubstring("})")) + }) -func TestStringConstraintsWithDefault(t *testing.T) { - p := String("env"). - Pattern("^(dev|staging|prod)$"). - Default("dev") + ginkgo.It("should generate ArrayParam OfEnum schema", func() { + p := Array("allowedMethods").OfEnum("GET", "PUT", "HEAD", "POST", "DELETE") - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`[...("GET" | "PUT" | "HEAD" | "POST" | "DELETE")]`)) + }) + }) - // Should have both default and pattern - if !strings.Contains(cue, `*"dev"`) { - t.Errorf("Should contain default, got:\n%s", cue) - } - if !strings.Contains(cue, `=~"^(dev|staging|prod)$"`) { - t.Errorf("Should contain pattern, got:\n%s", cue) - } -} + // --- Runtime Condition Tests --- -func TestIntConstraintsWithDefault(t *testing.T) { - p := Int("port"). - Min(1). - Max(65535). - Default(8080) + ginkgo.Context("Runtime Conditions", func() { + ginkgo.It("should generate string Contains condition", func() { + p := String("name") + cueStr := gen.conditionToCUE(p.Contains("prod")) + gomega.Expect(cueStr).To(gomega.Equal(`strings.Contains(parameter.name, "prod")`)) + }) - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + ginkgo.It("should generate string Matches condition", func() { + p := String("name") + cueStr := gen.conditionToCUE(p.Matches("^prod-")) + gomega.Expect(cueStr).To(gomega.Equal(`parameter.name =~ "^prod-"`)) + }) - if !strings.Contains(cue, "*8080") { - t.Errorf("Should contain default, got:\n%s", cue) - } - if !strings.Contains(cue, ">=1") { - t.Errorf("Should contain min, got:\n%s", cue) - } - if !strings.Contains(cue, "<=65535") { - t.Errorf("Should contain max, got:\n%s", cue) - } -} + ginkgo.It("should generate string StartsWith condition", func() { + p := String("name") + cueStr := gen.conditionToCUE(p.StartsWith("prod-")) + gomega.Expect(cueStr).To(gomega.Equal(`strings.HasPrefix(parameter.name, "prod-")`)) + }) -func TestEdgeCaseZeroValues(t *testing.T) { - // Min of 0 should still be generated - p := Int("count").Min(0).Max(10) + ginkgo.It("should generate string EndsWith condition", func() { + p := String("name") + cueStr := gen.conditionToCUE(p.EndsWith("-prod")) + gomega.Expect(cueStr).To(gomega.Equal(`strings.HasSuffix(parameter.name, "-prod")`)) + }) - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + ginkgo.It("should generate string In condition", func() { + p := String("name") + cueStr := gen.conditionToCUE(p.In("api", "web", "worker")) + gomega.Expect(cueStr).To(gomega.ContainSubstring(`parameter.name == "api"`)) + gomega.Expect(cueStr).To(gomega.ContainSubstring(`parameter.name == "web"`)) + gomega.Expect(cueStr).To(gomega.ContainSubstring(" || ")) + }) - if !strings.Contains(cue, ">=0") { - t.Errorf("Should contain >=0, got:\n%s", cue) - } -} + ginkgo.It("should generate int In condition", func() { + p := Int("port") + cueStr := gen.conditionToCUE(p.In(80, 443, 8080)) + gomega.Expect(cueStr).To(gomega.ContainSubstring("parameter.port == 80")) + gomega.Expect(cueStr).To(gomega.ContainSubstring("parameter.port == 443")) + }) -func TestEdgeCaseEmptyStringConditions(t *testing.T) { - gen := NewCUEGenerator() - p := String("name") + ginkgo.It("should generate bool IsFalse condition", func() { + p := Bool("enabled") + cueStr := gen.conditionToCUE(p.IsFalse()) + gomega.Expect(cueStr).To(gomega.Equal("!parameter.enabled")) + }) - // Empty string checks should work - containsEmpty := p.Contains("") - result := gen.conditionToCUE(containsEmpty) - if result != `strings.Contains(parameter.name, "")` { - t.Errorf("Empty contains = %q", result) - } + ginkgo.It("should generate float In condition", func() { + p := Float("ratio") + cueStr := gen.conditionToCUE(p.In(0.5, 1.0, 2.0)) + gomega.Expect(cueStr).To(gomega.ContainSubstring("parameter.ratio == 0.5")) + gomega.Expect(cueStr).To(gomega.ContainSubstring("parameter.ratio == 1")) + gomega.Expect(cueStr).To(gomega.ContainSubstring(" || ")) + }) + }) - startsEmpty := p.StartsWith("") - result2 := gen.conditionToCUE(startsEmpty) - if result2 != `strings.HasPrefix(parameter.name, "")` { - t.Errorf("Empty startsWith = %q", result2) - } -} + ginkgo.Context("String Length Conditions", func() { + ginkgo.DescribeTable("should generate correct CUE for length conditions", + func(condFn func(*StringParam) Condition, expected string) { + p := String("name") + cueStr := gen.conditionToCUE(condFn(p)) + gomega.Expect(cueStr).To(gomega.Equal(expected)) + }, + ginkgo.Entry("LenEq", func(p *StringParam) Condition { return p.LenEq(5) }, "len(parameter.name) == 5"), + ginkgo.Entry("LenGt", func(p *StringParam) Condition { return p.LenGt(5) }, "len(parameter.name) > 5"), + ginkgo.Entry("LenGte", func(p *StringParam) Condition { return p.LenGte(5) }, "len(parameter.name) >= 5"), + ginkgo.Entry("LenLt", func(p *StringParam) Condition { return p.LenLt(5) }, "len(parameter.name) < 5"), + ginkgo.Entry("LenLte", func(p *StringParam) Condition { return p.LenLte(5) }, "len(parameter.name) <= 5"), + ) + }) -func TestSingleValueIn(t *testing.T) { - gen := NewCUEGenerator() - p := String("status") + ginkgo.Context("Array Conditions", func() { + ginkgo.DescribeTable("should generate correct CUE for array conditions", + func(condFn func(*ArrayParam) Condition, expected string) { + p := Array("tags").Of(ParamTypeString) + cueStr := gen.conditionToCUE(condFn(p)) + gomega.Expect(cueStr).To(gomega.Equal(expected)) + }, + ginkgo.Entry("LenEq", func(p *ArrayParam) Condition { return p.LenEq(5) }, "len(parameter.tags) == 5"), + ginkgo.Entry("LenGt", func(p *ArrayParam) Condition { return p.LenGt(0) }, "len(parameter.tags) > 0"), + ginkgo.Entry("IsEmpty", func(p *ArrayParam) Condition { return p.IsEmpty() }, "len(parameter.tags) == 0"), + ginkgo.Entry("IsNotEmpty", func(p *ArrayParam) Condition { return p.IsNotEmpty() }, "len(parameter.tags) > 0"), + ginkgo.Entry("Contains", func(p *ArrayParam) Condition { return p.Contains("gpu") }, `list.Contains(parameter.tags, "gpu")`), + ) - // Single value In should work (even if it's equivalent to Eq) - cond := p.In("active") - result := gen.conditionToCUE(cond) + ginkgo.It("should generate array Contains with different element types", func() { + intArray := Array("ports").Of(ParamTypeInt) + gomega.Expect(gen.conditionToCUE(intArray.Contains(8080))).To(gomega.Equal(`list.Contains(parameter.ports, 8080)`)) - if result != `parameter.status == "active"` { - t.Errorf("Single value In = %q, want parameter.status == \"active\"", result) - } -} + boolArray := Array("flags").Of(ParamTypeBool) + gomega.Expect(gen.conditionToCUE(boolArray.Contains(true))).To(gomega.Equal(`list.Contains(parameter.flags, true)`)) + }) + }) -func TestSpecialCharactersInPattern(t *testing.T) { - // Patterns with special regex characters - p := String("email").Pattern(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + ginkgo.Context("Map Conditions", func() { + ginkgo.DescribeTable("should generate correct CUE for map conditions", + func(condFn func(*MapParam) Condition, expected string) { + p := Map("config") + cueStr := gen.conditionToCUE(condFn(p)) + gomega.Expect(cueStr).To(gomega.Equal(expected)) + }, + ginkgo.Entry("HasKey", func(p *MapParam) Condition { return p.HasKey("debug") }, "parameter.config.debug != _|_"), + ginkgo.Entry("LenEq", func(p *MapParam) Condition { return p.LenEq(5) }, "len(parameter.config) == 5"), + ginkgo.Entry("LenGt", func(p *MapParam) Condition { return p.LenGt(0) }, "len(parameter.config) > 0"), + ginkgo.Entry("IsEmpty", func(p *MapParam) Condition { return p.IsEmpty() }, "len(parameter.config) == 0"), + ginkgo.Entry("IsNotEmpty", func(p *MapParam) Condition { return p.IsNotEmpty() }, "len(parameter.config) > 0"), + ) + }) - gen := NewCUEGenerator() - comp := NewComponent("test").Params(p) - cue := gen.GenerateParameterSchema(comp) + // --- Chaining Tests --- - // Pattern should be properly quoted - if !strings.Contains(cue, `=~"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"`) { - t.Errorf("Pattern with special chars should be escaped, got:\n%s", cue) - } -} + ginkgo.Context("Constraint Chaining", func() { + ginkgo.It("should preserve all chained string constraints", func() { + p := String("name"). + Pattern("^[a-z]+$"). + MinLen(3). + MaxLen(63). + Description("The name") + + gomega.Expect(p.GetPattern()).To(gomega.Equal("^[a-z]+$")) + gomega.Expect(*p.GetMinLen()).To(gomega.Equal(3)) + gomega.Expect(*p.GetMaxLen()).To(gomega.Equal(63)) + gomega.Expect(p.GetDescription()).To(gomega.Equal("The name")) + }) + + ginkgo.It("should preserve all chained int constraints", func() { + p := Int("replicas").Min(1).Max(100).Default(3) + + gomega.Expect(*p.GetMin()).To(gomega.Equal(1)) + gomega.Expect(*p.GetMax()).To(gomega.Equal(100)) + gomega.Expect(p.HasDefault()).To(gomega.BeTrue()) + gomega.Expect(p.GetDefault()).To(gomega.Equal(3)) + }) + }) + + // --- Combined Schema + Runtime --- + + ginkgo.Context("Combined Schema and Runtime", func() { + ginkgo.It("should generate both schema constraints and runtime conditions", func() { + replicas := Int("replicas").Min(1).Max(100).Default(3) + + comp := NewComponent("test").Params(replicas) + schema := gen.GenerateParameterSchema(comp) + gomega.Expect(schema).To(gomega.ContainSubstring(">=1")) + gomega.Expect(schema).To(gomega.ContainSubstring("<=100")) + gomega.Expect(schema).To(gomega.ContainSubstring("*3")) + + condStr := gen.conditionToCUE(replicas.Gt(5)) + gomega.Expect(condStr).To(gomega.Equal("parameter.replicas > 5")) + }) + + ginkgo.It("should generate combined string constraints", func() { + p := String("hostname"). + Pattern("^[a-z][a-z0-9-]*$"). + MinLen(3). + MaxLen(63) + + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`=~"^[a-z][a-z0-9-]*$"`)) + gomega.Expect(cue).To(gomega.ContainSubstring("strings.MinRunes(3)")) + gomega.Expect(cue).To(gomega.ContainSubstring("strings.MaxRunes(63)")) + gomega.Expect(cue).To(gomega.ContainSubstring(" & ")) + }) + + ginkgo.It("should generate string constraints with default", func() { + p := String("env"). + Pattern("^(dev|staging|prod)$"). + Default("dev") + + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`*"dev"`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`=~"^(dev|staging|prod)$"`)) + }) + + ginkgo.It("should generate int constraints with default", func() { + p := Int("port").Min(1).Max(65535).Default(8080) + + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring("*8080")) + gomega.Expect(cue).To(gomega.ContainSubstring(">=1")) + gomega.Expect(cue).To(gomega.ContainSubstring("<=65535")) + }) + }) + + // --- Integration with SetIf --- + + ginkgo.Context("SetIf Integration", func() { + ginkgo.It("should generate CUE with various SetIf conditions", func() { + name := String("name") + replicas := Int("replicas").Min(1).Max(100) + tags := Array("tags").Of(ParamTypeString) + + comp := NewComponent("test-app"). + Params(name, replicas, tags). + Template(func(t *Template) { + deployment := NewResource("apps/v1", "Deployment"). + Set("metadata.name", name). + Set("spec.replicas", replicas). + SetIf(name.StartsWith("prod-"), "metadata.labels.env", Lit("production")). + SetIf(name.Contains("canary"), "metadata.labels.deployment", Lit("canary")). + SetIf(replicas.Gt(5), "spec.strategy.type", Lit("RollingUpdate")). + SetIf(tags.IsNotEmpty(), "metadata.annotations.has-tags", Lit("true")). + SetIf(tags.Contains("gpu"), "spec.template.spec.nodeSelector.accelerator", Lit("nvidia")) + t.Output(deployment) + }) + + cue := comp.ToCue() + gomega.Expect(cue).To(gomega.ContainSubstring(`strings.HasPrefix(parameter.name, "prod-")`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`strings.Contains(parameter.name, "canary")`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`parameter.replicas > 5`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`len(parameter.tags) > 0`)) + gomega.Expect(cue).To(gomega.ContainSubstring(`list.Contains(parameter.tags, "gpu")`)) + }) + }) + + // --- Edge Cases --- + + ginkgo.Context("Edge Cases", func() { + ginkgo.It("should handle zero as min value", func() { + p := Int("count").Min(0).Max(10) + + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(">=0")) + }) + + ginkgo.It("should handle empty string conditions", func() { + p := String("name") + gomega.Expect(gen.conditionToCUE(p.Contains(""))).To(gomega.Equal(`strings.Contains(parameter.name, "")`)) + gomega.Expect(gen.conditionToCUE(p.StartsWith(""))).To(gomega.Equal(`strings.HasPrefix(parameter.name, "")`)) + }) + + ginkgo.It("should handle single value In condition", func() { + p := String("status") + cueStr := gen.conditionToCUE(p.In("active")) + gomega.Expect(cueStr).To(gomega.Equal(`parameter.status == "active"`)) + }) + + ginkgo.It("should escape special regex characters in pattern", func() { + p := String("email").Pattern(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + + comp := NewComponent("test").Params(p) + cue := gen.GenerateParameterSchema(comp) + gomega.Expect(cue).To(gomega.ContainSubstring(`=~"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"`)) + }) + }) +}) diff --git a/pkg/definition/defkit/param_test.go b/pkg/definition/defkit/param_test.go index 1e7e36071..10bd1afe6 100644 --- a/pkg/definition/defkit/param_test.go +++ b/pkg/definition/defkit/param_test.go @@ -617,9 +617,9 @@ var _ = Describe("Parameters", func() { It("should create NotSet condition from param", func() { replicas := defkit.Int("replicas") cond := replicas.NotSet() - notCond, ok := cond.(*defkit.NotCondition) - Expect(ok).To(BeTrue(), "expected *NotCondition") - inner, ok := notCond.Inner().(*defkit.IsSetCondition) + notExpr, ok := cond.(*defkit.NotExpr) + Expect(ok).To(BeTrue(), "expected *NotExpr") + inner, ok := notExpr.Cond().(*defkit.IsSetCondition) Expect(ok).To(BeTrue(), "expected inner *IsSetCondition") Expect(inner.ParamName()).To(Equal("replicas")) }) @@ -1065,6 +1065,166 @@ var _ = Describe("Parameters", func() { }) }) + Context("StringParam NotEmpty", func() { + It("should default to false", func() { + p := defkit.String("name") + Expect(p.GetNotEmpty()).To(BeFalse()) + }) + + It("should set NotEmpty flag", func() { + p := defkit.String("name").NotEmpty() + Expect(p.GetNotEmpty()).To(BeTrue()) + }) + + It("should chain with other constraints", func() { + p := defkit.String("name").NotEmpty().Pattern("^[a-z]+$").MinLen(3) + Expect(p.GetNotEmpty()).To(BeTrue()) + Expect(p.GetPattern()).To(Equal("^[a-z]+$")) + Expect(p.GetMinLen()).NotTo(BeNil()) + Expect(*p.GetMinLen()).To(Equal(3)) + }) + }) + + Context("ArrayParam NotEmpty elements", func() { + It("should default to false", func() { + p := defkit.StringList("tags") + Expect(p.HasNotEmpty()).To(BeFalse()) + }) + + It("should set NotEmpty flag for elements", func() { + p := defkit.StringList("tags").NotEmpty() + Expect(p.HasNotEmpty()).To(BeTrue()) + }) + + It("should chain with other methods", func() { + p := defkit.StringList("tags").NotEmpty().Optional() + Expect(p.HasNotEmpty()).To(BeTrue()) + Expect(p.IsOptional()).To(BeTrue()) + }) + }) + + Context("ArrayParam OfEnum", func() { + It("should set schema for enum array", func() { + p := defkit.Array("methods").OfEnum("GET", "POST", "DELETE") + Expect(p.GetSchema()).To(ContainSubstring(`"GET"`)) + Expect(p.GetSchema()).To(ContainSubstring(`"POST"`)) + Expect(p.GetSchema()).To(ContainSubstring(`"DELETE"`)) + Expect(p.GetSchema()).To(ContainSubstring("|")) + }) + + It("should chain with other methods", func() { + p := defkit.Array("methods").OfEnum("GET", "POST").Optional() + Expect(p.IsOptional()).To(BeTrue()) + Expect(p.GetSchema()).To(ContainSubstring(`"GET"`)) + }) + }) + + Context("ArrayParam Validators", func() { + It("should store validators", func() { + v := defkit.Validate("check").WithName("_v") + p := defkit.Array("items").WithFields( + defkit.String("name"), + ).Validators(v) + + Expect(p.GetValidators()).To(HaveLen(1)) + Expect(p.GetValidators()[0]).To(Equal(v)) + }) + + It("should accumulate across calls", func() { + p := defkit.Array("items").WithFields(defkit.String("name")). + Validators(defkit.Validate("a").WithName("_a")). + Validators(defkit.Validate("b").WithName("_b")) + Expect(p.GetValidators()).To(HaveLen(2)) + }) + + It("should return empty when not set", func() { + p := defkit.Array("items") + Expect(p.GetValidators()).To(BeEmpty()) + }) + }) + + Context("MapParam Closed", func() { + It("should default to false", func() { + p := defkit.Object("config") + Expect(p.IsClosed()).To(BeFalse()) + }) + + It("should set closed flag", func() { + p := defkit.Object("config").Closed() + Expect(p.IsClosed()).To(BeTrue()) + }) + + It("should chain with WithFields", func() { + p := defkit.Object("config").Closed().WithFields( + defkit.String("name"), + ) + Expect(p.IsClosed()).To(BeTrue()) + Expect(p.GetFields()).To(HaveLen(1)) + }) + }) + + Context("MapParam Validators", func() { + It("should store validators", func() { + v := defkit.Validate("check").WithName("_v") + p := defkit.Object("governance").WithFields( + defkit.String("name"), + ).Validators(v) + + Expect(p.GetValidators()).To(HaveLen(1)) + Expect(p.GetValidators()[0]).To(Equal(v)) + }) + + It("should accumulate across calls", func() { + p := defkit.Object("governance"). + Validators(defkit.Validate("a").WithName("_a")). + Validators(defkit.Validate("b").WithName("_b")) + Expect(p.GetValidators()).To(HaveLen(2)) + }) + + It("should return empty when not set", func() { + p := defkit.Object("config") + Expect(p.GetValidators()).To(BeEmpty()) + }) + }) + + Context("MapParam ConditionalFields", func() { + It("should store conditional field branches", func() { + branch := defkit.WhenParam(defkit.Bool("x").Eq(true)). + Params(defkit.String("secret").Required()) + + p := defkit.Object("config").ConditionalFields(branch) + Expect(p.GetConditionalFields()).To(HaveLen(1)) + Expect(p.GetConditionalFields()[0]).To(Equal(branch)) + }) + + It("should accumulate branches across calls", func() { + b1 := defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("a")) + b2 := defkit.WhenParam(defkit.Bool("x").Eq(false)).Params(defkit.String("b")) + + p := defkit.Object("config"). + ConditionalFields(b1). + ConditionalFields(b2) + Expect(p.GetConditionalFields()).To(HaveLen(2)) + }) + + It("should return empty when not set", func() { + p := defkit.Object("config") + Expect(p.GetConditionalFields()).To(BeEmpty()) + }) + + It("should chain with other methods", func() { + p := defkit.Object("config").Optional(). + WithFields(defkit.String("base")). + ConditionalFields( + defkit.WhenParam(defkit.Bool("x").Eq(true)).Params(defkit.String("extra")), + ) + + Expect(p.IsOptional()).To(BeTrue()) + Expect(p.GetFields()).To(HaveLen(1)) + Expect(p.GetConditionalFields()).To(HaveLen(1)) + }) + }) + Context("ClosedStructOption", func() { It("should create an empty closed struct", func() { cs := defkit.ClosedStruct() diff --git a/pkg/definition/defkit/patch_container.go b/pkg/definition/defkit/patch_container.go index cc0b8324e..b8debafec 100644 --- a/pkg/definition/defkit/patch_container.go +++ b/pkg/definition/defkit/patch_container.go @@ -482,15 +482,15 @@ func ParamIsSet(name string) *IsSetCondition { // ParamNotSet creates a condition that checks if a parameter is NOT set. // This generates CUE: parameter.name == _|_ // -// This is a convenience wrapper around NotCondition{inner: IsSetCondition}. +// This is a convenience wrapper around Not(IsSetCondition). // // Example: // // // Set default only if not explicitly specified // tpl.Patch(). // SetIf(defkit.ParamNotSet("replicas"), "spec.replicas", defkit.Literal(1)) -func ParamNotSet(name string) *NotCondition { - return &NotCondition{inner: &IsSetCondition{paramName: name}} +func ParamNotSet(name string) *NotExpr { + return Not(&IsSetCondition{paramName: name}) } // --- ContextOutputExists condition --- diff --git a/pkg/definition/defkit/patch_container_test.go b/pkg/definition/defkit/patch_container_test.go index 070f4deec..a380ef4c7 100644 --- a/pkg/definition/defkit/patch_container_test.go +++ b/pkg/definition/defkit/patch_container_test.go @@ -935,7 +935,7 @@ var _ = Describe("PatchContainer", func() { It("ParamNotSet should store param name for CUE == _|_ generation", func() { cond := defkit.ParamNotSet("defaults") - inner := cond.Inner() + inner := cond.Cond() isSet, ok := inner.(*defkit.IsSetCondition) Expect(ok).To(BeTrue()) Expect(isSet.ParamName()).To(Equal("defaults")) diff --git a/pkg/definition/defkit/render.go b/pkg/definition/defkit/render.go index b4d08e85b..7b5debe3f 100644 --- a/pkg/definition/defkit/render.go +++ b/pkg/definition/defkit/render.go @@ -238,20 +238,14 @@ func evaluateCondition(cond Condition, ctx *TestRuntimeContext) bool { switch c := cond.(type) { case *IsSetCondition: return ctx.IsParamSet(c.paramName) - case *CompareCondition: - left := resolveConditionValue(c.left, ctx) - right := resolveConditionValue(c.right, ctx) - return compareValues(left, right, c.op) case *Comparison: left := resolveConditionValue(c.Left(), ctx) right := resolveConditionValue(c.Right(), ctx) return compareValues(left, right, string(c.Op())) case *AndCondition: return evaluateCondition(c.left, ctx) && evaluateCondition(c.right, ctx) - case *OrCondition: - return evaluateCondition(c.left, ctx) || evaluateCondition(c.right, ctx) - case *NotCondition: - return !evaluateCondition(c.inner, ctx) + case *NotExpr: + return !evaluateCondition(c.Cond(), ctx) case *LogicalExpr: if c.Op() == OpAnd { for _, sub := range c.Conditions() { @@ -268,8 +262,6 @@ func evaluateCondition(cond Condition, ctx *TestRuntimeContext) bool { } return false } - case *NotExpr: - return !evaluateCondition(c.Cond(), ctx) case *HasExposedPortsCondition: // Resolve the ports value and check if any have expose=true portsValue := resolveValue(c.ports, ctx) diff --git a/pkg/definition/defkit/render_test.go b/pkg/definition/defkit/render_test.go index 35ccd74fc..38e7a1bd9 100644 --- a/pkg/definition/defkit/render_test.go +++ b/pkg/definition/defkit/render_test.go @@ -716,6 +716,50 @@ var _ = Describe("Render", func() { }) }) + Context("evaluateCondition with NotExpr", func() { + It("should negate IsSet via NotExpr (ParamNotSet)", func() { + comp := defkit.NewComponent("test"). + Workload("v1", "ConfigMap"). + Params(defkit.String("optionalField").Optional()). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("v1", "ConfigMap"). + SetIf(defkit.ParamNotSet("optionalField"), "data.fallback", defkit.Lit("default")), + ) + }) + + // Without the param set, ParamNotSet should evaluate to true + rendered := comp.Render(defkit.TestContext()) + Expect(rendered.Get("data.fallback")).To(Equal("default")) + + // With the param set, ParamNotSet should evaluate to false + rendered2 := comp.Render(defkit.TestContext().WithParam("optionalField", "value")) + Expect(rendered2.Get("data.fallback")).To(BeNil()) + }) + + It("should negate via Not() wrapper with Comparison", func() { + enabled := defkit.Bool("enabled") + + comp := defkit.NewComponent("test"). + Workload("v1", "ConfigMap"). + Params(enabled.Default(true)). + Template(func(tpl *defkit.Template) { + tpl.Output( + defkit.NewResource("v1", "ConfigMap"). + SetIf(defkit.Not(defkit.Eq(enabled, defkit.Lit(true))), "data.disabled", defkit.Lit("yes")), + ) + }) + + // enabled=true => Not(true==true) => Not(true) => false => field absent + rendered := comp.Render(defkit.TestContext().WithParam("enabled", true)) + Expect(rendered.Get("data.disabled")).To(BeNil()) + + // enabled=false => Not(false==true) => Not(false) => true => field present + rendered2 := comp.Render(defkit.TestContext().WithParam("enabled", false)) + Expect(rendered2.Get("data.disabled")).To(Equal("yes")) + }) + }) + Context("evaluateCondition with nil", func() { It("should treat nil condition as true", func() { // This is tested indirectly: a SetIf with no condition should apply diff --git a/pkg/definition/defkit/resource.go b/pkg/definition/defkit/resource.go index 85eb732ac..f140b45bb 100644 --- a/pkg/definition/defkit/resource.go +++ b/pkg/definition/defkit/resource.go @@ -253,3 +253,88 @@ func (d *DirectiveOp) Path() string { return d.path } // GetDirective returns the directive string. func (d *DirectiveOp) GetDirective() string { return d.directive } + +// ConditionalStructOp represents a conditional struct block emitted in the output. +// When the condition is true, the struct builder function is called to generate +// CUE fields at the given path. +// +// Example: +// +// output.ConditionalStruct(replConfig.IsSet(), "spec.replicationConfiguration", func(b *OutputStructBuilder) { +// b.Set("role", defkit.Ref("parameter.replicationConfiguration.role")) +// }) +// +// Emits: +// +// if parameter["replicationConfiguration"] != _|_ { +// spec: replicationConfiguration: { +// role: parameter.replicationConfiguration.role +// } +// } +type ConditionalStructOp struct { + cond Condition + path string + builder func(b *OutputStructBuilder) +} + +func (c *ConditionalStructOp) resourceOp() {} + +// Cond returns the condition. +func (c *ConditionalStructOp) Cond() Condition { return c.cond } + +// Path returns the output path. +func (c *ConditionalStructOp) Path() string { return c.path } + +// Builder returns the struct builder function. +func (c *ConditionalStructOp) Builder() func(b *OutputStructBuilder) { return c.builder } + +// ConditionalStruct records a conditional struct block in the output. +// When the condition is true, the fields built by fn are emitted at the given path. +func (r *Resource) ConditionalStruct(cond Condition, path string, fn func(b *OutputStructBuilder)) *Resource { + op := &ConditionalStructOp{cond: cond, path: path, builder: fn} + if r.currentIf != nil { + r.currentIf.ops = append(r.currentIf.ops, op) + } else { + r.ops = append(r.ops, op) + } + return r +} + +// OutputStructBuilder collects field operations for building a conditional struct. +type OutputStructBuilder struct { + ops []structBuilderOp +} + +type structBuilderOp interface { + structBuilderOp() +} + +type structSetOp struct { + field string + value Value +} + +func (s *structSetOp) structBuilderOp() {} + +type structSetIfOp struct { + cond Condition + field string + value Value +} + +func (s *structSetIfOp) structBuilderOp() {} + +// Set adds an unconditional field assignment to the struct. +func (b *OutputStructBuilder) Set(field string, value Value) { + b.ops = append(b.ops, &structSetOp{field: field, value: value}) +} + +// SetIf adds a conditional field assignment to the struct. +func (b *OutputStructBuilder) SetIf(cond Condition, field string, value Value) { + b.ops = append(b.ops, &structSetIfOp{cond: cond, field: field, value: value}) +} + +// Ops returns all operations recorded in the struct builder. +func (b *OutputStructBuilder) Ops() []structBuilderOp { + return b.ops +} diff --git a/pkg/definition/defkit/resource_test.go b/pkg/definition/defkit/resource_test.go index e1d0e71f3..894c3a87e 100644 --- a/pkg/definition/defkit/resource_test.go +++ b/pkg/definition/defkit/resource_test.go @@ -203,6 +203,109 @@ var _ = Describe("Resource", func() { }) }) + Context("ConditionalStruct", func() { + It("should record a ConditionalStructOp", func() { + cond := defkit.Bool("flag").IsSet() + r := defkit.NewResource("v1", "ConfigMap"). + ConditionalStruct(cond, "spec.config", func(b *defkit.OutputStructBuilder) { + b.Set("key", defkit.Lit("value")) + }) + Expect(r.Ops()).To(HaveLen(1)) + csOp, ok := r.Ops()[0].(*defkit.ConditionalStructOp) + Expect(ok).To(BeTrue()) + Expect(csOp.Path()).To(Equal("spec.config")) + Expect(csOp.Cond()).To(Equal(cond)) + Expect(csOp.Builder()).NotTo(BeNil()) + }) + + It("should record ConditionalStructOp inside If block", func() { + flag := defkit.Bool("flag") + inner := defkit.Object("config").IsSet() + r := defkit.NewResource("v1", "ConfigMap"). + If(flag.Eq(true)). + ConditionalStruct(inner, "spec.nested", func(b *defkit.OutputStructBuilder) { + b.Set("x", defkit.Lit(1)) + }). + EndIf() + + Expect(r.Ops()).To(HaveLen(1)) + ifBlock, ok := r.Ops()[0].(*defkit.IfBlock) + Expect(ok).To(BeTrue()) + Expect(ifBlock.Ops()).To(HaveLen(1)) + _, isCS := ifBlock.Ops()[0].(*defkit.ConditionalStructOp) + Expect(isCS).To(BeTrue()) + }) + + It("should invoke the builder function and record ops", func() { + cond := defkit.Bool("flag").IsSet() + var builderCalled bool + r := defkit.NewResource("v1", "ConfigMap"). + ConditionalStruct(cond, "spec.data", func(b *defkit.OutputStructBuilder) { + builderCalled = true + b.Set("name", defkit.Lit("test")) + b.SetIf(defkit.Bool("debug").Eq(true), "debug", defkit.Lit(true)) + }) + + // Builder is not called at record time, only at CUE generation time + Expect(builderCalled).To(BeFalse()) + + // Call builder manually to verify it works + csOp := r.Ops()[0].(*defkit.ConditionalStructOp) + builder := &defkit.OutputStructBuilder{} + csOp.Builder()(builder) + Expect(builderCalled).To(BeTrue()) + Expect(builder.Ops()).To(HaveLen(2)) + }) + + It("should coexist with Set operations", func() { + r := defkit.NewResource("v1", "ConfigMap"). + Set("metadata.name", defkit.Lit("test")). + ConditionalStruct(defkit.Bool("x").IsSet(), "spec.x", func(b *defkit.OutputStructBuilder) { + b.Set("val", defkit.Lit(1)) + }). + Set("spec.type", defkit.Lit("Opaque")) + + Expect(r.Ops()).To(HaveLen(3)) + _, isSet1 := r.Ops()[0].(*defkit.SetOp) + _, isCS := r.Ops()[1].(*defkit.ConditionalStructOp) + _, isSet2 := r.Ops()[2].(*defkit.SetOp) + Expect(isSet1).To(BeTrue()) + Expect(isCS).To(BeTrue()) + Expect(isSet2).To(BeTrue()) + }) + }) + + Context("OutputStructBuilder", func() { + It("should record Set operations", func() { + b := &defkit.OutputStructBuilder{} + b.Set("name", defkit.Lit("test")) + b.Set("count", defkit.Lit(5)) + + Expect(b.Ops()).To(HaveLen(2)) + }) + + It("should record SetIf operations", func() { + b := &defkit.OutputStructBuilder{} + b.SetIf(defkit.Bool("flag").Eq(true), "enabled", defkit.Lit(true)) + + Expect(b.Ops()).To(HaveLen(1)) + }) + + It("should record mixed Set and SetIf operations in order", func() { + b := &defkit.OutputStructBuilder{} + b.Set("name", defkit.Lit("a")) + b.SetIf(defkit.Bool("x").Eq(true), "x", defkit.Lit(1)) + b.Set("type", defkit.Lit("b")) + + Expect(b.Ops()).To(HaveLen(3)) + }) + + It("should return empty ops when nothing recorded", func() { + b := &defkit.OutputStructBuilder{} + Expect(b.Ops()).To(BeEmpty()) + }) + }) + Context("Directive", func() { It("should record a Directive operation", func() { r := defkit.NewResource("apps/v1", "DaemonSet"). diff --git a/pkg/definition/defkit/validator.go b/pkg/definition/defkit/validator.go new file mode 100644 index 000000000..4b97851d6 --- /dev/null +++ b/pkg/definition/defkit/validator.go @@ -0,0 +1,239 @@ +/* +Copyright 2025 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defkit + +// Validator represents a CUE _validate* block pattern used for cross-field validation. +// Validators emit blocks like: +// +// _validateTenantName: { +// "tenantName must not end with a hyphen": true +// if tenantName =~ ".*-$" { +// "tenantName must not end with a hyphen": false +// } +// } +// +// Validators can be guarded (only active when a condition is true) and can be attached +// at different levels: top-level parameter, inside map/struct params, or inside array elements. +type Validator struct { + message string // the validation message (used as CUE field key) + failCond Condition // when this condition is true, validation fails + guardCond Condition // optional: validator only active when guard is true + name string // optional: override the CUE variable name (default: derived from message) +} + +// Validate creates a new Validator with the given error message. +// The message is used both as the CUE field key and as the error shown on failure. +func Validate(message string) *Validator { + return &Validator{message: message} +} + +// FailWhen sets the condition that causes validation to fail. +// When this condition evaluates to true, the validator emits false for the message key. +func (v *Validator) FailWhen(cond Condition) *Validator { + v.failCond = cond + return v +} + +// OnlyWhen sets a guard condition — the validator is only active when this condition is true. +// If the guard condition is false, the entire validator block is skipped. +func (v *Validator) OnlyWhen(guard Condition) *Validator { + v.guardCond = guard + return v +} + +// WithName overrides the CUE variable name for the validator block. +// By default, the name is derived from the message (e.g., "_validateTenantName"). +func (v *Validator) WithName(name string) *Validator { + v.name = name + return v +} + +// Message returns the validation message. +func (v *Validator) Message() string { return v.message } + +// FailCondition returns the fail condition. +func (v *Validator) FailCondition() Condition { return v.failCond } + +// GuardCondition returns the guard condition, or nil if not set. +func (v *Validator) GuardCondition() Condition { return v.guardCond } + +// CUEName returns the CUE variable name for this validator. +func (v *Validator) CUEName() string { return v.name } + +// LocalField creates a reference to a field in the current CUE scope (no "parameter." prefix). +// Used inside validators for condition-building on sibling fields. +// The name is emitted verbatim — supports dot-paths ("Principal.AWS") +// and array indexing ("expiration[0].date"). +// +// Compare with defkit.String("name") / defkit.Bool("name") which reference +// top-level parameters and emit "parameter.name" in CUE. +// +// Example: LocalField("tenantName").Matches(".*-$") +func LocalField(name string) *LocalFieldRef { + return &LocalFieldRef{fieldName: name} +} + +// LocalFieldRef represents a reference to a field within the current scope. +// It implements Value so it can be used with upstream Comparison, LenValueCondition, etc. +// It provides ergonomic condition builder methods: Matches(), Eq(), IsSet(), LenEq(), Gte(), etc. +type LocalFieldRef struct { + fieldName string +} + +// Value/Expr interface implementation — allows LocalFieldRef to be used +// with upstream types like Comparison, LenValueCondition, etc. +func (s *LocalFieldRef) expr() {} +func (s *LocalFieldRef) value() {} + +// Name returns the field name. +func (s *LocalFieldRef) Name() string { return s.fieldName } + +// Matches creates a condition that checks if this field matches a regex pattern. +// Example: LocalField("tenantName").Matches(".*-$") generates: tenantName =~ ".*-$" +// Reuses upstream RegexMatchCondition. +func (s *LocalFieldRef) Matches(pattern string) Condition { + return RegexMatch(s, pattern) +} + +// Eq creates a condition comparing this field to a value. +// Example: LocalField("type").Eq("aws") generates: type == "aws" +// Reuses upstream Comparison type. +func (s *LocalFieldRef) Eq(val any) Condition { + return Eq(s, Lit(val)) +} + +// Ne creates a condition checking this field is not equal to a value. +// Reuses upstream Comparison type. +func (s *LocalFieldRef) Ne(val any) Condition { + return Ne(s, Lit(val)) +} + +// IsSet creates a condition that checks if this field has a value (not bottom). +// Example: LocalField("role").IsSet() generates: role != _|_ +// Reuses upstream PathExistsCondition. +func (s *LocalFieldRef) IsSet() Condition { + return PathExists(s.fieldName) +} + +// NotSet creates a condition that checks if this field is not set (is bottom). +// Example: LocalField("role").NotSet() generates: role == _|_ +func (s *LocalFieldRef) NotSet() Condition { + return Not(PathExists(s.fieldName)) +} + +// LenEq creates a condition that checks if this field's length equals n. +// Example: LocalField("Principal.AWS").LenEq(0) generates: len(Principal.AWS) == 0 +// Reuses upstream LenValueCondition via LenEq(). +func (s *LocalFieldRef) LenEq(n int) Condition { + return LenEq(s, n) +} + +// LenGt creates a condition that checks if this field's length is greater than n. +// Reuses upstream LenValueCondition via LenGt(). +func (s *LocalFieldRef) LenGt(n int) Condition { + return LenGt(s, n) +} + +// IsEmpty creates a condition that checks if this field has length 0. +func (s *LocalFieldRef) IsEmpty() Condition { + return s.LenEq(0) +} + +// Gte creates a condition comparing this field >= another local field. +// Example: LocalField("days").Gte(LocalField("expiration[0].days")) +// generates: days >= expiration[0].days +// Reuses upstream Comparison type via Ge(). +func (s *LocalFieldRef) Gte(other *LocalFieldRef) Condition { + return Ge(s, other) +} + +// LenOfExpr wraps a Value expression and provides comparison methods that produce Conditions. +// It emits len() in CUE. Reuses upstream LenValueCondition. +// +// Example: LenOf(Plus(Lit("tenant-"), Reference("parameter.governance.tenantName"), Lit("-"), name)).Gt(63) +// generates: len("tenant-" + parameter.governance.tenantName + "-" + parameter.name) > 63 +type LenOfExpr struct { + inner Value +} + +// LenOf creates a length expression wrapper around any Value. +func LenOf(v Value) *LenOfExpr { + return &LenOfExpr{inner: v} +} + +// Gt creates a condition: len(inner) > n. Returns upstream *LenValueCondition. +func (l *LenOfExpr) Gt(n int) Condition { + return LenGt(l.inner, n) +} + +// Gte creates a condition: len(inner) >= n. Returns upstream *LenValueCondition. +func (l *LenOfExpr) Gte(n int) Condition { + return LenGe(l.inner, n) +} + +// Eq creates a condition: len(inner) == n. Returns upstream *LenValueCondition. +func (l *LenOfExpr) Eq(n int) Condition { + return LenEq(l.inner, n) +} + +// TimeParse creates a Value representing time.Parse(layout, expr) in CUE. +// Used for date comparison validators. +// +// Example: TimeParse("2006-01-02T15:04:05Z", LocalField("date")) +// generates: time.Parse("2006-01-02T15:04:05Z", date) +func TimeParse(layout string, field *LocalFieldRef) *TimeParseExpr { + return &TimeParseExpr{layout: layout, fieldName: field.fieldName} +} + +// TimeParseExpr represents a time.Parse() call in CUE. +type TimeParseExpr struct { + layout string + fieldName string +} + +func (t *TimeParseExpr) expr() {} +func (t *TimeParseExpr) value() {} + +// Layout returns the time layout string. +func (t *TimeParseExpr) Layout() string { return t.layout } + +// FieldName returns the field name passed to time.Parse. +func (t *TimeParseExpr) FieldName() string { return t.fieldName } + +// Gte creates a condition: time.Parse(layout, fieldA) >= time.Parse(layout, fieldB) +// Reuses upstream Comparison type via Ge(). +func (t *TimeParseExpr) Gte(other *TimeParseExpr) Condition { + return Ge(t, other) +} + +// RawCUECondition wraps a raw CUE expression string as a Condition. +// Use this for expressions too complex to model with the fluent API. +// +// Example: CUEExpr(`len("tenant-"+parameter.governance.tenantName+"-"+name) > 63`) +type RawCUECondition struct { + baseCondition + rawExpr string +} + +// CUEExpr creates a condition from a raw CUE expression string. +// The expression is emitted verbatim in the generated CUE. +func CUEExpr(rawExpr string) *RawCUECondition { + return &RawCUECondition{rawExpr: rawExpr} +} + +// Expr returns the raw CUE expression. +func (c *RawCUECondition) Expr() string { return c.rawExpr } diff --git a/pkg/definition/defkit/validator_test.go b/pkg/definition/defkit/validator_test.go new file mode 100644 index 000000000..785a041f2 --- /dev/null +++ b/pkg/definition/defkit/validator_test.go @@ -0,0 +1,627 @@ +/* +Copyright 2025 The KubeVela Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defkit_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/oam-dev/kubevela/pkg/definition/defkit" +) + +var _ = Describe("Validator", func() { + var gen *defkit.CUEGenerator + + BeforeEach(func() { + gen = defkit.NewCUEGenerator() + }) + + // --- Validator builder and accessors --- + + Context("Builder and Accessors", func() { + It("should create a validator with message", func() { + v := defkit.Validate("field is required") + Expect(v.Message()).To(Equal("field is required")) + Expect(v.CUEName()).To(BeEmpty()) + Expect(v.GuardCondition()).To(BeNil()) + Expect(v.FailCondition()).To(BeNil()) + }) + + It("should set and return WithName", func() { + v := defkit.Validate("msg").WithName("_validateFoo") + Expect(v.CUEName()).To(Equal("_validateFoo")) + }) + + It("should set and return FailWhen condition", func() { + cond := defkit.LocalField("x").Eq("") + v := defkit.Validate("msg").FailWhen(cond) + Expect(v.FailCondition()).To(Equal(cond)) + }) + + It("should set and return OnlyWhen guard condition", func() { + guard := defkit.Bool("flag").Eq(true) + v := defkit.Validate("msg").OnlyWhen(guard) + Expect(v.GuardCondition()).To(Equal(guard)) + }) + + It("should support full builder chain returning all accessors correctly", func() { + guard := defkit.Bool("x").Eq(true) + fail := defkit.LocalField("y").Eq("") + v := defkit.Validate("y is required"). + WithName("_validateY"). + OnlyWhen(guard). + FailWhen(fail) + + Expect(v.Message()).To(Equal("y is required")) + Expect(v.CUEName()).To(Equal("_validateY")) + Expect(v.GuardCondition()).To(Equal(guard)) + Expect(v.FailCondition()).To(Equal(fail)) + }) + }) + + // --- Validator CUE generation --- + + Context("Unguarded Validator CUE Generation", func() { + It("should generate a basic unguarded validator block", func() { + v := defkit.Validate("tenantName must not end with a hyphen"). + WithName("_validateTenantName"). + FailWhen(defkit.LocalField("tenantName").Matches(".*-$")) + + comp := defkit.NewComponent("test"). + Params(defkit.String("tenantName")). + Validators(v) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("_validateTenantName:")) + Expect(cue).To(ContainSubstring(`"tenantName must not end with a hyphen": true`)) + Expect(cue).To(ContainSubstring(`tenantName =~ ".*-$"`)) + Expect(cue).To(ContainSubstring(`"tenantName must not end with a hyphen": false`)) + }) + + It("should use fallback _validate name when no name is set", func() { + v := defkit.Validate("something is wrong") + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("_validate:")) + }) + + It("should generate validator with no fail condition", func() { + v := defkit.Validate("always passes").WithName("_validateAlways") + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring(`_validateAlways:`)) + Expect(cue).To(ContainSubstring(`"always passes": true`)) + Expect(cue).NotTo(ContainSubstring(`"always passes": false`)) + }) + }) + + Context("Guarded Validator CUE Generation", func() { + It("should wrap validator in guard condition", func() { + replConfig := defkit.Object("replicationConfiguration").Optional() + objectLock := defkit.Object("objectLock").Optional() + versioningEnabled := defkit.Bool("versioningEnabled").Default(true) + + v := defkit.Validate("Require versioningEnabled to be true when replication or object lock is configured"). + WithName("_validateVersioning"). + OnlyWhen(defkit.Or(replConfig.IsSet(), objectLock.IsSet())). + FailWhen(versioningEnabled.Eq(false)) + + comp := defkit.NewComponent("test"). + Params(replConfig, objectLock, versioningEnabled). + Validators(v) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring(`replicationConfiguration"] != _|_`)) + Expect(cue).To(ContainSubstring(`objectLock"] != _|_`)) + Expect(cue).To(ContainSubstring("parameter.versioningEnabled == false")) + Expect(cue).To(ContainSubstring("_validateVersioning:")) + }) + }) + + Context("Validator inside MapParam", func() { + It("should emit validator inside struct", func() { + v := defkit.Validate("name is required"). + WithName("_validateName"). + FailWhen(defkit.LocalField("name").Eq("")) + + mp := defkit.Object("governance").WithFields( + defkit.String("name"), + defkit.String("department"), + ).Validators(v) + + comp := defkit.NewComponent("test").Params(mp) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("governance: {")) + Expect(cue).To(ContainSubstring("_validateName:")) + Expect(cue).To(ContainSubstring(`name == ""`)) + }) + + It("should emit mutual exclusion validator inside struct", func() { + v := defkit.Validate("Principal and NotPrincipal cannot be used together"). + WithName("_validatePrincipal"). + FailWhen(defkit.And(defkit.LocalField("Principal").IsSet(), defkit.LocalField("NotPrincipal").IsSet())) + + comp := defkit.NewComponent("test"). + Params( + defkit.Object("statement").WithFields( + defkit.String("Principal").Optional(), + defkit.String("NotPrincipal").Optional(), + ).Validators(v), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("_validatePrincipal:")) + Expect(cue).To(ContainSubstring("Principal != _|_")) + Expect(cue).To(ContainSubstring("NotPrincipal != _|_")) + }) + }) + + Context("Validator inside ArrayParam", func() { + It("should emit validator inside array element struct", func() { + v := defkit.Validate("action is required"). + WithName("_validateAction"). + FailWhen(defkit.LocalField("action").Eq("")) + + arr := defkit.Array("rules").WithFields( + defkit.String("action"), + defkit.String("resource"), + ).Validators(v) + + comp := defkit.NewComponent("test").Params(arr) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("[...{")) + Expect(cue).To(ContainSubstring("_validateAction:")) + }) + + It("should emit non-empty length validator inside array element struct", func() { + arr := defkit.Array("corsRules").Optional().WithFields( + defkit.Array("allowedMethods").OfEnum("GET", "PUT", "HEAD", "POST", "DELETE"), + ).Validators( + defkit.Validate("allowedMethods cannot be empty"). + WithName("_validateAllowedMethods"). + FailWhen(defkit.LocalField("allowedMethods").IsEmpty()), + ) + + comp := defkit.NewComponent("test").Params(arr) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("len(allowedMethods) == 0")) + }) + }) + + // --- LocalFieldRef --- + + Context("LocalFieldRef", func() { + It("should return the field name", func() { + ref := defkit.LocalField("tenantName") + Expect(ref.Name()).To(Equal("tenantName")) + }) + + It("should support dot-path names", func() { + ref := defkit.LocalField("Principal.AWS") + Expect(ref.Name()).To(Equal("Principal.AWS")) + }) + + It("should support array-indexed names", func() { + ref := defkit.LocalField("expiration[0].date") + Expect(ref.Name()).To(Equal("expiration[0].date")) + }) + + It("should implement Value interface", func() { + ref := defkit.LocalField("test") + var v defkit.Value = ref + Expect(v).NotTo(BeNil()) + }) + + It("Matches should generate regex match CUE condition", func() { + v := defkit.Validate("bad pattern"). + WithName("_v"). + FailWhen(defkit.LocalField("name").Matches(".*-$")) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`name =~ ".*-$"`)) + }) + + It("Eq should generate equality CUE condition", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.LocalField("type").Eq("disabled")) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`type == "disabled"`)) + }) + + It("Ne should generate inequality CUE condition", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.LocalField("type").Ne("enabled")) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`type != "enabled"`)) + }) + + It("IsSet should generate path-exists CUE condition", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.Not(defkit.LocalField("role").IsSet())) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("role == _|_")) + }) + + It("NotSet should generate path-not-exists CUE condition", func() { + v := defkit.Validate("role must be set"). + WithName("_v"). + FailWhen(defkit.LocalField("role").NotSet()) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("role == _|_")) + }) + + It("LenEq should generate length equality CUE condition", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.LocalField("Principal.AWS").LenEq(0)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(Principal.AWS) == 0")) + }) + + It("LenGt should generate length greater-than CUE condition", func() { + v := defkit.Validate("too many"). + WithName("_v"). + FailWhen(defkit.LocalField("items").LenGt(10)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(items) > 10")) + }) + + It("IsEmpty should generate length == 0 CUE condition", func() { + v := defkit.Validate("empty"). + WithName("_v"). + FailWhen(defkit.LocalField("list").IsEmpty()) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(list) == 0")) + }) + + It("Gte should generate >= comparison between two local fields", func() { + v := defkit.Validate("days must be >= minDays"). + WithName("_v"). + FailWhen(defkit.Not(defkit.LocalField("days").Gte(defkit.LocalField("minDays")))) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("days >= minDays")) + }) + }) + + // --- LenOfExpr --- + + Context("LenOfExpr", func() { + It("Gt should generate len(value) > n", func() { + v := defkit.Validate("too long"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.LocalField("name")).Gt(63)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(name) > 63")) + }) + + It("Gte should generate len(value) >= n", func() { + v := defkit.Validate("too long"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.LocalField("data")).Gte(100)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(data) >= 100")) + }) + + It("Eq should generate len(value) == n", func() { + v := defkit.Validate("wrong length"). + WithName("_v"). + FailWhen(defkit.Not(defkit.LenOf(defkit.LocalField("code")).Eq(3))) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring("len(code) == 3")) + }) + + It("should work with complex expressions like Plus", func() { + v := defkit.Validate("combined name too long"). + WithName("_v"). + FailWhen(defkit.LenOf(defkit.Plus( + defkit.Lit("tenant-"), + defkit.Reference("parameter.name"), + )).Gt(63)) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + Expect(cue).To(ContainSubstring(`len("tenant-" + parameter.name) > 63`)) + }) + }) + + // --- TimeParse --- + + Context("TimeParse", func() { + It("should return correct accessors", func() { + tp := defkit.TimeParse("2006-01-02T15:04:05Z", defkit.LocalField("startDate")) + Expect(tp.Layout()).To(Equal("2006-01-02T15:04:05Z")) + Expect(tp.FieldName()).To(Equal("startDate")) + }) + + It("should generate time.Parse CUE expression in validator", func() { + v := defkit.Validate("start must be before end"). + WithName("_v"). + FailWhen(defkit.TimeParse("2006-01-02T15:04:05Z", defkit.LocalField("startDate")). + Gte(defkit.TimeParse("2006-01-02T15:04:05Z", defkit.LocalField("endDate")))) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02T15:04:05Z", startDate)`)) + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02T15:04:05Z", endDate)`)) + Expect(cue).To(ContainSubstring(">=")) + }) + + It("should use different layout formats", func() { + v := defkit.Validate("check"). + WithName("_v"). + FailWhen(defkit.TimeParse("2006-01-02", defkit.LocalField("d1")). + Gte(defkit.TimeParse("2006-01-02", defkit.LocalField("d2")))) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02", d1)`)) + Expect(cue).To(ContainSubstring(`time.Parse("2006-01-02", d2)`)) + }) + }) + + // --- RawCUECondition / CUEExpr --- + + Context("CUEExpr", func() { + It("should return raw expression from accessor", func() { + c := defkit.CUEExpr(`len(x) > 5`) + Expect(c.Expr()).To(Equal(`len(x) > 5`)) + }) + + It("should emit raw expression in guarded validator", func() { + existingResources := defkit.Bool("existingResources").Default(false) + + v := defkit.Validate("Combined name must be less than 64 characters"). + WithName("_validateNameLength"). + OnlyWhen(existingResources.Eq(false)). + FailWhen(defkit.CUEExpr(`len("tenant-"+parameter.governance.tenantName+"-"+name) > 63`)) + + comp := defkit.NewComponent("test"). + Params(existingResources). + Validators(v) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring(`len("tenant-"+parameter.governance.tenantName+"-"+name) > 63`)) + Expect(cue).To(ContainSubstring("if parameter.existingResources == false")) + }) + + It("should work inside And condition", func() { + v := defkit.Validate("complex check"). + WithName("_validateComplex"). + FailWhen(defkit.And( + defkit.CUEExpr(`len(parameter.name) > 10`), + defkit.CUEExpr(`parameter.name =~ "^test"`), + )) + + comp := defkit.NewComponent("test").Validators(v) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("len(parameter.name) > 10")) + Expect(cue).To(ContainSubstring(`parameter.name =~ "^test"`)) + }) + }) + + // --- ConditionalParams CUE generation --- + + Context("ConditionalParams CUE Generation", func() { + It("should generate conditional parameter blocks", func() { + existingResources := defkit.Bool("existingResources").Default(false) + + comp := defkit.NewComponent("test"). + Params(existingResources). + ConditionalParams(defkit.ConditionalParams( + defkit.WhenParam(existingResources.Eq(false)).Params( + defkit.Bool("forceDestroy").Default(false), + defkit.String("sseAlgorithm").Default("AES256").Values("AES256", "aws:kms"), + ), + defkit.WhenParam(existingResources.Eq(true)).Params( + defkit.Bool("forceDestroy").Optional(), + defkit.String("sseAlgorithm").Optional().Values("AES256", "aws:kms"), + ), + )) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("if parameter.existingResources == false")) + Expect(cue).To(ContainSubstring("if parameter.existingResources == true")) + Expect(cue).To(ContainSubstring(`forceDestroy: *false | bool`)) + Expect(cue).To(ContainSubstring("forceDestroy?: bool")) + }) + + It("should generate validators inside conditional blocks", func() { + existingResources := defkit.Bool("existingResources").Default(false) + kmsMasterKeyId := defkit.String("kmsMasterKeyId").Optional() + + comp := defkit.NewComponent("test"). + Params(existingResources, kmsMasterKeyId). + ConditionalParams(defkit.ConditionalParams( + defkit.WhenParam(existingResources.Eq(false)).Params( + defkit.String("sseAlgorithm").Default("AES256").Values("AES256", "aws:kms"), + ).Validators( + defkit.Validate("kmsMasterKeyId can only be specified when sseAlgorithm is aws:kms"). + WithName("_validateKms"). + FailWhen(defkit.And( + defkit.LocalField("sseAlgorithm").Ne("aws:kms"), + kmsMasterKeyId.IsSet(), + )), + ), + )) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("_validateKms:")) + Expect(cue).To(ContainSubstring(`sseAlgorithm != "aws:kms"`)) + }) + }) + + Context("ConditionalFields Inside MapParam CUE Generation", func() { + It("should generate conditional fields inside struct", func() { + existingResources := defkit.Bool("existingResources").Default(false) + + objectLock := defkit.Object("objectLock").Optional().ConditionalFields( + defkit.WhenParam(existingResources.Eq(false)).Params( + defkit.Int("retentionDays").Optional().Default(45).Min(1), + defkit.String("retentionMode").Optional().Default("GOVERNANCE").Values("GOVERNANCE", "COMPLIANCE"), + ), + defkit.WhenParam(existingResources.Eq(true)).Params( + defkit.Int("retentionDays").Min(1), + defkit.String("retentionMode").Values("GOVERNANCE", "COMPLIANCE"), + ), + ) + + comp := defkit.NewComponent("test").Params(existingResources, objectLock) + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("objectLock?: {")) + Expect(cue).To(ContainSubstring("if parameter.existingResources == false")) + Expect(cue).To(ContainSubstring(`retentionDays?: *45 | int & >=1`)) + }) + }) + + // --- ConditionalStruct on Resource CUE generation --- + + Context("ConditionalStruct CUE Generation", func() { + It("should generate conditional struct block in output", func() { + replConfig := defkit.Object("replicationConfiguration").Optional() + + comp := defkit.NewComponent("test"). + Params(replConfig). + Workload("apps/v1", "Deployment"). + Template(func(tpl *defkit.Template) { + output := tpl.Output(defkit.NewResource("v1", "ConfigMap")) + output.Set("metadata.name", defkit.Lit("test")). + ConditionalStruct(replConfig.IsSet(), "spec.replicationConfiguration", func(b *defkit.OutputStructBuilder) { + b.Set("role", defkit.Reference("parameter.replicationConfiguration.role")) + b.SetIf(replConfig.IsSet(), "enabled", defkit.Lit(true)) + }) + }) + + cue := gen.GenerateTemplate(comp) + + Expect(cue).To(ContainSubstring(`if parameter["replicationConfiguration"] != _|_`)) + Expect(cue).To(ContainSubstring("replicationConfiguration:")) + Expect(cue).To(ContainSubstring("role: parameter.replicationConfiguration.role")) + Expect(cue).To(ContainSubstring("enabled:")) + }) + + It("should generate conditional struct with SetIf inside", func() { + existingResources := defkit.Bool("existingResources").Default(false) + replConfig := defkit.Object("replicationConfiguration").Optional() + + comp := defkit.NewComponent("test"). + Params(existingResources, replConfig). + Workload("apps/v1", "Deployment"). + Template(func(tpl *defkit.Template) { + output := tpl.Output(defkit.NewResource("v1", "ConfigMap")) + output.Set("metadata.name", defkit.Lit("test")). + ConditionalStruct(replConfig.IsSet(), "spec.replication", func(b *defkit.OutputStructBuilder) { + b.Set("role", defkit.Reference("parameter.replicationConfiguration.role")) + b.SetIf(existingResources.Eq(false), "destinationBucketName", defkit.Lit("replica-bucket")) + }) + }) + + cue := gen.GenerateTemplate(comp) + + Expect(cue).To(ContainSubstring("parameter.existingResources == false")) + Expect(cue).To(ContainSubstring("destinationBucketName:")) + }) + }) + + // --- Integration: all features combined --- + + Context("All Features Integrated", func() { + It("should combine validators, conditional params, closed structs, and CUE expressions", func() { + existingResources := defkit.Bool("existingResources").Default(false) + governance := defkit.Object("governance").Closed().WithFields( + defkit.String("tenantName").NotEmpty(), + defkit.String("departmentCode").NotEmpty(), + ).Validators( + defkit.Validate("tenantName must not end with a hyphen"). + WithName("_validateTenant"). + FailWhen(defkit.LocalField("tenantName").Matches(".*-$")), + ) + + comp := defkit.NewComponent("s3-bucket"). + Params(existingResources, governance). + ConditionalParams(defkit.ConditionalParams( + defkit.WhenParam(existingResources.Eq(false)).Params( + defkit.Bool("forceDestroy").Default(false), + ), + defkit.WhenParam(existingResources.Eq(true)).Params( + defkit.Bool("forceDestroy").Optional(), + ), + )). + Validators( + defkit.Validate("Combined name check"). + WithName("_validateName"). + OnlyWhen(existingResources.Eq(false)). + FailWhen(defkit.CUEExpr(`len(parameter.governance.tenantName) > 63`)), + ) + + cue := gen.GenerateParameterSchema(comp) + + Expect(cue).To(ContainSubstring("existingResources: *false | bool")) + Expect(cue).To(ContainSubstring("governance: close({")) + Expect(cue).To(ContainSubstring(`!=""`)) + // Validator uses Matches which emits =~ (positive match inside fail block) + Expect(cue).To(ContainSubstring(`tenantName =~ ".*-$"`)) + Expect(cue).To(ContainSubstring("_validateTenant:")) + Expect(cue).To(ContainSubstring("if parameter.existingResources == false")) + Expect(cue).To(ContainSubstring("if parameter.existingResources == true")) + Expect(cue).To(ContainSubstring("forceDestroy: *false | bool")) + Expect(cue).To(ContainSubstring("forceDestroy?: bool")) + Expect(cue).To(ContainSubstring("_validateName:")) + Expect(cue).To(ContainSubstring(`len(parameter.governance.tenantName) > 63`)) + }) + }) +})