diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 521c13a..6a44bae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,11 +29,17 @@ First off, thank you for considering contributing to `goscaf`! It's people like 4. Build the project: `make build`. ## Project Structure -- `main.go`: Entry point. +- `main.go`: Entry point — delegates immediately to `cmd.Execute()`. - `cmd/`: CLI commands (Cobra-based). -- `internal/`: Core logic and templates. - - `internal/generator/`: Logic for generating files. - - `internal/templates/`: Project boilerplates. + - `cmd/root.go`: Root command and banner. + - `cmd/init.go`: `goscaf init` — collects config and drives the generator. + - `cmd/add.go`: `goscaf add` — adds a new service scaffold to an existing project. +- `internal/`: Core logic — not importable by external packages. + - `internal/config/`: `ProjectConfig` struct and typed constants for framework, logger, and database choices. + - `internal/userconfig/`: Loads and merges `~/.goscaf.yaml` (global) and `./.goscaf.yaml` (local) defaults. + - `internal/prompt/`: Interactive survey prompts; accepts a `UserConfig` to pre-fill defaults. + - `internal/generator/`: Orchestrates file writes for `goscaf init` and `goscaf add`. + - `internal/templates/`: Go `text/template` strings for every generated file. ## Style Guide - We follow standard Go idioms. diff --git a/README.md b/README.md index 9659fde..0e72ae2 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,105 @@ goscaf init my-api --framework fiber --logger zap --redis --kafka --docker --- +## Config file (.goscaf.yaml) + +Tired of answering the same prompts every time? Save your preferences once in a `.goscaf.yaml` file and `goscaf` will pre-fill every prompt with your defaults. You can still change any value at the prompt — the config just saves you the typing. + +### Two scopes, same format + +| File | Scope | Who should set it | +|---|---|---| +| `~/.goscaf.yaml` | Global — applies to all projects on this machine | Individual developers | +| `./.goscaf.yaml` | Local — applies only to the current directory | Teams (commit to the repo) | + +When both files exist, **local values win over global**. CLI flags always win over both. The full priority chain is: + +``` +hardcoded defaults < ~/.goscaf.yaml < ./.goscaf.yaml < CLI flags +``` + +### All supported fields + +```yaml +# .goscaf.yaml + +# Module prefix — goscaf appends / automatically. +# Example: "github.com/your-org" produces "github.com/your-org/my-api" +module_prefix: github.com/your-org + +# Go toolchain version to write into go.mod +go_version: "1.25.0" + +# HTTP framework: gin | fiber | chi | echo | gorilla | none +framework: gin + +# Structured logger: slog | zerolog | zap +logger: slog + +# Database driver: none | postgres | mysql | sqlite | mongo | gorm +db: none + +# Optional infrastructure clients +viper: true +redis: false +kafka: false +nats: false + +# DevOps scaffolding +docker: true +makefile: true +github: true +lint: true +swagger: false +git_repo: false +``` + +Any field you omit falls back to the next level in the priority chain — you only need to set what you actually want to override. + +### Common setups + +**Personal global config** — sets your usual module prefix and preferred logger, nothing else: + +```yaml +# ~/.goscaf.yaml +module_prefix: github.com/john-doe +logger: zap +go_version: "1.25.0" +``` + +**Team repo config** — standardises the stack across all services; commit this file so every engineer gets the same defaults: + +```yaml +# .goscaf.yaml (committed to the monorepo root) +module_prefix: github.com/acme-corp +framework: chi +logger: zerolog +viper: true +docker: true +makefile: true +github: true +lint: true +swagger: false +``` + +**Override a single field at the CLI** — team config says `framework: chi`, but you need fiber just this once: + +```bash +goscaf init payments-service --framework fiber +``` + +The rest of the fields still come from `.goscaf.yaml`; only `framework` is overridden. + +### What happens in each mode + +| Mode | Config file applied? | +|---|---| +| Interactive (`goscaf init `) | Yes — pre-fills prompt defaults | +| Flag-driven (`--framework`, `--logger`, …) | Yes — fills any field not covered by a flag | +| Defaults (`--defaults`) | No — hardcoded defaults only, config ignored | + +--- + ## Flags | Flag | Default | Description | diff --git a/bin/goscaf b/bin/goscaf index abb2cf0..b179468 100755 Binary files a/bin/goscaf and b/bin/goscaf differ diff --git a/cmd/init.go b/cmd/init.go index 564e0c4..60cddac 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -11,6 +11,7 @@ import ( "github.com/iyashjayesh/goscaf/internal/config" "github.com/iyashjayesh/goscaf/internal/generator" "github.com/iyashjayesh/goscaf/internal/prompt" + "github.com/iyashjayesh/goscaf/internal/userconfig" ) var ( @@ -45,6 +46,13 @@ var initCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { projectName := args[0] + // Load .goscaf.yaml (global then local, local wins). A missing file is + // not an error — uc will be nil and prompts fall back to hardcoded defaults. + uc, err := userconfig.Load() + if err != nil { + color.Yellow(" ⚠ could not read .goscaf.yaml: %v", err) + } + var cfg *config.ProjectConfig if flagDefaults { @@ -70,7 +78,8 @@ var initCmd = &cobra.Command{ } else if cmd.Flags().Changed("framework") || cmd.Flags().Changed("module") || cmd.Flags().Changed("go-version") || cmd.Flags().Changed("logger") || cmd.Flags().Changed("db") { - // Flags provided - use flag-driven mode (merge with defaults) + // Flags provided — start from flag values, then fill in any field the + // user did NOT explicitly set from .goscaf.yaml (flags always win). cfg = &config.ProjectConfig{ ProjectName: projectName, ModuleName: flagModule, @@ -90,14 +99,21 @@ var initCmd = &cobra.Command{ GitRepo: flagGitRepo, } if cfg.ModuleName == "" { - cfg.ModuleName = fmt.Sprintf("github.com/your-org/%s", projectName) + if uc != nil && uc.ModulePrefix != "" { + cfg.ModuleName = uc.ModulePrefix + "/" + projectName + } else { + cfg.ModuleName = fmt.Sprintf("github.com/your-org/%s", projectName) + } + } + if uc != nil { + applyUserConfig(cmd, cfg, uc) } } else { - // Interactive mode - var err error - cfg, err = prompt.Run(projectName) - if err != nil { - return fmt.Errorf("prompt failed: %w", err) + // Interactive mode — userconfig pre-fills prompt defaults. + var promptErr error + cfg, promptErr = prompt.Run(projectName, uc) + if promptErr != nil { + return fmt.Errorf("prompt failed: %w", promptErr) } } @@ -148,6 +164,53 @@ var initCmd = &cobra.Command{ }, } +// applyUserConfig fills cfg fields from uc for any flag the user did not +// explicitly pass on the command line. CLI flags always take precedence. +func applyUserConfig(cmd *cobra.Command, cfg *config.ProjectConfig, uc *userconfig.UserConfig) { + if !cmd.Flags().Changed("go-version") && uc.GoVersion != "" { + cfg.GoVersion = uc.GoVersion + } + if !cmd.Flags().Changed("framework") && uc.Framework != "" { + cfg.Framework = config.Framework(uc.Framework) + } + if !cmd.Flags().Changed("logger") && uc.Logger != "" { + cfg.Logger = config.Logger(uc.Logger) + } + if !cmd.Flags().Changed("db") && uc.DB != "" { + cfg.Database = config.Database(uc.DB) + } + if !cmd.Flags().Changed("viper") && uc.Viper != nil { + cfg.Viper = *uc.Viper + } + if !cmd.Flags().Changed("redis") && uc.Redis != nil { + cfg.Redis = *uc.Redis + } + if !cmd.Flags().Changed("kafka") && uc.Kafka != nil { + cfg.Kafka = *uc.Kafka + } + if !cmd.Flags().Changed("nats") && uc.NATS != nil { + cfg.NATS = *uc.NATS + } + if !cmd.Flags().Changed("docker") && uc.Docker != nil { + cfg.Docker = *uc.Docker + } + if !cmd.Flags().Changed("makefile") && uc.Makefile != nil { + cfg.Makefile = *uc.Makefile + } + if !cmd.Flags().Changed("github") && uc.GitHub != nil { + cfg.GitHub = *uc.GitHub + } + if !cmd.Flags().Changed("lint") && uc.Lint != nil { + cfg.Lint = *uc.Lint + } + if !cmd.Flags().Changed("swagger") && uc.Swagger != nil { + cfg.Swagger = *uc.Swagger + } + if !cmd.Flags().Changed("git-repo") && uc.GitRepo != nil { + cfg.GitRepo = *uc.GitRepo + } +} + func init() { rootCmd.AddCommand(initCmd) diff --git a/go.mod b/go.mod index f069a8a..6b7aac9 100644 --- a/go.mod +++ b/go.mod @@ -18,4 +18,5 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a827d4f..b7c3685 100644 --- a/go.sum +++ b/go.sum @@ -68,3 +68,5 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 71c4ae7..31dfd7d 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -6,16 +6,22 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/iyashjayesh/goscaf/internal/config" + "github.com/iyashjayesh/goscaf/internal/userconfig" ) // Run runs the interactive prompt flow and returns a populated ProjectConfig. -func Run(projectName string) (*config.ProjectConfig, error) { +// If uc is non-nil its values are used as pre-filled defaults; the user can +// still change any answer at the prompt. +func Run(projectName string, uc *userconfig.UserConfig) (*config.ProjectConfig, error) { cfg := &config.ProjectConfig{ ProjectName: projectName, } // 1. Module name moduleDefault := fmt.Sprintf("github.com/your-org/%s", projectName) + if uc != nil && uc.ModulePrefix != "" { + moduleDefault = uc.ModulePrefix + "/" + projectName + } if err := survey.AskOne(&survey.Input{ Message: "Module name:", Default: moduleDefault, @@ -24,22 +30,35 @@ func Run(projectName string) (*config.ProjectConfig, error) { } // 2. Go version - goVersionStr := "1.25.0" + goVersionDefault := "1.25.0" + if uc != nil && uc.GoVersion != "" { + goVersionDefault = uc.GoVersion + } + goVersionStr := goVersionDefault if err := survey.AskOne(&survey.Select{ Message: "Go version:", Options: []string{"1.25.0", "1.24.0", "1.23"}, - Default: "1.25.0", + Default: goVersionDefault, }, &goVersionStr); err != nil { return nil, fmt.Errorf("ask go version: %w", err) } cfg.GoVersion = goVersionStr // 3. HTTP framework - frameworkStr := "gin" + // The select option for gorilla is "gorilla/mux" but the config value is "gorilla". + frameworkDefault := "gin" + if uc != nil && uc.Framework != "" { + f := uc.Framework + if f == "gorilla" { + f = "gorilla/mux" + } + frameworkDefault = f + } + frameworkStr := frameworkDefault if err := survey.AskOne(&survey.Select{ Message: "HTTP framework:", Options: []string{"gin", "fiber", "chi", "echo", "gorilla/mux", "none"}, - Default: "gin", + Default: frameworkDefault, }, &frameworkStr); err != nil { return nil, fmt.Errorf("ask framework: %w", err) } @@ -49,11 +68,21 @@ func Run(projectName string) (*config.ProjectConfig, error) { cfg.Framework = config.Framework(frameworkStr) // 4. Structured logger - loggerStr := "slog" + // The select option for slog is "slog (stdlib)" but the config value is "slog". + loggerDefault := "slog (stdlib)" + if uc != nil && uc.Logger != "" { + switch uc.Logger { + case "slog": + loggerDefault = "slog (stdlib)" + default: + loggerDefault = uc.Logger + } + } + loggerStr := loggerDefault if err := survey.AskOne(&survey.Select{ Message: "Structured logger:", Options: []string{"slog (stdlib)", "zerolog", "zap"}, - Default: "slog (stdlib)", + Default: loggerDefault, }, &loggerStr); err != nil { return nil, fmt.Errorf("ask logger: %w", err) } @@ -69,7 +98,7 @@ func Run(projectName string) (*config.ProjectConfig, error) { // 5. Viper if err := survey.AskOne(&survey.Confirm{ Message: "Add Viper for config & env management?", - Default: true, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Viper }), true), }, &cfg.Viper); err != nil { return nil, fmt.Errorf("ask viper: %w", err) } @@ -77,7 +106,7 @@ func Run(projectName string) (*config.ProjectConfig, error) { // 6. Redis if err := survey.AskOne(&survey.Confirm{ Message: "Add Redis client (go-redis)?", - Default: false, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Redis }), false), }, &cfg.Redis); err != nil { return nil, fmt.Errorf("ask redis: %w", err) } @@ -85,7 +114,7 @@ func Run(projectName string) (*config.ProjectConfig, error) { // 7. Kafka if err := survey.AskOne(&survey.Confirm{ Message: "Add Kafka client (franz-go)?", - Default: false, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Kafka }), false), }, &cfg.Kafka); err != nil { return nil, fmt.Errorf("ask kafka: %w", err) } @@ -93,17 +122,21 @@ func Run(projectName string) (*config.ProjectConfig, error) { // 8. NATS if err := survey.AskOne(&survey.Confirm{ Message: "Add NATS client?", - Default: false, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.NATS }), false), }, &cfg.NATS); err != nil { return nil, fmt.Errorf("ask nats: %w", err) } // 9. Database driver - dbStr := "none" + dbDefault := "none" + if uc != nil && uc.DB != "" { + dbDefault = uc.DB + } + dbStr := dbDefault if err := survey.AskOne(&survey.Select{ Message: "Database driver:", Options: []string{"none", "postgres", "mysql", "sqlite", "mongo", "gorm"}, - Default: "none", + Default: dbDefault, }, &dbStr); err != nil { return nil, fmt.Errorf("ask database: %w", err) } @@ -112,50 +145,58 @@ func Run(projectName string) (*config.ProjectConfig, error) { // 10. Dockerfile + docker-compose if err := survey.AskOne(&survey.Confirm{ Message: "Add Dockerfile + docker-compose?", - Default: true, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Docker }), true), }, &cfg.Docker); err != nil { return nil, fmt.Errorf("ask docker: %w", err) } - // 10. Makefile + // 11. Makefile if err := survey.AskOne(&survey.Confirm{ Message: "Add Makefile?", - Default: true, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Makefile }), true), }, &cfg.Makefile); err != nil { return nil, fmt.Errorf("ask makefile: %w", err) } - // 11. GitHub Actions CI + // 12. GitHub Actions CI if err := survey.AskOne(&survey.Confirm{ Message: "Add GitHub Actions CI?", - Default: true, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.GitHub }), true), }, &cfg.GitHub); err != nil { return nil, fmt.Errorf("ask github: %w", err) } - // 12. golangci-lint config + // 13. golangci-lint config if err := survey.AskOne(&survey.Confirm{ Message: "Add golangci-lint config?", - Default: true, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Lint }), true), }, &cfg.Lint); err != nil { return nil, fmt.Errorf("ask lint: %w", err) } - // 13. Swagger/OpenAPI scaffold + // 14. Swagger/OpenAPI scaffold if err := survey.AskOne(&survey.Confirm{ Message: "Add Swagger/OpenAPI scaffold?", - Default: false, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.Swagger }), false), }, &cfg.Swagger); err != nil { return nil, fmt.Errorf("ask swagger: %w", err) } - // 14. Git repository + // 15. Git repository if err := survey.AskOne(&survey.Confirm{ Message: "Initialize github repository?", - Default: false, + Default: userconfig.BoolVal(ucBool(uc, func(u *userconfig.UserConfig) *bool { return u.GitRepo }), false), }, &cfg.GitRepo); err != nil { return nil, fmt.Errorf("failed to initialize git repository : %w", err) } return cfg, nil } + +// ucBool safely extracts a *bool field from uc, returning nil when uc is nil. +func ucBool(uc *userconfig.UserConfig, fn func(*userconfig.UserConfig) *bool) *bool { + if uc == nil { + return nil + } + return fn(uc) +} diff --git a/internal/userconfig/userconfig.go b/internal/userconfig/userconfig.go new file mode 100644 index 0000000..54b21b7 --- /dev/null +++ b/internal/userconfig/userconfig.go @@ -0,0 +1,152 @@ +package userconfig + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// UserConfig holds optional defaults loaded from .goscaf.yaml. +// String fields use empty string as "not set". +// Bool fields use *bool so nil (absent) is distinct from false (explicitly disabled). +type UserConfig struct { + ModulePrefix string `yaml:"module_prefix"` + GoVersion string `yaml:"go_version"` + Framework string `yaml:"framework"` + Logger string `yaml:"logger"` + DB string `yaml:"db"` + Viper *bool `yaml:"viper"` + Redis *bool `yaml:"redis"` + Kafka *bool `yaml:"kafka"` + NATS *bool `yaml:"nats"` + Docker *bool `yaml:"docker"` + Makefile *bool `yaml:"makefile"` + GitHub *bool `yaml:"github"` + Lint *bool `yaml:"lint"` + Swagger *bool `yaml:"swagger"` + GitRepo *bool `yaml:"git_repo"` +} + +// Load reads and merges ~/.goscaf.yaml (global) and ./.goscaf.yaml (local). +// Local values take precedence. Returns nil if neither file exists. +func Load() (*UserConfig, error) { + home, _ := os.UserHomeDir() + globalPath := "" + if home != "" { + globalPath = filepath.Join(home, ".goscaf.yaml") + } + return loadFrom(globalPath, ".goscaf.yaml") +} + +// loadFrom is the testable core: it reads from explicit paths rather than +// deriving them from the environment. +func loadFrom(globalPath, localPath string) (*UserConfig, error) { + global, err := readFile(globalPath) + if err != nil { + return nil, err + } + + local, err := readFile(localPath) + if err != nil { + return nil, err + } + + if global == nil && local == nil { + return nil, nil + } + + return merge(global, local), nil +} + +// readFile parses a single .goscaf.yaml file. Returns nil (not an error) when +// the file does not exist, so callers can treat absence as "no config". +func readFile(path string) (*UserConfig, error) { + if path == "" { + return nil, nil + } + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var cfg UserConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +// merge combines global and local, with local non-zero values taking precedence. +func merge(global, local *UserConfig) *UserConfig { + result := &UserConfig{} + + if global != nil { + *result = *global + } + + if local == nil { + return result + } + + if local.ModulePrefix != "" { + result.ModulePrefix = local.ModulePrefix + } + if local.GoVersion != "" { + result.GoVersion = local.GoVersion + } + if local.Framework != "" { + result.Framework = local.Framework + } + if local.Logger != "" { + result.Logger = local.Logger + } + if local.DB != "" { + result.DB = local.DB + } + if local.Viper != nil { + result.Viper = local.Viper + } + if local.Redis != nil { + result.Redis = local.Redis + } + if local.Kafka != nil { + result.Kafka = local.Kafka + } + if local.NATS != nil { + result.NATS = local.NATS + } + if local.Docker != nil { + result.Docker = local.Docker + } + if local.Makefile != nil { + result.Makefile = local.Makefile + } + if local.GitHub != nil { + result.GitHub = local.GitHub + } + if local.Lint != nil { + result.Lint = local.Lint + } + if local.Swagger != nil { + result.Swagger = local.Swagger + } + if local.GitRepo != nil { + result.GitRepo = local.GitRepo + } + + return result +} + +// BoolVal returns the dereferenced value of b, or fallback if b is nil. +func BoolVal(b *bool, fallback bool) bool { + if b == nil { + return fallback + } + return *b +} diff --git a/internal/userconfig/userconfig_test.go b/internal/userconfig/userconfig_test.go new file mode 100644 index 0000000..dfb74cf --- /dev/null +++ b/internal/userconfig/userconfig_test.go @@ -0,0 +1,236 @@ +package userconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// boolPtr is a test helper to get a pointer to a bool literal. +func boolPtr(v bool) *bool { return &v } + +// writeYAML writes content to a temp file and returns its path. +func writeYAML(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writeYAML: %v", err) + } + return path +} + +func TestLoadFromNoFiles(t *testing.T) { + dir := t.TempDir() + got, err := loadFrom( + filepath.Join(dir, "global.yaml"), + filepath.Join(dir, "local.yaml"), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Fatalf("expected nil when no files exist, got %+v", got) + } +} + +func TestLoadFromGlobalOnly(t *testing.T) { + dir := t.TempDir() + global := writeYAML(t, dir, "global.yaml", ` +module_prefix: github.com/global-org +framework: gin +logger: zap +viper: true +`) + + got, err := loadFrom(global, filepath.Join(dir, "local.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil config") + } + if got.ModulePrefix != "github.com/global-org" { + t.Errorf("ModulePrefix: got %q, want %q", got.ModulePrefix, "github.com/global-org") + } + if got.Framework != "gin" { + t.Errorf("Framework: got %q, want %q", got.Framework, "gin") + } + if got.Logger != "zap" { + t.Errorf("Logger: got %q, want %q", got.Logger, "zap") + } + if got.Viper == nil || !*got.Viper { + t.Errorf("Viper: expected true, got %v", got.Viper) + } +} + +func TestLoadFromLocalOnly(t *testing.T) { + dir := t.TempDir() + local := writeYAML(t, dir, "local.yaml", ` +framework: fiber +docker: false +`) + + got, err := loadFrom(filepath.Join(dir, "global.yaml"), local) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil config") + } + if got.Framework != "fiber" { + t.Errorf("Framework: got %q, want %q", got.Framework, "fiber") + } + if got.Docker == nil || *got.Docker { + t.Errorf("Docker: expected false pointer, got %v", got.Docker) + } +} + +func TestLocalOverridesGlobalString(t *testing.T) { + dir := t.TempDir() + global := writeYAML(t, dir, "global.yaml", ` +framework: gin +logger: slog +module_prefix: github.com/global-org +`) + local := writeYAML(t, dir, "local.yaml", ` +framework: fiber +module_prefix: github.com/local-org +`) + + got, err := loadFrom(global, local) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Framework != "fiber" { + t.Errorf("Framework: got %q, want fiber (local should win)", got.Framework) + } + if got.ModulePrefix != "github.com/local-org" { + t.Errorf("ModulePrefix: got %q, want github.com/local-org", got.ModulePrefix) + } + // logger not set in local — global value should survive + if got.Logger != "slog" { + t.Errorf("Logger: got %q, want slog (global should survive)", got.Logger) + } +} + +func TestLocalFalseBoolOverridesGlobalTrue(t *testing.T) { + dir := t.TempDir() + global := writeYAML(t, dir, "global.yaml", ` +docker: true +makefile: true +`) + local := writeYAML(t, dir, "local.yaml", ` +docker: false +`) + + got, err := loadFrom(global, local) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // local explicitly set docker to false — must win over global true + if got.Docker == nil || *got.Docker { + t.Errorf("Docker: expected false, got %v", got.Docker) + } + // makefile not touched by local — global true must survive + if got.Makefile == nil || !*got.Makefile { + t.Errorf("Makefile: expected true (from global), got %v", got.Makefile) + } +} + +func TestPartialLocalMerge(t *testing.T) { + dir := t.TempDir() + global := writeYAML(t, dir, "global.yaml", ` +module_prefix: github.com/global-org +go_version: "1.24.0" +framework: gin +logger: slog +viper: true +docker: true +makefile: true +github: true +lint: true +`) + local := writeYAML(t, dir, "local.yaml", ` +logger: zap +swagger: true +`) + + got, err := loadFrom(global, local) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // local overrides + if got.Logger != "zap" { + t.Errorf("Logger: got %q, want zap", got.Logger) + } + if got.Swagger == nil || !*got.Swagger { + t.Errorf("Swagger: expected true") + } + // global survives for untouched fields + if got.ModulePrefix != "github.com/global-org" { + t.Errorf("ModulePrefix: got %q, want github.com/global-org", got.ModulePrefix) + } + if got.Framework != "gin" { + t.Errorf("Framework: got %q, want gin", got.Framework) + } + if got.GoVersion != "1.24.0" { + t.Errorf("GoVersion: got %q, want 1.24.0", got.GoVersion) + } +} + +func TestInvalidYAMLReturnsError(t *testing.T) { + dir := t.TempDir() + bad := writeYAML(t, dir, "bad.yaml", ` +framework: [unclosed bracket +`) + + _, err := loadFrom(bad, filepath.Join(dir, "local.yaml")) + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } +} + +func TestBoolVal(t *testing.T) { + cases := []struct { + name string + b *bool + fallback bool + want bool + }{ + {"nil uses fallback true", nil, true, true}, + {"nil uses fallback false", nil, false, false}, + {"false pointer overrides true fallback", boolPtr(false), true, false}, + {"true pointer overrides false fallback", boolPtr(true), false, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := BoolVal(tc.b, tc.fallback) + if got != tc.want { + t.Errorf("BoolVal(%v, %v) = %v, want %v", tc.b, tc.fallback, got, tc.want) + } + }) + } +} + +func TestMergeNilGlobal(t *testing.T) { + local := &UserConfig{Framework: "echo", Docker: boolPtr(true)} + got := merge(nil, local) + if got.Framework != "echo" { + t.Errorf("Framework: got %q, want echo", got.Framework) + } + if got.Docker == nil || !*got.Docker { + t.Errorf("Docker: expected true") + } +} + +func TestMergeNilLocal(t *testing.T) { + global := &UserConfig{Framework: "chi", Logger: "zerolog"} + got := merge(global, nil) + if got.Framework != "chi" { + t.Errorf("Framework: got %q, want chi", got.Framework) + } + if got.Logger != "zerolog" { + t.Errorf("Logger: got %q, want zerolog", got.Logger) + } +}