Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/clierr/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ const (
// --- Debug stack resolver (gofasta debug stack) ---
CodeDebugStackParseFailed Code = "DEBUG_STACK_PARSE_FAILED"
CodeDebugSourceUnavailable Code = "DEBUG_SOURCE_UNAVAILABLE"

// --- Refactor (gofasta refactor feature-package) ---
//
// Ineligible — the project isn't in layered layout, so there's
// nothing to convert.
CodeRefactorIneligible Code = "REFACTOR_INELIGIBLE"
// Aborted — the migration ran the per-resource compile-gate and
// found a failure. The partial state is left for the user to
// inspect (or `git restore`).
CodeRefactorAborted Code = "REFACTOR_ABORTED"
// DirtyTree — the working tree has uncommitted changes; pass
// --force to proceed anyway.
CodeRefactorDirtyTree Code = "REFACTOR_DIRTY_TREE"
// ResourceNotFound — the named resource doesn't have a model file
// at the expected layered path.
CodeRefactorResourceNotFound Code = "REFACTOR_RESOURCE_NOT_FOUND"
)

// meta carries the remediation hint and docs URL for a code. Looked up
Expand Down
12 changes: 6 additions & 6 deletions internal/commands/commands_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ func TestRunExecute_UnknownSubcommand(t *testing.T) {
func TestRunNew_FakeSuccess(t *testing.T) {
chdirTemp(t)
withFakeExec(t, 0)
err := runNew("testapp", false, "postgres")
err := runNew("testapp", false, "postgres", "layered")
assert.NoError(t, err)
// The project dir should have been created
_, err = os.Stat(filepath.Join("testapp", "config.yaml"))
Expand All @@ -504,14 +504,14 @@ func TestRunNew_FakeSuccess(t *testing.T) {
func TestRunNew_FakeSuccess_GraphQL(t *testing.T) {
chdirTemp(t)
withFakeExec(t, 0)
err := runNew("gqlapp", true, "postgres")
err := runNew("gqlapp", true, "postgres", "layered")
assert.NoError(t, err)
}

func TestRunNew_GoModInitFails(t *testing.T) {
chdirTemp(t)
withFakeExec(t, 1)
err := runNew("failapp", false, "postgres")
err := runNew("failapp", false, "postgres", "layered")
assert.Error(t, err)
assert.Contains(t, err.Error(), "go mod init")
}
Expand All @@ -524,14 +524,14 @@ func TestRunNew_WarningBranches(t *testing.T) {
chdirTemp(t)
// mod init ok, mod edit -go ok, gofasta install ok, everything else fails
stagedFakeExec(t, 0, 0, 0, 1)
err := runNew("warnapp", false, "postgres")
err := runNew("warnapp", false, "postgres", "layered")
assert.NoError(t, err)
}

func TestRunNew_WarningBranches_GraphQL(t *testing.T) {
chdirTemp(t)
stagedFakeExec(t, 0, 0, 0, 1)
err := runNew("warnapp", true, "postgres")
err := runNew("warnapp", true, "postgres", "layered")
assert.NoError(t, err)
}

Expand All @@ -543,7 +543,7 @@ func TestRunNew_WarningBranches_GraphQL(t *testing.T) {
func TestRunNew_GofastaInstallFails(t *testing.T) {
chdirTemp(t)
stagedFakeExec(t, 0, 1) // go mod init ok, go get gofasta fails
err := runNew("failapp", false, "postgres")
err := runNew("failapp", false, "postgres", "layered")
assert.Error(t, err)
assert.Contains(t, err.Error(), "github.com/gofastadev/gofasta")
assert.Contains(t, err.Error(), "failed to install")
Expand Down
15 changes: 15 additions & 0 deletions internal/commands/configutil/configutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@
return driver
}

// ReadLayout reads the project.layout key from config.yaml. Returns
// "layered" or "feature" verbatim when set; returns "" when the key is
// missing so callers can fall back to filesystem detection without
// confusing an absent value with an explicit "layered" choice.
//
// The layered/feature dichotomy is the layout package's concern; this
// helper just surfaces the raw string. `gofasta new` writes this key at
// scaffold time so every project created with a layout-aware CLI
// version carries an authoritative value; projects scaffolded before
// the feature shipped get the empty-string return and fall back to
// filesystem detection in layout.Detect().
func ReadLayout() string {
return loadConfig().String("project.layout")

Check warning on line 146 in internal/commands/configutil/configutil.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/configutil/configutil.go#L145-L146

Added lines #L145 - L146 were not covered by tests
}

// BuildCacheEndpoint reads cache.* from config.yaml + env vars and
// returns the cache backend's host:port endpoint, plus an `enabled`
// flag indicating whether the app actually wants a network cache.
Expand Down
202 changes: 200 additions & 2 deletions internal/commands/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

"github.com/gofastadev/cli/internal/clierr"
"github.com/gofastadev/cli/internal/cliout"
"github.com/gofastadev/cli/internal/featurize"
"github.com/gofastadev/cli/internal/skeleton"
"github.com/gofastadev/cli/internal/termcolor"
"github.com/spf13/cobra"
Expand All @@ -26,6 +27,7 @@
ModulePath string `json:"module_path"`
GraphQL bool `json:"graphql"`
DBDriver string `json:"db_driver"`
Layout string `json:"layout"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
Expand All @@ -38,6 +40,7 @@
ModulePath string // Go module path: "github.com/myorg/myapp"
GraphQL bool // true when --graphql flag is passed
DBDriver string // "postgres" | "mysql" | "sqlite" | "sqlserver" | "clickhouse"
Layout string // "layered" | "feature"
}

// supportedDrivers is the canonical set of --driver values. The first
Expand All @@ -57,6 +60,22 @@
return false
}

// supportedLayouts is the canonical set of --layout values. The first
// entry is the default. Values match what configutil.ReadLayout returns
// and what layout.ParseKind accepts.
var supportedLayouts = []string{"layered", "feature"}

// isSupportedLayout reports whether v is one of the canonical layout
// strings. Used to validate the --layout flag before scaffolding.
func isSupportedLayout(v string) bool {
for _, l := range supportedLayouts {
if l == v {
return true
}
}
return false

Check warning on line 76 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L76

Added line #L76 was not covered by tests
}

// graphqlOnlyPaths lists skeleton paths that should be skipped for REST-only projects.
var graphqlOnlyPaths = []string{
"app/graphql/",
Expand Down Expand Up @@ -111,7 +130,14 @@
"--driver %q is not supported — valid values: %s",
driver, strings.Join(supportedDrivers, ", "))
}
return runNew(args[0], gql || gqlShort, driver)
layoutFlag, _ := cmd.Flags().GetString("layout")
layoutFlag = strings.ToLower(strings.TrimSpace(layoutFlag))
if !isSupportedLayout(layoutFlag) {
return clierr.Newf(clierr.CodeInvalidName,
"--layout %q is not supported — valid values: %s",
layoutFlag, strings.Join(supportedLayouts, ", "))

Check warning on line 138 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L136-L138

Added lines #L136 - L138 were not covered by tests
}
return runNew(args[0], gql || gqlShort, driver, layoutFlag)
},
}

Expand All @@ -121,6 +147,8 @@
newCmd.Flags().Bool("gql", false, "Shorthand for --graphql")
newCmd.Flags().String("driver", "postgres",
"Database driver: "+strings.Join(supportedDrivers, "|"))
newCmd.Flags().String("layout", "layered",
"Project layout: "+strings.Join(supportedLayouts, "|"))
}

// dotfileRenames maps embedded names to actual dotfile names.
Expand Down Expand Up @@ -166,11 +194,14 @@
var migrationsFSOverride fs.FS

//nolint:gocognit,gocyclo // linear scaffold pipeline; refactoring would obscure the flow.
func runNew(nameOrPath string, includeGraphQL bool, driver string) (resultErr error) {
func runNew(nameOrPath string, includeGraphQL bool, driver, layoutKind string) (resultErr error) {
projectDir, projectName, modulePath := resolveProjectPaths(nameOrPath)
if driver == "" {
driver = "postgres"
}
if layoutKind == "" {
layoutKind = "layered"

Check warning on line 203 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L203

Added line #L203 was not covered by tests
}

// In --json mode, redirect stdout to stderr for the duration of
// the scaffold so the dozens of decorative `termcolor.Print*` and
Expand All @@ -193,6 +224,7 @@
ModulePath: modulePath,
GraphQL: includeGraphQL,
DBDriver: driver,
Layout: layoutKind,
Success: resultErr == nil,
Error: errString(resultErr),
}, nil)
Expand All @@ -216,6 +248,7 @@
ModulePath: modulePath,
GraphQL: includeGraphQL,
DBDriver: driver,
Layout: layoutKind,
}

cliout.Header("🚀 Creating new gofasta project: %s", projectName)
Expand Down Expand Up @@ -322,6 +355,25 @@
output = content
}

// --layout=feature: route per-resource files through the
// featurize transformer and emit them at app/<snake>/ paths
// instead of the layered locations. Cross-cutting files
// (container.go, wire.go, index.routes.go) get a separate
// transform that adjusts imports + symbol references to
// reference the per-feature packages.
if data.Layout == "feature" {
rerouted, transformed, ferr := featurizeFile(outputPath, output, data.ModulePath, starterResources())
if ferr != nil {
return ferr

Check warning on line 367 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L365-L367

Added lines #L365 - L367 were not covered by tests
}
outputPath = rerouted
output = transformed

Check warning on line 370 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L369-L370

Added lines #L369 - L370 were not covered by tests
// Ensure new parent directory exists after potential reroute.
if dir := filepath.Dir(outputPath); dir != "." {
_ = os.MkdirAll(dir, 0o755)

Check warning on line 373 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L372-L373

Added lines #L372 - L373 were not covered by tests
}
}

cliout.Path(outputPath)
return os.WriteFile(outputPath, output, 0o644)
})
Expand Down Expand Up @@ -588,3 +640,149 @@
cmd.Stderr = os.Stderr
return cmd.Run()
}

// starterResources is the list of resources the User starter scaffold
// produces. Only one — "User" — today. When the scaffold ships a
// multi-resource starter, extend this slice.
func starterResources() []featurize.Resource {
return []featurize.Resource{
{Name: "User", Snake: "user", Plural: "Users"},

Check warning on line 649 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L647-L649

Added lines #L647 - L649 were not covered by tests
}
}

// featurizeFile re-routes and transforms one already-rendered template
// file when `--layout=feature` is active. Returns:
//
// rerouted — new output path (== outputPath when no reroute needed)
// transformed— possibly-rewritten content (== output when no transform)
// skip — true when the file shouldn't be written at all (the
// layered template is dead in feature mode and has no
// feature counterpart, e.g. layered-only orphan dirs).
//
// Three categories of files:
//
// 1. Per-resource: app/models/<s>.model.go, app/services/<s>.service.go,
// etc. — re-routed to app/<s>/* and content collapsed via
// featurize.TransformPerResource.
//
// 2. Cross-cutting per-layout: app/di/container.go, app/di/wire.go,
// app/rest/routes/index.routes.go — stay at their paths, but
// content rewritten to reference per-feature packages.
//
// 3. Shared infra that exists in `app/services/` but isn't a resource
// file: `password_generator.go` — moved into the feature package
// it serves (currently `app/user/`).
//
// Files that match none of these pass through unchanged (they're shared
// infra that lives in the same place in both layouts).
//
//nolint:gocognit,gocyclo // dispatch over 5 distinct file categories — splitting into helpers would just move the conditional tree without reducing branches.
func featurizeFile(outputPath string, content []byte, mod string, resources []featurize.Resource) (rerouted string, transformed []byte, err error) {

Check warning on line 680 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L680

Added line #L680 was not covered by tests
// Category 1: per-resource reroute + featurize.
for _, r := range resources {
for _, pair := range featurize.PerResourceMapping(r.Snake) {
if outputPath == pair.Layered {
out, terr := featurize.TransformPerResource(content, featurize.Options{
ModulePath: mod,
Resource: r,
})
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 690 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L682-L690

Added lines #L682 - L690 were not covered by tests
}
return pair.Feature, out, nil

Check warning on line 692 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L692

Added line #L692 was not covered by tests
}
}
}

// Category 3: password_generator.go follows the user feature (only
// consumer today). Move into app/user/ with the package rewritten.
if outputPath == "app/services/password_generator.go" {
out, terr := featurize.TransformPerResource(content, featurize.Options{
ModulePath: mod,
Resource: featurize.Resource{Name: "User", Snake: "user", Plural: "Users"},
})
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 705 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L699-L705

Added lines #L699 - L705 were not covered by tests
}
return "app/user/password_generator.go", out, nil

Check warning on line 707 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L707

Added line #L707 was not covered by tests
}

// Category 2: cross-cutting per-layout files.
switch outputPath {
case "app/di/container.go":
out, terr := featurize.TransformContainer(content, mod, resources)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 715 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L711-L715

Added lines #L711 - L715 were not covered by tests
}
return outputPath, out, nil
case "app/di/wire.go":
out, terr := featurize.TransformWire(content, mod, resources)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 721 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L717-L721

Added lines #L717 - L721 were not covered by tests
}
return outputPath, out, nil
case "app/rest/routes/index.routes.go":
out, terr := featurize.TransformIndexRoutes(content, mod, resources)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 727 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L723-L727

Added lines #L723 - L727 were not covered by tests
}
return outputPath, out, nil
case "app/di/providers/core.go":
out, terr := featurize.TransformCoreProviders(content, mod, resources)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 733 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L729-L733

Added lines #L729 - L733 were not covered by tests
}
return outputPath, out, nil

Check warning on line 735 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L735

Added line #L735 was not covered by tests
}

// Mock files in testutil/mocks/ — reroute the import block to
// the per-feature package. Match by filename suffix so future
// per-feature mocks (Order, Product, etc.) get the same treatment.
if strings.HasPrefix(outputPath, "testutil/mocks/") &&
(strings.HasSuffix(outputPath, "_repository_mock.go") ||
strings.HasSuffix(outputPath, "_service_mock.go")) {
base := filepath.Base(outputPath)

Check warning on line 744 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L741-L744

Added lines #L741 - L744 were not covered by tests
// extract <snake> from "<snake>_repository_mock.go" / "<snake>_service_mock.go"
snake := strings.TrimSuffix(base, "_repository_mock.go")
snake = strings.TrimSuffix(snake, "_service_mock.go")
for _, r := range resources {
if r.Snake == snake {
out, terr := featurize.TransformMock(content, mod, r)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 752 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L746-L752

Added lines #L746 - L752 were not covered by tests
}
return outputPath, out, nil

Check warning on line 754 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L754

Added line #L754 was not covered by tests
}
}
}

// Shared relocations — files that just move path with no source
// rewrites (e.g. app/dtos/aliases.go → app/shared/dtos/aliases.go).
for _, pair := range featurize.SharedRelocations() {
if outputPath == pair.Layered {
return pair.Feature, content, nil

Check warning on line 763 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L761-L763

Added lines #L761 - L763 were not covered by tests
}
}

// Shared infra files that stay in their layered location but
// reference the relocated shared dtos package. Only their import
// path needs flipping — no other source rewrite.
switch outputPath {

Check warning on line 770 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L770

Added line #L770 was not covered by tests
case "app/validators/app_validator.go",
"app/rest/controllers/validator.go",
"app/graphql/resolvers/user.resolvers.go":
out, terr := featurize.FixDtosImportPath(content, mod)
if terr != nil {
return outputPath, content, fmt.Errorf("featurize %s: %w", outputPath, terr)

Check warning on line 776 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L773-L776

Added lines #L773 - L776 were not covered by tests
}
return outputPath, out, nil

Check warning on line 778 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L778

Added line #L778 was not covered by tests
}

// Per-resource validator files stay in app/validators/ as-is.
// The user.validators.go template only references gorm + validator
// + slog (no per-resource types), so no transformation needed.
// If a future per-resource validator references the feature
// package, add a transformer case here.

return outputPath, content, nil

Check warning on line 787 in internal/commands/new.go

View check run for this annotation

Codecov / codecov/patch

internal/commands/new.go#L787

Added line #L787 was not covered by tests
}
Loading
Loading