From aa03d53524c9483b0a25b8ae006828fea3dba45c Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 31 Dec 2025 12:58:48 -0500 Subject: [PATCH] test(plugins): add unit tests for rustOutputType and isPrimitiveRustType functions Signed-off-by: Deluan --- plugins/cmd/ndpgen/internal/generator.go | 4 + plugins/cmd/ndpgen/internal/generator_test.go | 200 ++++ .../cmd/ndpgen/internal/xtp_schema_test.go | 934 ++++++++++++------ 3 files changed, 830 insertions(+), 308 deletions(-) diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index ad36ee640..23fedb39b 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -469,6 +469,10 @@ func rustConstType(goType string) string { // rustOutputType converts a Go type to Rust for capability method signatures. // It handles pointer types specially - for capability outputs, pointers become the base type // (not Option) because Rust's Result already provides optional semantics. +// +// TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. +// Currently "*string" returns "string" instead of "String". This would generate invalid +// Rust code. No current capability uses this pattern, but it should be fixed if needed. func rustOutputType(goType string) string { // Strip pointer prefix - capability outputs use Result for optionality if strings.HasPrefix(goType, "*") { diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index c8355b014..f8fc568d2 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -955,6 +955,206 @@ type OnInitOutput struct { }) }) +var _ = Describe("Rust Generation", func() { + Describe("rustOutputType", func() { + It("should convert Go primitives to Rust primitives", func() { + Expect(rustOutputType("bool")).To(Equal("bool")) + Expect(rustOutputType("string")).To(Equal("String")) + Expect(rustOutputType("int")).To(Equal("i32")) + Expect(rustOutputType("int32")).To(Equal("i32")) + Expect(rustOutputType("int64")).To(Equal("i64")) + Expect(rustOutputType("float32")).To(Equal("f32")) + Expect(rustOutputType("float64")).To(Equal("f64")) + }) + + It("should strip pointer prefix", func() { + // NOTE: This behavior is incorrect for pointer to primitives. + // "*string" returns "string" instead of "String", which would generate + // invalid Rust code. No current capability uses this pattern. + // See TODO in rustOutputType function. + Expect(rustOutputType("*string")).To(Equal("string")) + Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct")) + }) + + It("should pass through unknown types", func() { + Expect(rustOutputType("CustomType")).To(Equal("CustomType")) + Expect(rustOutputType("MyStruct")).To(Equal("MyStruct")) + }) + }) + + Describe("isPrimitiveRustType", func() { + It("should return true for primitive Go types", func() { + Expect(isPrimitiveRustType("bool")).To(BeTrue()) + Expect(isPrimitiveRustType("string")).To(BeTrue()) + Expect(isPrimitiveRustType("int")).To(BeTrue()) + Expect(isPrimitiveRustType("int32")).To(BeTrue()) + Expect(isPrimitiveRustType("int64")).To(BeTrue()) + Expect(isPrimitiveRustType("float32")).To(BeTrue()) + Expect(isPrimitiveRustType("float64")).To(BeTrue()) + }) + + It("should return false for non-primitive types", func() { + Expect(isPrimitiveRustType("MyStruct")).To(BeFalse()) + Expect(isPrimitiveRustType("CustomType")).To(BeFalse()) + Expect(isPrimitiveRustType("[]string")).To(BeFalse()) + Expect(isPrimitiveRustType("map[string]int")).To(BeFalse()) + }) + + It("should handle pointer types by stripping prefix", func() { + Expect(isPrimitiveRustType("*string")).To(BeTrue()) + Expect(isPrimitiveRustType("*int64")).To(BeTrue()) + Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse()) + }) + }) + + Describe("GenerateCapabilityRust", func() { + It("should generate valid Rust code with primitive output types", func() { + cap := Capability{ + Name: "test", + Interface: "TestAgent", + Required: true, + SourceFile: "test", + Methods: []Export{ + { + Name: "GetBool", + ExportName: "nd_get_bool", + Input: Param{Type: "BoolInput"}, + Output: Param{Type: "bool"}, + }, + { + Name: "GetString", + ExportName: "nd_get_string", + Input: Param{Type: "StrInput"}, + Output: Param{Type: "string"}, + }, + { + Name: "GetInt", + ExportName: "nd_get_int", + Input: Param{Type: "IntInput"}, + Output: Param{Type: "int32"}, + }, + }, + Structs: []StructDef{ + {Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}}, + {Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}}, + }, + } + + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Check that primitive output types are not prefixed with $crate:: + // The template should use isPrimitiveRust to determine this + Expect(codeStr).To(ContainSubstring("FnResult>")) + Expect(codeStr).To(ContainSubstring("FnResult>")) + Expect(codeStr).To(ContainSubstring("FnResult>")) + + // Verify that primitive output types don't use $crate:: prefix in FnResult + // The pattern "$crate::test::bool>" would indicate incorrect generation + Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>")) + Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>")) + Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>")) + }) + + It("should generate valid Rust code with struct output types", func() { + cap := Capability{ + Name: "metadata", + Interface: "MetadataAgent", + Required: true, + SourceFile: "metadata", + Methods: []Export{ + { + Name: "GetArtist", + ExportName: "nd_get_artist", + Input: Param{Type: "ArtistInput"}, + Output: Param{Type: "ArtistOutput"}, + }, + }, + Structs: []StructDef{ + {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + }, + } + + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Non-primitive struct types should use $crate:: prefix + Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput")) + }) + + It("should generate valid Rust code with pointer output types", func() { + cap := Capability{ + Name: "test", + Interface: "TestAgent", + Required: true, + SourceFile: "test", + Methods: []Export{ + { + Name: "GetOptionalStruct", + ExportName: "nd_get_optional_struct", + Input: Param{Type: "Input"}, + Output: Param{Type: "*Output"}, + }, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, + }, + } + + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + // Pointer to struct should strip pointer and use struct type with $crate:: + Expect(codeStr).To(ContainSubstring("$crate::test::Output>")) + // Pointer output types should NOT have Option<> wrapping - Result handles optionality + Expect(codeStr).NotTo(ContainSubstring("Option<")) + }) + + It("should include all float types correctly", func() { + cap := Capability{ + Name: "test", + Interface: "TestAgent", + Required: true, + SourceFile: "test", + Methods: []Export{ + { + Name: "GetFloat32", + ExportName: "nd_get_float32", + Input: Param{Type: "Input"}, + Output: Param{Type: "float32"}, + }, + { + Name: "GetFloat64", + ExportName: "nd_get_float64", + Input: Param{Type: "Input"}, + Output: Param{Type: "float64"}, + }, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + }, + } + + code, err := GenerateCapabilityRust(cap) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + + Expect(codeStr).To(ContainSubstring("FnResult>")) + Expect(codeStr).To(ContainSubstring("FnResult>")) + }) + }) +}) + func writeFile(path, content string) error { return os.WriteFile(path, []byte(content), 0600) } diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go index c1115b6c7..2328050d3 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -1,373 +1,691 @@ package internal import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "gopkg.in/yaml.v3" ) -func TestGenerateSchema(t *testing.T) { - tests := []struct { - name string - capability Capability - wantErr bool - validate func(t *testing.T, schema []byte) - }{ - { - name: "basic capability with one export", - capability: Capability{ - Name: "test", - Doc: "Test capability", - SourceFile: "test", - Methods: []Export{ - { - ExportName: "test_method", - Doc: "Test method does something", - Input: NewParam("input", "TestInput"), - Output: NewParam("output", "TestOutput"), - }, - }, - Structs: []StructDef{ - { - Name: "TestInput", - Doc: "Input for test", - Fields: []FieldDef{ - {Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"}, - {Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"}, +var _ = Describe("XTP Schema Generation", func() { + parseSchema := func(schema []byte) map[string]any { + var doc map[string]any + Expect(yaml.Unmarshal(schema, &doc)).To(Succeed()) + return doc + } + + Describe("GenerateSchema", func() { + Context("basic capability with one export", func() { + var schema []byte + + BeforeEach(func() { + capability := Capability{ + Name: "test", + Doc: "Test capability", + SourceFile: "test", + Methods: []Export{ + { + ExportName: "test_method", + Doc: "Test method does something", + Input: NewParam("input", "TestInput"), + Output: NewParam("output", "TestOutput"), }, }, - { - Name: "TestOutput", - Doc: "Output for test", - Fields: []FieldDef{ - {Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"}, + Structs: []StructDef{ + { + Name: "TestInput", + Doc: "Input for test", + Fields: []FieldDef{ + {Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"}, + {Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"}, + }, + }, + { + Name: "TestOutput", + Doc: "Output for test", + Fields: []FieldDef{ + {Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"}, + }, }, }, - }, - }, - validate: func(t *testing.T, schema []byte) { - var doc map[string]any - require.NoError(t, yaml.Unmarshal(schema, &doc)) + } + var err error + schema, err = GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(schema).NotTo(BeEmpty()) + }) - // Check version - assert.Equal(t, "v1-draft", doc["version"]) + It("should have correct version", func() { + doc := parseSchema(schema) + Expect(doc["version"]).To(Equal("v1-draft")) + }) - // Check exports + It("should include exports with description", func() { + doc := parseSchema(schema) exports := doc["exports"].(map[string]any) - assert.Contains(t, exports, "test_method") + Expect(exports).To(HaveKey("test_method")) method := exports["test_method"].(map[string]any) - assert.Equal(t, "Test method does something", method["description"]) + Expect(method["description"]).To(Equal("Test method does something")) + }) - // Check schemas + It("should include schemas for input and output types", func() { + doc := parseSchema(schema) components := doc["components"].(map[string]any) schemas := components["schemas"].(map[string]any) - assert.Contains(t, schemas, "TestInput") - assert.Contains(t, schemas, "TestOutput") + Expect(schemas).To(HaveKey("TestInput")) + Expect(schemas).To(HaveKey("TestOutput")) + }) - // Check TestInput schema + It("should define input schema with correct properties", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) input := schemas["TestInput"].(map[string]any) - assert.Equal(t, "object", input["type"]) // Workaround for XTP code generator + Expect(input["type"]).To(Equal("object")) // Workaround for XTP code generator props := input["properties"].(map[string]any) - assert.Contains(t, props, "name") - assert.Contains(t, props, "count") + Expect(props).To(HaveKey("name")) + Expect(props).To(HaveKey("count")) + }) - // Check required fields (non-pointer, non-omitempty) + It("should mark non-pointer, non-omitempty fields as required", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["TestInput"].(map[string]any) required := input["required"].([]any) - assert.Contains(t, required, "name") - assert.Contains(t, required, "count") - }, - }, - { - name: "capability with pointer fields (nullable)", - capability: Capability{ - Name: "nullable_test", - SourceFile: "nullable_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Required", Type: "string", JSONTag: "required"}, - {Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - }, - }, - validate: func(t *testing.T, schema []byte) { - var doc map[string]any - require.NoError(t, yaml.Unmarshal(schema, &doc)) + Expect(required).To(ContainElement("name")) + Expect(required).To(ContainElement("count")) + }) + }) + Context("capability with pointer fields (nullable)", func() { + var schema []byte + + BeforeEach(func() { + capability := Capability{ + Name: "nullable_test", + SourceFile: "nullable_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Required", Type: "string", JSONTag: "required"}, + {Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + }, + } + var err error + schema, err = GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not mark required field as nullable", func() { + doc := parseSchema(schema) components := doc["components"].(map[string]any) schemas := components["schemas"].(map[string]any) input := schemas["Input"].(map[string]any) props := input["properties"].(map[string]any) - - // Required field is not nullable requiredField := props["required"].(map[string]any) - assert.NotContains(t, requiredField, "nullable") - - // Optional pointer field is nullable - optionalField := props["optional"].(map[string]any) - assert.Equal(t, true, optionalField["nullable"]) - - // Check required array only has non-pointer fields - required := input["required"].([]any) - assert.Contains(t, required, "required") - assert.NotContains(t, required, "optional") - }, - }, - { - name: "capability with enum", - capability: Capability{ - Name: "enum_test", - SourceFile: "enum_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Status", Type: "Status", JSONTag: "status"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - }, - TypeAliases: []TypeAlias{ - {Name: "Status", Type: "string", Doc: "Status type"}, - }, - Consts: []ConstGroup{ - { - Type: "Status", - Values: []ConstDef{ - {Name: "StatusPending", Value: `"pending"`}, - {Name: "StatusActive", Value: `"active"`}, - {Name: "StatusDone", Value: `"done"`}, - }, - }, - }, - }, - validate: func(t *testing.T, schema []byte) { - var doc map[string]any - require.NoError(t, yaml.Unmarshal(schema, &doc)) + Expect(requiredField).NotTo(HaveKey("nullable")) + }) + It("should mark optional pointer field as nullable", func() { + doc := parseSchema(schema) components := doc["components"].(map[string]any) schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + optionalField := props["optional"].(map[string]any) + Expect(optionalField["nullable"]).To(BeTrue()) + }) - // Check enum is defined - assert.Contains(t, schemas, "Status") + It("should only include non-pointer fields in required array", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + required := input["required"].([]any) + Expect(required).To(ContainElement("required")) + Expect(required).NotTo(ContainElement("optional")) + }) + }) + + Context("capability with enum", func() { + var schema []byte + + BeforeEach(func() { + capability := Capability{ + Name: "enum_test", + SourceFile: "enum_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Status", Type: "Status", JSONTag: "status"}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + }, + TypeAliases: []TypeAlias{ + {Name: "Status", Type: "string", Doc: "Status type"}, + }, + Consts: []ConstGroup{ + { + Type: "Status", + Values: []ConstDef{ + {Name: "StatusPending", Value: `"pending"`}, + {Name: "StatusActive", Value: `"active"`}, + {Name: "StatusDone", Value: `"done"`}, + }, + }, + }, + } + var err error + schema, err = GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should define enum type with correct values", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + Expect(schemas).To(HaveKey("Status")) status := schemas["Status"].(map[string]any) - assert.Equal(t, "string", status["type"]) + Expect(status["type"]).To(Equal("string")) enum := status["enum"].([]any) - assert.ElementsMatch(t, []any{"pending", "active", "done"}, enum) + Expect(enum).To(ConsistOf("pending", "active", "done")) + }) - // Check $ref in Input + It("should use $ref for enum field in struct", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) input := schemas["Input"].(map[string]any) props := input["properties"].(map[string]any) statusRef := props["status"].(map[string]any) - assert.Equal(t, "#/components/schemas/Status", statusRef["$ref"]) - }, - }, - { - name: "capability with array types", - capability: Capability{ - Name: "array_test", - SourceFile: "array_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Tags", Type: "[]string", JSONTag: "tags"}, - {Name: "Items", Type: "[]Item", JSONTag: "items"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - { - Name: "Item", - Fields: []FieldDef{ - {Name: "ID", Type: "string", JSONTag: "id"}, - }, - }, - }, - }, - validate: func(t *testing.T, schema []byte) { - var doc map[string]any - require.NoError(t, yaml.Unmarshal(schema, &doc)) + Expect(statusRef["$ref"]).To(Equal("#/components/schemas/Status")) + }) + }) + Context("capability with array types", func() { + var schema []byte + + BeforeEach(func() { + capability := Capability{ + Name: "array_test", + SourceFile: "array_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Tags", Type: "[]string", JSONTag: "tags"}, + {Name: "Items", Type: "[]Item", JSONTag: "items"}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + { + Name: "Item", + Fields: []FieldDef{ + {Name: "ID", Type: "string", JSONTag: "id"}, + }, + }, + }, + } + var err error + schema, err = GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should define string array with primitive type", func() { + doc := parseSchema(schema) components := doc["components"].(map[string]any) schemas := components["schemas"].(map[string]any) input := schemas["Input"].(map[string]any) props := input["properties"].(map[string]any) - - // Check string array tags := props["tags"].(map[string]any) - assert.Equal(t, "array", tags["type"]) + Expect(tags["type"]).To(Equal("array")) tagItems := tags["items"].(map[string]any) - assert.Equal(t, "string", tagItems["type"]) + Expect(tagItems["type"]).To(Equal("string")) + }) - // Check struct array (uses $ref) + It("should define struct array with $ref", func() { + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) items := props["items"].(map[string]any) - assert.Equal(t, "array", items["type"]) + Expect(items["type"]).To(Equal("array")) itemItems := items["items"].(map[string]any) - assert.Equal(t, "#/components/schemas/Item", itemItems["$ref"]) - }, - }, - { - name: "capability with nullable ref", - capability: Capability{ - Name: "nullable_ref_test", - SourceFile: "nullable_ref_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true}, - }, - }, - }, - TypeAliases: []TypeAlias{ - {Name: "ErrorType", Type: "string"}, - }, - Consts: []ConstGroup{ - { - Type: "ErrorType", - Values: []ConstDef{ - {Name: "ErrorNone", Value: `"none"`}, - {Name: "ErrorFatal", Value: `"fatal"`}, - }, - }, - }, - }, - validate: func(t *testing.T, schema []byte) { - var doc map[string]any - require.NoError(t, yaml.Unmarshal(schema, &doc)) + Expect(itemItems["$ref"]).To(Equal("#/components/schemas/Item")) + }) + }) + Context("capability with nullable ref", func() { + It("should mark pointer to enum as nullable with $ref", func() { + capability := Capability{ + Name: "nullable_ref_test", + SourceFile: "nullable_ref_test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{ + {Name: "Value", Type: "string", JSONTag: "value"}, + }, + }, + { + Name: "Output", + Fields: []FieldDef{ + {Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true}, + }, + }, + }, + TypeAliases: []TypeAlias{ + {Name: "ErrorType", Type: "string"}, + }, + Consts: []ConstGroup{ + { + Type: "ErrorType", + Values: []ConstDef{ + {Name: "ErrorNone", Value: `"none"`}, + {Name: "ErrorFatal", Value: `"fatal"`}, + }, + }, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) components := doc["components"].(map[string]any) schemas := components["schemas"].(map[string]any) output := schemas["Output"].(map[string]any) props := output["properties"].(map[string]any) - - // Pointer to enum type should have $ref AND nullable status := props["status"].(map[string]any) - assert.Equal(t, "#/components/schemas/ErrorType", status["$ref"]) - assert.Equal(t, true, status["nullable"]) + Expect(status["$ref"]).To(Equal("#/components/schemas/ErrorType")) + Expect(status["nullable"]).To(BeTrue()) + }) + }) + }) + + Describe("goTypeToXTPTypeAndFormat", func() { + DescribeTable("should convert Go types to XTP types", + func(goType, wantType, wantFormat string) { + gotType, gotFormat := goTypeToXTPTypeAndFormat(goType) + Expect(gotType).To(Equal(wantType)) + Expect(gotFormat).To(Equal(wantFormat)) }, - }, - } + Entry("string", "string", "string", ""), + Entry("int", "int", "integer", "int32"), + Entry("int32", "int32", "integer", "int32"), + Entry("int64", "int64", "integer", "int64"), + Entry("float32", "float32", "number", "float"), + Entry("float64", "float64", "number", "float"), + Entry("bool", "bool", "boolean", ""), + Entry("[]byte", "[]byte", "string", "byte"), + Entry("unknown types default to object", "CustomType", "object", ""), + ) + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - schema, err := GenerateSchema(tt.capability) - if tt.wantErr { - assert.Error(t, err) - return + Describe("cleanDocForYAML", func() { + DescribeTable("should clean documentation strings", + func(doc, want string) { + Expect(cleanDocForYAML(doc)).To(Equal(want)) + }, + Entry("empty", "", ""), + Entry("single line", "Simple description", "Simple description"), + Entry("multiline", "First line\nSecond line", "First line\nSecond line"), + Entry("trailing newline", "Description\n", "Description"), + Entry("whitespace", " Description ", "Description"), + ) + }) + + Describe("isPrimitiveGoType", func() { + DescribeTable("should identify primitive Go types", + func(goType string, want bool) { + Expect(isPrimitiveGoType(goType)).To(Equal(want)) + }, + Entry("bool", "bool", true), + Entry("string", "string", true), + Entry("int", "int", true), + Entry("int32", "int32", true), + Entry("int64", "int64", true), + Entry("float32", "float32", true), + Entry("float64", "float64", true), + Entry("[]byte", "[]byte", true), + Entry("custom type", "CustomType", false), + Entry("struct type", "MyStruct", false), + Entry("slice of string", "[]string", false), + Entry("map type", "map[string]int", false), + ) + }) + + Describe("GenerateSchema with primitive output types", func() { + inputStruct := StructDef{ + Name: "Input", + Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}, + } + + Context("export with primitive string output", func() { + It("should use type instead of $ref", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "get_name", Input: NewParam("input", "Input"), Output: NewParam("output", "string")}, + }, + Structs: []StructDef{inputStruct}, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + Expect(schema).NotTo(BeEmpty()) + + doc := parseSchema(schema) + exports := doc["exports"].(map[string]any) + method := exports["get_name"].(map[string]any) + output := method["output"].(map[string]any) + Expect(output["type"]).To(Equal("string")) + Expect(output).NotTo(HaveKey("$ref")) + Expect(output["contentType"]).To(Equal("application/json")) + }) + }) + + Context("export with primitive bool output", func() { + It("should use boolean type", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "is_valid", Input: NewParam("input", "Input"), Output: NewParam("output", "bool")}, + }, + Structs: []StructDef{inputStruct}, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) + exports := doc["exports"].(map[string]any) + method := exports["is_valid"].(map[string]any) + output := method["output"].(map[string]any) + Expect(output["type"]).To(Equal("boolean")) + Expect(output).NotTo(HaveKey("$ref")) + }) + }) + + Context("export with primitive int output", func() { + It("should use integer type", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "get_count", Input: NewParam("input", "Input"), Output: NewParam("output", "int32")}, + }, + Structs: []StructDef{inputStruct}, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) + exports := doc["exports"].(map[string]any) + method := exports["get_count"].(map[string]any) + output := method["output"].(map[string]any) + Expect(output["type"]).To(Equal("integer")) + Expect(output).NotTo(HaveKey("$ref")) + }) + }) + + Context("export with pointer to primitive output", func() { + It("should strip pointer and use primitive type", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "get_optional_string", Input: NewParam("input", "Input"), Output: NewParam("output", "*string")}, + }, + Structs: []StructDef{inputStruct}, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) + exports := doc["exports"].(map[string]any) + method := exports["get_optional_string"].(map[string]any) + output := method["output"].(map[string]any) + Expect(output["type"]).To(Equal("string")) + Expect(output).NotTo(HaveKey("$ref")) + }) + }) + + Context("export with struct output", func() { + It("should still use $ref", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "get_result", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + inputStruct, + {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) + exports := doc["exports"].(map[string]any) + method := exports["get_result"].(map[string]any) + output := method["output"].(map[string]any) + Expect(output["$ref"]).To(Equal("#/components/schemas/Output")) + Expect(output).NotTo(HaveKey("type")) + }) + }) + }) + + Describe("collectUsedTypes", func() { + getSchemas := func(schema []byte) map[string]any { + doc := parseSchema(schema) + components, hasComponents := doc["components"].(map[string]any) + if !hasComponents { + return make(map[string]any) } - require.NoError(t, err) - assert.NotEmpty(t, schema) - - if tt.validate != nil { - tt.validate(t, schema) + schemas, ok := components["schemas"].(map[string]any) + if !ok { + return make(map[string]any) } + return schemas + } + + It("should only include types referenced by exports", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "UsedInput"), Output: NewParam("output", "UsedOutput")}, + }, + Structs: []StructDef{ + {Name: "UsedInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "UsedOutput", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, + {Name: "UnusedStruct", Fields: []FieldDef{{Name: "Foo", Type: "string", JSONTag: "foo"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + schemas := getSchemas(schema) + Expect(schemas).To(HaveKey("UsedInput")) + Expect(schemas).To(HaveKey("UsedOutput")) + Expect(schemas).NotTo(HaveKey("UnusedStruct")) }) - } -} -func TestGoTypeToXTPTypeAndFormat(t *testing.T) { - tests := []struct { - goType string - wantType string - wantFormat string - }{ - {"string", "string", ""}, - {"int", "integer", "int32"}, - {"int32", "integer", "int32"}, - {"int64", "integer", "int64"}, - {"float32", "number", "float"}, - {"float64", "number", "float"}, - {"bool", "boolean", ""}, - {"[]byte", "string", "byte"}, - // Unknown types default to object - {"CustomType", "object", ""}, - } + It("should include transitively referenced types", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "Output", Fields: []FieldDef{{Name: "Nested", Type: "NestedType", JSONTag: "nested"}}}, + {Name: "NestedType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) - for _, tt := range tests { - t.Run(tt.goType, func(t *testing.T) { - gotType, gotFormat := goTypeToXTPTypeAndFormat(tt.goType) - assert.Equal(t, tt.wantType, gotType) - assert.Equal(t, tt.wantFormat, gotFormat) + schemas := getSchemas(schema) + Expect(schemas).To(HaveKey("Input")) + Expect(schemas).To(HaveKey("Output")) + Expect(schemas).To(HaveKey("NestedType")) }) - } -} -func TestCleanDocForYAML(t *testing.T) { - tests := []struct { - name string - doc string - want string - }{ - { - name: "empty", - doc: "", - want: "", - }, - { - name: "single line", - doc: "Simple description", - want: "Simple description", - }, - { - name: "multiline", - doc: "First line\nSecond line", - want: "First line\nSecond line", - }, - { - name: "trailing newline", - doc: "Description\n", - want: "Description", - }, - { - name: "whitespace", - doc: " Description ", - want: "Description", - }, - } + It("should include array element types", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "Output", Fields: []FieldDef{{Name: "Items", Type: "[]Item", JSONTag: "items"}}}, + {Name: "Item", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := cleanDocForYAML(tt.doc) - assert.Equal(t, tt.want, got) + schemas := getSchemas(schema) + Expect(schemas).To(HaveKey("Input")) + Expect(schemas).To(HaveKey("Output")) + Expect(schemas).To(HaveKey("Item")) }) - } -} + + It("should include pointer types", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + {Name: "Output", Fields: []FieldDef{{Name: "Optional", Type: "*OptionalType", JSONTag: "optional"}}}, + {Name: "OptionalType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + schemas := getSchemas(schema) + Expect(schemas).To(HaveKey("Input")) + Expect(schemas).To(HaveKey("Output")) + Expect(schemas).To(HaveKey("OptionalType")) + }) + + It("should exclude primitive output types from schema", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "string")}, + }, + Structs: []StructDef{ + {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, + }, + } + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + schemas := getSchemas(schema) + Expect(schemas).To(HaveKey("Input")) + }) + }) + + Describe("GenerateSchema enum filtering", func() { + It("should only include enums that are actually used by exports", func() { + capability := Capability{ + Name: "test", + SourceFile: "test", + Methods: []Export{ + {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, + }, + Structs: []StructDef{ + { + Name: "Input", + Fields: []FieldDef{{Name: "Status", Type: "UsedStatus", JSONTag: "status"}}, + }, + { + Name: "Output", + Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}, + }, + }, + TypeAliases: []TypeAlias{ + {Name: "UsedStatus", Type: "string"}, + {Name: "UnusedStatus", Type: "string"}, + }, + Consts: []ConstGroup{ + { + Type: "UsedStatus", + Values: []ConstDef{ + {Name: "StatusActive", Value: `"active"`}, + {Name: "StatusInactive", Value: `"inactive"`}, + }, + }, + { + Type: "UnusedStatus", + Values: []ConstDef{ + {Name: "UnusedPending", Value: `"pending"`}, + }, + }, + }, + } + + schema, err := GenerateSchema(capability) + Expect(err).NotTo(HaveOccurred()) + + doc := parseSchema(schema) + components := doc["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + + // UsedStatus should be included because it's referenced by Input + Expect(schemas).To(HaveKey("UsedStatus")) + usedStatus := schemas["UsedStatus"].(map[string]any) + Expect(usedStatus["type"]).To(Equal("string")) + enum := usedStatus["enum"].([]any) + Expect(enum).To(ConsistOf("active", "inactive")) + + // UnusedStatus should NOT be included + Expect(schemas).NotTo(HaveKey("UnusedStatus")) + }) + }) +})