From 67b74488e4cca457273de6ad8e618731fc547b71 Mon Sep 17 00:00:00 2001 From: sazar Date: Sun, 14 Jun 2026 11:57:40 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20stabilization=20batch=203=20=E2=80=94=20?= =?UTF-8?q?~60=20generator=20bugs=20across=20all=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Massive stabilization pass. Every command was audited by generating real projects with every flag and **compiling the generated code**; this commit fixes the bugs that broke compilation, lied about success, produced incoherent output, or contradicted the documentation. Verified end-to-end: `goca init` + 2 features + tricky-field entity now produce a project that `go build ./...` compiles cleanly, with the DI container wired into cmd/server/main.go, both feature route groups served, and both entities registered for auto-migration. ## entity / mocks - Convert snake_case/multi-word field names to Go PascalCase (UserID, LastLogin) while keeping snake_case json/gorm tags (was: User_id). - Generate stub types for custom field types instead of emitting undefined identifiers. - Numeric validation now covers all int/uint/float subtypes coherently; no more unused ErrInvalid…Range constants; no required tag / dead checks on slice/pointer fields. - SQL seeds emit real values for slice/pointer/custom fields (was NULL). - Mocks now implement the REAL interfaces: correct method names/signatures and per-field finders; example test uses the real API + compile-time interface assertions. ## usecase - Standalone `goca usecase` compiles: generates the repository interface it depends on and drops the contradictory usecase-package interface. - `--async` emits real, compilable async wrappers (was undefined types). - Generic update method uses real DTO fields (was Spanish undefined fields). - `--dto-validation` actually calls input.Validate(). - Errors when the target entity doesn't exist. English-only output. ## repository - Elasticsearch repo imports esapi; Mongo repo implements full CRUD. - Postgres `--cache` generates the cache helper methods it calls (go-redis/v9). - Cache decorator only emits finders that exist; supports `--transactions`. - `--database` routes sqlite/sqlserver/postgres-json/elasticsearch/dynamodb to their real generators in the field-aware path (was: all → Postgres). ## di / integrate - `goca integrate` emits VALID Go for cmd/server/main.go (was unparseable: missing LHS, brace/else mismatches), targets cmd/server/main.go, and only integrates features that have all layers (skips orphan domain entities). - `goca di --wire` produces wire-processable code (gorm DB provider, matching return types); `--cache`/`--database mongodb` only emit constructors that exist. ## feature (flagship auto-integration — was a no-op that printed success) - Auto-migration actually inserts &domain.Entity{} into cmd/server/main.go. - Routes + DI container are really wired into main.go (idempotent). - Multiple features coexist: routes.go and the DI container are extended for the 2nd+ feature instead of being skipped. ## handler / middleware - handler+middleware-package output compiles (mux.MiddlewareFunc wrap). - Shared contextKey type moved to base middleware.go (request-id et al. build). - gRPC server uses the flat usecase output and real entity fields. - `go mod tidy` is best-effort (no longer corrupts go.mod on failure). - `--swagger` emits the documented @Summary/@Router/@Success annotations. - Lowercase middleware package names allowed; entity-existence is validated; success messages are truthful (no empty-dir false success). ## init - Validate --database/--api values; persist --api/--auth in .goca.yaml. - DB-aware docker-compose (image/port/env/healthcheck/volume), README, and config defaults for postgres/mysql/mongodb/sqlite/sqlserver/dynamodb/es; mongo healthcheck uses mongosh. - Refuse non-empty target dir without --force; fix fenced code blocks; drop the test-only `replace` directive; git init no longer hangs on gpg/stdin. ## analyze / doctor / config / safety - analyze no longer false-warns on goca's own layout (finds cmd/server/main.go and os.Getenv in pkg/config); `--output json` emits valid JSON; `--fail-on-warn` exits 2; detects `:=` hardcoded secrets. - `config validate` uses the real semantic validator (was a stub that passed invalid configs); `config init` validates --template/--database. - doctor DI check no longer false-negatives; dry-run never errors on existing files. ## template / ui / version / test-integration - `template list` lists templates; `template show`/`reset` implemented. - `--quiet` suppresses file/table output; `version` still prints under `--quiet`; version reads VCS info from build metadata. - test-integration: guards nonexistent entities, real MySQL setup (no permanent t.Skip), real testcontainers setup for `--container`. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/analyze.go | 19 +- cmd/analyze_checks.go | 47 ++- cmd/cache_decorator.go | 45 ++- cmd/cache_decorator_test.go | 5 +- cmd/cache_helpers_test.go | 23 +- cmd/config_debug.go | 79 +++-- cmd/coverage_batch6_test.go | 65 +++- cmd/di.go | 87 ++++- cmd/di_helpers_test.go | 17 +- cmd/doctor.go | 41 ++- cmd/entity.go | 275 ++++++++++++++- cmd/entity_generation_test.go | 8 +- cmd/entity_helpers_test.go | 9 +- cmd/feature.go | 193 +++++++++- cmd/generate_integration_batch3_test.go | 7 + cmd/generate_integration_batch4_test.go | 16 +- cmd/generate_integration_test.go | 2 +- cmd/handler.go | 222 ++++++++++-- cmd/handler_helpers_test.go | 58 ++- cmd/handler_other.go | 98 +++++- cmd/init.go | 109 +++++- cmd/init_docker.go | 94 ++++- cmd/init_project_files.go | 180 ++++++---- cmd/integrate.go | 361 ++++++++++++++++--- cmd/middleware.go | 23 +- cmd/middleware_templates.go | 4 +- cmd/middleware_test.go | 33 ++ cmd/mocks.go | 447 ++++++++---------------- cmd/mocks_test.go | 29 +- cmd/repository_fields.go | 138 ++++++++ cmd/repository_impl.go | 195 +++++------ cmd/repository_other_db.go | 6 +- cmd/safety.go | 4 +- cmd/safety_filesystem_test.go | 5 +- cmd/safety_test.go | 4 +- cmd/template_cmd.go | 224 ++++++++++-- cmd/test_integration.go | 183 +++++++--- cmd/ui.go | 6 + cmd/ui_test.go | 33 ++ cmd/usecase.go | 146 +++++--- cmd/usecase_methods_test.go | 26 +- cmd/version.go | 44 ++- internal/testing/suite.go | 8 +- 43 files changed, 2790 insertions(+), 828 deletions(-) diff --git a/cmd/analyze.go b/cmd/analyze.go index 88b848b..1782b78 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -77,6 +77,17 @@ func init() { //nolint:cyclop,funlen // runAnalyze orchestrates 6+ analysis categories func runAnalyze(_ *cobra.Command, _ []string) error { + jsonMode := analyzeOpts.output == "json" + + // In JSON mode, only valid JSON may go to stdout. Route all human/progress + // output (headers, steps, summary) to stderr so the JSON on stdout stays + // machine-parseable. The writer is restored before returning. + if jsonMode && ui != nil { + prevWriter := ui.writer + ui.writer = os.Stderr + defer func() { ui.writer = prevWriter }() + } + ui.Header("Goca Analyze — Deep Project Self-Analysis") ui.Blank() @@ -116,7 +127,7 @@ func runAnalyze(_ *cobra.Command, _ []string) error { ui.Blank() - if analyzeOpts.output == "json" { + if jsonMode { printAnalyzeJSON(results) } else { printAnalyzeReport(results) @@ -134,7 +145,11 @@ func runAnalyze(_ *cobra.Command, _ []string) error { return fmt.Errorf("analyze: %d rule(s) failed", failed) //nolint:err113 // dynamic count is intentional } if analyzeOpts.failOnWarn && warned > 0 { - return fmt.Errorf("analyze: %d warning(s) (--fail-on-warn)", warned) //nolint:err113 // dynamic count is intentional + // Warnings-only with --fail-on-warn must exit with code 2, distinct from + // the failure exit code 1. Returning an error here would map to exit 1 + // via Execute(), so exit directly with the documented code. + ui.Warning(fmt.Sprintf("analyze: %d warning(s) (--fail-on-warn)", warned)) + os.Exit(2) } return nil } diff --git a/cmd/analyze_checks.go b/cmd/analyze_checks.go index 2474dbc..6db41d2 100644 --- a/cmd/analyze_checks.go +++ b/cmd/analyze_checks.go @@ -454,7 +454,9 @@ func checkExportedFunctionsHaveDocs() analyzeResult { } func checkMainGoExists() analyzeResult { - candidates := []string{"main.go", "cmd/main.go"} + // Fixed entry points checked first, then any cmd//main.go (goca init + // generates cmd/server/main.go, so a fixed "cmd/main.go" check is not enough). + candidates := []string{"main.go", "cmd/main.go", "cmd/server/main.go"} for _, c := range candidates { if fileExists(c) { return analyzeResult{ @@ -465,11 +467,28 @@ func checkMainGoExists() analyzeResult { } } } + // Accept any cmd//main.go layout. + if entries, err := os.ReadDir("cmd"); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + c := filepath.Join("cmd", e.Name(), "main.go") + if fileExists(c) { + return analyzeResult{ + category: "Quality", + rule: "main-go", + status: "✓", + message: fmt.Sprintf("Entry point found at %s", c), + } + } + } + } return analyzeResult{ category: "Quality", rule: "main-go", status: "⚠", - message: "No main.go found at root or cmd/", + message: "No main.go found at root, cmd/, or cmd//", suggestion: "Run: goca init to generate project entry point", } } @@ -492,6 +511,8 @@ func checkNoHardcodedSecrets() analyzeResult { patterns := []string{ "password = \"", "secret = \"", "token = \"", "apikey = \"", "api_key = \"", "passwd = \"", + "password := \"", "secret := \"", "token := \"", + "apikey := \"", "api_key := \"", "passwd := \"", `password:"`, `secret:"`, `token:"`, } files, _ := analyzeGoFiles("internal", false) @@ -595,24 +616,24 @@ func checkNoInsecureHTTPClient() analyzeResult { } func checkEnvVarsForSensitiveConfig() analyzeResult { - // Verify sensitive config is read from environment, not config files or constants - files, _ := analyzeGoFiles("internal", true) + // Verify sensitive config is read from environment, not config files or constants. + // goca init generates pkg/config/config.go (which uses os.Getenv), so pkg/ must + // be scanned in addition to internal/ to avoid false warnings. usesEnv := false - for _, f := range files { - if strings.Contains(analyzeReadFile(f), "os.Getenv") { - usesEnv = true - break + for _, dir := range []string{"internal", "pkg"} { + if !dirExists(dir) { + continue } - } - // Check cache package separately - if !usesEnv && dirExists("internal/cache") { - cacheFiles, _ := analyzeGoFiles("internal/cache", true) - for _, f := range cacheFiles { + files, _ := analyzeGoFiles(dir, true) + for _, f := range files { if strings.Contains(analyzeReadFile(f), "os.Getenv") { usesEnv = true break } } + if usesEnv { + break + } } if usesEnv { return analyzeResult{ diff --git a/cmd/cache_decorator.go b/cmd/cache_decorator.go index 8581675..a819a2e 100644 --- a/cmd/cache_decorator.go +++ b/cmd/cache_decorator.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "path/filepath" "strings" ) @@ -18,6 +19,11 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager) moduleName := getModuleName() importPath := getImportPath(moduleName) + // Detect whether the interface includes transaction methods so the decorator + // can also delegate the WithTx methods and remain substitutable for the + // Repository interface. + transactions := interfaceHasTransactions(filepath.Join(repoDir, "interfaces.go"), entity) + var b strings.Builder b.WriteString("package repository\n\n") @@ -28,6 +34,9 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager) b.WriteString("\t\"time\"\n\n") b.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n\n", importPath)) b.WriteString("\t\"github.com/redis/go-redis/v9\"\n") + if transactions { + b.WriteString("\t\"gorm.io/gorm\"\n") + } b.WriteString(")\n\n") // Struct @@ -89,17 +98,15 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager) b.WriteString("\treturn entity, nil\n") b.WriteString("}\n\n") - // Dynamic search methods from fields — delegate only (no caching for search) + // Dynamic search methods from fields — delegate only (no caching for search). + // Only emit finders that actually exist on the interface (derived from the + // parsed fields); when no fields are known, emit none so we never reference + // an undefined inner method like FindByEmail on an entity without that field. if len(fields) > 0 { searchMethods := generateSearchMethods(fields, entity) for _, m := range searchMethods { generateCacheSearchMethodDelegate(&b, entity, m) } - } else { - // Default FindByEmail when no fields are specified - b.WriteString(fmt.Sprintf("func (r *Cached%sRepository) FindByEmail(email string) (*domain.%s, error) {\n", entity, entity)) - b.WriteString("\treturn r.inner.FindByEmail(email)\n") - b.WriteString("}\n\n") } // Update — delegate + invalidate @@ -140,11 +147,37 @@ func generateCacheDecorator(entity string, fields []Field, sm ...*SafetyManager) b.WriteString("\treturn entities, nil\n") b.WriteString("}\n") + // Transaction methods — delegate to inner so the decorator still satisfies a + // transactional Repository interface. + if transactions { + b.WriteString("\n") + fmt.Fprintf(&b, "func (r *Cached%sRepository) SaveWithTx(tx *gorm.DB, %s *domain.%s) error {\n", entity, entityLower, entity) + fmt.Fprintf(&b, "\treturn r.inner.SaveWithTx(tx, %s)\n", entityLower) + b.WriteString("}\n\n") + fmt.Fprintf(&b, "func (r *Cached%sRepository) UpdateWithTx(tx *gorm.DB, %s *domain.%s) error {\n", entity, entityLower, entity) + fmt.Fprintf(&b, "\treturn r.inner.UpdateWithTx(tx, %s)\n", entityLower) + b.WriteString("}\n\n") + fmt.Fprintf(&b, "func (r *Cached%sRepository) DeleteWithTx(tx *gorm.DB, id int) error {\n", entity) + b.WriteString("\treturn r.inner.DeleteWithTx(tx, id)\n") + b.WriteString("}\n") + } + if err := writeGoFile(filename, b.String(), sm...); err != nil { ui.Error(fmt.Sprintf("Error writing cache decorator: %v", err)) } } +// interfaceHasTransactions reports whether the generated repository interface for +// the entity declares transactional methods (SaveWithTx). It reads the already +// generated interfaces.go; if it cannot be read, it returns false. +func interfaceHasTransactions(interfacesPath, entity string) bool { + data, err := os.ReadFile(interfacesPath) + if err != nil { + return false + } + return strings.Contains(string(data), fmt.Sprintf("SaveWithTx(tx *gorm.DB, %s *domain.%s)", strings.ToLower(entity), entity)) +} + // generateCacheSearchMethodDelegate generates a delegate-only method for a search method. func generateCacheSearchMethodDelegate(b *strings.Builder, entity string, m SearchMethod) { paramName := strings.ToLower(m.FieldName) diff --git a/cmd/cache_decorator_test.go b/cmd/cache_decorator_test.go index d65a69e..829097f 100644 --- a/cmd/cache_decorator_test.go +++ b/cmd/cache_decorator_test.go @@ -116,8 +116,9 @@ func TestGenerateCacheDecorator_NoFields(t *testing.T) { require.NoError(t, err) src := string(content) - // Default FindByEmail delegate when no fields - assert.Contains(t, src, "func (r *CachedItemRepository) FindByEmail") + // With no fields the interface declares no finders, so the decorator must NOT + // emit a FindByEmail delegate (the inner repo has no such method). + assert.NotContains(t, src, "FindByEmail") } func TestGenerateCacheDecorator_DryRun(t *testing.T) { diff --git a/cmd/cache_helpers_test.go b/cmd/cache_helpers_test.go index 1b3f7fe..5ce7277 100644 --- a/cmd/cache_helpers_test.go +++ b/cmd/cache_helpers_test.go @@ -95,8 +95,23 @@ func TestGenerateCachePackage_Imports(t *testing.T) { assert.Contains(t, src, `"github.com/redis/go-redis/v9"`) } +// writeFakeCacheDecorators creates cached__repository.go stubs under the +// current working directory so cache-aware DI generation recognizes the decorators. +func writeFakeCacheDecorators(t *testing.T, features ...string) { + t.Helper() + repoDir := filepath.Join(DirInternal, DirRepository) + require.NoError(t, os.MkdirAll(repoDir, 0o755)) + for _, f := range features { + fn := filepath.Join(repoDir, "cached_"+strings.ToLower(f)+"_repository.go") + require.NoError(t, os.WriteFile(fn, []byte("package repository"), 0o644)) + } +} + func TestGenerateSetupRepositories_WithCache(t *testing.T) { - t.Parallel() + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + writeFakeCacheDecorators(t, "Product", "User") var b strings.Builder features := []string{"Product", "User"} @@ -110,7 +125,10 @@ func TestGenerateSetupRepositories_WithCache(t *testing.T) { } func TestGenerateSetupRepositories_WithCacheMySQL(t *testing.T) { - t.Parallel() + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + writeFakeCacheDecorators(t, "Order") var b strings.Builder generateSetupRepositories(&b, []string{"Order"}, "mysql", true) @@ -130,6 +148,7 @@ func TestGenerateManualDI_WithCache(t *testing.T) { os.Chdir(dir) require.NoError(t, os.WriteFile("go.mod", []byte("module testproject\n\ngo 1.21\n"), 0o644)) + writeFakeCacheDecorators(t, "Product") sm := NewSafetyManager(false, true, false) generateManualDI(filepath.Join(dir, "di"), []string{"Product"}, "postgres", true, sm) diff --git a/cmd/config_debug.go b/cmd/config_debug.go index dcb8da2..413f5bc 100644 --- a/cmd/config_debug.go +++ b/cmd/config_debug.go @@ -50,8 +50,8 @@ var configInitCmd = &cobra.Command{ This creates a comprehensive configuration file with intelligent defaults based on your project structure and specified options.`, - Run: func(cmd *cobra.Command, args []string) { - initializeConfig(cmd) + RunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd) }, } @@ -132,19 +132,44 @@ func showCurrentConfig() { validateConfigSilent(config) } +// allowed values for `goca config init` flags. +var ( + validConfigTemplates = []string{"web", "api", "microservice", "full", "default"} + validConfigDatabases = []string{"postgres", "postgres-json", "mysql", "mongodb", "sqlite", "sqlserver", "dynamodb", "elasticsearch"} +) + +func isOneOf(value string, allowed []string) bool { + for _, a := range allowed { + if value == a { + return true + } + } + return false +} + // initializeConfig creates a new configuration file. -func initializeConfig(cmd *cobra.Command) { +func initializeConfig(cmd *cobra.Command) error { template, _ := cmd.Flags().GetString("template") force, _ := cmd.Flags().GetBool("force") database, _ := cmd.Flags().GetString("database") handlers, _ := cmd.Flags().GetStringSlice("handlers") + // Validate flag values against the allowed sets (empty = use default). + if template != "" && !isOneOf(template, validConfigTemplates) { + ui.Error(fmt.Sprintf("Invalid template %q. Allowed: %s", template, strings.Join(validConfigTemplates, ", "))) + return fmt.Errorf("invalid template: %s", template) + } + if database != "" && !isOneOf(database, validConfigDatabases) { + ui.Error(fmt.Sprintf("Invalid database %q. Allowed: %s", database, strings.Join(validConfigDatabases, ", "))) + return fmt.Errorf("invalid database: %s", database) + } + configPath := ".goca.yaml" // Check if file exists if _, err := os.Stat(configPath); err == nil && !force { ui.Warning(fmt.Sprintf("File %s already exists. Use --force to overwrite.", configPath)) - return + return nil } ui.Info("Initializing GOCA configuration...") @@ -167,7 +192,7 @@ func initializeConfig(cmd *cobra.Command) { // Write config file if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil { ui.Error(fmt.Sprintf("Error writing configuration: %v", err)) - return + return fmt.Errorf("writing configuration: %w", err) } ui.Success(fmt.Sprintf("Configuration file created: %s", configPath)) @@ -175,6 +200,7 @@ func initializeConfig(cmd *cobra.Command) { ui.Info(fmt.Sprintf("Template applied: %s", template)) } ui.Dim("Tip: Run 'goca config show' to view the configuration") + return nil } // validateConfiguration validates the current config file. @@ -190,30 +216,39 @@ func validateConfiguration() error { return fmt.Errorf("configuration file not found: %s", configPath) } - data, err := os.ReadFile(configPath) - if err != nil { - ui.Error(fmt.Sprintf("Error reading file: %v", err)) - return fmt.Errorf("reading configuration: %w", err) + // Use the rich ConfigManager validator so semantic errors (bad database.type, + // empty module, invalid DI type, invalid naming, etc.) are reported instead of + // only checking that top-level sections are present. + cm := NewConfigManager() + loadErr := cm.LoadConfig(".") + + cfgErrors := cm.GetErrors() + if loadErr != nil && len(cfgErrors) == 0 { + // Parse/read failure (e.g. invalid YAML) — surface it directly. + ui.Error(fmt.Sprintf("Invalid configuration: %v", loadErr)) + return fmt.Errorf("invalid configuration: %w", loadErr) } - var config map[string]interface{} - if err := yaml.Unmarshal(data, &config); err != nil { - ui.Error(fmt.Sprintf("Invalid YAML: %v", err)) - return fmt.Errorf("invalid YAML: %w", err) - } - - // Validate structure - errors := validateConfigStructure(config) - if len(errors) == 0 { + if len(cfgErrors) == 0 { ui.Success("Configuration is valid") + if warnings := cm.GetWarnings(); len(warnings) > 0 { + ui.Warning(fmt.Sprintf("%d warning(s):", len(warnings))) + for i, w := range warnings { + ui.Println(fmt.Sprintf(" %d. %s: %s", i+1, w.Field, w.Message)) + } + } return nil } - ui.Warning(fmt.Sprintf("Found %d errors:", len(errors))) - for i, err := range errors { - ui.Println(fmt.Sprintf(" %d. %s", i+1, err)) + ui.Warning(fmt.Sprintf("Found %d error(s):", len(cfgErrors))) + for i, e := range cfgErrors { + detail := fmt.Sprintf("%s: %s", e.Field, e.Message) + if e.Value != "" { + detail += fmt.Sprintf(" (got %q)", e.Value) + } + ui.Println(fmt.Sprintf(" %d. %s", i+1, detail)) } - return fmt.Errorf("configuration has %d error(s)", len(errors)) + return fmt.Errorf("configuration has %d error(s)", len(cfgErrors)) } // showTemplateOptions shows available configuration templates. diff --git a/cmd/coverage_batch6_test.go b/cmd/coverage_batch6_test.go index 73df04a..b5deba8 100644 --- a/cmd/coverage_batch6_test.go +++ b/cmd/coverage_batch6_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "text/template" @@ -296,12 +297,52 @@ func main() { setupMainGoWithFeature("main.go", "Product", "github.com/test/proj", mainContent) } -func TestUpdateMainGoWithCompleteSetup_Coverage(t *testing.T) { +func TestWireFeatureIntoMainGo_Coverage(t *testing.T) { cleanup := ensureTestUI(t) defer cleanup() - result := updateMainGoWithCompleteSetup("main.go", "Product", "github.com/test/proj") - assert.True(t, result) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + + dir := t.TempDir() + os.Chdir(dir) + + os.WriteFile("go.mod", []byte("module github.com/test/proj\n\ngo 1.21\n"), 0o644) + + mainContent := `package main + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +func main() { + router := mux.NewRouter() + _ = http.StatusOK + _ = router +} +` + os.WriteFile("main.go", []byte(mainContent), 0o644) + + // First wiring run adds the container scaffold + Product routes. + err := wireFeatureIntoMainGo("main.go", "Product", "github.com/test/proj", mainContent) + assert.NoError(t, err) + + out, _ := os.ReadFile("main.go") + got := string(out) + assert.Contains(t, got, "container := di.NewContainer(db)") + assert.Contains(t, got, "apiRouter := router.PathPrefix(\"/api/v1\").Subrouter()") + assert.Contains(t, got, "apphttp.SetupProductRoutes(apiRouter, container.ProductUseCase())") + assert.Contains(t, got, "\"github.com/test/proj/internal/di\"") + + // Idempotency: re-running must not duplicate the container or routes. + err = wireFeatureIntoMainGo("main.go", "Product", "github.com/test/proj", got) + assert.NoError(t, err) + out2, _ := os.ReadFile("main.go") + got2 := string(out2) + assert.Equal(t, 1, strings.Count(got2, "container := di.NewContainer(db)")) + assert.Equal(t, 1, strings.Count(got2, "SetupProductRoutes(apiRouter")) } func TestDetectExistingFeatures_Coverage(t *testing.T) { @@ -319,15 +360,27 @@ func TestDetectExistingFeatures_Coverage(t *testing.T) { os.WriteFile(filepath.Join(domainDir, "errors.go"), []byte("package domain"), 0o644) // filtered os.WriteFile(filepath.Join(domainDir, "product_seeds.go"), []byte("package domain"), 0o644) // filtered - // Create handler dir + usecaseDir := filepath.Join("internal", "usecase") + repoDir := filepath.Join("internal", "repository") httpDir := filepath.Join("internal", "handler", "http") + os.MkdirAll(usecaseDir, 0o755) + os.MkdirAll(repoDir, 0o755) os.MkdirAll(httpDir, 0o755) + + // Product is a COMPLETE feature: domain + usecase + repository + handler. + os.WriteFile(filepath.Join(usecaseDir, "product_service.go"), []byte("package usecase"), 0o644) + os.WriteFile(filepath.Join(repoDir, "postgres_product_repository.go"), []byte("package repository"), 0o644) + os.WriteFile(filepath.Join(httpDir, "product_handler.go"), []byte("package http"), 0o644) + + // User is an orphan domain entity (no usecase/repository/handler) and must + // be skipped so the DI container never references missing constructors. + // Order has only a handler (no domain entity) and must also be skipped. os.WriteFile(filepath.Join(httpDir, "order_handler.go"), []byte("package http"), 0o644) features := detectExistingFeatures() assert.Contains(t, features, "Product") - assert.Contains(t, features, "User") - assert.Contains(t, features, "Order") + assert.NotContains(t, features, "User") + assert.NotContains(t, features, "Order") assert.NotContains(t, features, "Errors") } diff --git a/cmd/di.go b/cmd/di.go index 72ee517..7b366b4 100644 --- a/cmd/di.go +++ b/cmd/di.go @@ -15,6 +15,16 @@ const ( dbMongoDB = "mongodb" ) +// dbHandleType returns the Go type and its import path for the database handle +// passed to the container/wire injectors. MongoDB repositories take a +// *mongo.Database; everything else uses GORM's *gorm.DB. +func dbHandleType(database string) (goType, importPath string) { + if database == dbMongoDB { + return "*mongo.Database", "go.mongodb.org/mongo-driver/mongo" + } + return "*gorm.DB", "gorm.io/gorm" +} + var diCmd = &cobra.Command{ Use: "di", Short: "Generate dependency injection container", @@ -95,14 +105,21 @@ func generateManualDI(dir string, features []string, database string, cache bool filename := filepath.Join(dir, "container.go") + // Only wire the Redis cache layer when --cache was requested AND at least + // one of the features has a generated cache decorator. Otherwise the + // redisClient field / redis + time imports would be unused (or reference a + // non-existent NewCached%sRepository) and the container would not compile. + effectiveCache := cache && anyFeatureHasCacheDecorator(features) + dbType, dbImport := dbHandleType(database) + var content strings.Builder content.WriteString("package di\n\n") content.WriteString("import (\n") - if cache { + if effectiveCache { content.WriteString("\t\"time\"\n\n") } - content.WriteString("\t\"gorm.io/gorm\"\n") - if cache { + fmt.Fprintf(&content, "\t%q\n", dbImport) + if effectiveCache { content.WriteString("\t\"github.com/redis/go-redis/v9\"\n") } content.WriteString("\n") @@ -111,8 +128,8 @@ func generateManualDI(dir string, features []string, database string, cache bool content.WriteString(fmt.Sprintf("\t\"%s/internal/handler/http\"\n", importPath)) content.WriteString(")\n\n") // Container struct content.WriteString("type Container struct {\n") - content.WriteString("\tdb *gorm.DB\n") - if cache { + fmt.Fprintf(&content, "\tdb %s\n", dbType) + if effectiveCache { content.WriteString("\tredisClient *redis.Client\n") } content.WriteString("\n") @@ -141,11 +158,11 @@ func generateManualDI(dir string, features []string, database string, cache bool content.WriteString("}\n\n") // Constructor - if cache { - content.WriteString("func NewContainer(db *gorm.DB, redisClient *redis.Client) *Container {\n") + if effectiveCache { + fmt.Fprintf(&content, "func NewContainer(db %s, redisClient *redis.Client) *Container {\n", dbType) content.WriteString("\tc := &Container{db: db, redisClient: redisClient}\n") } else { - content.WriteString("func NewContainer(db *gorm.DB) *Container {\n") + fmt.Fprintf(&content, "func NewContainer(db %s) *Container {\n", dbType) content.WriteString("\tc := &Container{db: db}\n") } content.WriteString("\tc.setupRepositories()\n") @@ -155,7 +172,7 @@ func generateManualDI(dir string, features []string, database string, cache bool content.WriteString("}\n\n") // Setup methods - generateSetupRepositories(&content, features, database, cache) + generateSetupRepositories(&content, features, database, effectiveCache) generateSetupUseCases(&content, features) generateSetupHandlers(&content, features) @@ -182,7 +199,10 @@ func generateSetupRepositories(content *strings.Builder, features []string, data repoConstructor = fmt.Sprintf("repository.NewPostgres%sRepository(c.db)", feature) } - if cache { + // Only wrap with the Redis cache decorator when one was actually + // generated for this entity (goca repository --cache). Emitting + // NewCached%sRepository otherwise references an undefined constructor. + if cache && hasCacheDecorator(feature) { fmt.Fprintf(content, "\tbase%sRepo := %s\n", feature, repoConstructor) fmt.Fprintf(content, "\tc.%sRepo = repository.NewCached%sRepository(base%sRepo, c.redisClient, 5*time.Minute)\n", featureLower, feature, feature) @@ -194,6 +214,28 @@ func generateSetupRepositories(content *strings.Builder, features []string, data content.WriteString("}\n\n") } +// hasCacheDecorator reports whether a Redis cache decorator +// (cached__repository.go) was generated for the entity. +func hasCacheDecorator(feature string) bool { + path := filepath.Join(DirInternal, DirRepository, "cached_"+strings.ToLower(feature)+"_repository.go") + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +// anyFeatureHasCacheDecorator reports whether any feature has a generated cache +// decorator. +func anyFeatureHasCacheDecorator(features []string) bool { + for _, f := range features { + if hasCacheDecorator(f) { + return true + } + } + return false +} + func generateSetupUseCases(content *strings.Builder, features []string) { content.WriteString("func (c *Container) setupUseCases() {\n") @@ -261,9 +303,9 @@ func generateWireFile(dir string, features []string, database string, sm ...*Saf var content strings.Builder writeWireHeader(&content) - writeWireImports(&content, importPath) + writeWireImports(&content, importPath, database) writeWireSets(&content, features, database) - writeWireFunctions(&content, features) + writeWireFunctions(&content, features, database) if err := writeGoFile(filename, content.String(), sm...); err != nil { ui.Error(fmt.Sprintf("Error writing Wire file: %v", err)) @@ -279,10 +321,11 @@ func writeWireHeader(content *strings.Builder) { } // writeWireImports writes the import section for Wire file. -func writeWireImports(content *strings.Builder, importPath string) { +func writeWireImports(content *strings.Builder, importPath, database string) { + _, dbImport := dbHandleType(database) content.WriteString("import (\n") - content.WriteString("\t\"database/sql\"\n\n") content.WriteString("\t\"github.com/google/wire\"\n") + fmt.Fprintf(content, "\t%q\n\n", dbImport) fmt.Fprintf(content, "\t\"%s/internal/repository\"\n", importPath) fmt.Fprintf(content, "\t\"%s/internal/usecase\"\n", importPath) fmt.Fprintf(content, "\t\"%s/internal/handler/http\"\n", importPath) @@ -345,21 +388,27 @@ func writeAllSet(content *strings.Builder) { } // writeWireFunctions writes Wire initialization functions. -func writeWireFunctions(content *strings.Builder, features []string) { +// +// All injectors take the GORM *gorm.DB (the type repository constructors +// expect) and return the WireContainer that NewWireContainer provides, keeping +// the provider graph and the declared return types consistent so `wire` can +// generate code without errors. +func writeWireFunctions(content *strings.Builder, features []string, database string) { + dbType, _ := dbHandleType(database) for _, feature := range features { - fmt.Fprintf(content, "func Initialize%sHandler(db *sql.DB) *http.%sHandler {\n", - feature, feature) + fmt.Fprintf(content, "func Initialize%sHandler(db %s) *http.%sHandler {\n", + feature, dbType, feature) content.WriteString("\twire.Build(AllSet)\n") fmt.Fprintf(content, "\treturn &http.%sHandler{}\n", feature) content.WriteString("}\n\n") } - content.WriteString("func InitializeContainer(db *sql.DB) *Container {\n") + fmt.Fprintf(content, "func InitializeContainer(db %s) *WireContainer {\n", dbType) content.WriteString("\twire.Build(\n") content.WriteString("\t\tAllSet,\n") content.WriteString("\t\tNewWireContainer,\n") content.WriteString("\t)\n") - content.WriteString("\treturn &Container{}\n") + content.WriteString("\treturn &WireContainer{}\n") content.WriteString("}\n") } diff --git a/cmd/di_helpers_test.go b/cmd/di_helpers_test.go index 07703c1..f0f0585 100644 --- a/cmd/di_helpers_test.go +++ b/cmd/di_helpers_test.go @@ -81,13 +81,19 @@ func TestWriteWireHeader(t *testing.T) { func TestWriteWireImports(t *testing.T) { t.Parallel() var b strings.Builder - writeWireImports(&b, "mymodule") + writeWireImports(&b, "mymodule", "postgres") output := b.String() assert.Contains(t, output, "import (") - assert.Contains(t, output, "database/sql") + // Repositories take *gorm.DB, so wire injectors must too (not database/sql). + assert.Contains(t, output, "gorm.io/gorm") + assert.NotContains(t, output, "database/sql") assert.Contains(t, output, "github.com/google/wire") assert.Contains(t, output, "mymodule/internal/repository") assert.Contains(t, output, "mymodule/internal/usecase") + + var bMongo strings.Builder + writeWireImports(&bMongo, "mymodule", "mongodb") + assert.Contains(t, bMongo.String(), "go.mongodb.org/mongo-driver/mongo") } func TestWriteWireSets(t *testing.T) { @@ -155,9 +161,10 @@ func TestWriteAllSet(t *testing.T) { func TestWriteWireFunctions(t *testing.T) { t.Parallel() var b strings.Builder - writeWireFunctions(&b, []string{"Product"}) + writeWireFunctions(&b, []string{"Product"}, "postgres") output := b.String() - assert.Contains(t, output, "func InitializeProductHandler(db *sql.DB)") + assert.Contains(t, output, "func InitializeProductHandler(db *gorm.DB)") assert.Contains(t, output, "wire.Build(AllSet)") - assert.Contains(t, output, "func InitializeContainer(db *sql.DB)") + // Return type must match the only available provider (NewWireContainer). + assert.Contains(t, output, "func InitializeContainer(db *gorm.DB) *WireContainer") } diff --git a/cmd/doctor.go b/cmd/doctor.go index f005305..7e0a535 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -222,8 +222,8 @@ func checkDIContainer() doctorCheck { check := doctorCheck{name: "DI container"} candidates := []string{ - "internal/di", filepath.Join("internal", "di"), + filepath.Join("internal", "ioc"), } for _, dir := range candidates { @@ -234,12 +234,51 @@ func checkDIContainer() doctorCheck { } } + // No dedicated DI directory: DI may be wired manually in the entry point + // (goca init wires it in cmd/server/main.go). Treat that as informational + // rather than a warning. + if mainWiresDI() { + check.status = "✓" + check.message = "DI wired in main.go (no internal/di package)" + return check + } + check.status = "⚠" check.message = "No DI container directory found" check.suggestion = "Run: goca di to generate the dependency injection container" return check } +// mainWiresDI reports whether an entry-point main.go appears to perform manual +// dependency injection (constructing repositories/use cases/handlers). +func mainWiresDI() bool { + mainCandidates := []string{"main.go", "cmd/main.go", "cmd/server/main.go"} + if entries, err := os.ReadDir("cmd"); err == nil { + for _, e := range entries { + if e.IsDir() { + mainCandidates = append(mainCandidates, filepath.Join("cmd", e.Name(), "main.go")) + } + } + } + for _, m := range mainCandidates { + content, err := os.ReadFile(m) + if err != nil { + continue + } + s := string(content) + // Heuristic: manual wiring references the DI container or constructs + // the layered dependencies directly in main. + if strings.Contains(s, "NewContainer") || + strings.Contains(s, "/internal/di") || + (strings.Contains(s, "Repository(") && strings.Contains(s, "UseCase(")) || + (strings.Contains(s, "Repository(") && strings.Contains(s, "Handler(")) { + + return true + } + } + return false +} + func printChecks(checks []doctorCheck) { rows := make([][]string, len(checks)) for i, c := range checks { diff --git a/cmd/entity.go b/cmd/entity.go index a8e49d3..ef4e759 100644 --- a/cmd/entity.go +++ b/cmd/entity.go @@ -206,6 +206,18 @@ func parseFieldsWithValidation(fields string, withValidation bool) []Field { os.Exit(1) } + // Normalize field names to idiomatic Go PascalCase (handling snake_case, + // kebab-case and common initialisms like ID/URL/API). The struct tag keeps + // the snake_case form for json/gorm. The ID field is left untouched. + for i := range fieldsList { + if fieldsList[i].Name == "ID" { + continue + } + snake := strings.ToLower(strings.ReplaceAll(fieldsList[i].Name, "-", "_")) + fieldsList[i].Name = toGoFieldName(fieldsList[i].Name) + fieldsList[i].Tag = rebuildFieldTag(fieldsList[i].Tag, snake) + } + // If validation is enabled, add validate tags to the field tags if withValidation { for i := range fieldsList { @@ -229,24 +241,147 @@ func parseFieldsWithValidation(fields string, withValidation bool) []Field { return fieldsList } +// commonInitialisms maps lowercase word fragments to their idiomatic Go +// capitalization so field names like user_id -> UserID and api_url -> APIURL. +var commonInitialisms = map[string]string{ + "id": "ID", "url": "URL", "uri": "URI", "api": "API", "http": "HTTP", + "https": "HTTPS", "json": "JSON", "xml": "XML", "sql": "SQL", "uuid": "UUID", + "html": "HTML", "ip": "IP", "ssh": "SSH", "tcp": "TCP", "udp": "UDP", + "db": "DB", "ui": "UI", "ttl": "TTL", "ascii": "ASCII", "cpu": "CPU", +} + +// toGoFieldName converts an arbitrary field name (snake_case, kebab-case, +// camelCase or already PascalCase) into idiomatic Go PascalCase, honoring +// common initialisms. Examples: last_login -> LastLogin, user_id -> UserID. +func toGoFieldName(name string) string { + // Split on separators; also split camelCase boundaries. + var words []string + var cur strings.Builder + flush := func() { + if cur.Len() > 0 { + words = append(words, cur.String()) + cur.Reset() + } + } + runes := []rune(name) + for i, r := range runes { + if r == '_' || r == '-' || r == ' ' { + flush() + continue + } + // Split on lower->upper boundary (camelCase) to re-segment words. + if i > 0 && r >= 'A' && r <= 'Z' { + prev := runes[i-1] + if prev >= 'a' && prev <= 'z' { + flush() + } + } + cur.WriteRune(r) + } + flush() + + var result strings.Builder + for _, w := range words { + lower := strings.ToLower(w) + if init, ok := commonInitialisms[lower]; ok { + result.WriteString(init) + continue + } + result.WriteString(strings.ToUpper(lower[:1]) + lower[1:]) + } + return result.String() +} + +// rebuildFieldTag rewrites the json and gorm tag keys of an existing struct tag +// so they use the supplied snake_case field name, preserving any additional +// gorm options (e.g. uniqueIndex;not null) that were generated from the field. +func rebuildFieldTag(tag, snake string) string { + inner := strings.Trim(tag, "`") + // Replace json:"..." value with the snake_case name. + inner = replaceTagJSONName(inner, snake) + return "`" + inner + "`" +} + +// replaceTagJSONName replaces the value of the json tag key with the given name, +// keeping any options such as ",omitempty". +func replaceTagJSONName(inner, snake string) string { + const key = `json:"` + idx := strings.Index(inner, key) + if idx < 0 { + return inner + } + start := idx + len(key) + end := strings.Index(inner[start:], `"`) + if end < 0 { + return inner + } + end += start + val := inner[start:end] + // Preserve options after the first comma (e.g. ",omitempty"). + opts := "" + if c := strings.Index(val, ","); c >= 0 { + opts = val[c:] + } + return inner[:start] + snake + opts + inner[end:] +} + func getValidateTag(fieldName, fieldType string) string { - switch fieldType { - case FieldString: + switch { + case fieldType == FieldString: if fieldName == "Email" || strings.EqualFold(fieldName, "email") { return "required,email" } return "required" - case "int", "int64", "uint", "uint64": + case isSignedNumericType(fieldType): return "required,gte=0" - case "float64": - return "required,gte=0" - case "bool": + case isUnsignedIntType(fieldType): + // Unsigned integers are always >= 0; gte=0 would be redundant. + return "required" + case fieldType == "bool": return "" // Booleans don't usually need validation + case isSliceType(fieldType) || isPointerType(fieldType): + // required on a slice/pointer is dubious and the runtime Validate() body + // has no coherent check for these, so emit no validate tag (see ENTITY-9). + return "" default: return "required" } } +// isNumericType reports whether fieldType is any Go numeric subtype. +func isNumericType(fieldType string) bool { + return isSignedNumericType(fieldType) || isUnsignedIntType(fieldType) +} + +// isSignedNumericType reports whether fieldType is a signed integer (incl. rune) +// or a float, i.e. a type for which a "< 0" runtime check is meaningful. +func isSignedNumericType(fieldType string) bool { + switch fieldType { + case "int", "int8", "int16", "int32", "int64", "rune", "float32", "float64": + return true + } + return false +} + +// isUnsignedIntType reports whether fieldType is an unsigned integer subtype. +func isUnsignedIntType(fieldType string) bool { + switch fieldType { + case "uint", "uint8", "uint16", "uint32", "uint64", "byte": + return true + } + return false +} + +// isSliceType reports whether fieldType is a slice type. +func isSliceType(fieldType string) bool { + return strings.HasPrefix(fieldType, "[]") +} + +// isPointerType reports whether fieldType is a pointer type. +func isPointerType(fieldType string) bool { + return strings.HasPrefix(fieldType, "*") +} + func getGormTag(fieldName, fieldType string) string { switch fieldType { case FieldString: @@ -297,6 +432,9 @@ func generateEntityFile(dir, entityName string, fields []Field, validation, busi writeEntityHeader(&content, fields, businessRules, timestamps, softDelete) writeEntityStruct(&content, entityName, fields) + // Emit stub definitions for unknown custom/named types referenced by fields + // (e.g. status:UserStatus) so the generated package compiles (ENTITY-1). + writeCustomTypeStubs(&content, entityName, fields) if validation { writeValidationMethod(&content, entityName, fields) @@ -358,6 +496,62 @@ func writeEntityStruct(content *strings.Builder, entityName string, fields []Fie content.WriteString("}\n\n") } +// writeCustomTypeStubs emits a stub "type X string" definition for each unknown +// custom/named type referenced by a field, so the generated domain package +// compiles even when the user passes a type that does not yet exist. +func writeCustomTypeStubs(content *strings.Builder, entityName string, fields []Field) { + seen := map[string]bool{} + var stubs []string + for _, field := range fields { + base := customTypeBase(field.Type) + if base == "" || base == entityName || seen[base] { + continue + } + seen[base] = true + stubs = append(stubs, base) + } + for _, t := range stubs { + fmt.Fprintf(content, "// %s is a generated stub type. Replace with your own definition.\n", t) + fmt.Fprintf(content, "type %s string\n\n", t) + } +} + +// customTypeBase returns the underlying unqualified custom type name referenced +// by fieldType (unwrapping leading []/*/[N]), or "" when the base is a builtin, +// qualified (package.Type), composite or otherwise not a stubbable custom type. +func customTypeBase(fieldType string) string { + t := fieldType + for { + switch { + case strings.HasPrefix(t, "[]"): + t = strings.TrimPrefix(t, "[]") + case strings.HasPrefix(t, "*"): + t = strings.TrimPrefix(t, "*") + default: + goto unwrapped + } + } +unwrapped: + // Skip arrays, maps, channels, funcs, interfaces and qualified types. + if strings.ContainsAny(t, ".[]{}() <>") || strings.HasPrefix(t, "map") || + strings.HasPrefix(t, "chan") || strings.HasPrefix(t, "func") || + strings.HasPrefix(t, "interface") { + + return "" + } + // Known builtin types never need a stub. + for _, vt := range ValidFieldTypes { + if t == vt { + return "" + } + } + // A stubbable custom type is an exported identifier. + if t == "" || t[0] < 'A' || t[0] > 'Z' { + return "" + } + return t +} + // writeValidationMethod writes the Validate method for the entity. func writeValidationMethod(content *strings.Builder, entityName string, fields []Field) { entityVar := strings.ToLower(string(entityName[0])) @@ -389,10 +583,14 @@ func writeFieldValidation(content *strings.Builder, entityVar, entityName string fmt.Fprintf(content, "\t\treturn ErrInvalid%s%s\n", entityName, field.Name) content.WriteString("\t}\n") } - case "int", "int64", "float64": - fmt.Fprintf(content, "\tif %s.%s < 0 {\n", entityVar, field.Name) - fmt.Fprintf(content, "\t\treturn ErrInvalid%s%s\n", entityName, field.Name) - content.WriteString("\t}\n") + default: + // "< 0" is only meaningful for signed integers and floats; unsigned + // integers are always non-negative so no runtime check is emitted. + if isSignedNumericType(field.Type) { + fmt.Fprintf(content, "\tif %s.%s < 0 {\n", entityVar, field.Name) + fmt.Fprintf(content, "\t\treturn ErrInvalid%s%s\n", entityName, field.Name) + content.WriteString("\t}\n") + } } } @@ -565,11 +763,22 @@ func writeFieldErrors(content *strings.Builder, entityName string, fields []Fiel continue } - writeRequiredFieldError(content, entityName, field, existingErrors) + // Only declare ErrInvalid when Validate() actually emits a + // check for it (string emptiness or signed-numeric "< 0"); otherwise the + // constant would be declared but never used (ENTITY-9). + if fieldHasBaseValidation(field.Type) { + writeRequiredFieldError(content, entityName, field, existingErrors) + } writeTypeSpecificErrors(content, entityName, field, existingErrors) } } +// fieldHasBaseValidation reports whether writeFieldValidation emits a check that +// references the ErrInvalid "required" constant for this type. +func fieldHasBaseValidation(fieldType string) bool { + return fieldType == FieldString || isSignedNumericType(fieldType) +} + // writeRequiredFieldError writes the required field error. func writeRequiredFieldError(content *strings.Builder, entityName string, field Field, existingErrors []string) { fieldLower := strings.ToLower(field.Name) @@ -584,13 +793,14 @@ func writeRequiredFieldError(content *strings.Builder, entityName string, field func writeTypeSpecificErrors(content *strings.Builder, entityName string, field Field, existingErrors []string) { fieldLower := strings.ToLower(field.Name) - switch field.Type { - case FieldString: + switch { + case field.Type == FieldString: writeStringFieldErrors(content, entityName, field, fieldLower, existingErrors) - case "int", "int64", "uint", "uint64": - writeIntegerFieldErrors(content, entityName, field, fieldLower, existingErrors) - case "float64", "float32": + case field.Type == "float32" || field.Type == "float64": + // Range error only for signed types where the runtime "< 0" check exists. writeFloatFieldErrors(content, entityName, field, fieldLower, existingErrors) + case isSignedNumericType(field.Type): + writeIntegerFieldErrors(content, entityName, field, fieldLower, existingErrors) } } @@ -979,7 +1189,38 @@ func generateSQLSampleValue(field Field, index int) string { return "NOW()" default: - return "NULL" + return generateSQLCompositeSampleValue(field, index) + } +} + +// generateSQLCompositeSampleValue produces a non-NULL SQL literal for composite +// or custom field types (slices, pointers, custom named types). It keeps SQL +// seeds consistent with the Go seeds and valid for NOT NULL columns (ENTITY-7). +func generateSQLCompositeSampleValue(field Field, index int) string { + ft := field.Type + switch { + case strings.HasPrefix(ft, "[]"): + // Slices are persisted as text (e.g. comma-separated / JSON-like). + elem := strings.TrimPrefix(ft, "[]") + switch { + case elem == FieldString: + return fmt.Sprintf("'sample%d,sample%d'", index, index+1) + case isNumericType(elem): + return fmt.Sprintf("'%d,%d'", index*10, index*10+1) + case elem == FieldBool: + return "'true,false'" + default: + return fmt.Sprintf("'sample%d'", index) + } + case strings.HasPrefix(ft, "*"): + // Pointer: emit a value of the underlying type. + base := strings.TrimPrefix(ft, "*") + return generateSQLSampleValue(Field{Name: field.Name, Type: base}, index) + case ft == "time.Time": + return "NOW()" + default: + // Custom/named types (e.g. UserStatus): treat as text. + return fmt.Sprintf("'sample%d'", index) } } diff --git a/cmd/entity_generation_test.go b/cmd/entity_generation_test.go index 523db12..75bd5d7 100644 --- a/cmd/entity_generation_test.go +++ b/cmd/entity_generation_test.go @@ -394,7 +394,13 @@ func TestGenerateSQLSampleValue(t *testing.T) { {"float other", Field{Name: "Rate", Type: "float64"}, 1, "10.50"}, {"bool", Field{Name: "Active", Type: "bool"}, 1, "true"}, {"time", Field{Name: "Start", Type: "time.Time"}, 1, "NOW()"}, - {"unknown", Field{Name: "X", Type: "complex"}, 1, "NULL"}, + // ENTITY-7: composite/custom types emit a non-NULL value consistent with + // the Go seeds and valid for NOT NULL columns. + {"slice string", Field{Name: "Tags", Type: "[]string"}, 1, "'sample1,sample2'"}, + {"slice int", Field{Name: "Codes", Type: "[]int"}, 1, "'10,11'"}, + {"pointer", Field{Name: "LastLogin", Type: "*time.Time"}, 1, "NOW()"}, + {"custom type", Field{Name: "Status", Type: "UserStatus"}, 1, "'sample1'"}, + {"unknown", Field{Name: "X", Type: "complex"}, 1, "'sample1'"}, } for _, tc := range cases { diff --git a/cmd/entity_helpers_test.go b/cmd/entity_helpers_test.go index 7276728..8533633 100644 --- a/cmd/entity_helpers_test.go +++ b/cmd/entity_helpers_test.go @@ -21,11 +21,18 @@ func TestGetValidateTag(t *testing.T) { {name: "email lower", fieldName: "email", fieldType: "string", expected: "required,email"}, {name: "string field", fieldName: "Name", fieldType: "string", expected: "required"}, {name: "int field", fieldName: "Age", fieldType: "int", expected: "required,gte=0"}, + {name: "int32 field", fieldName: "Count", fieldType: "int32", expected: "required,gte=0"}, {name: "int64 field", fieldName: "Count", fieldType: "int64", expected: "required,gte=0"}, - {name: "uint field", fieldName: "ID", fieldType: "uint", expected: "required,gte=0"}, + {name: "float32 field", fieldName: "Rate", fieldType: "float32", expected: "required,gte=0"}, {name: "float64 field", fieldName: "Price", fieldType: "float64", expected: "required,gte=0"}, + // Unsigned integers are always >= 0, so gte=0 is omitted. + {name: "uint field", fieldName: "Qty", fieldType: "uint", expected: "required"}, + {name: "uint32 field", fieldName: "Qty", fieldType: "uint32", expected: "required"}, {name: "bool field", fieldName: "Active", fieldType: "bool", expected: ""}, {name: "time field", fieldName: "CreatedAt", fieldType: "time.Time", expected: "required"}, + // Slices/pointers get no validate tag (no coherent runtime check). + {name: "slice field", fieldName: "Tags", fieldType: "[]string", expected: ""}, + {name: "pointer field", fieldName: "LastLogin", fieldType: "*time.Time", expected: ""}, } for _, tc := range cases { diff --git a/cmd/feature.go b/cmd/feature.go index eac8e09..90e1e19 100644 --- a/cmd/feature.go +++ b/cmd/feature.go @@ -274,11 +274,13 @@ func generateCompleteFeature(featureName, fields, database, handlers string, val // 6. Register entity for auto-migration ui.Step(6, "Registering entity for auto-migration...") - if err := addEntityToAutoMigration(featureName, safetyMgr); err != nil { + if registered, err := registerEntityForAutoMigration(featureName); err != nil { ui.Warning(fmt.Sprintf("Could not register entity for auto-migration: %v", err)) ui.Dim(" Tip: Entity was created correctly, but you'll need to configure migration manually") - } else { + } else if registered { ui.Success(fmt.Sprintf("Entity %s registered for GORM auto-migration", featureName)) + } else { + ui.Dim(fmt.Sprintf(" Entity %s already registered for auto-migration", featureName)) } ui.Success("All layers generated successfully!") @@ -387,7 +389,9 @@ func addFeatureToDI(featureName string, cache bool, sm ...*SafetyManager) { updatedContent = addSetupMethodsToDI(updatedContent, featureName, featureLower, cache) updatedContent = addGetterMethodsToDI(updatedContent, featureName, featureLower) - if err := writeFile(diPath, updatedContent, sm...); err != nil { + // This is an in-place merge of an existing container.go that we just read, + // so honor dry-run/backup but bypass the "file already exists" guard. + if err := writeMergedFileSafe(diPath, updatedContent, sm...); err != nil { ui.Warning(fmt.Sprintf("Could not update DI container: %v", err)) return } @@ -525,9 +529,10 @@ func isFeatureAlreadyRegistered(content, featureName string) bool { // setupMainGoWithFeature sets up the main.go file with the new feature. func setupMainGoWithFeature(mainPath, featureName, moduleName, content string) { - // Always use complete GORM setup for consistency - ui.Dim(" Setting up complete main.go with DI...") - if !updateMainGoWithCompleteSetup(mainPath, featureName, moduleName) { + // Wire the feature into main.go: DI container + /api/v1 routes. + ui.Dim(" Wiring feature into main.go (DI container + routes)...") + if err := wireFeatureIntoMainGo(mainPath, featureName, moduleName, content); err != nil { + ui.Warning(fmt.Sprintf("Could not wire routes into main.go: %v", err)) printManualIntegrationInstructions(featureName) return } @@ -563,13 +568,177 @@ func init() { _ = featureCmd.MarkFlagRequired("fields") } -// updateMainGoWithCompleteSetup replaces the basic main.go with a complete DI-integrated version. -func updateMainGoWithCompleteSetup(mainPath, featureName, moduleName string) bool { - // Simplified to avoid format errors - ui.Dim(fmt.Sprintf(" Updating main.go for feature %s", featureName)) +// writeMergedFileSafe writes content that the caller has rebuilt from an +// existing file's current content (an in-place merge). When a SafetyManager is +// supplied it routes through WriteMergedFile (honoring dry-run/backup while +// allowing overwrite of an existing file); otherwise it writes directly. +func writeMergedFileSafe(path, content string, sm ...*SafetyManager) error { + if len(sm) > 0 && sm[0] != nil { + return sm[0].WriteMergedFile(path, content) + } + //#nosec G306 // generated Go source, standard 0644 perms + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + return nil +} + +// writeMainGoInPlace overwrites an existing generated file (such as +// cmd/server/main.go) that we have intentionally rebuilt from its current +// content. It deliberately does NOT go through the SafetyManager "file already +// exists" guard, because these are in-place edits of files we just read. +func writeMainGoInPlace(path, content string) error { + //#nosec G306 // generated Go source, standard 0644 perms + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + return nil +} + +// isEntityAlreadyMigrated reports whether entityReference (e.g. "&domain.User{}") +// already appears on a non-comment line of main.go. This avoids the brace-counting +// pitfalls of scanning the entities slice (entity literals themselves contain "{}"). +func isEntityAlreadyMigrated(content, entityReference string) bool { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "//") { + continue + } + if strings.Contains(line, entityReference) { + return true + } + } + return false +} + +// registerEntityForAutoMigration inserts &domain.{} into the +// runAutoMigrations entities slice in cmd/server/main.go. It is idempotent and +// adds the domain import if missing. Returns (true, nil) when it actually added +// the entity, (false, nil) when it was already present. +func registerEntityForAutoMigration(featureName string) (bool, error) { + mainPath, err := findMainGoFile() + if err != nil { + return false, err + } + + raw, err := os.ReadFile(mainPath) + if err != nil { + return false, fmt.Errorf("failed to read main.go: %w", err) + } + content := string(raw) + + entityReference := fmt.Sprintf("&domain.%s{}", featureName) + if isEntityAlreadyMigrated(content, entityReference) { + return false, nil + } + + moduleName := getModuleName() + if moduleName == "" { + return false, fmt.Errorf("could not determine module name from go.mod") //nolint:err113 + } + + // Use the robust import inserter so the import block stays well-formed. + updated := ensureMainGoImport(content, fmt.Sprintf("%s/internal/domain", moduleName)) + + updated, err = addEntityToMigrationSlice(updated, entityReference) + if err != nil { + return false, err + } + + if err := writeMainGoInPlace(mainPath, updated); err != nil { + return false, err + } + return true, nil +} + +// wireFeatureIntoMainGo edits cmd/server/main.go in-place so the generated app +// genuinely serves the feature: it instantiates the DI container (once) and +// registers the feature's routes under an /api/v1 subrouter. It is idempotent. +func wireFeatureIntoMainGo(mainPath, featureName, moduleName, content string) error { + featureLower := strings.ToLower(featureName) + + updated := content + + // 1. Ensure required imports (di + handler http packages). + updated = ensureMainGoImport(updated, fmt.Sprintf("%s/internal/di", moduleName)) + updated = ensureMainGoImport(updated, fmt.Sprintf("apphttp \"%s/internal/handler/http\"", moduleName)) + + // 2. Ensure the DI container + /api/v1 subrouter scaffold exist (once). + updated = ensureContainerScaffold(updated) + + // 3. Register this feature's routes (idempotent). + routeCall := fmt.Sprintf("apphttp.Setup%sRoutes(apiRouter, container.%sUseCase())", featureName, featureName) + if !strings.Contains(updated, routeCall) { + marker := wiringRoutesMarker + insertion := fmt.Sprintf("\t%s // %s routes\n%s", routeCall, featureLower, marker) + updated = strings.Replace(updated, marker, insertion, 1) + } + + if err := writeMainGoInPlace(mainPath, updated); err != nil { + return err + } + return nil +} + +// wiringRoutesMarker is the anchor comment after which feature route +// registrations are inserted. +const wiringRoutesMarker = "// goca:routes -- feature routes are registered above this line" + +// ensureMainGoImport adds an import line to the import block of main.go if it +// is not already present. importLine may be a plain path ("mod/pkg") or an +// aliased form ("alias \"mod/pkg\""). +func ensureMainGoImport(content, importLine string) string { + // Build the bare path for the presence check (strip alias + quotes). + bare := importLine + if idx := strings.Index(bare, "\""); idx != -1 { + bare = bare[idx:] + } + bare = strings.Trim(bare, "\"") + if strings.Contains(content, "\""+bare+"\"") { + return content + } + + // Normalize to a quoted (optionally aliased) import spec. + var spec string + if strings.Contains(importLine, "\"") { + spec = importLine + } else { + spec = "\"" + importLine + "\"" + } + + importStart := strings.Index(content, "import (") + if importStart == -1 { + return content + } + closeIdx := strings.Index(content[importStart:], "\n)") + if closeIdx == -1 { + return content + } + closeIdx += importStart + return content[:closeIdx] + "\n\t" + spec + content[closeIdx:] +} + +// ensureContainerScaffold injects (once) the DI container instantiation and an +// /api/v1 subrouter together with the route marker into main.go. +func ensureContainerScaffold(content string) string { + if strings.Contains(content, "container := di.NewContainer(db)") { + return content + } + + anchor := "\trouter := mux.NewRouter()\n" + if !strings.Contains(content, anchor) { + return content + } + + scaffold := anchor + "\n" + + "\t// Dependency injection container\n" + + "\tcontainer := di.NewContainer(db)\n" + + "\t_ = container\n\n" + + "\t// API v1 routes\n" + + "\tapiRouter := router.PathPrefix(\"/api/v1\").Subrouter()\n" + + "\t" + wiringRoutesMarker + "\n" - // For now just mark it as processed - return true + return strings.Replace(content, anchor, scaffold, 1) } // printManualIntegrationInstructions prints instructions for manual integration. diff --git a/cmd/generate_integration_batch3_test.go b/cmd/generate_integration_batch3_test.go index fdd6baa..9a76da3 100644 --- a/cmd/generate_integration_batch3_test.go +++ b/cmd/generate_integration_batch3_test.go @@ -240,4 +240,11 @@ func TestGenerateUpdateMethod(t *testing.T) { assert.Contains(t, result, "func (P *ProductService) UpdateProduct") assert.Contains(t, result, "UpdateProductInput") assert.Contains(t, result, "repo.FindByID") + // The generic update method must reference the actual generic DTO fields + // (pointer Name/Description), not the old hardcoded Spanish fields. + assert.Contains(t, result, "if input.Name != nil {") + assert.Contains(t, result, "product.Name = *input.Name") + assert.Contains(t, result, "if input.Description != nil {") + assert.NotContains(t, result, "Nombre") + assert.NotContains(t, result, "input.Email") } diff --git a/cmd/generate_integration_batch4_test.go b/cmd/generate_integration_batch4_test.go index 12512ee..d834267 100644 --- a/cmd/generate_integration_batch4_test.go +++ b/cmd/generate_integration_batch4_test.go @@ -126,7 +126,7 @@ func TestCreateReadme_DryRun(t *testing.T) { dir := t.TempDir() sm := NewSafetyManager(true, false, false) - createReadme(dir, "github.com/test/proj", sm) + createReadme(dir, "github.com/test/proj", DBPostgres, sm) assert.Len(t, sm.GetPendingFiles(), 1) assert.Contains(t, sm.GetPendingFiles()[0].Path, "README.md") } @@ -257,7 +257,7 @@ func TestGenerateUseCaseServiceWithFields_DryRun(t *testing.T) { os.Chdir(dir) sm := NewSafetyManager(true, false, false) - generateUseCaseServiceWithFields(dir, "Product", "Product", []string{"create", "get", "list", "update", "delete"}, false, "Name:string,Price:float64", sm) + generateUseCaseServiceWithFields(dir, "Product", "Product", []string{"create", "get", "list", "update", "delete"}, false, false, "Name:string,Price:float64", sm) assert.NotEmpty(t, sm.GetPendingFiles()) } @@ -273,7 +273,7 @@ func TestGenerateUseCaseServiceWithFields_Async_DryRun(t *testing.T) { os.Chdir(dir) sm := NewSafetyManager(true, false, false) - generateUseCaseServiceWithFields(dir, "Order", "Order", []string{"create", "get"}, true, "Total:float64,Status:string", sm) + generateUseCaseServiceWithFields(dir, "Order", "Order", []string{"create", "get"}, true, true, "Total:float64,Status:string", sm) assert.NotEmpty(t, sm.GetPendingFiles()) } @@ -412,7 +412,10 @@ func TestGetDatabasePort_Extended(t *testing.T) { {"postgres", "5432"}, {"mysql", "3306"}, {"mongodb", "27017"}, - {"sqlite", "5432"}, // falls through to default + {"sqlserver", "1433"}, + {"dynamodb", "8000"}, + {"elasticsearch", "9200"}, + {"sqlite", ""}, // file-based, no network port {"unknown", "5432"}, } for _, tc := range tests { @@ -432,7 +435,10 @@ func TestGetDatabaseUser_Extended(t *testing.T) { {"postgres", "postgres"}, {"mysql", "root"}, {"mongodb", "admin"}, - {"sqlite", "postgres"}, // falls through to default + {"sqlserver", "sa"}, + {"elasticsearch", "elastic"}, + {"dynamodb", "local"}, + {"sqlite", ""}, // file-based, no user {"unknown", "postgres"}, } for _, tc := range tests { diff --git a/cmd/generate_integration_test.go b/cmd/generate_integration_test.go index 8e09cb5..d9431ca 100644 --- a/cmd/generate_integration_test.go +++ b/cmd/generate_integration_test.go @@ -73,7 +73,7 @@ func TestDryRunGenerators_Sequential(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Chdir(dir)) require.NoError(t, os.WriteFile("go.mod", []byte("module testproject\n\ngo 1.21\n"), 0o644)) - generateHTTPHandlerFile(filepath.Join(dir, "handler"), "Product", false, "", sm) + generateHTTPHandlerFile(filepath.Join(dir, "handler"), "Product", false, false, "", sm) }) // Subtest: generateHTTPRoutesFile diff --git a/cmd/handler.go b/cmd/handler.go index e8e1882..56722da 100644 --- a/cmd/handler.go +++ b/cmd/handler.go @@ -111,13 +111,31 @@ var handlerCmd = &cobra.Command{ ui.DryRun("Previewing changes without creating files") } + // Validate that the entity and its usecase exist before generating a + // handler that would reference an undefined usecase.UseCase. + if !entityExistsForHandler(entity) { + ui.Warning(fmt.Sprintf("Entity %q not found in internal/domain — the generated handler will reference an undefined type. Generate it first with: goca entity %s", entity, entity)) + } + if !usecaseExistsForHandler(entity) { + ui.Warning(fmt.Sprintf("Usecase usecase.%sUseCase not found in internal/usecase — the generated handler will not compile until you run: goca usecase %s", entity, entity)) + } + + filesBefore := len(sm.GetCreatedFiles()) generateHandler(entity, effectiveHandlerType, effectiveMiddleware, effectiveValidation, effectiveSwagger, fileNamingConvention, sm) + filesWritten := len(sm.GetCreatedFiles()) - filesBefore if dryRun { sm.PrintSummary() return } + // If nothing was written (e.g. files already exist and --force was not + // given), don't claim success or touch dependencies. + if filesWritten == 0 { + ui.Warning(fmt.Sprintf("No files were generated for '%s' (they may already exist — use --force to overwrite).", entity)) + return + } + // Add required dependencies projectRoot, _ := os.Getwd() depMgr := NewDependencyManager(projectRoot, false) @@ -129,8 +147,12 @@ var handlerCmd = &cobra.Command{ } } if len(requiredDeps) > 0 { - if err := depMgr.UpdateGoMod(); err != nil { - ui.Warning(fmt.Sprintf("Could not update go.mod: %v", err)) + // Best-effort: `go mod tidy` performs network fetches and can fail for + // a module not yet pushed to its remote (internal/* become + // unresolvable). A failed tidy must never corrupt go.mod, so we snapshot + // go.mod/go.sum and restore them if tidy fails, warning only. + if err := updateGoModBestEffort(depMgr, projectRoot); err != nil { + ui.Warning(fmt.Sprintf("Could not update go.mod (left unchanged): %v", err)) } } @@ -138,6 +160,73 @@ var handlerCmd = &cobra.Command{ }, } +// entityExistsForHandler reports whether the domain entity file for the given +// entity exists under internal/domain. +func entityExistsForHandler(entity string) bool { + candidates := []string{ + filepath.Join(DirInternal, "domain", strings.ToLower(entity)+".go"), + filepath.Join(DirInternal, "domain", toSnakeCase(entity)+".go"), + filepath.Join(DirInternal, "domain", toKebabCase(entity)+".go"), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return true + } + } + return false +} + +// usecaseExistsForHandler reports whether a usecase.UseCase appears to be +// defined under internal/usecase. It scans the usecase package for the interface +// declaration so renamed files are still detected. +func usecaseExistsForHandler(entity string) bool { + usecaseDir := filepath.Join(DirInternal, "usecase") + entries, err := os.ReadDir(usecaseDir) + if err != nil { + return false + } + needle := fmt.Sprintf("type %sUseCase ", entity) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + data, err := os.ReadFile(filepath.Join(usecaseDir, e.Name())) + if err != nil { + continue + } + if strings.Contains(string(data), needle) { + return true + } + } + return false +} + +// updateGoModBestEffort runs the dependency manager's go mod tidy but guarantees +// that a failure leaves go.mod and go.sum exactly as they were. This prevents a +// failing network tidy (common for not-yet-pushed modules) from corrupting the +// project's module files. +func updateGoModBestEffort(depMgr *DependencyManager, projectRoot string) error { + goModPath := filepath.Join(projectRoot, "go.mod") + goSumPath := filepath.Join(projectRoot, "go.sum") + + goModBackup, goModErr := os.ReadFile(goModPath) + goSumBackup, goSumErr := os.ReadFile(goSumPath) + + if err := depMgr.UpdateGoMod(); err != nil { + // Restore the snapshots so a failed tidy never corrupts the module files. + if goModErr == nil { + //#nosec G703 // path is projectRoot/go.mod, restoring our own snapshot + _ = os.WriteFile(goModPath, goModBackup, 0o644) + } + if goSumErr == nil { + //#nosec G703 // path is projectRoot/go.sum, restoring our own snapshot + _ = os.WriteFile(goSumPath, goSumBackup, 0o644) + } + return err + } + return nil +} + func generateHandler(entity, handlerType string, middleware, validation, swagger bool, fileNamingConvention string, sm ...*SafetyManager) { switch handlerType { case HandlerHTTP: @@ -162,7 +251,7 @@ func generateHTTPHandler(entity string, middleware, validation, swagger bool, fi _ = os.MkdirAll(handlerDir, 0o755) // Generate handler file - generateHTTPHandlerFile(handlerDir, entity, validation, fileNamingConvention, sm...) + generateHTTPHandlerFile(handlerDir, entity, validation, swagger, fileNamingConvention, sm...) // Generate routes file generateHTTPRoutesFile(handlerDir, entity, middleware, sm...) @@ -178,7 +267,7 @@ func generateHTTPHandler(entity string, middleware, validation, swagger bool, fi } } -func generateHTTPHandlerFile(dir, entity string, validation bool, fileNamingConvention string, sm ...*SafetyManager) { +func generateHTTPHandlerFile(dir, entity string, validation, swagger bool, fileNamingConvention string, sm ...*SafetyManager) { // Apply naming convention to filename var filename string if fileNamingConvention == "snake_case" { @@ -219,11 +308,11 @@ func generateHTTPHandlerFile(dir, entity string, validation bool, fileNamingConv content.WriteString("}\n\n") // Generate HTTP methods - generateCreateHandlerMethod(&content, entity, handlerName, validation) - generateGetHandlerMethod(&content, entity, handlerName) - generateUpdateHandlerMethod(&content, entity, handlerName, validation) - generateDeleteHandlerMethod(&content, entity, handlerName) - generateListHandlerMethod(&content, entity, handlerName) + generateCreateHandlerMethod(&content, entity, handlerName, validation, swagger) + generateGetHandlerMethod(&content, entity, handlerName, swagger) + generateUpdateHandlerMethod(&content, entity, handlerName, validation, swagger) + generateDeleteHandlerMethod(&content, entity, handlerName, swagger) + generateListHandlerMethod(&content, entity, handlerName, swagger) if err := writeGoFile(filename, content.String(), sm...); err != nil { ui.Error(fmt.Sprintf("Error writing handler file: %v", err)) @@ -231,8 +320,39 @@ func generateHTTPHandlerFile(dir, entity string, validation bool, fileNamingConv } } -func generateCreateHandlerMethod(content *strings.Builder, entity, handlerName string, validation bool) { +// writeSwaggerAnnotations emits the swaggo godoc annotation block for a handler +// method when swagger is enabled. +func writeSwaggerAnnotations(content *strings.Builder, entity, summary, method, route, successCode, successType, bodyType string) { + entityLower := strings.ToLower(entity) + pluralTag := entityLower + "s" + fmt.Fprintf(content, "// %s godoc\n", summary) + fmt.Fprintf(content, "// @Summary %s\n", summary) + fmt.Fprintf(content, "// @Tags %s\n", pluralTag) + content.WriteString("// @Accept json\n") + content.WriteString("// @Produce json\n") + if strings.Contains(route, "{id}") { + fmt.Fprintf(content, "// @Param id path int true \"%s ID\"\n", entity) + } + if bodyType != "" { + fmt.Fprintf(content, "// @Param body body %s true \"%s payload\"\n", bodyType, entity) + } + if successType != "" { + fmt.Fprintf(content, "// @Success %s {object} %s\n", successCode, successType) + } else { + fmt.Fprintf(content, "// @Success %s\n", successCode) + } + content.WriteString("// @Failure 400 {object} map[string]string\n") + content.WriteString("// @Failure 500 {object} map[string]string\n") + fmt.Fprintf(content, "// @Router %s [%s]\n", route, method) +} + +func generateCreateHandlerMethod(content *strings.Builder, entity, handlerName string, validation, swagger bool) { handlerVar := strings.ToLower(string(handlerName[0])) + entityLower := strings.ToLower(entity) + + if swagger { + writeSwaggerAnnotations(content, entity, fmt.Sprintf("Create %s", entityLower), "post", "/"+entityLower+"s", "201", fmt.Sprintf("usecase.Create%sOutput", entity), fmt.Sprintf("usecase.Create%sInput", entity)) + } fmt.Fprintf(content, "func (%s *%s) Create%s(w http.ResponseWriter, r *http.Request) {\n", handlerVar, handlerName, entity) @@ -262,8 +382,13 @@ func generateCreateHandlerMethod(content *strings.Builder, entity, handlerName s content.WriteString("}\n\n") } -func generateGetHandlerMethod(content *strings.Builder, entity, handlerName string) { +func generateGetHandlerMethod(content *strings.Builder, entity, handlerName string, swagger bool) { handlerVar := strings.ToLower(string(handlerName[0])) + entityLower := strings.ToLower(entity) + + if swagger { + writeSwaggerAnnotations(content, entity, fmt.Sprintf("Get %s by ID", entityLower), "get", "/"+entityLower+"s/{id}", "200", fmt.Sprintf("usecase.Get%sOutput", entity), "") + } fmt.Fprintf(content, "func (%s *%s) Get%s(w http.ResponseWriter, r *http.Request) {\n", handlerVar, handlerName, entity) @@ -285,8 +410,13 @@ func generateGetHandlerMethod(content *strings.Builder, entity, handlerName stri content.WriteString("}\n\n") } -func generateUpdateHandlerMethod(content *strings.Builder, entity, handlerName string, validation bool) { +func generateUpdateHandlerMethod(content *strings.Builder, entity, handlerName string, validation, swagger bool) { handlerVar := strings.ToLower(string(handlerName[0])) + entityLower := strings.ToLower(entity) + + if swagger { + writeSwaggerAnnotations(content, entity, fmt.Sprintf("Update %s", entityLower), "put", "/"+entityLower+"s/{id}", "204", "", fmt.Sprintf("usecase.Update%sInput", entity)) + } fmt.Fprintf(content, "func (%s *%s) Update%s(w http.ResponseWriter, r *http.Request) {\n", handlerVar, handlerName, entity) @@ -319,8 +449,13 @@ func generateUpdateHandlerMethod(content *strings.Builder, entity, handlerName s content.WriteString("}\n\n") } -func generateDeleteHandlerMethod(content *strings.Builder, entity, handlerName string) { +func generateDeleteHandlerMethod(content *strings.Builder, entity, handlerName string, swagger bool) { handlerVar := strings.ToLower(string(handlerName[0])) + entityLower := strings.ToLower(entity) + + if swagger { + writeSwaggerAnnotations(content, entity, fmt.Sprintf("Delete %s", entityLower), "delete", "/"+entityLower+"s/{id}", "204", "", "") + } fmt.Fprintf(content, "func (%s *%s) Delete%s(w http.ResponseWriter, r *http.Request) {\n", handlerVar, handlerName, entity) @@ -340,8 +475,13 @@ func generateDeleteHandlerMethod(content *strings.Builder, entity, handlerName s content.WriteString("}\n\n") } -func generateListHandlerMethod(content *strings.Builder, entity, handlerName string) { +func generateListHandlerMethod(content *strings.Builder, entity, handlerName string, swagger bool) { handlerVar := strings.ToLower(string(handlerName[0])) + entityLower := strings.ToLower(entity) + + if swagger { + writeSwaggerAnnotations(content, entity, fmt.Sprintf("List %ss", entityLower), "get", "/"+entityLower+"s", "200", fmt.Sprintf("usecase.List%ssOutput", entity), "") + } fmt.Fprintf(content, "func (%s *%s) List%ss(w http.ResponseWriter, r *http.Request) {\n", handlerVar, handlerName, entity) @@ -359,13 +499,30 @@ func generateListHandlerMethod(content *strings.Builder, entity, handlerName str func generateHTTPRoutesFile(dir, entity string, middleware bool, sm ...*SafetyManager) { filename := filepath.Join(dir, "routes.go") + // Detect whether the standalone middleware package exists. + middlewarePkgExists := middlewarePackageExists() + + // If routes.go already exists (a previous feature created it), append only the + // new SetupRoutes function so multiple features coexist in one routes + // file — the package declaration, imports and middleware helpers are already + // present. Idempotent: do nothing when this entity's function already exists. + if existing, err := os.ReadFile(filename); err == nil { + if strings.Contains(string(existing), fmt.Sprintf("func Setup%sRoutes(", entity)) { + return + } + var fn strings.Builder + writeRouteSetupFunc(&fn, entity, middleware, middlewarePkgExists) + merged := strings.TrimRight(string(existing), "\n") + "\n\n" + fn.String() + if err := writeGoFileMerged(filename, merged, sm...); err != nil { + ui.Error(fmt.Sprintf("Error writing routes file: %v", err)) + } + return + } + // Get the module name from go.mod moduleName := getModuleName() importPath := getImportPath(moduleName) - // Detect whether the standalone middleware package exists. - middlewarePkgExists := middlewarePackageExists() - var content strings.Builder content.WriteString("package http\n\n") content.WriteString("import (\n") @@ -380,6 +537,23 @@ func generateHTTPRoutesFile(dir, entity string, middleware bool, sm ...*SafetyMa } content.WriteString(")\n\n") + writeRouteSetupFunc(&content, entity, middleware, middlewarePkgExists) + + if middleware && !middlewarePkgExists { + content.WriteString("\n// Middleware functions\n") + generateMiddlewareFunctions(&content) + } + + if err := writeGoFile(filename, content.String(), sm...); err != nil { + ui.Error(fmt.Sprintf("Error writing routes file: %v", err)) + return + } +} + +// writeRouteSetupFunc writes the SetupRoutes function body into content. +// Shared by the initial routes.go generation and the append path used when a +// later feature adds its routes to an existing file. +func writeRouteSetupFunc(content *strings.Builder, entity string, middleware, middlewarePkgExists bool) { entityLower := strings.ToLower(entity) pluralEntity := entityLower + "s" @@ -391,8 +565,8 @@ func generateHTTPRoutesFile(dir, entity string, middleware bool, sm ...*SafetyMa content.WriteString("\t// Apply middleware\n") content.WriteString(fmt.Sprintf("\t%sRouter := router.PathPrefix(\"/%s\").Subrouter()\n", entityLower, pluralEntity)) if middlewarePkgExists { - content.WriteString(fmt.Sprintf("\t%sRouter.Use(middleware.CORS(middleware.DefaultCORSConfig()))\n", entityLower)) - content.WriteString(fmt.Sprintf("\t%sRouter.Use(middleware.Logging())\n\n", entityLower)) + content.WriteString(fmt.Sprintf("\t%sRouter.Use(mux.MiddlewareFunc(middleware.CORS(middleware.DefaultCORSConfig())))\n", entityLower)) + content.WriteString(fmt.Sprintf("\t%sRouter.Use(mux.MiddlewareFunc(middleware.Logging()))\n\n", entityLower)) } else { content.WriteString(fmt.Sprintf("\t%sRouter.Use(corsMiddleware)\n", entityLower)) content.WriteString(fmt.Sprintf("\t%sRouter.Use(loggingMiddleware)\n\n", entityLower)) @@ -422,16 +596,6 @@ func generateHTTPRoutesFile(dir, entity string, middleware bool, sm ...*SafetyMa } content.WriteString("}\n") - - if middleware && !middlewarePkgExists { - content.WriteString("\n// Middleware functions\n") - generateMiddlewareFunctions(&content) - } - - if err := writeGoFile(filename, content.String(), sm...); err != nil { - ui.Error(fmt.Sprintf("Error writing routes file: %v", err)) - return - } } func generateMiddlewareFunctions(content *strings.Builder) { diff --git a/cmd/handler_helpers_test.go b/cmd/handler_helpers_test.go index 364c1a0..d5dc4ca 100644 --- a/cmd/handler_helpers_test.go +++ b/cmd/handler_helpers_test.go @@ -13,7 +13,7 @@ func TestGenerateCreateHandlerMethod(t *testing.T) { t.Run("without validation", func(t *testing.T) { t.Parallel() var b strings.Builder - generateCreateHandlerMethod(&b, "Product", "ProductHandler", false) + generateCreateHandlerMethod(&b, "Product", "ProductHandler", false, false) output := b.String() assert.Contains(t, output, "func (p *ProductHandler) CreateProduct(") assert.Contains(t, output, "CreateProductInput") @@ -25,7 +25,7 @@ func TestGenerateCreateHandlerMethod(t *testing.T) { t.Run("with validation", func(t *testing.T) { t.Parallel() var b strings.Builder - generateCreateHandlerMethod(&b, "Product", "ProductHandler", true) + generateCreateHandlerMethod(&b, "Product", "ProductHandler", true, false) output := b.String() assert.Contains(t, output, "validator.New().Struct(input)") assert.Contains(t, output, "StatusUnprocessableEntity") @@ -35,7 +35,7 @@ func TestGenerateCreateHandlerMethod(t *testing.T) { func TestGenerateGetHandlerMethod(t *testing.T) { t.Parallel() var b strings.Builder - generateGetHandlerMethod(&b, "Product", "ProductHandler") + generateGetHandlerMethod(&b, "Product", "ProductHandler", false) output := b.String() assert.Contains(t, output, "func (p *ProductHandler) GetProduct(") assert.Contains(t, output, "mux.Vars(r)") @@ -50,7 +50,7 @@ func TestGenerateUpdateHandlerMethod(t *testing.T) { t.Run("without validation", func(t *testing.T) { t.Parallel() var b strings.Builder - generateUpdateHandlerMethod(&b, "Product", "ProductHandler", false) + generateUpdateHandlerMethod(&b, "Product", "ProductHandler", false, false) output := b.String() assert.Contains(t, output, "func (p *ProductHandler) UpdateProduct(") assert.Contains(t, output, "UpdateProductInput") @@ -61,7 +61,7 @@ func TestGenerateUpdateHandlerMethod(t *testing.T) { t.Run("with validation", func(t *testing.T) { t.Parallel() var b strings.Builder - generateUpdateHandlerMethod(&b, "Product", "ProductHandler", true) + generateUpdateHandlerMethod(&b, "Product", "ProductHandler", true, false) output := b.String() assert.Contains(t, output, "validator.New().Struct(input)") }) @@ -70,7 +70,7 @@ func TestGenerateUpdateHandlerMethod(t *testing.T) { func TestGenerateDeleteHandlerMethod(t *testing.T) { t.Parallel() var b strings.Builder - generateDeleteHandlerMethod(&b, "Product", "ProductHandler") + generateDeleteHandlerMethod(&b, "Product", "ProductHandler", false) output := b.String() assert.Contains(t, output, "func (p *ProductHandler) DeleteProduct(") assert.Contains(t, output, "mux.Vars(r)") @@ -80,9 +80,53 @@ func TestGenerateDeleteHandlerMethod(t *testing.T) { func TestGenerateListHandlerMethod(t *testing.T) { t.Parallel() var b strings.Builder - generateListHandlerMethod(&b, "Product", "ProductHandler") + generateListHandlerMethod(&b, "Product", "ProductHandler", false) output := b.String() assert.Contains(t, output, "func (p *ProductHandler) ListProducts(") assert.Contains(t, output, "ListProducts()") assert.Contains(t, output, "json.NewEncoder(w).Encode(output)") } + +// HANDLER-5: --swagger must add @Summary/@Router/@Success godoc annotations. +func TestHandlerMethods_SwaggerAnnotations(t *testing.T) { + t.Parallel() + + t.Run("create has annotations", func(t *testing.T) { + t.Parallel() + var b strings.Builder + generateCreateHandlerMethod(&b, "Product", "ProductHandler", false, true) + out := b.String() + assert.Contains(t, out, "@Summary Create product") + assert.Contains(t, out, "@Router /products [post]") + assert.Contains(t, out, "@Success 201 {object} usecase.CreateProductOutput") + assert.Contains(t, out, "@Param body body usecase.CreateProductInput") + }) + + t.Run("get has path param and router", func(t *testing.T) { + t.Parallel() + var b strings.Builder + generateGetHandlerMethod(&b, "Product", "ProductHandler", true) + out := b.String() + assert.Contains(t, out, "@Router /products/{id} [get]") + assert.Contains(t, out, "@Param id path int true") + }) + + t.Run("no annotations when swagger disabled", func(t *testing.T) { + t.Parallel() + var b strings.Builder + generateCreateHandlerMethod(&b, "Product", "ProductHandler", false, false) + assert.NotContains(t, b.String(), "@Summary") + }) +} + +// HANDLER-3: protoType maps Go scalar types to proto3 types. +func TestProtoType(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "string": "string", "int": "int32", "int64": "int64", + "bool": "bool", "float64": "double", "time.Time": "", + } + for in, want := range cases { + assert.Equal(t, want, protoType(in), "protoType(%q)", in) + } +} diff --git a/cmd/handler_other.go b/cmd/handler_other.go index 9f47038..da07c6e 100644 --- a/cmd/handler_other.go +++ b/cmd/handler_other.go @@ -42,15 +42,26 @@ func generateProtoFile(dir, entity, fileNamingConvention string, sm ...*SafetyMa content.WriteString(fmt.Sprintf(" rpc List%ss(List%ssRequest) returns (List%ssResponse);\n", entity, entity, entity)) content.WriteString("}\n\n") + // Derive the proto fields from the real entity definition so the message + // shape matches the usecase output DTO. Fall back to id/name/email when the + // entity cannot be read. + entityFields := grpcEntityFields(entity) + content.WriteString(fmt.Sprintf("message %s {\n", entity)) content.WriteString(" int32 id = 1;\n") - content.WriteString(" string name = 2;\n") - content.WriteString(" string email = 3;\n") + idx := 2 + for _, f := range entityFields { + fmt.Fprintf(&content, " %s %s = %d;\n", protoType(f.Type), toSnakeCase(f.Name), idx) + idx++ + } content.WriteString("}\n\n") content.WriteString(fmt.Sprintf("message Create%sRequest {\n", entity)) - content.WriteString(" string name = 1;\n") - content.WriteString(" string email = 2;\n") + idx = 1 + for _, f := range entityFields { + fmt.Fprintf(&content, " %s %s = %d;\n", protoType(f.Type), toSnakeCase(f.Name), idx) + idx++ + } content.WriteString("}\n\n") content.WriteString(fmt.Sprintf("message Create%sResponse {\n", entity)) @@ -142,10 +153,15 @@ func generateGRPCServerFile(dir, entity, fileNamingConvention string, sm ...*Saf content.WriteString(fmt.Sprintf("\treturn &%sServer{usecase: uc}\n", entity)) content.WriteString("}\n\n") + // The usecase output DTOs are FLAT (output.ID, output.Name, ...), so the + // gRPC server maps flat output fields onto the nested protobuf message. + entityFields := grpcEntityFields(entity) + content.WriteString(fmt.Sprintf("func (s *%sServer) Create%s(ctx context.Context, req *pb.Create%sRequest) (*pb.Create%sResponse, error) {\n", entity, entity, entity, entity)) content.WriteString(fmt.Sprintf("\tinput := usecase.Create%sInput{\n", entity)) - content.WriteString("\t\tName: req.Name,\n") - content.WriteString("\t\tEmail: req.Email,\n") + for _, f := range entityFields { + fmt.Fprintf(&content, "\t\t%s: req.%s,\n", f.Name, protoGoFieldName(f.Name)) + } content.WriteString("\t}\n\n") content.WriteString(fmt.Sprintf("\toutput, err := s.usecase.Create%s(input)\n", entity)) @@ -155,9 +171,10 @@ func generateGRPCServerFile(dir, entity, fileNamingConvention string, sm ...*Saf content.WriteString(fmt.Sprintf("\treturn &pb.Create%sResponse{\n", entity)) content.WriteString(fmt.Sprintf("\t\t%s: &pb.%s{\n", entity, entity)) - content.WriteString(fmt.Sprintf("\t\t\tId: int32(output.%s.ID),\n", entity)) - content.WriteString(fmt.Sprintf("\t\t\tName: output.%s.Name,\n", entity)) - content.WriteString(fmt.Sprintf("\t\t\tEmail: output.%s.Email,\n", entity)) + content.WriteString("\t\t\tId: int32(output.ID),\n") + for _, f := range entityFields { + fmt.Fprintf(&content, "\t\t\t%s: output.%s,\n", protoGoFieldName(f.Name), f.Name) + } content.WriteString("\t\t},\n") content.WriteString("\t\tMessage: output.Message,\n") content.WriteString("\t}, nil\n") @@ -171,9 +188,10 @@ func generateGRPCServerFile(dir, entity, fileNamingConvention string, sm ...*Saf content.WriteString(fmt.Sprintf("\treturn &pb.%sResponse{\n", entity)) content.WriteString(fmt.Sprintf("\t\t%s: &pb.%s{\n", entity, entity)) - content.WriteString(fmt.Sprintf("\t\t\tId: int32(%s.ID),\n", entityLower)) - content.WriteString(fmt.Sprintf("\t\t\tName: %s.Name,\n", entityLower)) - content.WriteString(fmt.Sprintf("\t\t\tEmail: %s.Email,\n", entityLower)) + content.WriteString(fmt.Sprintf("\t\t\tId: int32(%s.ID),\n", entityLower)) + for _, f := range entityFields { + fmt.Fprintf(&content, "\t\t\t%s: %s.%s,\n", protoGoFieldName(f.Name), entityLower, f.Name) + } content.WriteString("\t\t},\n") content.WriteString("\t}, nil\n") content.WriteString("}\n") @@ -184,6 +202,62 @@ func generateGRPCServerFile(dir, entity, fileNamingConvention string, sm ...*Saf } } +// grpcEntityFields returns the non-system fields of an entity (excluding ID, +// which is always emitted explicitly) for use in proto/gRPC generation. It +// falls back to Name/Email when the entity definition cannot be read so a +// freshly scaffolded project still produces a coherent server. +func grpcEntityFields(entity string) []Field { + var fields []Field + if fs := readEntityFieldsString(entity); fs != "" { + for _, f := range parseFields(fs) { + if isSystemField(f.Name) { + continue + } + // Only scalar proto-mappable fields are supported by the scaffold. + if protoType(f.Type) == "" { + continue + } + fields = append(fields, f) + } + } + if len(fields) == 0 { + fields = []Field{ + {Name: "Name", Type: "string"}, + {Name: "Email", Type: "string"}, + } + } + return fields +} + +// protoType maps a Go scalar type to its proto3 equivalent. It returns an empty +// string for types without a natural scalar proto mapping. +func protoType(goType string) string { + switch goType { + case "string": + return "string" + case "int", "int32": + return "int32" + case "int64", "uint", "uint64": + return "int64" + case "float32": + return "float" + case "float64": + return "double" + case "bool": + return "bool" + default: + return "" + } +} + +// protoGoFieldName returns the Go field name protoc-gen-go produces for a proto +// field whose snake_case name derives from the given entity field. protoc +// converts snake_case to PascalCase, which for our PascalCase field names is the +// field name itself. +func protoGoFieldName(fieldName string) string { + return fieldName +} + // cliFlagFor returns the cobra flag accessor expression and declaration line // for a CLI command flag matching the given entity field. It reports ok=false // for types without a natural scalar flag (slices, maps, time, custom), which diff --git a/cmd/init.go b/cmd/init.go index 1253fc4..4db8ff2 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -71,6 +71,32 @@ Use --template to initialize with predefined configurations: } } + // Validate --database and --api against the allowed sets (INIT-B3). + if err := validateDatabaseFlag(database); err != nil { + ui.Error(err.Error()) + os.Exit(1) + } + if err := validateAPIFlag(api); err != nil { + ui.Error(err.Error()) + os.Exit(1) + } + + // Refuse to scaffold into a non-empty directory unless --force (INIT-B16). + force, _ := cmd.Flags().GetBool("force") + dryRun, _ := cmd.Flags().GetBool("dry-run") + if !dryRun && !force { + if nonEmpty, err := directoryHasFiles(projectName); err == nil && nonEmpty { + ui.Error(fmt.Sprintf("directory '%s' already exists and is not empty; use --force to scaffold into it anyway", projectName)) + os.Exit(1) + } + } + + // gRPC/GraphQL scaffolding is not yet implemented; the choice is still + // recorded in .goca.yaml, but warn so it is not silently ignored (INIT-B1). + if api == APITypeGRPC || api == APITypeGraphQL { + ui.Warning(fmt.Sprintf("API type '%s' is recorded in .goca.yaml but only REST handlers are scaffolded; gRPC/GraphQL scaffolding is not yet implemented", api)) + } + // Validate template if provided if template != "" { if !ValidateTemplateName(template) { @@ -110,8 +136,6 @@ Use --template to initialize with predefined configurations: stop := ui.Spinner(fmt.Sprintf("Creating project '%s'", projectName)) // Initialize safety manager - dryRun, _ := cmd.Flags().GetBool("dry-run") - force, _ := cmd.Flags().GetBool("force") backup, _ := cmd.Flags().GetBool("backup") sm := NewSafetyManager(dryRun, force, backup) @@ -149,6 +173,79 @@ Use --template to initialize with predefined configurations: }, } +// validDatabases / validAPIs are the allowed values for the corresponding flags. +var ( + validDatabases = []string{DBPostgres, DBPostgresJSON, DBMySQL, DBMongoDB, DBSQLite, DBSQLServer, DBDynamoDB, DBElasticsearch} + validAPIs = []string{APITypeRest, APITypeGRPC, APITypeGraphQL} +) + +// validateDatabaseFlag rejects database values outside the supported set (INIT-B3). +func validateDatabaseFlag(database string) error { + for _, d := range validDatabases { + if database == d { + return nil + } + } + return fmt.Errorf("invalid --database '%s'; valid values: %s", database, strings.Join(validDatabases, ", ")) +} + +// validateAPIFlag rejects api values outside the supported set (INIT-B3). +func validateAPIFlag(api string) error { + for _, a := range validAPIs { + if api == a { + return nil + } + } + return fmt.Errorf("invalid --api '%s'; valid values: %s", api, strings.Join(validAPIs, ", ")) +} + +// directoryHasFiles reports whether dir exists and contains at least one entry. +func directoryHasFiles(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return len(entries) > 0, nil +} + +// persistInitChoices records the --api and --auth selections into the generated +// .goca.yaml. The config generator rebuilds the file from defaults and ignores +// these flags, so we patch the rendered YAML directly (INIT-B1, INIT-B14). +func persistInitChoices(configPath, api string, auth bool) error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + content := string(data) + + // Flip features.auth.enabled when --auth was requested. Match the auth block + // specifically so we do not touch other "enabled: false" keys. Be tolerant of + // the marshaler's indentation by locating the "auth:" line and rewriting the + // first "enabled: false" that follows it. + if auth { + if idx := strings.Index(content, "auth:"); idx >= 0 { + rest := content[idx:] + if rel := strings.Index(rest, "enabled: false"); rel >= 0 { + abs := idx + rel + content = content[:abs] + "enabled: true" + content[abs+len("enabled: false"):] + } + } + } + + // Record the API type at the top level if not already present. + if !strings.Contains(content, "\napi:") { + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += fmt.Sprintf("api:\n type: %s\n", api) + } + + return os.WriteFile(configPath, []byte(content), 0o600) +} + func createProjectStructure(projectName, module, database string, auth bool, api string, configIntegration *ConfigIntegration, generateConfig bool, template string, sm ...*SafetyManager) { // Create main directories dirs := []string{ @@ -187,7 +284,7 @@ func createProjectStructure(projectName, module, database string, auth bool, api createGitignore(projectName, sm...) // Create README.md - createReadme(projectName, module, sm...) + createReadme(projectName, module, database, sm...) // Create config createConfig(projectName, module, database, sm...) @@ -233,6 +330,12 @@ func createProjectStructure(projectName, module, database string, auth bool, api if err := configIntegration.GenerateConfigFile(projectName, projectName, module, database); err != nil { ui.Warning(fmt.Sprintf("Failed to generate config file: %v", err)) } else { + // GenerateConfigFile rebuilds the config from scratch, dropping the + // merged --auth/--api flags, so persist them into the written file + // (INIT-B1, INIT-B14). + if err := persistInitChoices(configPath, api, auth); err != nil { + ui.Warning(fmt.Sprintf("Failed to record api/auth in config file: %v", err)) + } ui.FileCreated(fmt.Sprintf("Generated configuration file: %s", configPath)) } } diff --git a/cmd/init_docker.go b/cmd/init_docker.go index 1d297c1..e4a9cad 100644 --- a/cmd/init_docker.go +++ b/cmd/init_docker.go @@ -75,7 +75,7 @@ This directory contains database migration files. ## Usage ### Using golang-migrate tool -bash +` + "```bash" + ` # Install golang-migrate go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest @@ -84,16 +84,16 @@ migrate -path ./migrations -database "postgres://user:password@localhost/dbname? # Rollback last migration migrate -path ./migrations -database "postgres://user:password@localhost/dbname?sslmode=disable" down 1 - +` + "```" + ` ### Manual execution -bash +` + "```bash" + ` # Apply migration psql -h localhost -U postgres -d your_db -f migrations/001_initial.up.sql -# Rollback migration +# Rollback migration psql -h localhost -U postgres -d your_db -f migrations/001_initial.down.sql - +` + "```" + ` ## Creating new migrations 1. Create new files: 002_description.up.sql and 002_description.down.sql @@ -250,8 +250,24 @@ CMD ["./%s"] ui.Warning(fmt.Sprintf("Error creating Dockerfile: %v", err)) } - // Docker Compose - dockerComposeContent := fmt.Sprintf(`version: '3.8' + // Docker Compose. SQLite is file-based, so emit only the app service with + // no DB container (INIT-B6). + var dockerComposeContent string + if database == DBSQLite { + dockerComposeContent = fmt.Sprintf(`version: '3.8' + +services: + %s: + build: . + ports: + - "8080:8080" + environment: + - DB_NAME=%s + restart: unless-stopped +`, projectName, projectName) + } else { + port := getDatabasePort(database) + dockerComposeContent = fmt.Sprintf(`version: '3.8' services: %s: @@ -260,6 +276,7 @@ services: - "8080:8080" environment: - DB_HOST=database + - DB_PORT=%s - DB_USER=%s - DB_PASSWORD=password - DB_NAME=%s @@ -274,7 +291,7 @@ services: ports: - "%s:%s" volumes: - - db_data:/var/lib/postgresql/data + - db_data:%s healthcheck: test: %s interval: 10s @@ -284,7 +301,8 @@ services: volumes: db_data: -`, projectName, getDatabaseUser(database), projectName, getDatabaseImage(database), getDatabaseEnvVars(database, projectName), getDatabasePort(database), getDatabasePort(database), getDatabaseHealthCheck(database)) +`, projectName, port, getDatabaseUser(database), projectName, getDatabaseImage(database), getDatabaseEnvVars(database, projectName), port, port, getDatabaseVolumePath(database), getDatabaseHealthCheck(database)) + } if err := writeFile(filepath.Join(projectName, "docker-compose.yml"), dockerComposeContent, sm...); err != nil { ui.Warning(fmt.Sprintf("Error creating docker-compose.yml: %v", err)) @@ -333,33 +351,71 @@ coverage.html func getDatabaseImage(database string) string { switch database { - case "mysql": + case DBMySQL: return "mysql:8.0" - case "mongodb": + case DBMongoDB: return "mongo:7.0" - default: + case DBSQLServer: + return "mcr.microsoft.com/mssql/server:2022-latest" + case DBDynamoDB: + return "amazon/dynamodb-local:latest" + case DBElasticsearch: + return "docker.elastic.co/elasticsearch/elasticsearch:8.10.1" + default: // postgres, postgres-json return "postgres:15" } } func getDatabaseEnvVars(database, projectName string) string { switch database { - case "mysql": + case DBMySQL: return fmt.Sprintf("\n - MYSQL_ROOT_PASSWORD=password\n - MYSQL_DATABASE=%s", projectName) - case "mongodb": + case DBMongoDB: return fmt.Sprintf("\n - MONGO_INITDB_ROOT_USERNAME=admin\n - MONGO_INITDB_ROOT_PASSWORD=password\n - MONGO_INITDB_DATABASE=%s", projectName) - default: + case DBSQLServer: + return "\n - ACCEPT_EULA=Y\n - MSSQL_SA_PASSWORD=Your_password123" + case DBDynamoDB: + return "\n - AWS_ACCESS_KEY_ID=local\n - AWS_SECRET_ACCESS_KEY=local" + case DBElasticsearch: + return "\n - discovery.type=single-node\n - xpack.security.enabled=false\n - ES_JAVA_OPTS=-Xms512m -Xmx512m" + default: // postgres, postgres-json return fmt.Sprintf("\n - POSTGRES_USER=postgres\n - POSTGRES_PASSWORD=password\n - POSTGRES_DB=%s", projectName) } } func getDatabaseHealthCheck(database string) string { switch database { - case "mysql": + case DBMySQL: return `["CMD", "mysqladmin", "ping", "-h", "localhost"]` - case "mongodb": - return `["CMD", "mongo", "--eval", "db.adminCommand('ping')"]` - default: + case DBMongoDB: + // mongo:7.0 ships mongosh, not the removed legacy mongo shell (INIT-B7). + return `["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]` + case DBSQLServer: + return `["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Your_password123 -Q 'SELECT 1' || exit 1"]` + case DBDynamoDB: + return `["CMD-SHELL", "curl -f http://localhost:8000 || exit 1"]` + case DBElasticsearch: + return `["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]` + default: // postgres, postgres-json return `["CMD-SHELL", "pg_isready -U postgres"]` } } + +// getDatabaseVolumePath returns the in-container data directory for the database +// image, so the named volume is mounted at the correct path (INIT-B5). +func getDatabaseVolumePath(database string) string { + switch database { + case DBMySQL: + return "/var/lib/mysql" + case DBMongoDB: + return "/data/db" + case DBSQLServer: + return "/var/opt/mssql" + case DBDynamoDB: + return "/home/dynamodblocal/data" + case DBElasticsearch: + return "/usr/share/elasticsearch/data" + default: // postgres, postgres-json + return "/var/lib/postgresql/data" + } +} diff --git a/cmd/init_project_files.go b/cmd/init_project_files.go index 29c2b85..f792ae5 100644 --- a/cmd/init_project_files.go +++ b/cmd/init_project_files.go @@ -46,10 +46,10 @@ func createGoMod(projectName, module, database string, auth bool, sm ...*SafetyM case DBElasticsearch: baseDeps += ` github.com/elastic/go-elasticsearch/v8 v8.10.1` - default: // postgres as fallback + default: // sqlite as the safe fallback (matches createMainGo/databaseURLBody) baseDeps += ` gorm.io/gorm v1.25.5 - gorm.io/driver/postgres v1.5.4` + gorm.io/driver/sqlite v1.5.4` } // Add JWT dependency if auth is enabled @@ -69,13 +69,6 @@ go 1.21 %s `, module, dependencies) - // Add replace directive for test modules to make them resolvable locally - if strings.Contains(module, "github.com/goca/testproject") { - content += ` -replace github.com/goca/testproject => ./ -` - } - if err := writeFile(filepath.Join(projectName, "go.mod"), content, sm...); err != nil { ui.Warning(fmt.Sprintf("Error writing go.mod: %v", err)) return @@ -173,10 +166,12 @@ func initializeGitRepository(projectName string) error { return fmt.Errorf("failed to add files to git: %w", err) } - // Create initial commit + // Create initial commit. Disable GPG signing and detach stdin so the + // command never blocks waiting for a passphrase/prompt (INIT-GIT). commitMessage := "Initial commit - Goca Clean Architecture project" - cmdCommit := exec.Command("git", "commit", "-m", commitMessage) + cmdCommit := exec.Command("git", "-c", "commit.gpgsign=false", "commit", "-m", commitMessage) cmdCommit.Dir = projectPath + cmdCommit.Stdin = nil if err := cmdCommit.Run(); err != nil { return fmt.Errorf("failed to create initial commit: %w", err) } @@ -184,7 +179,10 @@ func initializeGitRepository(projectName string) error { return nil } -func createReadme(projectName, module string, sm ...*SafetyManager) { +func createReadme(projectName, module, database string, sm ...*SafetyManager) { + dbDisplay := getDatabaseDisplayName(database) + dbSection := getReadmeDatabaseSection(database, projectName) + dbTroubleshooting := getReadmeDatabaseTroubleshooting(database, projectName) content := fmt.Sprintf(`# %s Generated with Goca - Go Clean Architecture Code Generator @@ -193,7 +191,7 @@ Generated with Goca - Go Clean Architecture Code Generator This project follows Clean Architecture principles: -- **Domain**: Entities and business rules +- **Domain**: Entities and business rules - **Use Cases**: Application logic - **Repository**: Data abstraction - **Handler**: Delivery adapters @@ -204,15 +202,9 @@ This project follows Clean Architecture principles: `+"```bash\n"+`go mod tidy `+"```\n"+` -### 2. Configure database (PostgreSQL): - -#### Option A: Using Docker (Recommended) -`+"```bash\n"+"# Run PostgreSQL\ndocker run --name postgres-dev \\\n -e POSTGRES_PASSWORD=password \\\n -e POSTGRES_DB=%s \\\n -p 5432:5432 \\\n -d postgres:15\n\n# Or using docker-compose\nup -d"+"```\n"+` +### 2. Configure database (%s): -#### Option B: Local PostgreSQL -`+"```bash\n"+`# Create database -createdb %s -`+"```\n"+` +%s ### 3. Configure environment variables: `+"```bash\n"+`# Copy example file @@ -292,32 +284,7 @@ make fmt ## Troubleshooting -### Error: "dial tcp [::1]:5432: connection refused" -PostgreSQL database is not running. - -**Solution:** -`+"```bash\n"+`# With Docker -docker run --name postgres-dev \ - -e POSTGRES_PASSWORD=password \ - -e POSTGRES_DB=%s \ - -p 5432:5432 \ - -d postgres:15 - -# Verify it's running -docker ps -`+"```\n"+` - -### Error: "database not configured" -Database environment variables are not configured. - -**Solution:** -`+"```bash\n"+`# Configure in .env -DB_HOST=localhost -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=password -DB_NAME=%s -`+"```\n"+` +%s ### Error: "command not found: goca" Goca CLI is not installed or not in PATH. @@ -334,9 +301,9 @@ goca version Application runs but cannot connect to database. **Solution:** -1. Verify PostgreSQL is running +1. Verify %s is running 2. Verify environment variables in .env -3. Test connection manually: `+"`"+`psql -h localhost -U postgres -d %s`+"`"+` +3. Verify your connection settings in .env ## Additional Resources @@ -356,7 +323,7 @@ This project was generated with Goca. To contribute: --- Generated with [Goca](https://github.com/sazardev/goca) -`, cases.Title(language.English).String(projectName), projectName, projectName, projectName, projectName, projectName, projectName, projectName) +`, cases.Title(language.English).String(projectName), dbDisplay, dbSection, projectName, projectName, dbTroubleshooting, dbDisplay) if err := writeFile(filepath.Join(projectName, "README.md"), content, sm...); err != nil { ui.Warning(fmt.Sprintf("Error writing README.md: %v", err)) @@ -364,6 +331,75 @@ Generated with [Goca](https://github.com/sazardev/goca) } } +// getDatabaseDisplayName returns a human-friendly name for the database. +func getDatabaseDisplayName(database string) string { + switch database { + case DBPostgres, DBPostgresJSON: + return "PostgreSQL" + case DBMySQL: + return "MySQL" + case DBMongoDB: + return "MongoDB" + case DBSQLite: + return "SQLite" + case DBSQLServer: + return "SQL Server" + case DBDynamoDB: + return "DynamoDB" + case DBElasticsearch: + return "Elasticsearch" + default: + return database + } +} + +// getReadmeDatabaseSection returns the DB-specific "Configure database" section. +func getReadmeDatabaseSection(database, projectName string) string { + fence := func(s string) string { return "```bash\n" + s + "\n```" } + switch database { + case DBSQLite: + return "SQLite is file-based; no server is required. The database file is created automatically on first run." + case DBMySQL: + return "#### Option A: Using Docker (Recommended)\n" + + fence(fmt.Sprintf("# Run MySQL\ndocker run --name mysql-dev \\\n -e MYSQL_ROOT_PASSWORD=password \\\n -e MYSQL_DATABASE=%s \\\n -p 3306:3306 \\\n -d mysql:8.0\n\n# Or using docker-compose\ndocker-compose up -d", projectName)) + case DBMongoDB: + return "#### Option A: Using Docker (Recommended)\n" + + fence(fmt.Sprintf("# Run MongoDB\ndocker run --name mongo-dev \\\n -e MONGO_INITDB_ROOT_USERNAME=admin \\\n -e MONGO_INITDB_ROOT_PASSWORD=password \\\n -e MONGO_INITDB_DATABASE=%s \\\n -p 27017:27017 \\\n -d mongo:7.0\n\n# Or using docker-compose\ndocker-compose up -d", projectName)) + case DBSQLServer: + return "#### Option A: Using Docker (Recommended)\n" + + fence("# Run SQL Server\ndocker run --name sqlserver-dev \\\n -e ACCEPT_EULA=Y \\\n -e MSSQL_SA_PASSWORD=Your_password123 \\\n -p 1433:1433 \\\n -d mcr.microsoft.com/mssql/server:2022-latest\n\n# Or using docker-compose\ndocker-compose up -d") + case DBDynamoDB: + return "#### Option A: Using Docker (Recommended)\n" + + fence("# Run DynamoDB Local\ndocker run --name dynamodb-dev \\\n -p 8000:8000 \\\n -d amazon/dynamodb-local:latest\n\n# Or using docker-compose\ndocker-compose up -d") + case DBElasticsearch: + return "#### Option A: Using Docker (Recommended)\n" + + fence("# Run Elasticsearch\ndocker run --name elasticsearch-dev \\\n -e discovery.type=single-node \\\n -e xpack.security.enabled=false \\\n -p 9200:9200 \\\n -d docker.elastic.co/elasticsearch/elasticsearch:8.10.1\n\n# Or using docker-compose\ndocker-compose up -d") + default: // postgres, postgres-json + return "#### Option A: Using Docker (Recommended)\n" + + fence(fmt.Sprintf("# Run PostgreSQL\ndocker run --name postgres-dev \\\n -e POSTGRES_PASSWORD=password \\\n -e POSTGRES_DB=%s \\\n -p 5432:5432 \\\n -d postgres:15\n\n# Or using docker-compose\ndocker-compose up -d", projectName)) + + "\n\n#### Option B: Local PostgreSQL\n" + + fence(fmt.Sprintf("# Create database\ncreatedb %s", projectName)) + } +} + +// getReadmeDatabaseTroubleshooting returns the DB-specific troubleshooting block. +func getReadmeDatabaseTroubleshooting(database, projectName string) string { + fence := func(s string) string { return "```bash\n" + s + "\n```" } + display := getDatabaseDisplayName(database) + port := getDatabasePort(database) + user := getDatabaseUser(database) + + if database == DBSQLite { + return "### Error: \"unable to open database file\"\n" + + "SQLite cannot create or access the database file.\n\n" + + "**Solution:** Ensure the application has write permission in its working directory." + } + + envBlock := fmt.Sprintf("# Configure in .env\nDB_HOST=localhost\nDB_PORT=%s\nDB_USER=%s\nDB_PASSWORD=password\nDB_NAME=%s", port, user, projectName) + + return fmt.Sprintf("### Error: \"connection refused\"\n%s database is not running.\n\n**Solution:** Start the database service (see step 2) and verify it is reachable on port %s.\n\n### Error: \"database not configured\"\nDatabase environment variables are not configured.\n\n**Solution:**\n%s", display, port, fence(envBlock)) +} + func createConfig(projectName, _, database string, sm ...*SafetyManager) { dbURLBody := databaseURLBody(database) // "fmt" is only needed when the DSN is built with fmt.Sprintf (every driver @@ -414,8 +450,8 @@ func Load() *Config { LogLevel: getEnv("LOG_LEVEL", "info"), Database: DatabaseConfig{ Host: getEnv("DB_HOST", "localhost"), - Port: getEnv("DB_PORT", "5432"), - User: getEnv("DB_USER", "postgres"), + Port: getEnv("DB_PORT", "%s"), + User: getEnv("DB_USER", "%s"), Password: getEnv("DB_PASSWORD", ""), Name: getEnv("DB_NAME", "%s"), SSLMode: getEnv("DB_SSL_MODE", "disable"), @@ -462,7 +498,7 @@ func getEnvAsDuration(key string, defaultValue string) time.Duration { duration, _ := time.ParseDuration(defaultValue) return duration } -`, fmtImport, projectName, dbURLBody) +`, fmtImport, getConfigDefaultPort(database), getDatabaseUser(database), projectName, dbURLBody) if err := writeGoFile(filepath.Join(projectName, "pkg", "config", "config.go"), content, sm...); err != nil { ui.Warning(fmt.Sprintf("Error writing config.go: %v", err)) @@ -484,8 +520,10 @@ func databaseURLBody(database string) string { return "\treturn fmt.Sprintf(\"sqlserver://%s:%s@%s:%s?database=%s\",\n\t\tc.Database.User, c.Database.Password, c.Database.Host, c.Database.Port, c.Database.Name)" case DBMongoDB: return "\tif c.Database.User != \"\" && c.Database.Password != \"\" {\n\t\treturn fmt.Sprintf(\"mongodb://%s:%s@%s:%s\", c.Database.User, c.Database.Password, c.Database.Host, c.Database.Port)\n\t}\n\treturn fmt.Sprintf(\"mongodb://%s:%s\", c.Database.Host, c.Database.Port)" - default: // postgres, postgres-json + case DBPostgres, DBPostgresJSON: return "\treturn fmt.Sprintf(\"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s\",\n\t\tc.Database.Host, c.Database.Port, c.Database.User, c.Database.Password, c.Database.Name, c.Database.SSLMode)" + default: // safe fallback: sqlite file path (matches createGoMod/createMainGo) + return "\tname := c.Database.Name\n\tif name == \"\" {\n\t\tname = \"app\"\n\t}\n\treturn name + \".db\"" } } @@ -648,24 +686,46 @@ JWT_EXPIRY=24h } } +// getConfigDefaultPort returns the DB_PORT default for the generated config.go. +// SQLite has no network port, so it returns an empty string. +func getConfigDefaultPort(database string) string { + return getDatabasePort(database) +} + func getDatabasePort(database string) string { switch database { - case "mysql": + case DBMySQL: return "3306" - case "mongodb": + case DBMongoDB: return "27017" - default: // postgres + case DBSQLServer: + return "1433" + case DBDynamoDB: + return "8000" + case DBElasticsearch: + return "9200" + case DBSQLite: + return "" + default: // postgres, postgres-json return "5432" } } func getDatabaseUser(database string) string { switch database { - case "mysql": + case DBMySQL: return "root" - case "mongodb": + case DBMongoDB: return "admin" - default: // postgres + case DBSQLServer: + return "sa" + case DBElasticsearch: + return "elastic" + case DBDynamoDB: + return "local" + case DBSQLite: + return "" + default: // postgres, postgres-json return "postgres" } } diff --git a/cmd/integrate.go b/cmd/integrate.go index a6bc464..38288cc 100644 --- a/cmd/integrate.go +++ b/cmd/integrate.go @@ -66,53 +66,116 @@ Useful for projects that have unintegrated features.`, }, } -// detectExistingFeatures scans the project for existing features. +// detectExistingFeatures scans the project for COMPLETE features. A feature is +// only integrated when all four layers exist for it: a domain entity, a usecase +// service, a repository implementation, and an HTTP handler. Orphan domain +// entities (a domain/*.go with no usecase/repository/handler) are skipped, so +// the generated DI container never references constructors that do not exist. func detectExistingFeatures() []string { var features []string - // Look for domain entities in internal/domain domainDir := filepath.Join(DirInternal, DirDomain) - if entries, err := os.ReadDir(domainDir); err == nil { - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") { - name := strings.TrimSuffix(entry.Name(), ".go") - // Skip common files, seed files and test files - if name != "errors" && name != "validations" && name != "common" && !strings.HasSuffix(name, "_seeds") && !strings.HasSuffix(name, "_test") { - // Reconstruct the PascalCase feature name from the snake_case - // file name (e.g. user_profile.go -> UserProfile) so multi-word - // entities are wired under their real type name. - if len(name) > 0 { - features = append(features, snakeToPascal(name)) - } - } - } + entries, err := os.ReadDir(domainDir) + if err != nil { + return features + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + name := strings.TrimSuffix(entry.Name(), ".go") + // Skip shared/common files, seed files and test files. + if name == "errors" || name == "validations" || name == "common" || + strings.HasSuffix(name, "_seeds") || strings.HasSuffix(name, "_test") || name == "" { + + continue + } + + if hasAllFeatureLayers(name) { + // Reconstruct the PascalCase feature name from the snake_case file + // name (e.g. user_profile.go -> UserProfile). + features = append(features, snakeToPascal(name)) + } else { + ui.Dim(fmt.Sprintf(" Skipping %s: incomplete feature (missing usecase/repository/handler)", snakeToPascal(name))) } } - // Also look for handlers in internal/handler/http + return features +} + +// hasAllFeatureLayers reports whether the entity (named by its domain file's +// base name, snake_case or concatenated) has a usecase service, a repository +// implementation, and an HTTP handler. +// +// File naming differs across goca's generators (snake_case vs concatenated +// lowercase depending on the naming convention), so layer files are matched by +// their normalized key: lowercased with underscores/hyphens removed. +func hasAllFeatureLayers(entityFileBase string) bool { + key := normalizeNameKey(entityFileBase) + + usecaseDir := filepath.Join(DirInternal, DirUseCase) + repoDir := filepath.Join(DirInternal, DirRepository) httpDir := filepath.Join(DirInternal, DirHandler, DirHTTP) - if entries, err := os.ReadDir(httpDir); err == nil { - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), "_handler.go") { - name := strings.TrimSuffix(entry.Name(), "_handler.go") - featureName := snakeToPascal(name) - - // Only add if not already in the list - found := false - for _, existing := range features { - if strings.EqualFold(existing, featureName) { - found = true - break - } - } - if !found { - features = append(features, featureName) - } + + hasUseCase := dirHasLayerFile(usecaseDir, key, "_service.go", nil) + hasHandler := dirHasLayerFile(httpDir, key, "_handler.go", nil) + // Repository implementations are driver-prefixed + // (postgres_/mysql_/mongo__repository.go). Ignore the cache + // decorator (cached_*): a base implementation is still required. + hasRepo := dirHasLayerFile(repoDir, key, "_repository.go", func(fn string) bool { + return strings.HasPrefix(fn, "cached_") + }) + + return hasUseCase && hasRepo && hasHandler +} + +// repoDriverPrefixes are the driver tokens prepended to repository +// implementation filenames (e.g. postgres_user_repository.go). +var repoDriverPrefixes = []string{"postgres_", "postgresjson_", "mysql_", "mongo_", "sqlite_", "sqlserver_"} + +// dirHasLayerFile reports whether dir contains a file that, after stripping the +// given suffix (and any leading repository driver prefix), normalizes to key. +// Files for which skip returns true are ignored. +func dirHasLayerFile(dir, key, suffix string, skip func(string) bool) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() { + continue + } + fn := strings.ToLower(e.Name()) + if skip != nil && skip(fn) { + continue + } + if !strings.HasSuffix(fn, suffix) { + continue + } + base := strings.TrimSuffix(fn, suffix) + // Strip a leading driver prefix so postgres_user_repository.go matches + // the "user" entity. + for _, p := range repoDriverPrefixes { + if strings.HasPrefix(base, p) { + base = strings.TrimPrefix(base, p) + break } } + if normalizeNameKey(base) == key { + return true + } } + return false +} - return features +// normalizeNameKey lowercases a name and removes underscores and hyphens so +// "user_profile", "user-profile" and "userprofile" all compare equal. +func normalizeNameKey(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "_", "") + s = strings.ReplaceAll(s, "-", "") + return s } // integrateFeatures integrates multiple features. @@ -164,11 +227,13 @@ func createOrUpdateDIContainer(features []string, sm ...*SafetyManager) { // updateMainGoWithAllFeatures updates main.go to include all features. func updateMainGoWithAllFeatures(features []string, sm ...*SafetyManager) { - // Try multiple possible locations for main.go + // Try multiple possible locations for main.go. The init-generated + // entrypoint lives at cmd/server/main.go, so prefer it to avoid creating a + // duplicate `package main` at the project root. possiblePaths := []string{ - "main.go", filepath.Join("cmd", "server", "main.go"), filepath.Join("cmd", "main.go"), + "main.go", } var mainPath string @@ -215,8 +280,15 @@ func updateMainGoWithAllFeatures(features []string, sm ...*SafetyManager) { } // createCompleteMainGo creates a new main.go with all features. +// +// It writes to cmd/server/main.go (the init-generated entrypoint location) so +// the result lives alongside the canonical layout instead of creating a +// duplicate `package main` at the project root. func createCompleteMainGo(features []string, sm ...*SafetyManager) { - mainPath := "main.go" + mainPath := filepath.Join("cmd", "server", "main.go") + if err := os.MkdirAll(filepath.Dir(mainPath), 0o755); err != nil { + ui.Warning(fmt.Sprintf("Could not create %s directory: %v", filepath.Dir(mainPath), err)) + } moduleName := getModuleName() createCompleteMainGoWithFeatures(mainPath, features, moduleName, sm...) } @@ -253,18 +325,221 @@ import ( "time" "github.com/gorilla/mux" - "gorm.io/gorm" "gorm.io/driver/postgres" + "gorm.io/gorm" + "%s/internal/di" "%s/pkg/config" "%s/pkg/logger" ) type HealthStatus struct { - Status string `+"`"+`json:"status"`+"`"+` - Timestamp time.Time `+"`"+`json:"timestamp"`+"`"+` - Services map[string]string `+"`"+`json:"services"`+"`"+` - Version string `+"`"+`json:"version"`+"`"+"\n}\n\nvar (\n\t// Build information (set by build flags)\n\tVersion = \"dev\"\n\tBuildTime = \"unknown\"\n\tdb *gorm.DB\n)\n\nfunc main() {\n\t// Load configuration\n\tcfg := config.Load()\n\t\n\t// Initialize logger\n\tlogger.Init()\n\t\n\tlog.Printf(\"Starting application v%%s (built: %%s)\", Version, BuildTime)\n\tlog.Printf(\"Environment: %%s\", cfg.Environment)\n\t\n\t// Connect to database with retry\n\tvar err error\n\tdb, err = connectToDatabase(cfg)\n\tif err != nil {\n\t\tlog.Printf(\"Warning: Database connection failed: %%v\", err)\n\t\tlog.Printf(\"Server will start in degraded mode. Check your database configuration.\")\n\t\tlog.Printf(\"Tip: Configure database environment variables in .env file\")\n\t\tdb = nil // Ensure db is nil for health checks\n\t} else {\n\t\tlog.Printf(\"Database connected successfully\")\n\t\t\n\t\t// Run auto-migrations if database is connected\n\t\tif err := runAutoMigrations(db); err != nil {\n\t\t\tlog.Printf(\"Warning: Auto-migration failed: %%v\", err)\n\t\t\tlog.Printf(\"Tip: You may need to run migrations manually\")\n\t\t} else {\n\t\t\tlog.Printf(\"Database schema is up to date\")\n\t\t}\n\t\n\t// Setup DI container\n\t:= di.NewContainer(db)\n\t\n\t// Setup router\n\t:= mux.NewRouter()\n\t\n\t// Health check endpoints\n\trouter.HandleFunc(\"/health\", healthCheckHandler).Methods(\"GET\")\n\trouter.HandleFunc(\"/health/ready\", readinessHandler).Methods(\"GET\")\n\trouter.HandleFunc(\"/health/live\", livenessHandler).Methods(\"GET\")\n%s\n\t// Setup HTTP server with timeouts\n\tserver := &http.Server{\n\t\tAddr: \":\" + cfg.Port,\n\t\tHandler: router,\n\t\tReadTimeout: cfg.Server.ReadTimeout,\n\t\tWriteTimeout: cfg.Server.WriteTimeout,\n\t\tIdleTimeout: cfg.Server.IdleTimeout,\n\t}\n\t\n\t// Start server in goroutine\n\tgo func() {\n\t\tlog.Printf(\"Server starting on port %%s\", cfg.Port)\n\t\tif err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\t\tlog.Fatalf(\"Server startup failed: %%v\", err)\n\t\t}\n\t}()\n\t\n\t// Wait for interrupt signal to gracefully shutdown\n\tquit := make(chan os.Signal, 1)\n\tsignal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)\n\t<-quit\n\t\n\tlog.Println(\"Shutting down server...\")\n\t\n\t// Graceful shutdown with timeout\n\tctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\t\n\tif err := server.Shutdown(ctx); err != nil {\n\t\tlog.Printf(\"Server forced to shutdown: %%v\", err)\n\t}\n\t\n\tlog.Println(\"Server exited\")\n}\n\nfunc connectToDatabase(cfg *config.Config) (*gorm.DB, error) {\n\tdsn := cfg.GetDatabaseURL()\n\t\n\tlog.Printf(\"Connecting to database at %%s:%%s/%%s\", cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)\n\t\n\t// Check if this is development mode without database\n\tif cfg.Environment == \"development\" && cfg.Database.Password == \"\" {\n\t\tlog.Println(\"Warning: Development mode detected: No database password set\")\n\t\tlog.Println(\"To connect to PostgreSQL, set environment variables:\")\n\t\tlog.Println(\" DB_HOST=localhost\")\n\t\tlog.Println(\" DB_PORT=5432\") \n\t\tlog.Println(\" DB_USER=postgres\")\n\t\tlog.Println(\" DB_PASSWORD=your_password\")\n\t\tlog.Println(\" DB_NAME=your_database\")\n\t\tlog.Println(\"Server will continue without database connection...\")\n\t\treturn nil, fmt.Errorf(\"development mode: database not configured\")\n\t}\n\t\n\t// Retry connection up to 5 times\n\tfor i := 0; i < 5; i++ {\n\t\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Attempt %%d: Failed to open database connection: %%v\", i+1, err)\n\t\t\ttime.Sleep(time.Duration(i+1) * time.Second)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// Get underlying sql.DB for connection pool configuration\n\t\tsqlDB, err := db.DB()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"Attempt %%d: Failed to get underlying SQL DB: %%v\", i+1, err)\n\t\t\ttime.Sleep(time.Duration(i+1) * time.Second)\n\t\t\tcontinue\n\t\t}\n\t\t\n\t\t// Configure connection pool\n\t\tsqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)\n\t\tsqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)\n\t\tsqlDB.SetConnMaxLifetime(cfg.Database.MaxLifetime)\n\t\t\n\t\t// Test the connection\n\t\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\terr = sqlDB.PingContext(ctx)\n\t\tcancel()\n\t\t\n\t\tif err == nil {\n\t\t\treturn db, nil\n\t\t}\n\t\t\n\t\tlog.Printf(\"Attempt %%d: Database ping failed: %%v\", i+1, err)\n\t\tsqlDBClose, _ := db.DB()\n\t\tif sqlDBClose != nil {\n\t\t\tsqlDBClose.Close()\n\t\t}\n\t\ttime.Sleep(time.Duration(i+1) * time.Second)\n\t}\n\t\n\treturn nil, fmt.Errorf(\"failed to connect to database after 5 attempts\")\n}\n\nfunc runAutoMigrations(db *gorm.DB) error {\n\tlog.Println(\"Running database auto-migrations...\")\n\t\n\t// Import and register all domain models here\n\t// Example: db.AutoMigrate(&domain.User{}, &domain.Product{})\n\t\n\treturn nil\n}\n\nfunc healthCheckHandler(w http.ResponseWriter, r *http.Request) {\n\tstatus := HealthStatus{\n\t\tStatus: \"healthy\",\n\t\tTimestamp: time.Now(),\n\t\tServices: make(map[string]string),\n\t\tVersion: Version,\n\t}\n\t\n\t// Check database connection\n\tif db != nil {\n\t\tsqlDB, err := db.DB()\n\t\tif err != nil || sqlDB.Ping() != nil {\n\t\t\tstatus.Services[\"database\"] = \"unhealthy\"\n\t\t\tstatus.Status = \"degraded\"\n\t\t} else {\n\t\t\tstatus.Services[\"database\"] = \"healthy\"\n\t\t} else {\n\t\tstatus.Services[\"database\"] = \"not configured\"\n\t\tstatus.Status = \"degraded\"\n\t}\n\t\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif status.Status == \"healthy\" {\n\t\tw.WriteHeader(http.StatusOK)\n\t} else {\n\t\tw.WriteHeader(http.StatusServiceUnavailable)\n\t}\n\tjson.NewEncoder(w).Encode(status)\n}\n\nfunc readinessHandler(w http.ResponseWriter, r *http.Request) {\n\t// Check if application is ready to serve traffic\n\tif db != nil {\n\t\tsqlDB, _ := db.DB()\n\t\tif sqlDB != nil && sqlDB.Ping() == nil {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Write([]byte(\"ready\"))\n\t\t\treturn\n\t\t}\n\tw.WriteHeader(http.StatusServiceUnavailable)\n\tw.Write([]byte(\"not ready\"))\n}\n\nfunc livenessHandler(w http.ResponseWriter, r *http.Request) {\n\t// Application is alive if it can respond\n\tw.WriteHeader(http.StatusOK)\n\tw.Write([]byte(\"alive\"))", moduleName, moduleName, moduleName, routesSB.String()) + Status string `+"`json:\"status\"`"+` + Timestamp time.Time `+"`json:\"timestamp\"`"+` + Services map[string]string `+"`json:\"services\"`"+` + Version string `+"`json:\"version\"`"+` +} + +var ( + // Build information (set by build flags) + Version = "dev" + BuildTime = "unknown" + db *gorm.DB +) + +func main() { + // Load configuration + cfg := config.Load() + + // Initialize logger + logger.Init() + + log.Printf("Starting application v%%s (built: %%s)", Version, BuildTime) + log.Printf("Environment: %%s", cfg.Environment) + + // Connect to database with retry + var err error + db, err = connectToDatabase(cfg) + if err != nil { + log.Printf("Warning: Database connection failed: %%v", err) + log.Printf("Server will start in degraded mode. Check your database configuration.") + log.Printf("Tip: Configure database environment variables in .env file") + db = nil // Ensure db is nil for health checks + } else { + log.Printf("Database connected successfully") + + // Run auto-migrations if database is connected + if err := runAutoMigrations(db); err != nil { + log.Printf("Warning: Auto-migration failed: %%v", err) + log.Printf("Tip: You may need to run migrations manually") + } else { + log.Printf("Database schema is up to date") + } + } + + // Setup DI container + container := di.NewContainer(db) + + // Setup router + router := mux.NewRouter() + + // Health check endpoints + router.HandleFunc("/health", healthCheckHandler).Methods("GET") + router.HandleFunc("/health/ready", readinessHandler).Methods("GET") + router.HandleFunc("/health/live", livenessHandler).Methods("GET") +%s + // Setup HTTP server with timeouts + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + } + + // Start server in goroutine + go func() { + log.Printf("Server starting on port %%s", cfg.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server startup failed: %%v", err) + } + }() + + // Wait for interrupt signal to gracefully shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Printf("Server forced to shutdown: %%v", err) + } + + log.Println("Server exited") +} + +func connectToDatabase(cfg *config.Config) (*gorm.DB, error) { + dsn := cfg.GetDatabaseURL() + + log.Printf("Connecting to database at %%s:%%s/%%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Name) + + // Check if this is development mode without database + if cfg.Environment == "development" && cfg.Database.Password == "" { + log.Println("Warning: Development mode detected: No database password set") + log.Println("To connect to PostgreSQL, set environment variables:") + log.Println(" DB_HOST=localhost") + log.Println(" DB_PORT=5432") + log.Println(" DB_USER=postgres") + log.Println(" DB_PASSWORD=your_password") + log.Println(" DB_NAME=your_database") + log.Println("Server will continue without database connection...") + return nil, fmt.Errorf("development mode: database not configured") + } + + // Retry connection up to 5 times + for i := 0; i < 5; i++ { + conn, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Printf("Attempt %%d: Failed to open database connection: %%v", i+1, err) + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + + // Get underlying sql.DB for connection pool configuration + sqlDB, err := conn.DB() + if err != nil { + log.Printf("Attempt %%d: Failed to get underlying SQL DB: %%v", i+1, err) + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + + // Configure connection pool + sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + sqlDB.SetConnMaxLifetime(cfg.Database.MaxLifetime) + + // Test the connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = sqlDB.PingContext(ctx) + cancel() + + if err == nil { + return conn, nil + } + + log.Printf("Attempt %%d: Database ping failed: %%v", i+1, err) + sqlDB.Close() + time.Sleep(time.Duration(i+1) * time.Second) + } + + return nil, fmt.Errorf("failed to connect to database after 5 attempts") +} + +func runAutoMigrations(db *gorm.DB) error { + log.Println("Running database auto-migrations...") + + // Import and register all domain models here + // Example: db.AutoMigrate(&domain.User{}, &domain.Product{}) + + return nil +} + +func healthCheckHandler(w http.ResponseWriter, r *http.Request) { + status := HealthStatus{ + Status: "healthy", + Timestamp: time.Now(), + Services: make(map[string]string), + Version: Version, + } + + // Check database connection + if db != nil { + sqlDB, err := db.DB() + if err != nil || sqlDB.Ping() != nil { + status.Services["database"] = "unhealthy" + status.Status = "degraded" + } else { + status.Services["database"] = "healthy" + } + } else { + status.Services["database"] = "not configured" + status.Status = "degraded" + } + + w.Header().Set("Content-Type", "application/json") + if status.Status == "healthy" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + } + json.NewEncoder(w).Encode(status) +} + +func readinessHandler(w http.ResponseWriter, r *http.Request) { + // Check if application is ready to serve traffic + if db != nil { + sqlDB, _ := db.DB() + if sqlDB != nil && sqlDB.Ping() == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ready")) + return + } + } + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("not ready")) +} + +func livenessHandler(w http.ResponseWriter, r *http.Request) { + // Application is alive if it can respond + w.WriteHeader(http.StatusOK) + w.Write([]byte("alive")) +} +`, moduleName, moduleName, moduleName, routesSB.String()) if err := writeFile(mainPath, newMainContent, sm...); err != nil { ui.Warning(fmt.Sprintf("Could not create main.go: %v", err)) diff --git a/cmd/middleware.go b/cmd/middleware.go index 8941796..85ed71e 100644 --- a/cmd/middleware.go +++ b/cmd/middleware.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "regexp" "strings" "github.com/spf13/cobra" @@ -35,8 +36,10 @@ multiple middleware functions.`, RunE: func(cmd *cobra.Command, args []string) error { name := args[0] - validator := NewFieldValidator() - if err := validator.ValidateEntityName(name); err != nil { + // The argument is a package label (e.g. "api"), not an entity, so it does + // not need to start with an uppercase letter. Only require a non-empty, + // identifier-like token. + if err := validateMiddlewareName(name); err != nil { return err } @@ -84,6 +87,22 @@ func init() { middlewareCmd.Flags().Bool("backup", false, "Backup existing files before overwriting") } +// middlewareNamePattern matches a valid middleware package label: it must start +// with a letter and contain only letters, numbers, hyphens or underscores. +var middlewareNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + +// validateMiddlewareName validates the package-label argument of the middleware +// command. Unlike entity names, lowercase is allowed (e.g. "api"). +func validateMiddlewareName(name string) error { + if name == "" { + return fmt.Errorf("middleware name cannot be empty") + } + if !middlewareNamePattern.MatchString(name) { + return fmt.Errorf("invalid middleware name %q: must start with a letter and contain only letters, numbers, '-' or '_'", name) + } + return nil +} + // parseMiddlewareTypes splits a comma-separated types string and normalizes them. func parseMiddlewareTypes(typesStr string) []string { raw := strings.Split(typesStr, ",") diff --git a/cmd/middleware_templates.go b/cmd/middleware_templates.go index 89a4a3d..d949b35 100644 --- a/cmd/middleware_templates.go +++ b/cmd/middleware_templates.go @@ -9,6 +9,9 @@ func generateChainMiddleware(module string) string { var b strings.Builder b.WriteString("package middleware\n\n") b.WriteString("import \"net/http\"\n\n") + b.WriteString("// contextKey is the private type used for context value keys set by\n") + b.WriteString("// middleware in this package (e.g. claims, request ID).\n") + b.WriteString("type contextKey string\n\n") b.WriteString("// Middleware is a function that wraps an http.Handler.\n") b.WriteString("type Middleware func(http.Handler) http.Handler\n\n") b.WriteString("// Chain composes multiple middleware functions into a single Middleware.\n") @@ -129,7 +132,6 @@ func generateAuthMiddleware() string { b.WriteString("\t\"strings\"\n\n") b.WriteString("\t\"github.com/golang-jwt/jwt/v5\"\n") b.WriteString(")\n\n") - b.WriteString("type contextKey string\n\n") b.WriteString("const claimsKey contextKey = \"claims\"\n\n") b.WriteString("// ClaimsFromContext extracts JWT claims from the request context.\n") b.WriteString("func ClaimsFromContext(ctx context.Context) (jwt.MapClaims, bool) {\n") diff --git a/cmd/middleware_test.go b/cmd/middleware_test.go index 88596d3..0e1ed15 100644 --- a/cmd/middleware_test.go +++ b/cmd/middleware_test.go @@ -55,6 +55,39 @@ func TestValidateMiddlewareTypes_Invalid(t *testing.T) { assert.Contains(t, err.Error(), "banana") } +// ─── validateMiddlewareName (HANDLER-6) ────────────────────────────────────── + +func TestValidateMiddlewareName_AllowsLowercase(t *testing.T) { + assert.NoError(t, validateMiddlewareName("api")) + assert.NoError(t, validateMiddlewareName("Api")) + assert.NoError(t, validateMiddlewareName("my-mw_1")) +} + +func TestValidateMiddlewareName_Rejects(t *testing.T) { + assert.Error(t, validateMiddlewareName("")) + assert.Error(t, validateMiddlewareName("1api")) + assert.Error(t, validateMiddlewareName("bad name")) +} + +// ─── HANDLER-2: contextKey lives in the always-generated base file ──────────── + +func TestChainMiddleware_DefinesContextKey(t *testing.T) { + out := generateChainMiddleware("github.com/test/proj") + assert.Contains(t, out, "type contextKey string") +} + +func TestRequestIDMiddleware_NoDuplicateContextKey(t *testing.T) { + // request_id.go uses contextKey but must NOT redefine it. + out := generateRequestIDMiddleware() + assert.Contains(t, out, "contextKey") + assert.NotContains(t, out, "type contextKey string") +} + +func TestAuthMiddleware_NoDuplicateContextKey(t *testing.T) { + out := generateAuthMiddleware() + assert.NotContains(t, out, "type contextKey string") +} + // ─── Template generators ───────────────────────────────────────────────────── func TestGenerateChainMiddleware(t *testing.T) { diff --git a/cmd/mocks.go b/cmd/mocks.go index bcd75ea..015879d 100644 --- a/cmd/mocks.go +++ b/cmd/mocks.go @@ -103,10 +103,18 @@ func generateMocks(entityName string, all, repository, usecase, handler bool, sm importPath := getImportPath(getModuleName()) + // Recover the entity's fields so the generated mocks include the same + // per-field finder methods (FindBy) that the real repository + // interface declares. + var fields []Field + if fs := readEntityFieldsString(entityName); fs != "" { + fields = parseFields(fs) + } + // Generate repository mock if all || repository { mockFile := filepath.Join(mocksDir, fmt.Sprintf("mock_%s_repository.go", strings.ToLower(entityName))) - content := fixGeneratedModulePath(generateRepositoryMock(entityName), importPath) + content := fixGeneratedModulePath(generateRepositoryMock(entityName, fields), importPath) if err := writeFile(mockFile, content, sm...); err != nil { return err } @@ -147,157 +155,118 @@ func generateMocks(entityName string, all, repository, usecase, handler bool, sm return nil } -// generateRepositoryMock generates a mock for repository interface. -func generateRepositoryMock(entityName string) string { +// generateRepositoryMock generates a mock that satisfies repository.Repository. +// The generated finder methods (FindBy) mirror those produced for the real +// repository interface by generateSearchMethods, so the mock implements the +// interface exactly. +func generateRepositoryMock(entityName string, fields []Field) string { lowerEntity := strings.ToLower(entityName) - return fmt.Sprintf( - `package mocks - -import ( - "github.com/stretchr/testify/mock" - "github.com/sazardev/goca/internal/domain" -) - -// Mock%sRepository is a mock implementation of repository.%sRepository -type Mock%sRepository struct { - mock.Mock -} - -// Save mocks the Save method -func (m *Mock%sRepository) Save(%s *domain.%s) error { - args := m.Called(%s) - return args.Error(0) -} - -// FindByID mocks the FindByID method -func (m *Mock%sRepository) FindByID(id int) (*domain.%s, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.%s), args.Error(1) -} - -// TODO: Add custom finder methods here (e.g., FindByEmail, FindByName, etc.) -// These methods should match your repository interface definition in internal/repository/interfaces.go -// Example: -// func (m *Mock%sRepository) FindByEmail(email string) (*domain.%s, error) { -// args := m.Called(email) -// if args.Get(0) == nil { -// return nil, args.Error(1) -// } -// return args.Get(0).(*domain.%s), args.Error(1) -// } - -// Update mocks the Update method -func (m *Mock%sRepository) Update(%s *domain.%s) error { - args := m.Called(%s) - return args.Error(0) -} - -// Delete mocks the Delete method -func (m *Mock%sRepository) Delete(id int) error { - args := m.Called(id) - return args.Error(0) -} - -// FindAll mocks the FindAll method -func (m *Mock%sRepository) FindAll() ([]domain.%s, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) + var b strings.Builder + b.WriteString("package mocks\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"github.com/stretchr/testify/mock\"\n") + b.WriteString("\t\"github.com/sazardev/goca/internal/domain\"\n") + b.WriteString(")\n\n") + + fmt.Fprintf(&b, "// Mock%sRepository is a mock implementation of repository.%sRepository\n", entityName, entityName) + fmt.Fprintf(&b, "type Mock%sRepository struct {\n\tmock.Mock\n}\n\n", entityName) + + // Save + fmt.Fprintf(&b, "// Save mocks the Save method\n") + fmt.Fprintf(&b, "func (m *Mock%sRepository) Save(%s *domain.%s) error {\n", entityName, lowerEntity, entityName) + fmt.Fprintf(&b, "\targs := m.Called(%s)\n\treturn args.Error(0)\n}\n\n", lowerEntity) + + // FindByID + fmt.Fprintf(&b, "// FindByID mocks the FindByID method\n") + fmt.Fprintf(&b, "func (m *Mock%sRepository) FindByID(id int) (*domain.%s, error) {\n", entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called(id)\n\tif args.Get(0) == nil {\n\t\treturn nil, args.Error(1)\n\t}\n") + fmt.Fprintf(&b, "\treturn args.Get(0).(*domain.%s), args.Error(1)\n}\n\n", entityName) + + // Per-field finders, matching generateSearchMethods. + for _, method := range generateSearchMethods(fields, entityName) { + paramName := strings.ToLower(method.FieldName) + fmt.Fprintf(&b, "// %s mocks the %s method\n", method.MethodName, method.MethodName) + fmt.Fprintf(&b, "func (m *Mock%sRepository) %s(%s %s) (*domain.%s, error) {\n", + entityName, method.MethodName, paramName, method.FieldType, entityName) + fmt.Fprintf(&b, "\targs := m.Called(%s)\n\tif args.Get(0) == nil {\n\t\treturn nil, args.Error(1)\n\t}\n", paramName) + fmt.Fprintf(&b, "\treturn args.Get(0).(*domain.%s), args.Error(1)\n}\n\n", entityName) } - return args.Get(0).([]domain.%s), args.Error(1) -} - -// NewMock%sRepository creates a new mock repository -func NewMock%sRepository() *Mock%sRepository { - return &Mock%sRepository{} -} -`, - entityName, entityName, entityName, - entityName, lowerEntity, entityName, lowerEntity, - entityName, entityName, entityName, - entityName, entityName, entityName, - entityName, lowerEntity, entityName, lowerEntity, - entityName, - entityName, entityName, entityName, - entityName, entityName, entityName, entityName, - ) -} - -// generateUseCaseMock generates a mock for use case interface. -func generateUseCaseMock(entityName string) string { - return fmt.Sprintf( - `package mocks - -import ( - "github.com/stretchr/testify/mock" - "github.com/sazardev/goca/internal/domain" - "github.com/sazardev/goca/internal/usecase" -) - -// Mock%sUseCase is a mock implementation of usecase.%sUseCase -type Mock%sUseCase struct { - mock.Mock -} -// Create mocks the Create method -func (m *Mock%sUseCase) Create(input usecase.Create%sInput) (*usecase.Create%sOutput, error) { - args := m.Called(input) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*usecase.Create%sOutput), args.Error(1) -} + // Update + fmt.Fprintf(&b, "// Update mocks the Update method\n") + fmt.Fprintf(&b, "func (m *Mock%sRepository) Update(%s *domain.%s) error {\n", entityName, lowerEntity, entityName) + fmt.Fprintf(&b, "\targs := m.Called(%s)\n\treturn args.Error(0)\n}\n\n", lowerEntity) -// GetByID mocks the GetByID method -func (m *Mock%sUseCase) GetByID(id uint) (*domain.%s, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.%s), args.Error(1) -} + // Delete + fmt.Fprintf(&b, "// Delete mocks the Delete method\n") + fmt.Fprintf(&b, "func (m *Mock%sRepository) Delete(id int) error {\n", entityName) + fmt.Fprintf(&b, "\targs := m.Called(id)\n\treturn args.Error(0)\n}\n\n") -// Update mocks the Update method -func (m *Mock%sUseCase) Update(id uint, input usecase.Update%sInput) (*domain.%s, error) { - args := m.Called(id, input) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.%s), args.Error(1) -} + // FindAll + fmt.Fprintf(&b, "// FindAll mocks the FindAll method\n") + fmt.Fprintf(&b, "func (m *Mock%sRepository) FindAll() ([]domain.%s, error) {\n", entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called()\n\tif args.Get(0) == nil {\n\t\treturn nil, args.Error(1)\n\t}\n") + fmt.Fprintf(&b, "\treturn args.Get(0).([]domain.%s), args.Error(1)\n}\n\n", entityName) -// Delete mocks the Delete method -func (m *Mock%sUseCase) Delete(id uint) error { - args := m.Called(id) - return args.Error(0) -} + fmt.Fprintf(&b, "// NewMock%sRepository creates a new mock repository\n", entityName) + fmt.Fprintf(&b, "func NewMock%sRepository() *Mock%sRepository {\n\treturn &Mock%sRepository{}\n}\n", + entityName, entityName, entityName) -// List mocks the List method -func (m *Mock%sUseCase) List() (*usecase.List%sOutput, error) { - args := m.Called() - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*usecase.List%sOutput), args.Error(1) + return b.String() } -// NewMock%sUseCase creates a new mock use case -func NewMock%sUseCase() *Mock%sUseCase { - return &Mock%sUseCase{} -} -`, - entityName, entityName, entityName, - entityName, entityName, entityName, entityName, - entityName, entityName, entityName, - entityName, entityName, entityName, entityName, - entityName, - entityName, entityName, entityName, - entityName, entityName, entityName, entityName, - ) +// generateUseCaseMock generates a mock that satisfies usecase.UseCase +// exactly: Create, Get, Update, Delete and +// Lists, with the same signatures the real interface declares. +func generateUseCaseMock(entityName string) string { + var b strings.Builder + b.WriteString("package mocks\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"github.com/stretchr/testify/mock\"\n") + b.WriteString("\t\"github.com/sazardev/goca/internal/domain\"\n") + b.WriteString("\t\"github.com/sazardev/goca/internal/usecase\"\n") + b.WriteString(")\n\n") + + fmt.Fprintf(&b, "// Mock%sUseCase is a mock implementation of usecase.%sUseCase\n", entityName, entityName) + fmt.Fprintf(&b, "type Mock%sUseCase struct {\n\tmock.Mock\n}\n\n", entityName) + + // Create(input CreateInput) (CreateOutput, error) + fmt.Fprintf(&b, "// Create%s mocks the Create%s method\n", entityName, entityName) + fmt.Fprintf(&b, "func (m *Mock%sUseCase) Create%s(input usecase.Create%sInput) (usecase.Create%sOutput, error) {\n", + entityName, entityName, entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called(input)\n") + fmt.Fprintf(&b, "\treturn args.Get(0).(usecase.Create%sOutput), args.Error(1)\n}\n\n", entityName) + + // Get(id int) (*domain., error) + fmt.Fprintf(&b, "// Get%s mocks the Get%s method\n", entityName, entityName) + fmt.Fprintf(&b, "func (m *Mock%sUseCase) Get%s(id int) (*domain.%s, error) {\n", entityName, entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called(id)\n\tif args.Get(0) == nil {\n\t\treturn nil, args.Error(1)\n\t}\n") + fmt.Fprintf(&b, "\treturn args.Get(0).(*domain.%s), args.Error(1)\n}\n\n", entityName) + + // Update(id int, input UpdateInput) error + fmt.Fprintf(&b, "// Update%s mocks the Update%s method\n", entityName, entityName) + fmt.Fprintf(&b, "func (m *Mock%sUseCase) Update%s(id int, input usecase.Update%sInput) error {\n", + entityName, entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called(id, input)\n\treturn args.Error(0)\n}\n\n") + + // Delete(id int) error + fmt.Fprintf(&b, "// Delete%s mocks the Delete%s method\n", entityName, entityName) + fmt.Fprintf(&b, "func (m *Mock%sUseCase) Delete%s(id int) error {\n", entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called(id)\n\treturn args.Error(0)\n}\n\n") + + // Lists() (ListOutput, error) + fmt.Fprintf(&b, "// List%ss mocks the List%ss method\n", entityName, entityName) + fmt.Fprintf(&b, "func (m *Mock%sUseCase) List%ss() (usecase.List%sOutput, error) {\n", + entityName, entityName, entityName) + fmt.Fprintf(&b, "\targs := m.Called()\n") + fmt.Fprintf(&b, "\treturn args.Get(0).(usecase.List%sOutput), args.Error(1)\n}\n\n", entityName) + + fmt.Fprintf(&b, "// NewMock%sUseCase creates a new mock use case\n", entityName) + fmt.Fprintf(&b, "func NewMock%sUseCase() *Mock%sUseCase {\n\treturn &Mock%sUseCase{}\n}\n", + entityName, entityName, entityName) + + return b.String() } // generateHandlerMock generates a mock for HTTP handler interface. @@ -358,8 +327,7 @@ func NewMock%sHandler() *Mock%sHandler { func generateMockUsageExamples(entityName string) string { lowerEntity := strings.ToLower(entityName) - return fmt.Sprintf( - `package examples + return fmt.Sprintf(`package examples import ( "errors" @@ -369,190 +337,63 @@ import ( "github.com/stretchr/testify/mock" "github.com/sazardev/goca/internal/domain" "github.com/sazardev/goca/internal/mocks" + "github.com/sazardev/goca/internal/repository" "github.com/sazardev/goca/internal/usecase" ) -// Example: Testing use case with mocked repository -func TestCreate%s_WithMockRepository(t *testing.T) { - // Arrange - mockRepo := mocks.NewMock%sRepository() - service := usecase.New%sService(mockRepo) - - input := usecase.Create%sInput{ - // Add your input fields here - } +// Compile-time assertions that the generated mocks satisfy the real interfaces. +var ( + _ repository.%[1]sRepository = (*mocks.Mock%[1]sRepository)(nil) + _ usecase.%[1]sUseCase = (*mocks.Mock%[1]sUseCase)(nil) +) - expected%s := &domain.%s{ - ID: 1, - // Add your expected fields here - } +// Example: stubbing repository methods and verifying expectations. +func TestMock%[1]sRepository_Usage(t *testing.T) { + mockRepo := mocks.NewMock%[1]sRepository() - // Setup mock expectation - mockRepo.On("Save", mock.AnythingOfType("*domain.%s")).Return(nil).Run(func(args mock.Arguments) { - %s := args.Get(0).(*domain.%s) - %s.ID = 1 // Simulate auto-increment - }) + expected := &domain.%[1]s{ID: 1} + mockRepo.On("FindByID", 1).Return(expected, nil) + mockRepo.On("Save", mock.AnythingOfType("*domain.%[1]s")).Return(nil) - // Act - output, err := service.Create(input) + got, err := mockRepo.FindByID(1) + assert.NoError(t, err) + assert.Equal(t, expected, got) - // Assert + err = mockRepo.Save(&domain.%[1]s{}) assert.NoError(t, err) - assert.NotNil(t, output) - assert.Equal(t, expected%s.ID, output.%s.ID) - // Verify all expectations were met mockRepo.AssertExpectations(t) - mockRepo.AssertCalled(t, "Save", mock.AnythingOfType("*domain.%s")) } -// Example: Testing error scenarios -func TestGet%sByID_NotFound_WithMockRepository(t *testing.T) { - // Arrange - mockRepo := mocks.NewMock%sRepository() - service := usecase.New%sService(mockRepo) +// Example: stubbing an error return. +func TestMock%[1]sRepository_NotFound(t *testing.T) { + mockRepo := mocks.NewMock%[1]sRepository() - expectedErr := errors.New("%s not found") + expectedErr := errors.New("%[2]s not found") mockRepo.On("FindByID", 999).Return(nil, expectedErr) - // Act - result, err := service.GetByID(999) - - // Assert - assert.Error(t, err) - assert.Nil(t, result) + got, err := mockRepo.FindByID(999) + assert.Nil(t, got) assert.Equal(t, expectedErr, err) - // Verify expectations - mockRepo.AssertExpectations(t) -} - -// Example: Testing use case with multiple repository calls -func TestUpdate%s_WithMockRepository(t *testing.T) { - // Arrange - mockRepo := mocks.NewMock%sRepository() - service := usecase.New%sService(mockRepo) - - existing%s := &domain.%s{ - ID: 1, - // Add fields - } - - updateInput := usecase.Update%sInput{ - // Add update fields - } - - // Setup mock expectations (FindByID then Update) - mockRepo.On("FindByID", 1).Return(existing%s, nil) - mockRepo.On("Update", mock.AnythingOfType("*domain.%s")).Return(nil) - - // Act - result, err := service.Update(1, updateInput) - - // Assert - assert.NoError(t, err) - assert.NotNil(t, result) - - // Verify call order and expectations mockRepo.AssertExpectations(t) - mockRepo.AssertNumberOfCalls(t, "FindByID", 1) - mockRepo.AssertNumberOfCalls(t, "Update", 1) -} - -// Example: Testing handler with mocked use case -func TestCreate%sHandler_WithMockUseCase(t *testing.T) { - // Arrange - mockUC := mocks.NewMock%sUseCase() - // handler := http.New%sHandler(mockUC) - - expectedOutput := &usecase.Create%sOutput{ - %s: domain.%s{ID: 1}, - Message: "%s created successfully", - } - - mockUC.On("Create", mock.AnythingOfType("usecase.Create%sInput")).Return(expectedOutput, nil) - - // Act - // Create HTTP request and response recorder - // Call handler method - // result, err := mockUC.Create(input) - - // Assert - // assert.NoError(t, err) - // assert.Equal(t, http.StatusCreated, recorder.Code) - - // Verify expectations - mockUC.AssertExpectations(t) } -// Example: Testing argument matchers -func TestSave%s_WithArgumentMatchers(t *testing.T) { - mockRepo := mocks.NewMock%sRepository() +// Example: stubbing use-case methods. +func TestMock%[1]sUseCase_Usage(t *testing.T) { + mockUC := mocks.NewMock%[1]sUseCase() - // Match any %s with specific field value - mockRepo.On("Save", mock.MatchedBy(func(%s *domain.%s) bool { - return %s.ID > 0 - })).Return(nil) + mockUC.On("Get%[1]s", 1).Return(&domain.%[1]s{ID: 1}, nil) + mockUC.On("Delete%[1]s", 1).Return(nil) - // Test with matching condition - valid%s := &domain.%s{ID: 1} - err := mockRepo.Save(valid%s) + got, err := mockUC.Get%[1]s(1) assert.NoError(t, err) + assert.NotNil(t, got) - // Test with non-matching condition - invalid%s := &domain.%s{ID: 0} - err = mockRepo.Save(invalid%s) - assert.Error(t, err) // Will fail because matcher doesn't match - - mockRepo.AssertExpectations(t) -} - -// Example: Testing method call verification -func TestDelete%s_CallVerification(t *testing.T) { - mockRepo := mocks.NewMock%sRepository() - service := usecase.New%sService(mockRepo) - - mockRepo.On("Delete", 1).Return(nil) - - // Act - err := service.Delete(1) - - // Assert + err = mockUC.Delete%[1]s(1) assert.NoError(t, err) - // Verify Delete was called exactly once with argument 1 - mockRepo.AssertCalled(t, "Delete", 1) - mockRepo.AssertNumberOfCalls(t, "Delete", 1) - mockRepo.AssertNotCalled(t, "FindByID") + mockUC.AssertExpectations(t) } -`, - entityName, // TestCreate%s_WithMockRepository - entityName, entityName, entityName, // NewMock%sRepository, New%sService, Create%sInput - entityName, entityName, // expected%s, domain.%s - entityName, // *domain.%s - lowerEntity, entityName, lowerEntity, // %s := args.Get(0), domain.%s, %s.ID = 1 - entityName, entityName, // expected%s.ID, output.%s.ID - entityName, // *domain.%s - entityName, // TestGet%sByID_NotFound - entityName, entityName, // NewMock%sRepository, New%sService - lowerEntity, // %s not found - entityName, // TestUpdate%s_WithMockRepository - entityName, entityName, // NewMock%sRepository, New%sService - entityName, entityName, // existing%s, domain.%s - entityName, // Update%sInput - entityName, entityName, // existing%s, *domain.%s - entityName, // TestCreate%sHandler - entityName, entityName, entityName, // NewMock%sUseCase, New%sHandler, Create%sOutput - entityName, entityName, // %s: domain.%s - entityName, // %s created successfully - entityName, // Create%sInput - lowerEntity, // TestSave%s_WithArgumentMatchers - entityName, // NewMock%sRepository - lowerEntity, // Match any %s with - lowerEntity, entityName, lowerEntity, // func(%s *domain.%s) bool, %s.ID > 0 - entityName, entityName, entityName, // valid%s, domain.%s, valid%s - entityName, entityName, entityName, // invalid%s, domain.%s, invalid%s - entityName, // TestDelete%s_CallVerification - entityName, entityName, // NewMock%sRepository, New%sService - ) +`, entityName, lowerEntity) } diff --git a/cmd/mocks_test.go b/cmd/mocks_test.go index 99995f6..c00901d 100644 --- a/cmd/mocks_test.go +++ b/cmd/mocks_test.go @@ -8,14 +8,19 @@ import ( func TestGenerateRepositoryMock(t *testing.T) { t.Parallel() - result := generateRepositoryMock("Product") + fields := parseFields("name:string,email:string,age:int") + result := generateRepositoryMock("Product", fields) assert.Contains(t, result, "MockProductRepository") assert.Contains(t, result, "mock.Mock") assert.Contains(t, result, "func (m *MockProductRepository) Save(") - assert.Contains(t, result, "func (m *MockProductRepository) FindByID(") + assert.Contains(t, result, "func (m *MockProductRepository) FindByID(id int)") assert.Contains(t, result, "func (m *MockProductRepository) Update(") - assert.Contains(t, result, "func (m *MockProductRepository) Delete(") + assert.Contains(t, result, "func (m *MockProductRepository) Delete(id int)") assert.Contains(t, result, "func (m *MockProductRepository) FindAll(") + // Per-field finders matching the real repository interface. + assert.Contains(t, result, "func (m *MockProductRepository) FindByName(name string) (*domain.Product, error)") + assert.Contains(t, result, "func (m *MockProductRepository) FindByEmail(email string) (*domain.Product, error)") + assert.NotContains(t, result, "TODO") assert.Contains(t, result, "NewMockProductRepository") assert.Contains(t, result, "domain.Product") } @@ -25,11 +30,12 @@ func TestGenerateUseCaseMock(t *testing.T) { result := generateUseCaseMock("Product") assert.Contains(t, result, "MockProductUseCase") assert.Contains(t, result, "mock.Mock") - assert.Contains(t, result, "func (m *MockProductUseCase) Create(") - assert.Contains(t, result, "func (m *MockProductUseCase) GetByID(") - assert.Contains(t, result, "func (m *MockProductUseCase) Update(") - assert.Contains(t, result, "func (m *MockProductUseCase) Delete(") - assert.Contains(t, result, "func (m *MockProductUseCase) List(") + // Method names and signatures must match usecase.ProductUseCase exactly. + assert.Contains(t, result, "func (m *MockProductUseCase) CreateProduct(input usecase.CreateProductInput) (usecase.CreateProductOutput, error)") + assert.Contains(t, result, "func (m *MockProductUseCase) GetProduct(id int) (*domain.Product, error)") + assert.Contains(t, result, "func (m *MockProductUseCase) UpdateProduct(id int, input usecase.UpdateProductInput) error") + assert.Contains(t, result, "func (m *MockProductUseCase) DeleteProduct(id int) error") + assert.Contains(t, result, "func (m *MockProductUseCase) ListProducts() (usecase.ListProductOutput, error)") assert.Contains(t, result, "NewMockProductUseCase") assert.Contains(t, result, "usecase.CreateProductInput") } @@ -50,7 +56,12 @@ func TestGenerateHandlerMock(t *testing.T) { func TestGenerateMockUsageExamples(t *testing.T) { t.Parallel() result := generateMockUsageExamples("Product") - assert.Contains(t, result, "TestCreate") + assert.Contains(t, result, "TestMockProductRepository_Usage") assert.Contains(t, result, "MockProductRepository") assert.Contains(t, result, "assert") + // The example must use the real API, not the old nonexistent methods. + assert.NotContains(t, result, "service.Create(") + assert.NotContains(t, result, "output.Product.ID") + // Compile-time interface assertions are included. + assert.Contains(t, result, "_ repository.ProductRepository = (*mocks.MockProductRepository)(nil)") } diff --git a/cmd/repository_fields.go b/cmd/repository_fields.go index 82d7fb2..513aa57 100644 --- a/cmd/repository_fields.go +++ b/cmd/repository_fields.go @@ -75,11 +75,149 @@ func generateRepositoryImplementationWithFields(dir, entity, database string, fi generateMySQLRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) case DBMongoDB: generateMongoRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) + case DBPostgresJSON: + generatePostgresJSONRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) + case DBSQLServer: + generateSQLServerRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) + case DBSQLite: + generateSQLiteRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) + case DBElasticsearch: + generateElasticsearchRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) + case DBDynamoDB: + generateDynamoDBRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) default: generatePostgresRepositoryWithFields(dir, entity, fields, cache, transactions, sm...) } } +// The dedicated DB generators (in repository_other_db.go) already emit a full +// CRUD set. For the field-aware path the interface additionally declares the +// per-field finders, so we generate the base repository and then append the +// finder implementations rendered in the backend's native style. + +func generatePostgresJSONRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { + generatePostgresJSONRepository(dir, entity, cache, transactions, sm...) + appendGormFinders(dir, "postgres_json_"+strings.ToLower(entity)+"_repository.go", fmt.Sprintf("postgresJSON%sRepository", entity), entity, fields, sm...) +} + +func generateSQLServerRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { + generateSQLServerRepository(dir, entity, cache, transactions, sm...) + appendGormFinders(dir, "sqlserver_"+strings.ToLower(entity)+"_repository.go", fmt.Sprintf("sqlserver%sRepository", entity), entity, fields, sm...) +} + +func generateSQLiteRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { + generateSQLiteRepository(dir, entity, cache, transactions, sm...) + appendSQLiteFinders(dir, entity, fields, sm...) +} + +func generateElasticsearchRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { + generateElasticsearchRepository(dir, entity, cache, transactions, sm...) + appendDelegatingFinders(dir, "elasticsearch_"+strings.ToLower(entity)+"_repository.go", fmt.Sprintf("elasticsearch%sRepository", entity), "e", entity, fields, sm...) +} + +func generateDynamoDBRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { + generateDynamoDBRepository(dir, entity, cache, transactions, sm...) + appendDelegatingFinders(dir, "dynamodb_"+strings.ToLower(entity)+"_repository.go", fmt.Sprintf("dynamodb%sRepository", entity), "d", entity, fields, sm...) +} + +// appendGormFinders appends GORM-based per-field finder implementations to an +// already-generated repository file whose receiver exposes a `db *gorm.DB`. +func appendGormFinders(dir, file, repoName, entity string, fields []Field, sm ...*SafetyManager) { + methods := generateSearchMethods(fields, entity) + if len(methods) == 0 { + return + } + var b strings.Builder + for _, m := range methods { + b.WriteString(m.generateSearchMethodImplementation(strings.ToLower(string(repoName[0])), repoName, entity)) + } + appendToRepoFile(filepath.Join(dir, file), b.String(), nil, sm...) +} + +// appendSQLiteFinders appends raw-SQL per-field finders for the SQLite repo. +func appendSQLiteFinders(dir, entity string, fields []Field, sm ...*SafetyManager) { + methods := generateSearchMethods(fields, entity) + if len(methods) == 0 { + return + } + entityLower := strings.ToLower(entity) + repoName := fmt.Sprintf("sqlite%sRepository", entity) + var b strings.Builder + for _, m := range methods { + paramName := strings.ToLower(m.FieldName) + fmt.Fprintf(&b, "func (s *%s) %s(%s %s) %s {\n", repoName, m.MethodName, paramName, m.FieldType, m.ReturnType) + b.WriteString("\tvar data []byte\n") + fmt.Fprintf(&b, "\tquery := \"SELECT data FROM %ss WHERE json_extract(data, '$.%s') = ? LIMIT 1\"\n", entityLower, m.FieldName) + fmt.Fprintf(&b, "\tif err := s.db.QueryRow(query, %s).Scan(&data); err != nil {\n", paramName) + fmt.Fprintf(&b, "\t\tif err == sql.ErrNoRows {\n\t\t\treturn nil, fmt.Errorf(\"%s not found\")\n\t\t}\n", entity) + b.WriteString("\t\treturn nil, fmt.Errorf(\"failed to query: %w\", err)\n\t}\n") + fmt.Fprintf(&b, "\tvar %s domain.%s\n", entityLower, entity) + fmt.Fprintf(&b, "\tif err := json.Unmarshal(data, &%s); err != nil {\n", entityLower) + b.WriteString("\t\treturn nil, fmt.Errorf(\"failed to unmarshal: %w\", err)\n\t}\n") + fmt.Fprintf(&b, "\treturn &%s, nil\n", entityLower) + b.WriteString("}\n\n") + } + appendToRepoFile(filepath.Join(dir, "sqlite_"+entityLower+"_repository.go"), b.String(), nil, sm...) +} + +// appendDelegatingFinders appends per-field finders that reuse FindAll and filter +// in memory — used for backends (Elasticsearch, DynamoDB) where a dedicated query +// per field is out of scope but the interface still requires the method. +func appendDelegatingFinders(dir, file, repoName, recv, entity string, fields []Field, sm ...*SafetyManager) { + methods := generateSearchMethods(fields, entity) + if len(methods) == 0 { + return + } + entityLower := strings.ToLower(entity) + var b strings.Builder + for _, m := range methods { + paramName := strings.ToLower(m.FieldName) + fmt.Fprintf(&b, "func (%s *%s) %s(%s %s) %s {\n", recv, repoName, m.MethodName, paramName, m.FieldType, m.ReturnType) + fmt.Fprintf(&b, "\titems, err := %s.FindAll()\n", recv) + b.WriteString("\tif err != nil {\n\t\treturn nil, err\n\t}\n") + b.WriteString("\tfor i := range items {\n") + fmt.Fprintf(&b, "\t\tif items[i].%s == %s {\n", m.FieldName, paramName) + b.WriteString("\t\t\treturn &items[i], nil\n\t\t}\n") + b.WriteString("\t}\n") + fmt.Fprintf(&b, "\treturn nil, fmt.Errorf(\"%s not found\")\n", entityLower) + b.WriteString("}\n\n") + } + appendToRepoFile(filepath.Join(dir, file), b.String(), []string{"fmt"}, sm...) +} + +// appendToRepoFile appends generated method source to an existing repository +// file, respecting the SafetyManager (dry-run) if provided. ensureImports lists +// stdlib import paths the appended code requires; any not already present are +// injected into the file's import block. +func appendToRepoFile(path, methods string, ensureImports []string, sm ...*SafetyManager) { + if strings.TrimSpace(methods) == "" { + return + } + if len(sm) > 0 && sm[0] != nil && sm[0].DryRun { + return + } + existing, err := os.ReadFile(path) + if err != nil { + return + } + src := string(existing) + for _, imp := range ensureImports { + quoted := "\"" + imp + "\"" + if strings.Contains(src, quoted) { + continue + } + // Inject right after the opening of the import block. + if idx := strings.Index(src, "import (\n"); idx != -1 { + pos := idx + len("import (\n") + src = src[:pos] + "\t" + quoted + "\n" + src[pos:] + } + } + combined := strings.TrimRight(src, "\n") + "\n\n" + methods + if err := writeGoFile(path, combined); err != nil { + fmt.Printf("Error appending finders to %s: %v\n", path, err) + } +} + // generatePostgresRepositoryWithFields generates a PostgreSQL repository. func generatePostgresRepositoryWithFields(dir, entity string, fields []Field, cache, transactions bool, sm ...*SafetyManager) { generateGormRepositoryWithFields(dir, entity, "Postgres", fields, cache, transactions, sm...) diff --git a/cmd/repository_impl.go b/cmd/repository_impl.go index 385e81f..3de5cb8 100644 --- a/cmd/repository_impl.go +++ b/cmd/repository_impl.go @@ -19,10 +19,11 @@ func generatePostgresRepository(dir, entity string, cache, transactions bool, sm content.WriteString("\t\"gorm.io/gorm\"\n") content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", getImportPath(moduleName))) if cache { - content.WriteString("\t\"time\"\n") - content.WriteString("\t\"encoding/json\"\n") - content.WriteString("\t\"github.com/go-redis/redis/v8\"\n") content.WriteString("\t\"context\"\n") + content.WriteString("\t\"encoding/json\"\n") + content.WriteString("\t\"fmt\"\n") + content.WriteString("\t\"time\"\n") + content.WriteString("\t\"github.com/redis/go-redis/v9\"\n") } content.WriteString(")\n\n") @@ -63,11 +64,50 @@ func generatePostgresRepository(dir, entity string, cache, transactions bool, sm generatePostgresTransactionMethods(&content, entity, repoName) } + if cache { + generatePostgresCacheHelpers(&content, entity, repoName) + } + if err := writeGoFile(filename, content.String(), sm...); err != nil { fmt.Printf("Error creating PostgreSQL repository file: %v\n", err) } } +// generatePostgresCacheHelpers generates the Redis cache helper methods used by +// the cache-enabled Postgres repository (getFromCache/setCache/invalidateCache). +func generatePostgresCacheHelpers(content *strings.Builder, entity, repoName string) { + entityLower := strings.ToLower(entity) + repoVar := strings.ToLower(string(repoName[0])) + + fmt.Fprintf(content, "func (%s *%s) cacheKey(id int) string {\n", repoVar, repoName) + fmt.Fprintf(content, "\treturn fmt.Sprintf(\"%s:%%d\", id)\n", entityLower) + content.WriteString("}\n\n") + + // getFromCache + fmt.Fprintf(content, "func (%s *%s) getFromCache(id int) *domain.%s {\n", repoVar, repoName, entity) + fmt.Fprintf(content, "\tif %s.cache == nil {\n\t\treturn nil\n\t}\n", repoVar) + fmt.Fprintf(content, "\tdata, err := %s.cache.Get(context.Background(), %s.cacheKey(id)).Bytes()\n", repoVar, repoVar) + content.WriteString("\tif err != nil {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(content, "\t%s := &domain.%s{}\n", entityLower, entity) + fmt.Fprintf(content, "\tif json.Unmarshal(data, %s) != nil {\n\t\treturn nil\n\t}\n", entityLower) + fmt.Fprintf(content, "\treturn %s\n", entityLower) + content.WriteString("}\n\n") + + // setCache + fmt.Fprintf(content, "func (%s *%s) setCache(%s *domain.%s) {\n", repoVar, repoName, entityLower, entity) + fmt.Fprintf(content, "\tif %s.cache == nil {\n\t\treturn\n\t}\n", repoVar) + fmt.Fprintf(content, "\tif data, err := json.Marshal(%s); err == nil {\n", entityLower) + fmt.Fprintf(content, "\t\t%s.cache.Set(context.Background(), %s.cacheKey(int(%s.ID)), data, %s.cacheTTL)\n", repoVar, repoVar, entityLower, repoVar) + content.WriteString("\t}\n") + content.WriteString("}\n\n") + + // invalidateCache + fmt.Fprintf(content, "func (%s *%s) invalidateCache(id int) {\n", repoVar, repoName) + fmt.Fprintf(content, "\tif %s.cache == nil {\n\t\treturn\n\t}\n", repoVar) + fmt.Fprintf(content, "\t%s.cache.Del(context.Background(), %s.cacheKey(id))\n", repoVar, repoVar) + content.WriteString("}\n\n") +} + func generatePostgresSaveMethod(content *strings.Builder, entity, repoName string, cache bool) { entityLower := strings.ToLower(entity) repoVar := strings.ToLower(string(repoName[0])) @@ -78,7 +118,7 @@ func generatePostgresSaveMethod(content *strings.Builder, entity, repoName strin if cache { content.WriteString("\tif result.Error == nil {\n") - fmt.Fprintf(content, "\t\t%s.invalidateCache(%s.ID)\n", repoVar, entityLower) + fmt.Fprintf(content, "\t\t%s.invalidateCache(int(%s.ID))\n", repoVar, entityLower) content.WriteString("\t}\n") } @@ -140,7 +180,7 @@ func generatePostgresUpdateMethod(content *strings.Builder, entity, repoName str if cache { content.WriteString("\tif result.Error == nil {\n") - fmt.Fprintf(content, "\t\t%s.invalidateCache(%s.ID)\n", repoVar, entityLower) + fmt.Fprintf(content, "\t\t%s.invalidateCache(int(%s.ID))\n", repoVar, entityLower) content.WriteString("\t}\n") } @@ -208,103 +248,11 @@ func generatePostgresTransactionMethods(content *strings.Builder, entity, repoNa } func generateMySQLRepository(dir, entity string, cache, transactions bool, sm ...*SafetyManager) { - entityLower := strings.ToLower(entity) - filename := filepath.Join(dir, "mysql_"+entityLower+"_repository.go") - - // Get the module name from go.mod - moduleName := getModuleName() - - var content strings.Builder - content.WriteString("package repository\n\n") - content.WriteString("import (\n") - content.WriteString("\t\"gorm.io/gorm\"\n") - content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", getImportPath(moduleName))) - if cache { - content.WriteString("\t\"time\"\n") - content.WriteString("\t\"encoding/json\"\n") - content.WriteString("\t\"github.com/go-redis/redis/v8\"\n") - content.WriteString("\t\"context\"\n") - } - content.WriteString(")\n\n") - - // MySQL repository structure (using GORM) - repoName := fmt.Sprintf("mysql%sRepository", entity) - content.WriteString(fmt.Sprintf("type %s struct {\n", repoName)) - content.WriteString("\tdb *gorm.DB\n") - if cache { - content.WriteString("\tredis *redis.Client\n") - } - content.WriteString("}\n\n") - - content.WriteString(fmt.Sprintf("func NewMySQL%sRepository(db *gorm.DB", entity)) - if cache { - content.WriteString(", redis *redis.Client") - } - content.WriteString(fmt.Sprintf(") %sRepository {\n", entity)) - content.WriteString(fmt.Sprintf("\treturn &%s{\n", repoName)) - content.WriteString("\t\tdb: db,\n") - if cache { - content.WriteString("\t\tredis: redis,\n") - } - content.WriteString("\t}\n") - content.WriteString("}\n\n") - - // Save method - content.WriteString(fmt.Sprintf("func (r *%s) Save(%s *domain.%s) error {\n", - repoName, entityLower, entity)) - content.WriteString(fmt.Sprintf("\tresult := r.db.Create(%s)\n", entityLower)) - content.WriteString("\treturn result.Error\n") - content.WriteString("}\n\n") - - // FindByID method - content.WriteString(fmt.Sprintf("func (r *%s) FindByID(id int) (*domain.%s, error) {\n", - repoName, entity)) - content.WriteString(fmt.Sprintf("\t%s := &domain.%s{}\n", entityLower, entity)) - content.WriteString(fmt.Sprintf("\tresult := r.db.First(%s, id)\n", entityLower)) - content.WriteString("\tif result.Error != nil {\n") - content.WriteString("\t\treturn nil, result.Error\n") - content.WriteString("\t}\n") - content.WriteString(fmt.Sprintf("\treturn %s, nil\n", entityLower)) - content.WriteString("}\n\n") - - // FindByEmail method - content.WriteString(fmt.Sprintf("func (r *%s) FindByEmail(email string) (*domain.%s, error) {\n", - repoName, entity)) - content.WriteString(fmt.Sprintf("\t%s := &domain.%s{}\n", entityLower, entity)) - content.WriteString(fmt.Sprintf("\tresult := r.db.Where(\"email = ?\", email).First(%s)\n", entityLower)) - content.WriteString("\tif result.Error != nil {\n") - content.WriteString("\t\treturn nil, result.Error\n") - content.WriteString("\t}\n") - content.WriteString(fmt.Sprintf("\treturn %s, nil\n", entityLower)) - content.WriteString("}\n\n") - - // Update method - content.WriteString(fmt.Sprintf("func (r *%s) Update(%s *domain.%s) error {\n", - repoName, entityLower, entity)) - content.WriteString(fmt.Sprintf("\tresult := r.db.Save(%s)\n", entityLower)) - content.WriteString("\treturn result.Error\n") - content.WriteString("}\n\n") - - // Delete method - content.WriteString(fmt.Sprintf("func (r *%s) Delete(id int) error {\n", repoName)) - content.WriteString(fmt.Sprintf("\tresult := r.db.Delete(&domain.%s{}, id)\n", entity)) - content.WriteString("\treturn result.Error\n") - content.WriteString("}\n\n") - - // FindAll method - content.WriteString(fmt.Sprintf("func (r *%s) FindAll() ([]domain.%s, error) {\n", - repoName, entity)) - content.WriteString(fmt.Sprintf("\tvar %ss []domain.%s\n", entityLower, entity)) - content.WriteString(fmt.Sprintf("\tresult := r.db.Find(&%ss)\n", entityLower)) - content.WriteString("\tif result.Error != nil {\n") - content.WriteString("\t\treturn nil, result.Error\n") - content.WriteString("\t}\n") - content.WriteString(fmt.Sprintf("\treturn %ss, nil\n", entityLower)) - content.WriteString("}\n") - - if err := writeGoFile(filename, content.String(), sm...); err != nil { - fmt.Printf("Error creating MySQL repository file: %v\n", err) - } + // MySQL and PostgreSQL share the same GORM-based implementation; the concrete + // SQL driver is selected by the dialector in main.go. They therefore use the + // single NewPostgresRepository(*gorm.DB) constructor that the DI + // container references uniformly for all GORM-backed SQL databases. + generatePostgresRepository(dir, entity, cache, transactions, sm...) } func generateMongoRepository(dir, entity string, cache, transactions bool, sm ...*SafetyManager) { @@ -361,6 +309,49 @@ func generateMongoRepository(dir, entity string, cache, transactions bool, sm .. content.WriteString("\treturn nil\n") content.WriteString("}\n\n") + // FindByID method + content.WriteString(fmt.Sprintf("func (r *%s) FindByID(id int) (*domain.%s, error) {\n", repoName, entity)) + content.WriteString("\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n") + content.WriteString("\tdefer cancel()\n\n") + content.WriteString(fmt.Sprintf("\t%s := &domain.%s{}\n", entityLower, entity)) + content.WriteString(fmt.Sprintf("\tif err := r.collection.FindOne(ctx, bson.M{\"id\": id}).Decode(%s); err != nil {\n", entityLower)) + content.WriteString("\t\treturn nil, err\n") + content.WriteString("\t}\n") + content.WriteString(fmt.Sprintf("\treturn %s, nil\n", entityLower)) + content.WriteString("}\n\n") + + // Update method + content.WriteString(fmt.Sprintf("func (r *%s) Update(%s *domain.%s) error {\n", repoName, entityLower, entity)) + content.WriteString("\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n") + content.WriteString("\tdefer cancel()\n\n") + content.WriteString(fmt.Sprintf("\t_, err := r.collection.ReplaceOne(ctx, bson.M{\"id\": %s.ID}, %s)\n", entityLower, entityLower)) + content.WriteString("\treturn err\n") + content.WriteString("}\n\n") + + // Delete method + content.WriteString(fmt.Sprintf("func (r *%s) Delete(id int) error {\n", repoName)) + content.WriteString("\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n") + content.WriteString("\tdefer cancel()\n\n") + content.WriteString("\t_, err := r.collection.DeleteOne(ctx, bson.M{\"id\": id})\n") + content.WriteString("\treturn err\n") + content.WriteString("}\n\n") + + // FindAll method + content.WriteString(fmt.Sprintf("func (r *%s) FindAll() ([]domain.%s, error) {\n", repoName, entity)) + content.WriteString("\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n") + content.WriteString("\tdefer cancel()\n\n") + content.WriteString("\tcursor, err := r.collection.Find(ctx, bson.M{})\n") + content.WriteString("\tif err != nil {\n") + content.WriteString("\t\treturn nil, err\n") + content.WriteString("\t}\n") + content.WriteString("\tdefer cursor.Close(ctx)\n\n") + content.WriteString(fmt.Sprintf("\tvar %ss []domain.%s\n", entityLower, entity)) + content.WriteString(fmt.Sprintf("\tif err := cursor.All(ctx, &%ss); err != nil {\n", entityLower)) + content.WriteString("\t\treturn nil, err\n") + content.WriteString("\t}\n") + content.WriteString(fmt.Sprintf("\treturn %ss, nil\n", entityLower)) + content.WriteString("}\n\n") + if err := writeGoFile(filename, content.String(), sm...); err != nil { fmt.Printf("Error creating MongoDB repository file: %v\n", err) } diff --git a/cmd/repository_other_db.go b/cmd/repository_other_db.go index d02ce28..7f6c7ea 100644 --- a/cmd/repository_other_db.go +++ b/cmd/repository_other_db.go @@ -14,7 +14,6 @@ func generatePostgresJSONRepository(dir, entity string, cache, transactions bool var content strings.Builder content.WriteString("package repository\n\n") content.WriteString("import (\n") - content.WriteString("\t\"encoding/json\"\n") content.WriteString("\t\"gorm.io/datatypes\"\n") content.WriteString("\t\"gorm.io/gorm\"\n") content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", getImportPath(moduleName))) @@ -155,9 +154,9 @@ func generateElasticsearchRepository(dir, entity string, cache, transactions boo content.WriteString("\t\"bytes\"\n") content.WriteString("\t\"context\"\n") content.WriteString("\t\"encoding/json\"\n") - content.WriteString("\t\"fmt\"\n") - content.WriteString("\t\"github.com/elastic/go-elasticsearch/v8\"\n") content.WriteString("\t\"strconv\"\n") + content.WriteString("\t\"github.com/elastic/go-elasticsearch/v8\"\n") + content.WriteString("\t\"github.com/elastic/go-elasticsearch/v8/esapi\"\n") content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", getImportPath(moduleName))) content.WriteString(")\n\n") @@ -276,7 +275,6 @@ func generateDynamoDBRepository(dir, entity string, cache, transactions bool, sm content.WriteString("package repository\n\n") content.WriteString("import (\n") content.WriteString("\t\"context\"\n") - content.WriteString("\t\"encoding/json\"\n") content.WriteString("\t\"fmt\"\n") content.WriteString("\t\"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue\"\n") content.WriteString("\t\"github.com/aws/aws-sdk-go-v2/service/dynamodb\"\n") diff --git a/cmd/safety.go b/cmd/safety.go index d7ff464..ba0edcb 100644 --- a/cmd/safety.go +++ b/cmd/safety.go @@ -43,8 +43,10 @@ func (sm *SafetyManager) CheckFileConflict(filePath string) error { if _, err := os.Stat(filePath); err == nil { // File exists if sm.DryRun { + // In dry-run mode an existing file is not an error: it is simply + // recorded as a conflict and previewed as a "would overwrite". sm.conflicts = append(sm.conflicts, filePath) - return fmt.Errorf("file already exists (dry-run): %s", filePath) + return nil } if !sm.Force { diff --git a/cmd/safety_filesystem_test.go b/cmd/safety_filesystem_test.go index b4b3d25..00ad4f0 100644 --- a/cmd/safety_filesystem_test.go +++ b/cmd/safety_filesystem_test.go @@ -36,10 +36,11 @@ func TestSafetyManager_CheckFileConflict(t *testing.T) { f := filepath.Join(dir, "existing.go") require.NoError(t, os.WriteFile(f, []byte("package x"), 0o644)) + // In dry-run mode an existing file must NOT cause an error; it is just + // recorded as a conflict and previewed as "would overwrite". sm := NewSafetyManager(true, false, false) err := sm.CheckFileConflict(f) - assert.Error(t, err) - assert.Contains(t, err.Error(), "dry-run") + assert.NoError(t, err) assert.Len(t, sm.GetConflicts(), 1) }) diff --git a/cmd/safety_test.go b/cmd/safety_test.go index 8b4426b..512a325 100644 --- a/cmd/safety_test.go +++ b/cmd/safety_test.go @@ -80,8 +80,8 @@ func TestSafetyManager_CheckFileConflict_DryRun(t *testing.T) { sm := NewSafetyManager(true, false, false) err := sm.CheckFileConflict(filePath) - require.Error(t, err) - assert.Contains(t, err.Error(), "dry-run") + // Dry-run must not error on existing files; it records the conflict only. + require.NoError(t, err) assert.Contains(t, sm.GetConflicts(), filePath) } diff --git a/cmd/template_cmd.go b/cmd/template_cmd.go index d540280..72e3769 100644 --- a/cmd/template_cmd.go +++ b/cmd/template_cmd.go @@ -1,9 +1,12 @@ package cmd import ( + "bufio" "fmt" "os" "path/filepath" + "sort" + "strings" "github.com/spf13/cobra" ) @@ -11,8 +14,8 @@ import ( var templateManagementCmd = &cobra.Command{ Use: "template", Short: "Manage custom templates", - Long: `Manage custom templates for code generation. -Initialize, list, and customize templates for personalized code generation.`, + Long: `Manage custom templates for code generation. +Initialize, list, show, and reset templates for personalized code generation.`, } var templateInitCmd = &cobra.Command{ @@ -51,52 +54,229 @@ Creates .goca/templates/ with customizable templates for all layers.`, }, } +// resolveTemplateDir returns the absolute templates directory for the current project. +func resolveTemplateDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error getting working directory: %w", err) + } + + configManager := NewConfigManager() + if err := configManager.LoadConfig(wd); err != nil { + return "", fmt.Errorf("error loading config: %w", err) + } + + config := configManager.GetConfig() + dir := ".goca/templates" + if config != nil && config.Templates.Directory != "" { + dir = config.Templates.Directory + } + + return filepath.Join(wd, dir), nil +} + +// collectTemplateFiles walks the template directory and returns the list of +// template names (relative path without extension) and their absolute paths. +func collectTemplateFiles(templateDir string) (map[string]string, error) { + result := make(map[string]string) + err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".tmpl") && !strings.HasSuffix(path, ".tpl") { + return nil + } + rel, err := filepath.Rel(templateDir, path) + if err != nil { + return err + } + name := strings.TrimSuffix(rel, filepath.Ext(rel)) + name = filepath.ToSlash(name) + result[name] = path + return nil + }) + return result, err +} + var templateListCmd = &cobra.Command{ Use: "list", Short: "List available templates", Long: `List all available custom templates in the project.`, Run: func(cmd *cobra.Command, args []string) { - ui.Info("Checking for templates...") - - // Get current working directory - wd, err := os.Getwd() + templateDir, err := resolveTemplateDir() if err != nil { - ui.Error(fmt.Sprintf("Error getting working directory: %v", err)) + ui.Error(err.Error()) + os.Exit(1) + } + + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + ui.Warning("No templates directory found.") + ui.Dim("Tip: Run 'goca template init' to create templates") return } - ui.KeyValue("Working directory", wd) + files, err := collectTemplateFiles(templateDir) + if err != nil { + ui.Error(fmt.Sprintf("Error reading templates: %v", err)) + os.Exit(1) + } - // Try simple config manager first - configManager := NewConfigManager() - if err := configManager.LoadConfig(wd); err != nil { - ui.Error(fmt.Sprintf("Error loading config: %v", err)) + if len(files) == 0 { + ui.Warning("No templates found.") + ui.Dim("Tip: Run 'goca template init' to create templates") return } - config := configManager.GetConfig() - if config == nil { - ui.Warning("Config is nil") + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + + if quietMode { + // Plain, script-friendly output under --quiet. + for _, name := range names { + ui.Println(name) + } return } - ui.KeyValue("Templates dir", config.Templates.Directory) + ui.Header(fmt.Sprintf("Available templates (%d):", len(names))) + rows := make([][]string, 0, len(names)) + for _, name := range names { + rel, _ := filepath.Rel(templateDir, files[name]) + rows = append(rows, []string{name, filepath.ToSlash(rel)}) + } + ui.Table([]string{"Template", "File"}, rows) + }, +} - // Try template manager - templateDir := filepath.Join(wd, config.Templates.Directory) - ui.KeyValue("Full template path", templateDir) +var templateShowCmd = &cobra.Command{ + Use: "show ", + Short: "Display the content of a template", + Long: `Display the raw content of a custom template. + +The is the template's relative path without extension, +e.g. "domain/entity" or "usecase/dto". Run 'goca template list' to see names.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := filepath.ToSlash(args[0]) + + templateDir, err := resolveTemplateDir() + if err != nil { + ui.Error(err.Error()) + os.Exit(1) + } if _, err := os.Stat(templateDir); os.IsNotExist(err) { - ui.Warning("No templates directory found.") + ui.Error("No templates directory found.") ui.Dim("Tip: Run 'goca template init' to create templates") - return + os.Exit(1) } - ui.Success("Template directory exists") + files, err := collectTemplateFiles(templateDir) + if err != nil { + ui.Error(fmt.Sprintf("Error reading templates: %v", err)) + os.Exit(1) + } + + // Accept either the bare name or a name including the extension. + name = strings.TrimSuffix(name, ".tmpl") + name = strings.TrimSuffix(name, ".tpl") + + path, ok := files[name] + if !ok { + ui.Error(fmt.Sprintf("Template %q not found", args[0])) + ui.Dim("Tip: Run 'goca template list' to see available templates") + os.Exit(1) + } + + content, err := os.ReadFile(path) + if err != nil { + ui.Error(fmt.Sprintf("Error reading template %q: %v", name, err)) + os.Exit(1) + } + + // Print raw content (ungated) so it is usable in scripts. + ui.Printf("%s", string(content)) + if len(content) > 0 && content[len(content)-1] != '\n' { + ui.Println("") + } + }, +} + +var templateResetForce bool + +var templateResetCmd = &cobra.Command{ + Use: "reset", + Short: "Reset all templates to built-in defaults", + Long: `Reset all custom templates to the built-in defaults. + +This removes the existing templates directory (after backing it up) and +regenerates the default templates. Because this is destructive, you must +pass --force to confirm.`, + Run: func(cmd *cobra.Command, args []string) { + templateDir, err := resolveTemplateDir() + if err != nil { + ui.Error(err.Error()) + os.Exit(1) + } + + exists := true + if _, statErr := os.Stat(templateDir); os.IsNotExist(statErr) { + exists = false + } + + if exists && !templateResetForce { + ui.Warning(fmt.Sprintf("This will delete and regenerate all templates in: %s", templateDir)) + if ui != nil && ui.IsInteractive() { + ui.Printf("Type 'yes' to continue: ") + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(line)) != "yes" { + ui.Info("Reset cancelled.") + return + } + } else { + ui.Error("Refusing to reset without confirmation. Re-run with --force.") + os.Exit(1) + } + } + + // Back up the existing templates directory before removing it. + if exists { + backupDir := templateDir + ".bak" + _ = os.RemoveAll(backupDir) + if err := os.Rename(templateDir, backupDir); err != nil { + ui.Error(fmt.Sprintf("Failed to back up existing templates: %v", err)) + os.Exit(1) + } + ui.Info(fmt.Sprintf("Backed up existing templates to: %s", backupDir)) + } + + // Regenerate defaults via the template manager (creates default tree). + configIntegration := NewConfigIntegration() + if err := configIntegration.LoadConfigForProject(); err != nil { + ui.Error(fmt.Sprintf("Could not load configuration: %v", err)) + os.Exit(1) + } + if err := configIntegration.InitializeTemplateSystem(); err != nil { + ui.Error(fmt.Sprintf("Error regenerating templates: %v", err)) + os.Exit(1) + } + + ui.Success("Templates reset to built-in defaults.") }, } func init() { + templateResetCmd.Flags().BoolVar(&templateResetForce, "force", false, "Reset without confirmation") + templateManagementCmd.AddCommand(templateInitCmd) templateManagementCmd.AddCommand(templateListCmd) + templateManagementCmd.AddCommand(templateShowCmd) + templateManagementCmd.AddCommand(templateResetCmd) } diff --git a/cmd/test_integration.go b/cmd/test_integration.go index f974754..d7eb7ae 100644 --- a/cmd/test_integration.go +++ b/cmd/test_integration.go @@ -51,6 +51,23 @@ Examples: return } + // Guard: the generated tests reference domain., repository + // constructors and usecase services for the entity. Generating them for an + // entity that doesn't exist produces non-compiling code, so refuse early. + entityFile := filepath.Join("internal", "domain", strings.ToLower(entityName)+".go") + if _, err := os.Stat(entityFile); os.IsNotExist(err) { + validator.errorHandler.HandleError(fmt.Errorf("entity %q not found (%s does not exist). Generate it first with 'goca entity %s ...'", entityName, entityFile, entityName), "test-integration") + return + } + + // Validate the requested database is supported for generation. + switch integrationTestDatabase { + case "postgres", "mysql", "sqlite": + default: + validator.errorHandler.HandleError(fmt.Errorf("unsupported --database %q (supported: postgres, mysql, sqlite)", integrationTestDatabase), "test-integration") + return + } + // Initialize safety manager dryRun, _ := cmd.Flags().GetBool("dry-run") force, _ := cmd.Flags().GetBool("force") @@ -362,60 +379,145 @@ func New%[1]sFixtureList(count int) []*domain.%[1]s { // generateHelpersContent generates database helpers for integration tests. func generateHelpersContent(database string, withContainer bool, entityName string) string { - containerSetup := "" - if withContainer { - containerSetup = ` - // Using test containers - // TODO: Implement test container setup - // Example with testcontainers-go: - // ctx := context.Background() - // req := testcontainers.ContainerRequest{ - // Image: "postgres:15", - // ExposedPorts: []string{"5432/tcp"}, - // Env: map[string]string{ - // "POSTGRES_USER": "test", - // "POSTGRES_PASSWORD": "test", - // "POSTGRES_DB": "testdb", - // }, - // WaitingFor: wait.ForLog("database system is ready to accept connections"), - // } - // container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - // ContainerRequest: req, - // Started: true, - // }) - // if err != nil { - // t.Fatalf("failed to start container: %v", err) - // }` - } + imports := ` "fmt" + "os" + "testing" - content := fmt.Sprintf(`package integration + _ "github.com/go-sql-driver/mysql" // MySQL driver + _ "github.com/lib/pq" // PostgreSQL driver + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm"` -import ( + // Postgres / MySQL setup branches. When --container is enabled, the DSN is + // produced by a real testcontainers-go container; otherwise it falls back to + // a conventional local DSN (overridable through env vars). + postgresSetup := ` dsn := getenv("TEST_POSTGRES_DSN", "host=localhost user=test password=test dbname=goca_test port=5432 sslmode=disable") + dialector = postgres.Open(dsn)` + mysqlSetup := ` dsn := getenv("TEST_MYSQL_DSN", "test:test@tcp(localhost:3306)/goca_test?charset=utf8mb4&parseTime=True&loc=Local") + dialector = mysql.Open(dsn)` + + if withContainer { + imports = ` "context" "fmt" + "os" "testing" - _ "github.com/lib/pq" // PostgreSQL driver + _ "github.com/go-sql-driver/mysql" // MySQL driver + _ "github.com/lib/pq" // PostgreSQL driver + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" - "gorm.io/gorm" + "gorm.io/gorm"` + + postgresSetup = ` dsn := startPostgresContainer(t) + dialector = postgres.Open(dsn)` + mysqlSetup = ` dsn := startMySQLContainer(t) + dialector = mysql.Open(dsn)` + } + + containerHelpers := "" + if withContainer { + containerHelpers = ` +// startPostgresContainer starts a throwaway PostgreSQL container and returns its DSN. +func startPostgresContainer(t *testing.T) string { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: "postgres:15-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "test", + "POSTGRES_PASSWORD": "test", + "POSTGRES_DB": "goca_test", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start postgres container: %v", err) + } + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("failed to get mapped port: %v", err) + } + return fmt.Sprintf("host=%s user=test password=test dbname=goca_test port=%s sslmode=disable", host, port.Port()) +} + +// startMySQLContainer starts a throwaway MySQL container and returns its DSN. +func startMySQLContainer(t *testing.T) string { + t.Helper() + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: "mysql:8", + ExposedPorts: []string{"3306/tcp"}, + Env: map[string]string{ + "MYSQL_ROOT_PASSWORD": "test", + "MYSQL_USER": "test", + "MYSQL_PASSWORD": "test", + "MYSQL_DATABASE": "goca_test", + }, + WaitingFor: wait.ForListeningPort("3306/tcp"), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start mysql container: %v", err) + } + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("failed to get container host: %v", err) + } + port, err := container.MappedPort(ctx, "3306/tcp") + if err != nil { + t.Fatalf("failed to get mapped port: %v", err) + } + return fmt.Sprintf("test:test@tcp(%s:%s)/goca_test?charset=utf8mb4&parseTime=True&loc=Local", host, port.Port()) +} +` + } + + content := fmt.Sprintf(`package integration + +import ( +%[1]s ) +// getenv returns the value of the environment variable key, or def if unset. +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + // setupTestDatabase initializes a test database connection func setupTestDatabase(t *testing.T, dbType string) *gorm.DB { t.Helper() - var dsn string var dialector gorm.Dialector switch dbType { case "postgres": - %[1]s - // Using in-memory or test database - dsn = "host=localhost user=test password=test dbname=goca_test port=5432 sslmode=disable" - dialector = postgres.Open(dsn) +%[2]s case "mysql": - // TODO: Add MySQL setup - t.Skip("MySQL integration tests not implemented yet") +%[3]s case "sqlite": // SQLite is perfect for fast integration tests — no external server needed dialector = sqlite.Open(":memory:") @@ -434,6 +536,7 @@ func setupTestDatabase(t *testing.T, dbType string) *gorm.DB { return db } +%[4]s // cleanupTestDatabase cleans up the test database func cleanupTestDatabase(t *testing.T, db *gorm.DB) { @@ -516,7 +619,7 @@ func seedTestData(t *testing.T, db *gorm.DB) { func ptr[T any](v T) *T { return &v } -`, containerSetup) +`, imports, postgresSetup, mysqlSetup, containerHelpers) return replaceHelperTODOs(content, entityName) } @@ -780,12 +883,6 @@ func replaceFixtureTODOs(content string, fields []Field, entityName string) stri // replaceHelperTODOs replaces TODO placeholders with entity-specific guidance in helper content. func replaceHelperTODOs(content, entityName string) string { - content = strings.Replace(content, - "// TODO: Implement test container setup", - "// Implement test container setup with testcontainers-go:", 1) - content = strings.Replace(content, - "// TODO: Add MySQL setup", - "// Add MySQL setup (e.g. dsn = \"user:pass@tcp(localhost:3306)/goca_test?parseTime=true\"):", 1) content = strings.Replace(content, "// TODO: Add auto-migration for test entities", "// Auto-migrate entity (requires domain import): db.AutoMigrate(&domain."+entityName+"{})", 1) diff --git a/cmd/ui.go b/cmd/ui.go index 134e76e..69164d8 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -149,6 +149,9 @@ func (u *UIRenderer) FileCreated(path string) { if u == nil { return } + if u.verbosity < 1 { + return + } check := lipgloss.NewStyle().Foreground(colorGreen).Render("✓") dimPath := lipgloss.NewStyle().Foreground(colorDim).Render(path) fmt.Fprintf(u.writer, " %s Created: %s\n", check, dimPath) @@ -210,6 +213,9 @@ func (u *UIRenderer) Table(headers []string, rows [][]string) { if u == nil { return } + if u.verbosity < 1 { + return + } if len(headers) == 0 { return } diff --git a/cmd/ui_test.go b/cmd/ui_test.go index 760dde4..628fe7d 100644 --- a/cmd/ui_test.go +++ b/cmd/ui_test.go @@ -267,3 +267,36 @@ func TestUIRenderer_KeyValueFromConfig(t *testing.T) { assert.Contains(t, output, "postgres") assert.Contains(t, output, "from config") } + +func TestFileCreated_QuietSuppressed(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + NewUIRenderer(&buf, true, 0).FileCreated("internal/domain/x.go") + assert.Empty(t, buf.String(), "FileCreated must be suppressed under quiet") + + var buf2 bytes.Buffer + NewUIRenderer(&buf2, true, 1).FileCreated("internal/domain/x.go") + assert.Contains(t, buf2.String(), "Created") +} + +func TestTable_QuietSuppressed(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + NewUIRenderer(&buf, true, 0).Table([]string{"A"}, [][]string{{"1"}}) + assert.Empty(t, buf.String(), "Table must be suppressed under quiet") + + var buf2 bytes.Buffer + NewUIRenderer(&buf2, true, 1).Table([]string{"A"}, [][]string{{"1"}}) + assert.NotEmpty(t, buf2.String()) +} + +func TestSuccessError_VisibleUnderQuiet(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + r := NewUIRenderer(&buf, true, 0) + r.Success("done") + r.Error("boom") + out := buf.String() + assert.Contains(t, out, "done") + assert.Contains(t, out, "boom") +} diff --git a/cmd/usecase.go b/cmd/usecase.go index cd70439..191c090 100644 --- a/cmd/usecase.go +++ b/cmd/usecase.go @@ -34,6 +34,17 @@ clear interfaces and encapsulated business logic.`, os.Exit(1) } + // Ensure the target entity actually exists. Generating a use case for a + // non-existent entity produces code that references domain. and + // won't compile, so fail fast with a clear message instead of silently + // emitting broken output. + entityFile := filepath.Join(DirInternal, DirDomain, strings.ToLower(entity)+".go") + if _, err := os.Stat(entityFile); os.IsNotExist(err) { + ui.Error(fmt.Sprintf("Entity '%s' not found: expected %s", entity, entityFile)) + ui.Dim(fmt.Sprintf("Run 'goca entity %s --fields \"...\"' first to generate the domain entity.", entity)) + os.Exit(1) + } + // Merge only explicitly changed CLI flags with config flags := map[string]interface{}{} if cmd.Flags().Changed("operations") { @@ -127,8 +138,20 @@ func generateUseCaseWithFields(usecaseName, entity, operations string, dtoValida // Generate files generateDTOFileWithFields(usecaseDir, entity, ops, dtoValidation, fields, sm...) generateUseCaseInterface(usecaseDir, usecaseName, entity, ops, sm...) - generateUseCaseServiceWithFields(usecaseDir, usecaseName, entity, ops, async, fields, sm...) - generateUseCaseInterfaces(usecaseDir, entity, sm...) + generateUseCaseServiceWithFields(usecaseDir, usecaseName, entity, ops, dtoValidation, async, fields, sm...) + + // The service depends on repository.Repository (correct Clean + // Architecture layering). To make a standalone `goca usecase` compile, also + // generate the repository interface stub in package repository. This reuses + // the exact same generator as `goca repository`, so the two stay identical + // and idempotent (the generator no-ops if the interface already exists). + repoDir := filepath.Join(DirInternal, DirRepository) + _ = os.MkdirAll(repoDir, 0o755) + if fields != "" { + generateRepositoryInterfaceWithFields(repoDir, entity, parseFields(fields), false, sm...) + } else { + generateRepositoryInterface(repoDir, entity, false, sm...) + } } func parseOperations(operations string) []string { @@ -318,7 +341,7 @@ func generateUseCaseInterface(dir, usecaseName, entity string, operations []stri } } -func generateUseCaseServiceWithFields(dir, usecaseName, entity string, operations []string, async bool, fields string, sm ...*SafetyManager) { +func generateUseCaseServiceWithFields(dir, usecaseName, entity string, operations []string, dtoValidation, async bool, fields string, sm ...*SafetyManager) { // Get the module name from go.mod moduleName := getModuleName() @@ -328,20 +351,27 @@ func generateUseCaseServiceWithFields(dir, usecaseName, entity string, operation var content strings.Builder content.WriteString("package usecase\n\n") content.WriteString("import (\n") + if async { + content.WriteString("\t\"log\"\n\n") + } content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", getImportPath(moduleName))) content.WriteString(fmt.Sprintf("\t\"%s/internal/messages\"\n", getImportPath(moduleName))) content.WriteString(fmt.Sprintf("\t\"%s/internal/repository\"\n", getImportPath(moduleName))) content.WriteString(")\n\n") + // Async support types, defined once per service file when --async is set. + if async { + content.WriteString("// AsyncTask represents a unit of work to be processed asynchronously.\n") + content.WriteString("type AsyncTask func()\n\n") + } + // Service struct serviceName := fmt.Sprintf("%sService", entityLower) content.WriteString(fmt.Sprintf("type %s struct {\n", serviceName)) content.WriteString(fmt.Sprintf("\trepo repository.%sRepository\n", entity)) if async { - content.WriteString("\t// Canal para procesamiento asíncrono\n") + content.WriteString("\t// asyncChannel buffers tasks for asynchronous processing.\n") content.WriteString("\tasyncChannel chan AsyncTask\n") - content.WriteString("\t// Logger para operaciones asíncronas\n") - content.WriteString("\tlogger Logger\n") } content.WriteString("}\n\n") @@ -353,15 +383,39 @@ func generateUseCaseServiceWithFields(dir, usecaseName, entity string, operation // unexported struct keeps its lowercased name. content.WriteString(fmt.Sprintf("func New%sService(repo repository.%sRepository) %s {\n", entity, entity, interfaceName)) - content.WriteString(fmt.Sprintf("\treturn &%s{repo: repo}\n", serviceName)) + if async { + content.WriteString(fmt.Sprintf("\ts := &%s{\n", serviceName)) + content.WriteString("\t\trepo: repo,\n") + content.WriteString("\t\tasyncChannel: make(chan AsyncTask, 100),\n") + content.WriteString("\t}\n") + content.WriteString("\tgo s.processAsyncTasks()\n") + content.WriteString("\treturn s\n") + } else { + content.WriteString(fmt.Sprintf("\treturn &%s{repo: repo}\n", serviceName)) + } content.WriteString("}\n\n") + // Async worker: drains the channel and runs queued tasks sequentially. + if async { + content.WriteString("// processAsyncTasks runs queued tasks from the async channel.\n") + content.WriteString(fmt.Sprintf("func (%s *%s) processAsyncTasks() {\n", string(serviceName[0]), serviceName)) + content.WriteString(fmt.Sprintf("\tfor task := range %s.asyncChannel {\n", string(serviceName[0]))) + content.WriteString("\t\ttask()\n") + content.WriteString("\t}\n") + content.WriteString("}\n\n") + } + + // The generated CreateInput.Validate() method only exists when DTO + // validation is enabled AND the DTO was built from real entity fields, so + // only emit the input.Validate() call in that case. + callDTOValidate := dtoValidation && fields != "" + // Generate methods for each operation for _, op := range operations { switch op { case "create": if fields != "" { - generateCreateMethodWithFields(&content, serviceName, entity, fields) + generateCreateMethodWithFields(&content, serviceName, entity, fields, callDTOValidate) } else { generateCreateMethod(&content, serviceName, entity) } @@ -380,6 +434,17 @@ func generateUseCaseServiceWithFields(dir, usecaseName, entity string, operation } } + // When --async is enabled, emit a fire-and-forget wrapper for the create + // operation that queues the work on the service's async channel. + if async { + for _, op := range operations { + if op == "create" { + generateAsyncCreateMethod(&content, serviceName, entity) + break + } + } + } + if err := writeGoFile(filename, content.String(), sm...); err != nil { ui.Error(fmt.Sprintf("Error creating use case service with fields: %v", err)) } @@ -393,9 +458,8 @@ func generateCreateMethod(content *strings.Builder, serviceName, entity string) serviceVar, serviceName, entity, entity, entity) fmt.Fprintf(content, "\t%s := domain.%s{\n", entityLower, entity) content.WriteString("\t\t// Automatic field mapping - adjust according to your entity\n") - content.WriteString("\t\t// Nombre: input.Nombre,\n") - content.WriteString("\t\t// Email: input.Email,\n") - content.WriteString("\t\t// Edad: input.Edad,\n") + content.WriteString("\t\t// Name: input.Name,\n") + content.WriteString("\t\t// Description: input.Description,\n") content.WriteString("\t}\n\n") fmt.Fprintf(content, "\tif err := %s.Validate(); err != nil {\n", entityLower) @@ -413,13 +477,22 @@ func generateCreateMethod(content *strings.Builder, serviceName, entity string) content.WriteString("}\n\n") } -func generateCreateMethodWithFields(content *strings.Builder, serviceName, entity, fields string) { +func generateCreateMethodWithFields(content *strings.Builder, serviceName, entity, fields string, callDTOValidate bool) { entityLower := strings.ToLower(entity) serviceVar := string(serviceName[0]) fieldsList := parseFields(fields) fmt.Fprintf(content, "func (%s *%s) Create%s(input Create%sInput) (Create%sOutput, error) {\n", serviceVar, serviceName, entity, entity, entity) + + // Validate the input DTO first when DTO validation is enabled, so malformed + // requests are rejected before building the domain entity. + if callDTOValidate { + content.WriteString("\tif err := input.Validate(); err != nil {\n") + fmt.Fprintf(content, "\t\treturn Create%sOutput{}, err\n", entity) + content.WriteString("\t}\n\n") + } + fmt.Fprintf(content, "\t%s := domain.%s{\n", entityLower, entity) // Map fields from input to entity @@ -506,12 +579,14 @@ func generateUpdateMethod(content *strings.Builder, serviceName, entity string) content.WriteString("\t\treturn err\n") content.WriteString("\t}\n\n") + // The generic UpdateInput DTO exposes optional (pointer) Name and + // Description fields; apply them only when set. Adjust to your entity. content.WriteString("\t// Update fields according to your entity\n") - content.WriteString("\tif input.Nombre != \"\" {\n") - fmt.Fprintf(content, "\t\t%s.Nombre = input.Nombre\n", entityVar) + content.WriteString("\tif input.Name != nil {\n") + fmt.Fprintf(content, "\t\t%s.Name = *input.Name\n", entityVar) content.WriteString("\t}\n") - content.WriteString("\tif input.Email != \"\" {\n") - fmt.Fprintf(content, "\t\t%s.Email = input.Email\n", entityVar) + content.WriteString("\tif input.Description != nil {\n") + fmt.Fprintf(content, "\t\t%s.Description = *input.Description\n", entityVar) content.WriteString("\t}\n") content.WriteString("\t// Add more fields as needed\n\n") @@ -547,35 +622,28 @@ func generateListMethod(content *strings.Builder, serviceName, entity string) { content.WriteString("}\n\n") } -func generateUseCaseInterfaces(dir, entity string, sm ...*SafetyManager) { - // Get the module name from go.mod - moduleName := getModuleName() - - filename := filepath.Join(dir, "interfaces.go") - - var content strings.Builder - content.WriteString("package usecase\n\n") - content.WriteString(fmt.Sprintf("import \"%s/internal/domain\"\n\n", getImportPath(moduleName))) - - entityLower := strings.ToLower(entity) - content.WriteString(fmt.Sprintf("type %sRepository interface {\n", entity)) - content.WriteString(fmt.Sprintf("\tSave(%s *domain.%s) error\n", entityLower, entity)) - content.WriteString(fmt.Sprintf("\tFindByID(id int) (*domain.%s, error)\n", entity)) - content.WriteString(fmt.Sprintf("\tUpdate(%s *domain.%s) error\n", entityLower, entity)) - content.WriteString("\tDelete(id int) error\n") - content.WriteString(fmt.Sprintf("\tFindAll() ([]domain.%s, error)\n", entity)) - content.WriteString("}\n") +// generateAsyncCreateMethod emits a fire-and-forget wrapper around Create that +// queues the work on the service's async channel and logs any error. +func generateAsyncCreateMethod(content *strings.Builder, serviceName, entity string) { + serviceVar := string(serviceName[0]) - if err := writeGoFileMerged(filename, content.String(), sm...); err != nil { - ui.Error(fmt.Sprintf("Error creating interfaces file: %v", err)) - } + fmt.Fprintf(content, "// Create%sAsync queues the creation of a %s for asynchronous processing.\n", + entity, strings.ToLower(entity)) + fmt.Fprintf(content, "func (%s *%s) Create%sAsync(input Create%sInput) {\n", + serviceVar, serviceName, entity, entity) + fmt.Fprintf(content, "\t%s.asyncChannel <- func() {\n", serviceVar) + fmt.Fprintf(content, "\t\tif _, err := %s.Create%s(input); err != nil {\n", serviceVar, entity) + fmt.Fprintf(content, "\t\t\tlog.Printf(\"async create %s failed: %%v\", err)\n", strings.ToLower(entity)) + content.WriteString("\t\t}\n") + content.WriteString("\t}\n") + content.WriteString("}\n\n") } func generateCreateDTOWithFields(content *strings.Builder, entity string, validation bool, fields string) { fieldsList := parseFields(fields) // Generate Create Input DTO - fmt.Fprintf(content, "// Create%sInput DTO para crear un nuevo %s\n", entity, strings.ToLower(entity)) + fmt.Fprintf(content, "// Create%sInput is the DTO for creating a new %s.\n", entity, strings.ToLower(entity)) fmt.Fprintf(content, "type Create%sInput struct {\n", entity) for _, field := range fieldsList { @@ -600,7 +668,7 @@ func generateCreateDTOWithFields(content *strings.Builder, entity string, valida // Generate validation method for the DTO if validation { - fmt.Fprintf(content, "// Validate valida los datos del DTO Create%sInput\n", entity) + fmt.Fprintf(content, "// Validate checks the Create%sInput DTO fields.\n", entity) fmt.Fprintf(content, "func (r *Create%sInput) Validate() error {\n", entity) for _, field := range fieldsList { diff --git a/cmd/usecase_methods_test.go b/cmd/usecase_methods_test.go index 299061b..f82e3b2 100644 --- a/cmd/usecase_methods_test.go +++ b/cmd/usecase_methods_test.go @@ -17,6 +17,9 @@ func TestGenerateCreateMethod_Pure(t *testing.T) { assert.Contains(t, result, "func (p *productService) CreateProduct") assert.Contains(t, result, "CreateProductInput") assert.Contains(t, result, "CreateProductOutput") + // Generated comments/identifiers must be English, not the old Spanish ones. + assert.NotContains(t, result, "Nombre") + assert.NotContains(t, result, "Edad") } func TestGenerateGetMethod_Pure(t *testing.T) { @@ -61,8 +64,29 @@ func TestGenerateUpdateMethodWithFields_Pure(t *testing.T) { func TestGenerateCreateMethodWithFields_Pure(t *testing.T) { t.Parallel() var sb strings.Builder - generateCreateMethodWithFields(&sb, "productService", "Product", "Name:string,Price:float64") + generateCreateMethodWithFields(&sb, "productService", "Product", "Name:string,Price:float64", true) result := sb.String() assert.Contains(t, result, "func (p *productService) CreateProduct") assert.Contains(t, result, "Name") + // When DTO validation is enabled, the create method must call input.Validate(). + assert.Contains(t, result, "input.Validate()") +} + +func TestGenerateCreateMethodWithFields_NoDTOValidate(t *testing.T) { + t.Parallel() + var sb strings.Builder + generateCreateMethodWithFields(&sb, "productService", "Product", "Name:string,Price:float64", false) + result := sb.String() + assert.Contains(t, result, "func (p *productService) CreateProduct") + assert.NotContains(t, result, "input.Validate()") +} + +func TestGenerateAsyncCreateMethod(t *testing.T) { + t.Parallel() + var sb strings.Builder + generateAsyncCreateMethod(&sb, "productService", "Product") + result := sb.String() + assert.Contains(t, result, "func (p *productService) CreateProductAsync(input CreateProductInput)") + assert.Contains(t, result, "p.asyncChannel <- func() {") + assert.Contains(t, result, "p.CreateProduct(input)") } diff --git a/cmd/version.go b/cmd/version.go index d19e383..69e568f 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -30,10 +30,13 @@ var versionCmd = &cobra.Command{ if short { ui.Println(Version) } else { - ui.Header(fmt.Sprintf("Goca v%s", Version)) - ui.KeyValue("Build", BuildTime) - ui.KeyValue("Go Version", GoVersion) - ui.KeyValue("Git Commit", GitCommit) + // version is an explicit query command: its output must not be + // blanked by --quiet, so use the ungated Println path rather than + // Header/KeyValue (which are gated by verbosity < 1). + ui.Println(fmt.Sprintf("Goca v%s", Version)) + ui.Println(fmt.Sprintf("Build: %s", BuildTime)) + ui.Println(fmt.Sprintf("Go Version: %s", GoVersion)) + ui.Println(fmt.Sprintf("Git Commit: %s", GitCommit)) } }, } @@ -41,10 +44,39 @@ var versionCmd = &cobra.Command{ func init() { // When Version is still the default "dev", try to read the module version // embedded by the Go toolchain (set when using `go install module@version`). - if Version == "dev" { - if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + // Also fall back to the VCS build settings for BuildTime/GitCommit when the + // ldflags vars are still their default "unknown" (e.g. `go install`/`go build`). + if info, ok := debug.ReadBuildInfo(); ok { + if Version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" { Version = strings.TrimPrefix(info.Main.Version, "v") } + + var vcsRevision, vcsTime string + var vcsModified bool + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + vcsRevision = s.Value + case "vcs.time": + vcsTime = s.Value + case "vcs.modified": + vcsModified = s.Value == "true" + } + } + + if GitCommit == "unknown" && vcsRevision != "" { + commit := vcsRevision + if len(commit) > 12 { + commit = commit[:12] + } + if vcsModified { + commit += "-dirty" + } + GitCommit = commit + } + if BuildTime == "unknown" && vcsTime != "" { + BuildTime = vcsTime + } } versionCmd.Flags().BoolP("short", "s", false, "Display only the version number") } diff --git a/internal/testing/suite.go b/internal/testing/suite.go index d195b41..e3d801d 100644 --- a/internal/testing/suite.go +++ b/internal/testing/suite.go @@ -438,7 +438,6 @@ func (ts *TestSuite) verifyFeatureStructure(entity string) { "internal/handler/http/routes.go", "internal/domain/errors.go", "internal/usecase/dto.go", - "internal/usecase/interfaces.go", "internal/repository/interfaces.go", "internal/messages/errors.go", "internal/messages/responses.go", @@ -536,8 +535,11 @@ func (ts *TestSuite) verifyUseCases(entity string) { ts.verifyFileExists(dtoFile) ts.verifyGoSyntax(dtoFile) - // Verify interfaces - interfacesFile := filepath.Join(ts.projectPath, "internal/usecase", "interfaces.go") + // Verify the repository interface the service depends on. The use case + // command no longer emits a contradictory usecase-package repository + // interface; the service depends on repository.Repository, whose + // interface lives in internal/repository/interfaces.go. + interfacesFile := filepath.Join(ts.projectPath, "internal/repository", "interfaces.go") ts.verifyFileExists(interfacesFile) ts.verifyGoSyntax(interfacesFile) }