diff --git a/cmd/dev/cmd_api.go b/cmd/dev/cmd_api.go index c76e39b..7c3de17 100644 --- a/cmd/dev/cmd_api.go +++ b/cmd/dev/cmd_api.go @@ -1,8 +1,8 @@ package dev import ( - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" ) // addAPICommands adds the 'api' command and its subcommands to the given parent command. @@ -17,6 +17,6 @@ func addAPICommands(parent *cli.Command) { // Add the 'sync' command to 'api' addSyncCommand(apiCmd) - // TODO: Add the 'test-gen' command to 'api' - // addTestGenCommand(apiCmd) + // Add the 'test-gen' command to 'api' + addTestGenCommand(apiCmd) } diff --git a/cmd/dev/cmd_api_testgen.go b/cmd/dev/cmd_api_testgen.go new file mode 100644 index 0000000..62e35e9 --- /dev/null +++ b/cmd/dev/cmd_api_testgen.go @@ -0,0 +1,112 @@ +package dev + +import ( + "bytes" + "path/filepath" + "text/template" + + "dappco.re/go/core/i18n" + coreio "dappco.re/go/core/io" + "forge.lthn.ai/core/cli/pkg/cli" +) + +func addTestGenCommand(parent *cli.Command) { + testGenCmd := &cli.Command{ + Use: "test-gen", + Short: i18n.T("cmd.dev.api.test_gen.short"), + Long: i18n.T("cmd.dev.api.test_gen.long"), + RunE: func(cmd *cli.Command, args []string) error { + if err := runTestGen(); err != nil { + return cli.Wrap(err, i18n.Label("error")) + } + cli.Text(i18n.T("i18n.done.sync", "public API tests")) + return nil + }, + } + + parent.AddCommand(testGenCmd) +} + +func runTestGen() error { + pkgDir := "pkg" + internalDirs, err := coreio.Local.List(pkgDir) + if err != nil { + return cli.Wrap(err, "failed to read pkg directory") + } + + for _, dir := range internalDirs { + if !dir.IsDir() || dir.Name() == "core" { + continue + } + + serviceName := dir.Name() + internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go") + publicDir := serviceName + publicTestFile := filepath.Join(publicDir, serviceName+"_test.go") + + if !coreio.Local.IsFile(internalFile) { + continue + } + + symbols, err := getExportedSymbols(internalFile) + if err != nil { + return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName)) + } + + if len(symbols) == 0 { + continue + } + + if err := generatePublicAPITestFile(publicDir, publicTestFile, serviceName, symbols); err != nil { + return cli.Wrap(err, cli.Sprintf("error generating public API test file for service '%s'", serviceName)) + } + } + + return nil +} + +const publicAPITestTemplate = `// Code generated by "core dev api test-gen"; DO NOT EDIT. +package {{.ServiceName}} + +import ( + impl "forge.lthn.ai/core/cli/{{.ServiceName}}" +) + +{{range .Symbols}} +{{- if eq .Kind "type"}} +type _ = impl.{{.Name}} +{{- else if eq .Kind "const"}} +const _ = impl.{{.Name}} +{{- else if eq .Kind "var"}} +var _ = impl.{{.Name}} +{{- else if eq .Kind "func"}} +var _ = impl.{{.Name}} +{{- end}} +{{end}} +` + +func generatePublicAPITestFile(dir, path, serviceName string, symbols []symbolInfo) error { + if err := coreio.Local.EnsureDir(dir); err != nil { + return err + } + + tmpl, err := template.New("publicAPITest").Parse(publicAPITestTemplate) + if err != nil { + return err + } + + data := struct { + ServiceName string + Symbols []symbolInfo + }{ + ServiceName: serviceName, + Symbols: symbols, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return err + } + + return coreio.Local.Write(path, buf.String()) +} diff --git a/cmd/dev/cmd_api_testgen_test.go b/cmd/dev/cmd_api_testgen_test.go new file mode 100644 index 0000000..4ee50c5 --- /dev/null +++ b/cmd/dev/cmd_api_testgen_test.go @@ -0,0 +1,70 @@ +package dev + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "dappco.re/go/core/io" +) + +func TestRunTestGen_Good(t *testing.T) { + tmpDir := t.TempDir() + + originalWD, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + require.NoError(t, os.Chdir(tmpDir)) + + serviceDir := filepath.Join(tmpDir, "pkg", "demo") + require.NoError(t, io.Local.EnsureDir(serviceDir)) + require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo.go"), `package demo + +type Example struct{} + +const Answer = 42 + +var Value = Example{} + +func Run() {} +`)) + + require.NoError(t, runTestGen()) + + generatedPath := filepath.Join(tmpDir, "demo", "demo_test.go") + content, err := io.Local.Read(generatedPath) + require.NoError(t, err) + + require.Contains(t, content, `// Code generated by "core dev api test-gen"; DO NOT EDIT.`) + require.Contains(t, content, `package demo`) + require.Contains(t, content, `impl "forge.lthn.ai/core/cli/demo"`) + require.Contains(t, content, `type _ = impl.Example`) + require.Contains(t, content, `const _ = impl.Answer`) + require.Contains(t, content, `var _ = impl.Value`) + require.Contains(t, content, `var _ = impl.Run`) +} + +func TestGeneratePublicAPITestFile_Good(t *testing.T) { + tmpDir := t.TempDir() + + require.NoError(t, generatePublicAPITestFile( + filepath.Join(tmpDir, "demo"), + filepath.Join(tmpDir, "demo", "demo_test.go"), + "demo", + []symbolInfo{ + {Name: "Example", Kind: "type"}, + {Name: "Answer", Kind: "const"}, + }, + )) + + content, err := io.Local.Read(filepath.Join(tmpDir, "demo", "demo_test.go")) + require.NoError(t, err) + + require.True(t, strings.Contains(content, `type _ = impl.Example`)) + require.True(t, strings.Contains(content, `const _ = impl.Answer`)) +} diff --git a/cmd/dev/cmd_dev.go b/cmd/dev/cmd_dev.go index bcb3fab..88ae1ce 100644 --- a/cmd/dev/cmd_dev.go +++ b/cmd/dev/cmd_dev.go @@ -19,6 +19,7 @@ // // API Tools: // - api sync: Synchronize public service APIs +// - api test-gen: Generate compile-time API test stubs // // Dev Environment (VM management): // - install: Download dev environment image @@ -33,8 +34,8 @@ package dev import ( - "forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/i18n" + "forge.lthn.ai/core/cli/pkg/cli" _ "dappco.re/go/core/devops/locales" ) diff --git a/locales/en.json b/locales/en.json index c77bc36..24b8f0e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -8,7 +8,11 @@ "short": "Multi-repo development workflows", "long": "Development workflow commands for managing multiple repositories.\n\nIncludes git operations, forge integration, CI status, and dev environment management.", "api": { - "short": "API synchronisation tools" + "short": "API synchronisation tools", + "test_gen": { + "short": "Generate public API test stubs", + "long": "Scan internal service packages and generate compile-time tests for their public API wrappers." + } }, "health": { "short": "Quick health check across all repos",