diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da15611..c8bb12d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,10 @@ jobs: restore-keys: | ${{ runner.os }}-go- - - name: Vet - run: go vet ./... - - - name: Fix (diff mode) - continue-on-error: true - run: go fix -diff ./... + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12 - name: Test run: go test -race -count=1 ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..12d5681 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,16 @@ +version: "2" +linters: + default: standard + enable: + - wsl_v5 + - whitespace + - modernize + # - wrapcheck + settings: + errcheck: + # Output to stdout/stderr is best-effort: the output Writer interface has + # no error channel and reporting a write failure would itself need a + # working stream. See internal/util/output/writer.go. + exclude-functions: + - fmt.Fprintln + - charm.land/lipgloss/v2.Fprintln diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index ec90222..121aea8 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -1,17 +1,30 @@ # https://taskfile.dev version: "3" +includes: + docs: ./taskfiles/Taskfile.docs.yml + md: ./taskfiles/Taskfile.md.yml + lint: + taskfile: ./taskfiles/Taskfile.lint.yml + flatten: true + silent: true # env: # BUILDKIT_PROGRESS: plain tasks: + dc: desc: Run a docker compose command internal: true + vars: + FIXUID: + sh: echo ${FIXUID:-$(id -u)} + FIXGID: + sh: echo ${FIXGID:-$(id -g)} cmds: - - docker compose {{.SUB_CMD}} {{.CLI_ARGS}} + - FIXUID={{ .FIXUID }} FIXGID={{ .FIXGID }} docker compose {{ .SUB_CMD }} {{ .CLI_ARGS }} dc:build: desc: Run docker compose build @@ -23,12 +36,21 @@ tasks: dc:run:*: desc: Run a one-off docker compose command vars: - SERVICE: "{{index .MATCH 0}}" + SERVICE: '{{ index .MATCH 0 }}' + RUN_FLAGS: '{{ .RUN_FLAGS | default "" }}' + USER_FLAG: '{{ (ne (.NON_ROOT | default "true") "false") | ternary " --user $(id -u):$(id -g)" "" }}' + COMPOSE_SERVICES: + sh: COMPOSE_PROFILES="*" docker compose config --services cmds: - task: dc vars: - SUB_CMD: "run --rm {{.SERVICE}} {{.SUB_CMD}}" - CLI_ARGS: "{{.CLI_ARGS}}" + SUB_CMD: 'run --rm {{ .RUN_FLAGS }} {{ .USER_FLAG }} {{ .SERVICE }} {{ .SUB_CMD }}' + CLI_ARGS: '{{ .CLI_ARGS }}' + requires: + vars: + - name: SERVICE + enum: + ref: .COMPOSE_SERVICES | splitLines | compact dc:shell: desc: Shell into a docker compose service @@ -53,27 +75,6 @@ tasks: --set "go-binary.args.SPECS_VERSION={{.SPECS_VERSION}}" go-binary - vet: - desc: Run go vet - cmds: - - task: dc:run:go-builder - vars: - SUB_CMD: go vet - - fix: - desc: Run go fix - cmds: - - task: dc:run:go-builder - vars: - SUB_CMD: "go fix ./..." - - fix:diff: - desc: Run go fix in diff mode - cmds: - - task: dc:run:go-builder - vars: - SUB_CMD: "go fix -diff ./..." - test: desc: Run go tests cmds: @@ -88,60 +89,6 @@ tasks: vars: SUB_CMD: release --snapshot --clean - docs:build: - desc: Build the static documentation site into docs/public/ - cmds: - - task: dc:run:hugo - vars: - SUB_CMD: --minify - - docs:serve: - desc: Start Hugo development server with live reload (http://localhost:1313) - cmds: - - task: dc - vars: - SUB_CMD: "run --rm --service-ports hugo server --bind 0.0.0.0" - - docs:preview: - desc: Build and serve the static docs site locally (http://localhost:8080) - cmds: - - task: dc:run:hugo - vars: - SUB_CMD: --minify --baseURL http://localhost:8080 - - task: dc - vars: - SUB_CMD: "run --rm --service-ports docs-preview" - - docs:mod:tidy: - desc: Tidy Hugo module dependencies and regenerate go.sum - cmds: - - task: dc:run:hugo - vars: - SUB_CMD: mod tidy - - md:check: - desc: Check the style of Markdown files with markdownlint - cmds: - - task: dc:run:node - vars: - SUB_CMD: "npx --yes markdownlint-cli2 {{.SUB_CMD}}" - - md:fix: - desc: Fix Markdown style — align tables, then apply autofixable lint rules - cmds: - - task: md:fix-tables - - task: md:check - vars: - SUB_CMD: --fix - - md:fix-tables: - desc: Align table spacing in Markdown files - cmds: - # dotglob so hidden directories (e.g. .github) are covered too. - - task: dc:run:node - vars: - SUB_CMD: bash -c "shopt -s globstar dotglob && npx --yes markdown-table-formatter **/*.md" - cleanup: desc: Cleanup workspace summary: | diff --git a/compose.yml b/compose.yml index 4bbef83..c615152 100644 --- a/compose.yml +++ b/compose.yml @@ -1,50 +1,91 @@ volumes: - go-mod: - go-build-cache: - hugo-mod: + cache: services: + volume-init: + image: busybox:1.37.0-musl + user: root + volumes: + - cache:/cache + command: + - sh + - -c + - | + mkdir -p /cache/go-build + mkdir -p /cache/go-mod + mkdir -p /cache/golangci-lint + chown -R ${FIXUID:-1000}:${FIXGID:-1000} /cache + go-builder: profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} build: context: . dockerfile: Dockerfile target: builder-download + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + depends_on: + volume-init: + condition: service_completed_successfully volumes: - .:/src - - go-mod:/go/pkg/mod - - go-build-cache:/root/.cache/go-build + - cache:/cache go-binary: profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} build: context: . dockerfile: Dockerfile target: export + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - cache:/cache goreleaser: profiles: ["build"] + user: ${FIXUID:-1000}:${FIXGID:-1000} # Latest version: https://hub.docker.com/r/goreleaser/goreleaser/tags image: goreleaser/goreleaser:v2.15.1 working_dir: /src + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + depends_on: + volume-init: + condition: service_completed_successfully volumes: - .:/src - - go-mod:/go/pkg/mod - - go-build-cache:/root/.cache/go-build + - cache:/cache hugo: profiles: ["docs"] + user: ${FIXUID:-1000}:${FIXGID:-1000} # Latest version: https://hub.docker.com/r/hugomods/hugo/tags image: hugomods/hugo:debian-go-git-non-root-0.161.1 working_dir: /src/docs + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod volumes: - .:/src - - hugo-mod:/root/go/pkg/mod + - cache:/cache + depends_on: + volume-init: + condition: service_completed_successfully ports: - "1313:1313" docs-preview: profiles: ["docs"] + user: ${FIXUID:-1000}:${FIXGID:-1000} image: nginx:alpine volumes: - ./docs/public:/usr/share/nginx/html:ro @@ -59,3 +100,20 @@ services: working_dir: /src volumes: - .:/src + + golangci-lint: + profiles: ["lint"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/r/golangci/golangci-lint/tags + image: golangci/golangci-lint:v2.12.2-alpine + working_dir: /src + environment: + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + GOLANGCI_LINT_CACHE: /cache/golangci-lint + depends_on: + volume-init: + condition: service_completed_successfully + volumes: + - .:/src + - cache:/cache diff --git a/docs/content/docs/architecture/template-engine.md b/docs/content/docs/architecture/template-engine.md index 937ff34..224c4d6 100644 --- a/docs/content/docs/architecture/template-engine.md +++ b/docs/content/docs/architecture/template-engine.md @@ -297,13 +297,13 @@ All of Go's standard `text/template` built-ins are available, plus: ### Custom Functions (`internal/template/specsregistry.go`) -| Function | Signature | Description | -|------------------|-------------------------------------------------------------------|---------------------------------| -| `hostname` | `() string` | System hostname | -| `username` | `() string` | Current OS username | -| `toBinary` | `(n int) string` | Format integer as binary string | -| `formatFilesize` | `(bytes float64) string` | Human-readable size (KB/MB/GB…) | -| `password` | `(length, digits, symbols int, noUpper, allowRepeat bool) string` | Secure random password | +| Function | Signature | Description | +|------------------|-------------------------------------------------------------------|-----------------------------------------| +| `hostname` | `() string` | System hostname | +| `username` | `() string` | Current OS username (env/UID fallbacks) | +| `toBinary` | `(n int) string` | Format integer as binary string | +| `formatFilesize` | `(bytes float64) string` | Human-readable size (KB/MB/GB…) | +| `password` | `(length, digits, symbols int, noUpper, allowRepeat bool) string` | Secure random password | ### Sprout Functions diff --git a/docs/content/docs/template-functions.md b/docs/content/docs/template-functions.md index caad603..09f28f6 100644 --- a/docs/content/docs/template-functions.md +++ b/docs/content/docs/template-functions.md @@ -7,13 +7,13 @@ Templates have access to 200+ functions provided by [Sprout](https://github.com/ ## Specs functions -| Function | Signature | Description | -|------------------|---------------------------------------------------------------------------|--------------------------------------------| -| `hostname` | `hostname` → `string` | System hostname | -| `username` | `username` → `string` | Current OS username | -| `toBinary` | `toBinary ` → `string` | Integer to binary string | -| `formatFilesize` | `formatFilesize ` → `string` | Human-readable file size (e.g. `"1.0 MB"`) | -| `password` | `password ` → `string` | Generate a secure random password | +| Function | Signature | Description | +|------------------|---------------------------------------------------------------------------|------------------------------------------------------------------------------------------| +| `hostname` | `hostname` → `string` | System hostname | +| `username` | `username` → `string` | Current OS username (falls back to `$USER`/`$LOGNAME`/`$USERNAME`, then the numeric UID) | +| `toBinary` | `toBinary ` → `string` | Integer to binary string | +| `formatFilesize` | `formatFilesize ` → `string` | Human-readable file size (e.g. `"1.0 MB"`) | +| `password` | `password ` → `string` | Generate a secure random password | ```text Default registry: {{ hostname }}.azurecr.io diff --git a/internal/cmd/metadata_test.go b/internal/cmd/metadata_test.go index cd4313c..6f4477c 100644 --- a/internal/cmd/metadata_test.go +++ b/internal/cmd/metadata_test.go @@ -21,13 +21,16 @@ func TestWriteMetadata_PreservesSuppliedCreated(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata: %v", err) } + if got == nil { t.Fatal("LoadMetadata returned nil metadata") } - if !got.Created.Time.Equal(want) { + + if !got.Created.Equal(want) { t.Errorf("Created = %s, want %s", got.Created.Time, want) } - if !got.Updated.Time.Equal(want) { + + if !got.Updated.Equal(want) { t.Errorf("Updated = %s, want %s", got.Updated.Time, want) } } @@ -48,6 +51,7 @@ func TestWriteMetadata_UpgradeRoundTripPreservesCreated(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata: %v", err) } + if meta == nil { t.Fatal("LoadMetadata returned nil metadata") } @@ -64,18 +68,23 @@ func TestWriteMetadata_UpgradeRoundTripPreservesCreated(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata after upgrade: %v", err) } + if upgraded == nil { t.Fatal("LoadMetadata returned nil metadata after upgrade") } - if !upgraded.Created.Time.Equal(original) { + + if !upgraded.Created.Equal(original) { t.Errorf("Created after upgrade = %s, want %s", upgraded.Created.Time, original) } - if !upgraded.Updated.Time.Equal(upgradedAt) { + + if !upgraded.Updated.Equal(upgradedAt) { t.Errorf("Updated after upgrade = %s, want %s", upgraded.Updated.Time, upgradedAt) } + if upgraded.Commit != "new-sha" { t.Errorf("Commit after upgrade = %q, want %q", upgraded.Commit, "new-sha") } + if upgraded.Version != "v1.1.0" { t.Errorf("Version after upgrade = %q, want %q", upgraded.Version, "v1.1.0") } diff --git a/internal/cmd/reset_registry.go b/internal/cmd/reset_registry.go index 882a381..6ab77b8 100644 --- a/internal/cmd/reset_registry.go +++ b/internal/cmd/reset_registry.go @@ -3,8 +3,8 @@ package cmd import ( "os" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" + "github.com/spf13/cobra" ) func newResetRegistryCmd(app *App) *cobra.Command { @@ -18,10 +18,13 @@ func newResetRegistryCmd(app *App) *cobra.Command { if err := os.RemoveAll(dir); err != nil { return err } + if err := os.MkdirAll(dir, 0755); err != nil { return err } + app.Output.Info("registry reset at %s", dir) + return nil }, } diff --git a/internal/cmd/reset_registry_test.go b/internal/cmd/reset_registry_test.go index 9d6b7d9..346e85b 100644 --- a/internal/cmd/reset_registry_test.go +++ b/internal/cmd/reset_registry_test.go @@ -34,10 +34,12 @@ func TestResetRegistry_WipesAndRecreates(t *testing.T) { func TestResetRegistry_HiddenFromHelp(t *testing.T) { app := NewApp() root := newRootCmd(app) + cmd, _, err := root.Find([]string{"reset-registry"}) if err != nil || cmd == nil || cmd.Name() != "reset-registry" { t.Fatal("reset-registry command not found") } + if !cmd.Hidden { t.Error("expected reset-registry to be hidden") } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6595ec1..1848132 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -55,6 +55,7 @@ Use "specs --help" for more information about a command.`, // --debug and --output=json are set. if debug { app.level.Set(slog.LevelDebug) + if outputFlag == "json" { slog.SetDefault(slog.New(slog.NewJSONHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{Level: app.level}))) } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 6e9fea5..30226ed 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -25,6 +25,7 @@ func executeCmdWithApp(args ...string) (*App, string, error) { cmd.SetErr(buf) cmd.SetArgs(args) err := cmd.Execute() + return app, buf.String(), err } @@ -33,6 +34,7 @@ func TestHelp_ExitsZero(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, "specs") { t.Errorf("expected output to contain 'specs', got: %q", out) } @@ -50,6 +52,7 @@ func TestHookEnvPrefix_Default(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if app.HookEnvPrefix != specs.HookEnvPrefix { t.Errorf("HookEnvPrefix = %q, want %q", app.HookEnvPrefix, specs.HookEnvPrefix) } @@ -60,6 +63,7 @@ func TestHookEnvPrefix_Disabled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if app.HookEnvPrefix != "" { t.Errorf("HookEnvPrefix = %q, want empty string", app.HookEnvPrefix) } diff --git a/internal/cmd/template_delete.go b/internal/cmd/template_delete.go index fabafef..ec0f305 100644 --- a/internal/cmd/template_delete.go +++ b/internal/cmd/template_delete.go @@ -4,9 +4,9 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" "github.com/specsnl/specs-cli/internal/util/validate" + "github.com/spf13/cobra" ) func newTemplateDeleteCmd(app *App) *cobra.Command { @@ -24,15 +24,19 @@ func newTemplateDeleteCmd(app *App) *cobra.Command { if err := validate.Name(name); err != nil { return err } + path := specs.TemplatePath(name) if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("%w: %s", specs.ErrTemplateNotFound, name) } + if err := os.RemoveAll(path); err != nil { return err } + app.Output.Info("template %q deleted", name) } + return nil }, } diff --git a/internal/cmd/template_delete_test.go b/internal/cmd/template_delete_test.go index e9fe0ef..f3e1886 100644 --- a/internal/cmd/template_delete_test.go +++ b/internal/cmd/template_delete_test.go @@ -28,13 +28,16 @@ func TestDelete_Aliases(t *testing.T) { for _, alias := range []string{"rm", "remove", "del"} { t.Run(alias, func(t *testing.T) { withTempRegistry(t) + src := makeFakeTemplate(t) if _, err := executeCmd("template", "save", src, "my-tpl"); err != nil { t.Fatal(err) } + if _, err := executeCmd("template", alias, "my-tpl"); err != nil { t.Fatalf("template %s: %v", alias, err) } + if _, err := os.Stat(specs.TemplatePath("my-tpl")); !os.IsNotExist(err) { t.Errorf("expected my-tpl to be deleted via %q", alias) } @@ -58,6 +61,7 @@ func TestDelete_MultipleArgs(t *testing.T) { if _, err := executeCmd("template", "save", src, "tpl-a"); err != nil { t.Fatal(err) } + if _, err := executeCmd("template", "save", src, "tpl-b"); err != nil { t.Fatal(err) } diff --git a/internal/cmd/template_download.go b/internal/cmd/template_download.go index 066b2a7..5a61425 100644 --- a/internal/cmd/template_download.go +++ b/internal/cmd/template_download.go @@ -5,12 +5,12 @@ import ( "os" "time" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/host" "github.com/specsnl/specs-cli/internal/specs" pkgtemplate "github.com/specsnl/specs-cli/internal/template" pkggit "github.com/specsnl/specs-cli/internal/util/git" "github.com/specsnl/specs-cli/internal/util/validate" + "github.com/spf13/cobra" ) func newTemplateDownloadCmd(app *App) *cobra.Command { @@ -27,6 +27,7 @@ func newTemplateDownloadCmd(app *App) *cobra.Command { if err := validate.Name(name); err != nil { return err } + if err := specs.EnsureRegistry(); err != nil { return err } @@ -35,6 +36,7 @@ func newTemplateDownloadCmd(app *App) *cobra.Command { if err != nil { return err } + if src.IsLocal() { return specs.ErrLocalSource } @@ -43,6 +45,7 @@ func newTemplateDownloadCmd(app *App) *cobra.Command { if _, err := os.Stat(dest); err == nil && !force { return fmt.Errorf("%w: %s — use --force to overwrite", specs.ErrTemplateAlreadyExists, name) } + if err := os.RemoveAll(dest); err != nil { return err } @@ -73,12 +76,14 @@ func newTemplateDownloadCmd(app *App) *cobra.Command { // git layer logs describe result or failure desc, _ := pkggit.Describe(dest) + now := time.Now().UTC() if err := pkgtemplate.SaveMetadata(dest, name, src.CloneURL, branch, desc.Commit, desc.Version, now, now); err != nil { return err } app.Output.Info("template %q downloaded", name) + return nil }, } diff --git a/internal/cmd/template_list.go b/internal/cmd/template_list.go index 32a7951..e385f63 100644 --- a/internal/cmd/template_list.go +++ b/internal/cmd/template_list.go @@ -8,10 +8,10 @@ import ( "sync" "time" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" pkgtemplate "github.com/specsnl/specs-cli/internal/template" pkggit "github.com/specsnl/specs-cli/internal/util/git" + "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) @@ -31,9 +31,11 @@ func isTrackable(meta *pkgtemplate.Metadata) bool { if meta == nil || meta.Repository == "" { return false } + if isLocalRepo(meta.Repository) { return meta.Commit != "" } + return meta.Branch != "" } @@ -60,12 +62,15 @@ func newTemplateListCmd(app *App) *cobra.Command { } var tmplEntries []templateEntry + for _, e := range entries { if !e.IsDir() { continue } + name := e.Name() root := specs.TemplatePath(name) + meta, err := pkgtemplate.LoadMetadata(root) if err != nil { slog.Debug("failed to parse template metadata", "template", name, "error", err) @@ -80,6 +85,7 @@ func newTemplateListCmd(app *App) *cobra.Command { } } } + var status *pkgtemplate.TemplateStatus if meta != nil && meta.Repository != "" && meta.Branch != "" { status, err = pkgtemplate.LoadStatus(root) @@ -87,13 +93,16 @@ func newTemplateListCmd(app *App) *cobra.Command { slog.Debug("failed to load template status", "template", name, "error", err) } } + tmplEntries = append(tmplEntries, templateEntry{name: name, meta: meta, status: status}) } // Refresh stale statuses in parallel, capped at 8 concurrent checks. // A top-level timeout guards the whole phase; each check also has its own timeout. const maxConcurrency = 8 + var mu sync.Mutex + networkErrorSeen := false refreshCtx, cancelRefresh := context.WithTimeout(cmd.Context(), app.refreshTimeout) @@ -111,12 +120,15 @@ func newTemplateListCmd(app *App) *cobra.Command { if entry.status != nil && !entry.status.NeedsRefresh(Version) { continue } + i, name := i, entry.name repo, branch := entry.meta.Repository, entry.meta.Branch commit, version := entry.meta.Commit, entry.meta.Version local := isLocalRepo(repo) + eg.Go(func() error { root := specs.TemplatePath(name) + var result pkggit.RemoteCheckResult if local { // Local templates compare against the source path on disk, not a @@ -128,6 +140,7 @@ func newTemplateListCmd(app *App) *cobra.Command { // git layer logs the check-remote start/result result = app.checkRemoteFn(checkCtx, root, repo, branch) } + newStatus := &pkgtemplate.TemplateStatus{ CheckedAt: pkgtemplate.JSONTime{Time: time.Now().UTC()}, IsUpToDate: result.IsUpToDate, @@ -138,18 +151,23 @@ func newTemplateListCmd(app *App) *cobra.Command { if err := pkgtemplate.SaveStatus(root, newStatus); err != nil { slog.Debug("failed to save template status", "template", name, "error", err) } + mu.Lock() tmplEntries[i].status = newStatus + if result.ErrorKind == pkggit.CheckErrorNetwork { networkErrorSeen = true } mu.Unlock() + return nil }) } + _ = eg.Wait() headers := []string{"Name", "Repository", "Version", "Status", "Created", "Updated"} + var rows [][]string for _, entry := range tmplEntries { @@ -157,11 +175,13 @@ func newTemplateListCmd(app *App) *cobra.Command { if entry.meta != nil { repo = entry.meta.Repository created = entry.meta.Created.String() + updated = entry.meta.Updated.String() if entry.meta.Version != "" { version = entry.meta.Version } } + statusStr := statusLabel(entry.status, isTrackable(entry.meta)) rows = append(rows, []string{entry.name, repo, version, statusStr, created, updated}) } @@ -191,9 +211,11 @@ func statusLabel(status *pkgtemplate.TemplateStatus, tracked bool) string { if !tracked { return "-" } + if status == nil { return "unknown" } + switch status.ErrorKind { case pkggit.CheckErrorNetwork: return "unknown (offline?)" @@ -206,11 +228,14 @@ func statusLabel(status *pkgtemplate.TemplateStatus, tracked bool) string { case pkggit.CheckErrorUnknown: return "check failed" } + if status.IsUpToDate { return "up-to-date" } + if status.LatestVersion != "" { return "update: " + status.LatestVersion } + return "update available" } diff --git a/internal/cmd/template_list_test.go b/internal/cmd/template_list_test.go index 3bf6415..ec75b0d 100644 --- a/internal/cmd/template_list_test.go +++ b/internal/cmd/template_list_test.go @@ -33,6 +33,7 @@ func executeCmdWithCheckFn( cmd.SetErr(buf) cmd.SetArgs(args) err := cmd.Execute() + return buf.String(), err } @@ -64,6 +65,7 @@ func TestList_ShowsTemplate(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "my-tpl") { t.Errorf("expected output to contain 'my-tpl', got: %q", out) } @@ -79,9 +81,11 @@ func TestList_JSONOutput(t *testing.T) { if err != nil { t.Fatalf("template list --output=json: %v", err) } + if !strings.Contains(out, `"Name"`) { t.Errorf("expected JSON with Name key, got: %q", out) } + if !strings.Contains(out, "my-tpl") { t.Errorf("expected JSON to contain 'my-tpl', got: %q", out) } @@ -89,12 +93,15 @@ func TestList_JSONOutput(t *testing.T) { func TestList_UpdatedColumn(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "local-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + created := time.Now().Add(-30 * 24 * time.Hour).UTC() updated := time.Now().Add(-2 * 24 * time.Hour).UTC() + if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "/local/path", "", "", "", created, updated); err != nil { t.Fatal(err) } @@ -104,6 +111,7 @@ func TestList_UpdatedColumn(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "Updated") { t.Errorf("expected table to contain 'Updated' header, got: %q", out) } @@ -113,6 +121,7 @@ func TestList_UpdatedColumn(t *testing.T) { if err != nil { t.Fatalf("template list --output=json: %v", err) } + if !strings.Contains(jsonOut, `"Updated"`) { t.Errorf("expected JSON to contain 'Updated' key, got: %q", jsonOut) } @@ -120,6 +129,7 @@ func TestList_UpdatedColumn(t *testing.T) { func TestList_StatusColumn_LocalNoStatus(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "local-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) @@ -134,6 +144,7 @@ func TestList_StatusColumn_LocalNoStatus(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, `"Status":"-"`) { t.Errorf("expected '-' status for local template, got: %q", out) } @@ -141,6 +152,7 @@ func TestList_StatusColumn_LocalNoStatus(t *testing.T) { func TestList_StatusColumn_EmptyBranchWithGitRepo(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "remote-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) @@ -151,13 +163,17 @@ func TestList_StatusColumn_EmptyBranchWithGitRepo(t *testing.T) { if err != nil { t.Fatalf("PlainInit: %v", err) } + wt, _ := repo.Worktree() + if err := os.WriteFile(filepath.Join(tmplDir, "f.txt"), []byte("x"), 0644); err != nil { t.Fatal(err) } + if _, err := wt.Add("f.txt"); err != nil { t.Fatal(err) } + sig := &object.Signature{Name: "t", Email: "t@t.com", When: time.Now()} if _, err := wt.Commit("init", &gogit.CommitOptions{Author: sig}); err != nil { t.Fatal(err) @@ -182,6 +198,7 @@ func TestList_StatusColumn_EmptyBranchWithGitRepo(t *testing.T) { if strings.Contains(out, `"Status":"-"`) { t.Errorf("expected non-dash status when branch resolved from git HEAD, got: %q", out) } + if !strings.Contains(out, "up-to-date") { t.Errorf("expected 'up-to-date' in output, got: %q", out) } @@ -189,10 +206,12 @@ func TestList_StatusColumn_EmptyBranchWithGitRepo(t *testing.T) { func TestList_StatusColumn_FreshUpToDate(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "remote-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "remote-tpl", "https://example.com/repo", "main", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -210,6 +229,7 @@ func TestList_StatusColumn_FreshUpToDate(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "up-to-date") { t.Errorf("expected 'up-to-date' in output, got: %q", out) } @@ -245,10 +265,12 @@ func TestStatusLabel(t *testing.T) { func TestList_StatusColumn_NetworkWarn(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "remote-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "remote-tpl", "https://example.com/repo", "main", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -273,18 +295,22 @@ func TestList_StatusColumn_NetworkWarn(t *testing.T) { func TestList_ConcurrencyCap(t *testing.T) { const numTemplates = 20 + registryDir := withTempRegistry(t) // Create numTemplates templates with stale statuses so all trigger a refresh. for i := range numTemplates { name := fmt.Sprintf("tpl-%02d", i) + tmplDir := filepath.Join(registryDir, name) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, name, "https://example.com/repo", "main", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } + stale := &pkgtemplate.TemplateStatus{ CheckedAt: pkgtemplate.JSONTime{Time: time.Now().Add(-25 * time.Hour)}, ErrorKind: pkggit.CheckErrorNetwork, @@ -302,6 +328,7 @@ func TestList_ConcurrencyCap(t *testing.T) { fake := func(ctx context.Context, dir, url, branch string) pkggit.RemoteCheckResult { mu.Lock() + current++ if current > peak { peak = current @@ -312,9 +339,11 @@ func TestList_ConcurrencyCap(t *testing.T) { case <-time.After(20 * time.Millisecond): case <-ctx.Done(): } + mu.Lock() current-- mu.Unlock() + return pkggit.RemoteCheckResult{IsUpToDate: true} } @@ -329,13 +358,16 @@ func TestList_ConcurrencyCap(t *testing.T) { func TestList_PerCheckTimeout(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "slow-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "slow-tpl", "https://example.com/repo", "main", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } + stale := &pkgtemplate.TemplateStatus{ CheckedAt: pkgtemplate.JSONTime{Time: time.Now().Add(-25 * time.Hour)}, ErrorKind: pkggit.CheckErrorNetwork, @@ -345,10 +377,12 @@ func TestList_PerCheckTimeout(t *testing.T) { } var called atomic.Bool + fake := func(ctx context.Context, dir, url, branch string) pkggit.RemoteCheckResult { called.Store(true) // Block until the per-check context times out. <-ctx.Done() + return pkggit.RemoteCheckResult{ErrorKind: pkggit.CheckErrorNetwork} } @@ -359,6 +393,7 @@ func TestList_PerCheckTimeout(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !called.Load() { t.Fatal("expected fake checkRemoteFn to be called") } diff --git a/internal/cmd/template_local_test.go b/internal/cmd/template_local_test.go index be1d540..112aafa 100644 --- a/internal/cmd/template_local_test.go +++ b/internal/cmd/template_local_test.go @@ -20,17 +20,22 @@ var localSig = &object.Signature{Name: "t", Email: "t@t.com", When: time.Date(20 func localCommit(t *testing.T, repo *gogit.Repository, dir, label string) plumbing.Hash { t.Helper() + wt, _ := repo.Worktree() + if err := os.WriteFile(filepath.Join(dir, label+".txt"), []byte(label), 0644); err != nil { t.Fatal(err) } + if _, err := wt.Add(label + ".txt"); err != nil { t.Fatal(err) } + h, err := wt.Commit(label, &gogit.CommitOptions{Author: localSig}) if err != nil { t.Fatal(err) } + return h } @@ -39,14 +44,17 @@ func localCommit(t *testing.T, repo *gogit.Repository, dir, label string) plumbi func makeLocalTemplate(t *testing.T, registryDir, name string) (string, *gogit.Repository, string) { t.Helper() src := t.TempDir() + repo, err := gogit.PlainInit(src, false) if err != nil { t.Fatalf("PlainInit: %v", err) } + h := localCommit(t, repo, src, "init") if _, err := repo.CreateTag("1.0.0", h, nil); err != nil { t.Fatalf("CreateTag: %v", err) } + desc, err := pkggit.Describe(src) if err != nil { t.Fatalf("Describe: %v", err) @@ -56,10 +64,12 @@ func makeLocalTemplate(t *testing.T, registryDir, name string) (string, *gogit.R if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + now := time.Now().UTC() if err := pkgtemplate.SaveMetadata(tmplDir, name, "local:"+src, "", desc.Commit, desc.Version, now, now); err != nil { t.Fatalf("SaveMetadata: %v", err) } + return src, repo, tmplDir } @@ -71,6 +81,7 @@ func TestList_LocalSource_UpToDate(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "up-to-date") { t.Errorf("expected 'up-to-date' for unchanged local source, got: %q", out) } @@ -87,9 +98,11 @@ func TestList_LocalSource_Advanced(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "update") { t.Errorf("expected an 'update' status when local source advanced, got: %q", out) } + if strings.Contains(out, "up-to-date") { t.Errorf("expected NOT up-to-date when local source advanced, got: %q", out) } @@ -107,6 +120,7 @@ func TestList_LocalSource_Missing(t *testing.T) { if err != nil { t.Fatalf("template list: %v", err) } + if !strings.Contains(out, "source missing") { t.Errorf("expected 'source missing' when local source path is gone, got: %q", out) } @@ -116,10 +130,12 @@ func TestList_LocalSource_Missing(t *testing.T) { // different specs version is re-checked rather than trusted. func TestList_VersionMismatchForcesRefresh(t *testing.T) { registryDir := withTempRegistry(t) + tmplDir := filepath.Join(registryDir, "remote-tpl") if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "remote-tpl", "https://example.com/repo", "main", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -134,17 +150,21 @@ func TestList_VersionMismatchForcesRefresh(t *testing.T) { } var called atomic.Bool + fake := func(_ context.Context, _, _, _ string) pkggit.RemoteCheckResult { called.Store(true) return pkggit.RemoteCheckResult{IsUpToDate: true} } + out, err := executeCmdWithCheckFn(fake, "template", "list") if err != nil { t.Fatalf("template list: %v", err) } + if !called.Load() { t.Error("expected a refresh (checkRemoteFn call) when stored SpecsVersion differs") } + if !strings.Contains(out, "up-to-date") { t.Errorf("expected refreshed 'up-to-date' status, got: %q", out) } diff --git a/internal/cmd/template_rename.go b/internal/cmd/template_rename.go index 6ea2e2f..56ccecd 100644 --- a/internal/cmd/template_rename.go +++ b/internal/cmd/template_rename.go @@ -4,9 +4,9 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" "github.com/specsnl/specs-cli/internal/util/validate" + "github.com/spf13/cobra" ) func newTemplateRenameCmd(app *App) *cobra.Command { @@ -21,6 +21,7 @@ func newTemplateRenameCmd(app *App) *cobra.Command { if err := validate.Name(newName); err != nil { return err } + if err := specs.EnsureRegistry(); err != nil { return err } @@ -40,6 +41,7 @@ func newTemplateRenameCmd(app *App) *cobra.Command { } app.Output.Info("template %q renamed to %q", oldName, newName) + return nil }, } diff --git a/internal/cmd/template_rename_test.go b/internal/cmd/template_rename_test.go index 17a826a..4fa31c3 100644 --- a/internal/cmd/template_rename_test.go +++ b/internal/cmd/template_rename_test.go @@ -23,6 +23,7 @@ func TestRename_Success(t *testing.T) { if _, err := os.Stat(specs.TemplatePath("new-tpl")); err != nil { t.Error("expected new-tpl to exist") } + if _, err := os.Stat(specs.TemplatePath("old-tpl")); !os.IsNotExist(err) { t.Error("expected old-tpl to be gone") } @@ -35,9 +36,11 @@ func TestRename_MvAlias(t *testing.T) { if _, err := executeCmd("template", "save", src, "old-tpl"); err != nil { t.Fatal(err) } + if _, err := executeCmd("template", "mv", "old-tpl", "new-tpl"); err != nil { t.Fatalf("template mv: %v", err) } + if _, err := os.Stat(specs.TemplatePath("new-tpl")); err != nil { t.Error("expected new-tpl to exist after mv") } @@ -68,6 +71,7 @@ func TestRename_NameConflict_IsErrTemplateAlreadyExists(t *testing.T) { if _, err := executeCmd("template", "save", src, "old-tpl"); err != nil { t.Fatal(err) } + if _, err := executeCmd("template", "save", src, "new-tpl"); err != nil { t.Fatal(err) } diff --git a/internal/cmd/template_save.go b/internal/cmd/template_save.go index 8ea0e26..df35436 100644 --- a/internal/cmd/template_save.go +++ b/internal/cmd/template_save.go @@ -6,12 +6,12 @@ import ( "path/filepath" "time" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" pkgtemplate "github.com/specsnl/specs-cli/internal/template" pkggit "github.com/specsnl/specs-cli/internal/util/git" "github.com/specsnl/specs-cli/internal/util/osutil" "github.com/specsnl/specs-cli/internal/util/validate" + "github.com/spf13/cobra" ) func newTemplateSaveCmd(app *App) *cobra.Command { @@ -27,6 +27,7 @@ func newTemplateSaveCmd(app *App) *cobra.Command { if err := validate.Name(name); err != nil { return err } + if err := specs.EnsureRegistry(); err != nil { return err } @@ -39,20 +40,25 @@ func newTemplateSaveCmd(app *App) *cobra.Command { if err := os.RemoveAll(dest); err != nil { return err } + if err := osutil.CopyDir(srcPath, dest); err != nil { return err } + absPath, err := filepath.Abs(srcPath) if err != nil { return err } + desc, _ := pkggit.Describe(srcPath) + now := time.Now().UTC() if err := pkgtemplate.SaveMetadata(dest, name, "local:"+absPath, "", desc.Commit, desc.Version, now, now); err != nil { return err } app.Output.Info("template %q saved", name) + return nil }, } diff --git a/internal/cmd/template_save_test.go b/internal/cmd/template_save_test.go index 1997851..f32842b 100644 --- a/internal/cmd/template_save_test.go +++ b/internal/cmd/template_save_test.go @@ -15,13 +15,16 @@ import ( // makeFakeTemplate creates a minimal template directory structure in dir. func makeFakeTemplate(t *testing.T) string { t.Helper() + dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, specs.TemplateDirFile), 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte("variables: []\n"), 0644); err != nil { t.Fatal(err) } + return dir } @@ -73,6 +76,7 @@ func TestSave_Force(t *testing.T) { if _, err := executeCmd("template", "save", src, "my-tpl"); err != nil { t.Fatal(err) } + if _, err := executeCmd("template", "save", "--force", src, "my-tpl"); err != nil { t.Fatalf("template save --force: %v", err) } @@ -82,6 +86,7 @@ func TestSave_InvalidName(t *testing.T) { withTempRegistry(t) src := makeFakeTemplate(t) + _, err := executeCmd("template", "save", src, "bad name") if err == nil { t.Fatal("expected error for invalid name") @@ -100,6 +105,7 @@ func TestSave_StoresLocalAbsolutePath(t *testing.T) { if err != nil { t.Fatalf("reading metadata: %v", err) } + var meta pkgtemplate.Metadata if err := json.Unmarshal(data, &meta); err != nil { t.Fatalf("parsing metadata: %v", err) @@ -108,6 +114,7 @@ func TestSave_StoresLocalAbsolutePath(t *testing.T) { if !strings.HasPrefix(meta.Repository, "local:") { t.Errorf("Repository should start with \"local:\", got %q", meta.Repository) } + absPath := strings.TrimPrefix(meta.Repository, "local:") if !filepath.IsAbs(absPath) { t.Errorf("path after \"local:\" should be absolute, got %q", absPath) diff --git a/internal/cmd/template_test.go b/internal/cmd/template_test.go index f95a360..2bce3a2 100644 --- a/internal/cmd/template_test.go +++ b/internal/cmd/template_test.go @@ -10,6 +10,7 @@ func TestTemplateGroup_Help(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, "template") { t.Errorf("expected output to contain 'template', got: %q", out) } diff --git a/internal/cmd/template_update.go b/internal/cmd/template_update.go index 0d1006f..6008825 100644 --- a/internal/cmd/template_update.go +++ b/internal/cmd/template_update.go @@ -6,10 +6,10 @@ import ( "strings" "time" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" pkgtemplate "github.com/specsnl/specs-cli/internal/template" pkggit "github.com/specsnl/specs-cli/internal/util/git" + "github.com/spf13/cobra" ) func newTemplateUpdateCmd(app *App) *cobra.Command { @@ -30,6 +30,7 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { if err != nil { return err } + for _, e := range entries { if e.IsDir() { names = append(names, e.Name()) @@ -43,20 +44,24 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { for _, name := range names { root := specs.TemplatePath(name) + meta, err := pkgtemplate.LoadMetadata(root) if err != nil { slog.Debug("failed to parse template metadata", "template", name, "error", err) } + if meta == nil || meta.Repository == "" { continue } var result pkggit.RemoteCheckResult + if isLocalRepo(meta.Repository) { if meta.Commit == "" { // Nothing recorded to compare the source path against. continue } + checkedCount++ // git layer logs the check-local start/result result = pkggit.CheckLocalSource(strings.TrimPrefix(meta.Repository, localRepoPrefix), meta.Commit, meta.Version) @@ -68,6 +73,7 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { slog.Debug("could not resolve branch from local HEAD, skipping", "template", name, "error", err) continue } + branch = b // Persist the resolved branch so future runs skip this fallback. if err := pkgtemplate.SaveMetadata(root, name, meta.Repository, branch, meta.Commit, meta.Version, meta.Created.Time, meta.Updated.Time); err != nil { @@ -116,10 +122,12 @@ func newTemplateUpdateCmd(app *App) *cobra.Command { if len(updatesAvailable) > 0 { for _, name := range updatesAvailable { root := specs.TemplatePath(name) + s, err := pkgtemplate.LoadStatus(root) if err != nil { slog.Debug("failed to load template status", "template", name, "error", err) } + if s != nil && s.LatestVersion != "" { app.Output.Info("template %q has an update available: %s", name, s.LatestVersion) } else { diff --git a/internal/cmd/template_update_test.go b/internal/cmd/template_update_test.go index eb1fb93..706c00c 100644 --- a/internal/cmd/template_update_test.go +++ b/internal/cmd/template_update_test.go @@ -26,6 +26,7 @@ func TestUpdate_NamedLocalTemplate_Skipped(t *testing.T) { if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "local:/some/local/path", "", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -54,6 +55,7 @@ func TestUpdate_NamedLocalTemplate_ProducesNoOutput(t *testing.T) { if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "local:/some/local/path", "", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -62,6 +64,7 @@ func TestUpdate_NamedLocalTemplate_ProducesNoOutput(t *testing.T) { if err != nil { t.Fatalf("template update local-tpl: %v", err) } + if out != "" { t.Errorf("expected no output for local/skipped template, got: %q", out) } @@ -81,6 +84,7 @@ func TestUpdate_LocalTemplate_WithGitHistory_ProducesNoOutput(t *testing.T) { if err := os.MkdirAll(filepath.Join(tmplDir, ".git"), 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "local-git-tpl", "local:/Users/user/my-template", "", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -89,6 +93,7 @@ func TestUpdate_LocalTemplate_WithGitHistory_ProducesNoOutput(t *testing.T) { if err != nil { t.Fatalf("template update local-git-tpl: %v", err) } + if out != "" { t.Errorf("expected no output — must not attempt network check of local: repository, got: %q", out) } @@ -101,6 +106,7 @@ func TestUpdate_NoArgs_EmptyRegistry_ProducesNoOutput(t *testing.T) { if err != nil { t.Fatalf("template update with empty registry: %v", err) } + if out != "" { t.Errorf("expected no output for empty registry, got: %q", out) } diff --git a/internal/cmd/template_upgrade.go b/internal/cmd/template_upgrade.go index 432ef44..2721b93 100644 --- a/internal/cmd/template_upgrade.go +++ b/internal/cmd/template_upgrade.go @@ -3,9 +3,9 @@ package cmd import ( "os" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/registry" "github.com/specsnl/specs-cli/internal/specs" + "github.com/spf13/cobra" ) func newTemplateUpgradeCmd(app *App) *cobra.Command { @@ -21,11 +21,13 @@ func newTemplateUpgradeCmd(app *App) *cobra.Command { upgradeAll := len(args) == 0 var names []string + if upgradeAll { entries, err := os.ReadDir(specs.TemplateDir()) if err != nil { return err } + for _, e := range entries { if e.IsDir() { names = append(names, e.Name()) @@ -42,8 +44,10 @@ func newTemplateUpgradeCmd(app *App) *cobra.Command { app.Output.Warn("template %q: %v", name, err) continue } + return err } + switch { case res.IsLocal: app.Output.Info("template %q has no trackable source (no remote branch or git history) — skipping", name) diff --git a/internal/cmd/template_upgrade_test.go b/internal/cmd/template_upgrade_test.go index 66cf89e..5d481e9 100644 --- a/internal/cmd/template_upgrade_test.go +++ b/internal/cmd/template_upgrade_test.go @@ -17,6 +17,7 @@ func TestUpgrade_LocalSkipped(t *testing.T) { if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "local-tpl", "local:/some/local/path", "", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } @@ -42,6 +43,7 @@ func TestUpgrade_LocalTemplate_WithGitHistory(t *testing.T) { if err := os.MkdirAll(filepath.Join(tmplDir, ".git"), 0755); err != nil { t.Fatal(err) } + if err := pkgtemplate.SaveMetadata(tmplDir, "local-git-tpl", "local:/Users/user/my-template", "", "", "", time.Now().UTC(), time.Now().UTC()); err != nil { t.Fatal(err) } diff --git a/internal/cmd/template_use.go b/internal/cmd/template_use.go index 8eed1c4..60a72aa 100644 --- a/internal/cmd/template_use.go +++ b/internal/cmd/template_use.go @@ -72,6 +72,7 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) // Inject the running CLI version so Get() can enforce a template's __specs__version // constraint. Covers both `specs use` and `specs template use`, which share this path. cfg.Version = Version + tmpl, err := pkgtemplate.Get(templateRoot, cfg) if err != nil { return err @@ -81,6 +82,7 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if err != nil { return err } + h, err := hooks.Load(templateRoot, rawConfig, a.HookEnvPrefix) if err != nil { return err @@ -98,13 +100,16 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if err != nil { return err } + for k := range fileVals { if specs.IsReservedName(k) { return fmt.Errorf("%w: %q (from --values)", specs.ErrReservedVariableName, k) } + provided[k] = true finalSource[k] = "values_file" } + ctx = values.Merge(ctx, fileVals) } @@ -113,9 +118,11 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if err != nil { return err } + if specs.IsReservedName(k) { return fmt.Errorf("%w: %q (from --arg)", specs.ErrReservedVariableName, k) } + ctx[k] = v provided[k] = true finalSource[k] = "arg_flag" @@ -127,6 +134,7 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) } } else { resolveSelectDefaults(ctx) + for k := range tmpl.Referenced { if !provided[k] { finalSource[k] = "default" @@ -151,6 +159,7 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if err != nil { return err } + if !confirmed { skipHooks = true } @@ -158,6 +167,7 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if !skipHooks && h.HasPreUse() { a.Output.Info("running pre-use hook…") + if err := h.Run("pre-use", templateRoot, ctx, tmpl.FuncMap(), tmpl.Delims()); err != nil { return err } @@ -167,7 +177,11 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if err != nil { return err } - defer os.RemoveAll(tmp) + defer func() { + if err := os.RemoveAll(tmp); err != nil { + a.Output.Warn("failed to remove temp dir %s: %v", tmp, err) + } + }() tmpl.Context = ctx if err := tmpl.Execute(tmp); err != nil { @@ -177,28 +191,33 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) if len(tmpl.Warnings) > 0 { for _, w := range tmpl.Warnings { a.Output.Warn("%s: copied verbatim due to render error: %v", w.Path, w.Err) + if w.Preview != "" { a.Output.Warn(" %s: first 80 chars: %s", filepath.Join(targetDir, w.Path), w.Preview) } } + a.Output.Warn("run 'specs template validate %s' to see all issues", filepath.Base(templateRoot)) } if err := os.MkdirAll(targetDir, 0755); err != nil { return err } + if err := osutil.CopyDir(tmp, targetDir); err != nil { return err } if !skipHooks && h.HasPostUse() { a.Output.Info("running post-use hook…") + if err := h.Run("post-use", targetDir, ctx, tmpl.FuncMap(), tmpl.Delims()); err != nil { return err } } a.Output.Info("done — files written to %s", targetDir) + return nil } @@ -214,6 +233,7 @@ func (a *App) confirmRemoteHooks(h *hooks.Hooks, ctx map[string]any, tmpl *pkgte if err != nil { return false, fmt.Errorf("rendering %s hooks for preview: %w", trigger, err) } + for _, cmd := range rendered { a.Output.Warn(" %s: %s", trigger, strings.TrimSpace(cmd)) } @@ -229,6 +249,7 @@ func (a *App) confirmRemoteHooks(h *hooks.Hooks, ctx map[string]any, tmpl *pkgte )).Run(); err != nil { return false, fmt.Errorf("hook confirmation: %w", err) } + return proceed, nil } @@ -256,12 +277,14 @@ func promptContext( } var alwaysKeys []string + remaining := make(map[string]bool) // conditional keys not yet resolved for _, k := range sortedKeys(schema) { if !referenced[k] { continue // never used in templates or computed expressions } + if _, conditional := conds[k]; conditional { remaining[k] = true } else { @@ -279,6 +302,7 @@ func promptContext( for _, k := range alwaysKeys { resolved[k] = true } + for k := range provided { resolved[k] = true } @@ -287,14 +311,17 @@ func promptContext( for len(remaining) > 0 { // Find keys whose gate variables are all resolved (or not in schema). var ready []string + for k := range remaining { allResolved := true + for _, gk := range conds[k].Keys() { if schemaKeys[gk] && !resolved[gk] { allResolved = false break } } + if allResolved { ready = append(ready, k) } @@ -307,6 +334,7 @@ func promptContext( sort.Strings(ready) var toPrompt []string + for _, k := range ready { if conds[k].Eval(ctx) { toPrompt = append(toPrompt, k) @@ -319,6 +347,7 @@ func promptContext( for _, k := range ready { resolved[k] = true + delete(remaining, k) } } @@ -336,6 +365,7 @@ func runPromptPass( finalSource map[string]string, ) error { var fields []huh.Field + stringResults := make(map[string]*string) boolResults := make(map[string]*bool) @@ -343,6 +373,7 @@ func runPromptPass( if provided[key] { continue } + defaultVal := schema[key] switch v := defaultVal.(type) { @@ -351,6 +382,7 @@ func runPromptPass( if s, ok := ctx[key].(string); ok { current = s } + ptr := new(string) *ptr = current stringResults[key] = ptr @@ -365,6 +397,7 @@ func runPromptPass( if b, ok := ctx[key].(bool); ok { current = b } + ptr := new(bool) *ptr = current boolResults[key] = ptr @@ -378,10 +411,12 @@ func runPromptPass( if len(opts) == 0 { continue } + selected := opts[0] if s, ok := ctx[key].(string); ok { selected = s } + ptr := new(string) *ptr = selected stringResults[key] = ptr @@ -405,10 +440,12 @@ func runPromptPass( ctx[k] = *p finalSource[k] = "prompt" } + for k, p := range boolResults { ctx[k] = *p finalSource[k] = "prompt" } + return nil } @@ -418,7 +455,9 @@ func sortedKeys[V any](m map[string]V) []string { for k := range m { keys = append(keys, k) } + sort.Strings(keys) + return keys } @@ -437,10 +476,12 @@ func resolveSelectDefaults(ctx map[string]any) { // toStringOptions coerces a []any (from YAML) to a []string, skipping non-strings. func toStringOptions(v []any) []string { opts := make([]string, 0, len(v)) + for _, item := range v { if s, ok := item.(string); ok { opts = append(opts, s) } } + return opts } diff --git a/internal/cmd/template_use_test.go b/internal/cmd/template_use_test.go index 2b9ae1d..d19338a 100644 --- a/internal/cmd/template_use_test.go +++ b/internal/cmd/template_use_test.go @@ -16,30 +16,37 @@ import ( func makeTemplateWithVar(t *testing.T, varName, defaultVal string) string { t.Helper() dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := varName + ": " + defaultVal + "\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + content := "hello {{." + varName + "}}" if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte(content), 0644); err != nil { t.Fatal(err) } + return dir } // saveAndUse is a helper that saves src under name and runs template use with extra args. func saveAndUse(t *testing.T, src, name, target string, extraArgs ...string) error { t.Helper() + if _, err := executeCmd("template", "save", src, name); err != nil { t.Fatalf("template save: %v", err) } + args := append([]string{"template", "use"}, extraArgs...) args = append(args, name, target) _, err := executeCmd(args...) + return err } @@ -47,13 +54,16 @@ func TestTemplateUse_UseDefaults(t *testing.T) { withTempRegistry(t) src := makeTemplateWithVar(t, "Name", "world") target := t.TempDir() + if err := saveAndUse(t, src, "tpl", target, "--use-defaults"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "hello world" { t.Errorf("got %q, want %q", string(got), "hello world") } @@ -63,13 +73,16 @@ func TestTemplateUse_ArgOverride(t *testing.T) { withTempRegistry(t) src := makeTemplateWithVar(t, "Name", "default") target := t.TempDir() + if err := saveAndUse(t, src, "tpl", target, "--use-defaults", "--arg", "Name=test"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "hello test" { t.Errorf("got %q, want %q", string(got), "hello test") } @@ -81,6 +94,7 @@ func TestTemplateUse_ValuesFile(t *testing.T) { vf := filepath.Join(t.TempDir(), "vals.json") data, _ := json.Marshal(map[string]string{"Name": "from-file"}) + if err := os.WriteFile(vf, data, 0644); err != nil { t.Fatal(err) } @@ -89,10 +103,12 @@ func TestTemplateUse_ValuesFile(t *testing.T) { if err := saveAndUse(t, src, "tpl", target, "--use-defaults", "--values", vf); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "hello from-file" { t.Errorf("got %q, want %q", string(got), "hello from-file") } @@ -104,6 +120,7 @@ func TestTemplateUse_ArgBeatsValues(t *testing.T) { vf := filepath.Join(t.TempDir(), "vals.json") data, _ := json.Marshal(map[string]string{"Name": "file-value"}) + if err := os.WriteFile(vf, data, 0644); err != nil { t.Fatal(err) } @@ -112,10 +129,12 @@ func TestTemplateUse_ArgBeatsValues(t *testing.T) { if err := saveAndUse(t, src, "tpl", target, "--use-defaults", "--values", vf, "--arg", "Name=arg-value"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "hello arg-value" { t.Errorf("got %q, want %q", string(got), "hello arg-value") } @@ -126,14 +145,17 @@ func TestTemplateUse_ReservedValueRenderedIntoOutput(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "Name: demo\n__specs__version: \">=0.0.1\"\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("v={{ .__specs__version }}"), 0644); err != nil { t.Fatal(err) } @@ -142,10 +164,12 @@ func TestTemplateUse_ReservedValueRenderedIntoOutput(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "v=>=0.0.1" { t.Errorf("got %q, want %q", string(got), "v=>=0.0.1") } @@ -168,11 +192,13 @@ func TestTemplateUse_ReservedValuesFileRejected(t *testing.T) { vf := filepath.Join(t.TempDir(), "vals.json") data, _ := json.Marshal(map[string]string{"__foo": "bar"}) + if err := os.WriteFile(vf, data, 0644); err != nil { t.Fatal(err) } target := t.TempDir() + err := saveAndUse(t, src, "tpl", target, "--use-defaults", "--values", vf) if !errors.Is(err, specs.ErrReservedVariableName) { t.Fatalf("expected ErrReservedVariableName, got %v", err) @@ -193,10 +219,12 @@ func TestTemplateUse_DelimitersArgAllowed(t *testing.T) { func TestTemplateUse_NotFound(t *testing.T) { withTempRegistry(t) + _, err := executeCmd("template", "use", "--use-defaults", "no-such-name", t.TempDir()) if err == nil { t.Fatal("expected error for unknown name") } + if !errors.Is(err, specs.ErrTemplateNotFound) { t.Errorf("expected ErrTemplateNotFound, got %v", err) } @@ -206,16 +234,19 @@ func TestTemplateUse_NoHooks(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } // Sentinel written by the post-use hook to confirm it ran. sentinel := filepath.Join(t.TempDir(), "hook-ran") + project := "Name: x\nhooks:\n post-use:\n - touch " + sentinel + "\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "f.txt"), []byte("x"), 0644); err != nil { t.Fatal(err) } @@ -224,6 +255,7 @@ func TestTemplateUse_NoHooks(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults", "--no-hooks"); err != nil { t.Fatalf("template use: %v", err) } + if _, err := os.Stat(sentinel); err == nil { t.Error("post-use hook ran despite --no-hooks") } @@ -234,14 +266,17 @@ func TestTemplateUse_ConditionalSkipped(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "UseDB: false\nDbName: mydb\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("{{if .UseDB}}DB={{.DbName}}{{end}}"), 0644); err != nil { t.Fatal(err) } @@ -264,14 +299,17 @@ func TestTemplateUse_ConditionalIncluded(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "UseDB: false\nDbName: mydb\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("{{if .UseDB}}DB={{.DbName}}{{end}}"), 0644); err != nil { t.Fatal(err) } @@ -280,10 +318,12 @@ func TestTemplateUse_ConditionalIncluded(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults", "--arg", "UseDB=true"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "DB=mydb" { t.Errorf("got %q, want %q", string(got), "DB=mydb") } @@ -294,14 +334,17 @@ func TestTemplateUse_ConditionalArgOverride(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "UseDB: false\nDbName: defaultdb\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("{{if .UseDB}}DB={{.DbName}}{{end}}"), 0644); err != nil { t.Fatal(err) } @@ -310,10 +353,12 @@ func TestTemplateUse_ConditionalArgOverride(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults", "--arg", "UseDB=true"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "DB=defaultdb" { t.Errorf("got %q, want %q", string(got), "DB=defaultdb") } @@ -325,18 +370,22 @@ func TestTemplateUse_ConditionalArgOverride(t *testing.T) { func makeConditionalTemplate(t *testing.T) string { t.Helper() dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "UseDB: false\nDbType: \"pg\"\nPgPort: \"5432\"\nMyPort: \"3306\"\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + content := `{{if .UseDB}}{{if eq .DbType "pg"}}pg={{.PgPort}}{{else}}my={{.MyPort}}{{end}}{{end}}` if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte(content), 0644); err != nil { t.Fatal(err) } + return dir } @@ -348,15 +397,18 @@ func TestTemplateUse_NestedEq_InnerSkippedWhenOuterGateChanges(t *testing.T) { withTempRegistry(t) dir := makeConditionalTemplate(t) target := t.TempDir() + if err := saveAndUse(t, dir, "tpl", target, "--arg", "UseDB=true", "--arg", "DbType=mysql", "--arg", "MyPort=3306", ); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "my=3306" { t.Errorf("got %q, want %q", string(got), "my=3306") } @@ -368,15 +420,18 @@ func TestTemplateUse_NestedEq_InnerIncludedWhenConditionMet(t *testing.T) { withTempRegistry(t) dir := makeConditionalTemplate(t) target := t.TempDir() + if err := saveAndUse(t, dir, "tpl", target, "--arg", "UseDB=true", "--arg", "DbType=pg", "--arg", "PgPort=5432", ); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "pg=5432" { t.Errorf("got %q, want %q", string(got), "pg=5432") } @@ -388,25 +443,31 @@ func TestTemplateUse_UnreferencedVarNotRequired(t *testing.T) { // this exercises the referenced filter: Unused is stripped before runPromptPass. withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "Name: world\nUnused: \"\"\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("hello {{.Name}}"), 0644); err != nil { t.Fatal(err) } + target := t.TempDir() if err := saveAndUse(t, dir, "tpl", target, "--arg", "Name=world"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "hello world" { t.Errorf("got %q, want %q", string(got), "hello world") } @@ -418,25 +479,31 @@ func TestTemplateUse_ComputedOnlyVar_IsUsed(t *testing.T) { // a variable lands in Referenced via computed-expression scanning, not template scanning. withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "Name: acme\ncomputed:\n DbName: \"{{.Name}}_db\"\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("db={{.DbName}}"), 0644); err != nil { t.Fatal(err) } + target := t.TempDir() if err := saveAndUse(t, dir, "tpl", target, "--arg", "Name=acme"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("out.txt missing: %v", err) } + if string(got) != "db=acme_db" { t.Errorf("got %q, want %q", string(got), "db=acme_db") } @@ -445,22 +512,29 @@ func TestTemplateUse_ComputedOnlyVar_IsUsed(t *testing.T) { func makeTemplateWithSelectVar(t *testing.T, varName string, options []string) string { t.Helper() dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + var project strings.Builder + project.WriteString(varName + ":\n") + for _, opt := range options { project.WriteString(" - " + opt + "\n") } + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project.String()), 0644); err != nil { t.Fatal(err) } + content := "selected {{." + varName + "}}" if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte(content), 0644); err != nil { t.Fatal(err) } + return dir } @@ -468,13 +542,16 @@ func TestTemplateUse_UseDefaults_SelectFirstItem(t *testing.T) { withTempRegistry(t) src := makeTemplateWithSelectVar(t, "foobar", []string{"one", "two", "three"}) target := t.TempDir() + if err := saveAndUse(t, src, "tpl", target, "--use-defaults"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "selected one" { t.Errorf("got %q, want %q", string(got), "selected one") } @@ -484,13 +561,16 @@ func TestTemplateUse_UseDefaults_SelectArgOverride(t *testing.T) { withTempRegistry(t) src := makeTemplateWithSelectVar(t, "foobar", []string{"one", "two", "three"}) target := t.TempDir() + if err := saveAndUse(t, src, "tpl", target, "--use-defaults", "--arg", "foobar=two"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "selected two" { t.Errorf("got %q, want %q", string(got), "selected two") } @@ -500,14 +580,17 @@ func TestTemplateUse_ComputedAvailable(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + project := "Name: hello\ncomputed:\n Upper: \"{{ toUpper .Name }}\"\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("{{.Upper}}"), 0644); err != nil { t.Fatal(err) } @@ -516,10 +599,12 @@ func TestTemplateUse_ComputedAvailable(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults"); err != nil { t.Fatalf("template use: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "HELLO" { t.Errorf("got %q, want %q", string(got), "HELLO") } @@ -528,13 +613,16 @@ func TestTemplateUse_ComputedAvailable(t *testing.T) { func TestTemplateUse_ProjectYMLFile(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYMLFile), []byte("Name: from-yml\n"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "out.txt"), []byte("{{.Name}}"), 0644); err != nil { t.Fatal(err) } @@ -543,10 +631,12 @@ func TestTemplateUse_ProjectYMLFile(t *testing.T) { if err := saveAndUse(t, dir, "tpl", target, "--use-defaults"); err != nil { t.Fatalf("template use with project.yml: %v", err) } + got, err := os.ReadFile(filepath.Join(target, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "from-yml" { t.Errorf("got %q, want %q", string(got), "from-yml") } @@ -556,15 +646,19 @@ func TestTemplateUse_SafeMode_SkipsHooks(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + sentinel := filepath.Join(t.TempDir(), "hook-ran") + project := "Name: x\nhooks:\n post-use:\n - touch " + sentinel + "\n" if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(project), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tmplDir, "f.txt"), []byte("x"), 0644); err != nil { t.Fatal(err) } @@ -572,9 +666,11 @@ func TestTemplateUse_SafeMode_SkipsHooks(t *testing.T) { if _, err := executeCmd("template", "save", dir, "tpl"); err != nil { t.Fatalf("template save: %v", err) } + if _, err := executeCmd("--safe-mode", "template", "use", "--use-defaults", "tpl", t.TempDir()); err != nil { t.Fatalf("template use --safe-mode: %v", err) } + if _, err := os.Stat(sentinel); err == nil { t.Error("post-use hook ran despite --safe-mode") } @@ -590,6 +686,7 @@ func TestExecuteTemplate_RemoteHooks_RunsWithYes(t *testing.T) { app := NewApp() target := t.TempDir() + err := app.executeTemplate(dir, target, executeOpts{ useDefaults: true, remote: true, @@ -598,6 +695,7 @@ func TestExecuteTemplate_RemoteHooks_RunsWithYes(t *testing.T) { if err != nil { t.Fatalf("executeTemplate: %v", err) } + if _, err := os.Stat(sentinel); err != nil { t.Error("post-use hook did not run with remote=true, yes=true") } @@ -613,6 +711,7 @@ func TestExecuteTemplate_RemoteHooks_SafeMode(t *testing.T) { app := NewApp() app.SafeMode = true + err := app.executeTemplate(dir, t.TempDir(), executeOpts{ useDefaults: true, remote: true, @@ -620,6 +719,7 @@ func TestExecuteTemplate_RemoteHooks_SafeMode(t *testing.T) { if err != nil { t.Fatalf("executeTemplate: %v", err) } + if _, err := os.Stat(sentinel); err == nil { t.Error("hook ran despite safe-mode on remote source") } @@ -634,6 +734,7 @@ func TestExecuteTemplate_SafeMode_AllowHooks(t *testing.T) { app := NewApp() app.SafeMode = true + err := app.executeTemplate(dir, t.TempDir(), executeOpts{ useDefaults: true, allowHooks: true, @@ -641,6 +742,7 @@ func TestExecuteTemplate_SafeMode_AllowHooks(t *testing.T) { if err != nil { t.Fatalf("executeTemplate: %v", err) } + if _, err := os.Stat(sentinel); err != nil { t.Error("hook did not run despite --allow-hooks overriding --safe-mode") } @@ -649,13 +751,16 @@ func TestExecuteTemplate_SafeMode_AllowHooks(t *testing.T) { func TestTemplateUse_AmbiguousProjectFiles(t *testing.T) { withTempRegistry(t) dir := t.TempDir() + tmplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte("Name: yaml\n"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYMLFile), []byte("Name: yml\n"), 0644); err != nil { t.Fatal(err) } @@ -663,6 +768,7 @@ func TestTemplateUse_AmbiguousProjectFiles(t *testing.T) { if _, err := executeCmd("template", "save", dir, "tpl"); err != nil { t.Fatalf("template save: %v", err) } + _, err := executeCmd("template", "use", "--use-defaults", "tpl", t.TempDir()) if err == nil { t.Fatal("expected error when both project.yaml and project.yml exist, got nil") diff --git a/internal/cmd/template_validate.go b/internal/cmd/template_validate.go index cea7e71..8a74b8a 100644 --- a/internal/cmd/template_validate.go +++ b/internal/cmd/template_validate.go @@ -7,10 +7,10 @@ import ( "os" "path/filepath" - "github.com/spf13/cobra" "github.com/specsnl/specs-cli/internal/specs" pkgtemplate "github.com/specsnl/specs-cli/internal/template" "github.com/specsnl/specs-cli/internal/util/exit" + "github.com/spf13/cobra" ) func newTemplateValidateCmd(app *App) *cobra.Command { @@ -32,6 +32,7 @@ func newTemplateValidateCmd(app *App) *cobra.Command { cfg := app.templateConfig() cfg.ContinueOnRenderError = true // validate collects all render errors; never aborts early + tmpl, err := pkgtemplate.Get(templateRoot, cfg) if err != nil { return fmt.Errorf("invalid template: %w", err) @@ -46,6 +47,7 @@ func newTemplateValidateCmd(app *App) *cobra.Command { if err != nil { return fmt.Errorf("resolving computed values: %w", err) } + tmpl.Context = computed } @@ -53,7 +55,11 @@ func newTemplateValidateCmd(app *App) *cobra.Command { if err != nil { return err } - defer os.RemoveAll(tmp) + defer func() { + if err := os.RemoveAll(tmp); err != nil { + app.Output.Warn("failed to remove temp dir %s: %v", tmp, err) + } + }() if err := tmpl.Execute(tmp); err != nil { return fmt.Errorf("template render error: %w", err) @@ -83,21 +89,26 @@ func newTemplateValidateCmd(app *App) *cobra.Command { if len(tmpl.Warnings) > 0 { code |= exit.ValidateRender } + if pkgtemplate.HasUnknown(issues) { code |= exit.ValidateUnknown } + if strict && pkgtemplate.HasUnused(issues) { code |= exit.ValidateUnused } + if code != 0 { return &exit.ExitError{Code: code} } app.Output.Info("template is valid") + return nil }, } cmd.Flags().BoolVar(&strict, "strict", false, "Treat unused variables and computed values as errors") + return cmd } diff --git a/internal/cmd/template_validate_test.go b/internal/cmd/template_validate_test.go index b279061..c4586fb 100644 --- a/internal/cmd/template_validate_test.go +++ b/internal/cmd/template_validate_test.go @@ -51,6 +51,7 @@ func TestValidate_MissingTemplateDir(t *testing.T) { withTempRegistry(t) src := t.TempDir() + _, err := executeCmd("template", "validate", src) if err == nil { t.Fatal("expected error for missing template/ subdir") @@ -60,23 +61,28 @@ func TestValidate_MissingTemplateDir(t *testing.T) { // makeValidateTemplate creates a template with project.yaml and specific template files. func makeValidateTemplate(t *testing.T, projectYAML string, files map[string]string) string { t.Helper() + dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "project.yaml"), []byte(projectYAML), 0644); err != nil { t.Fatal(err) } + templateDir := filepath.Join(dir, "template") if err := os.MkdirAll(templateDir, 0755); err != nil { t.Fatal(err) } + for name, content := range files { abs := filepath.Join(templateDir, name) if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(abs, []byte(content), 0644); err != nil { t.Fatal(err) } } + return dir } @@ -84,10 +90,11 @@ func validateExitCode(err error) int { if err == nil { return 0 } - var exitErr *exit.ExitError - if errors.As(err, &exitErr) { + + if exitErr, ok := errors.AsType[*exit.ExitError](err); ok { return exitErr.Code } + return -1 // unexpected error type } @@ -103,9 +110,11 @@ func TestValidate_UnusedVariable(t *testing.T) { if err != nil { t.Fatalf("expected exit 0, got error: %v", err) } + if !strings.Contains(out, "DatabasePort") { t.Errorf("expected warning about DatabasePort, got: %q", out) } + if !strings.Contains(out, "defined but never used") { t.Errorf("expected 'defined but never used' in output, got: %q", out) } @@ -123,9 +132,11 @@ func TestValidate_UnusedComputed(t *testing.T) { if err != nil { t.Fatalf("expected exit 0, got error: %v", err) } + if !strings.Contains(out, "Slug") { t.Errorf("expected warning about Slug, got: %q", out) } + if !strings.Contains(out, "defined but never used") { t.Errorf("expected 'defined but never used' in output, got: %q", out) } @@ -141,13 +152,17 @@ func TestValidate_UnknownVariable(t *testing.T) { ) out, err := executeCmd("template", "validate", src) + const wantCode = exit.ValidateRender | exit.ValidateUnknown + if code := validateExitCode(err); code != wantCode { t.Errorf("expected exit %d, got %d (err=%v)", wantCode, code, err) } + if !strings.Contains(out, "AppName") { t.Errorf("expected warning about AppName, got: %q", out) } + if !strings.Contains(out, "not defined in project.yaml") { t.Errorf("expected 'not defined in project.yaml' in output, got: %q", out) } @@ -176,7 +191,9 @@ func TestValidate_StrictUnusedAndUnknown(t *testing.T) { ) _, err := executeCmd("template", "validate", "--strict", src) + const wantCode = exit.ValidateRender | exit.ValidateUnused | exit.ValidateUnknown + if code := validateExitCode(err); code != wantCode { t.Errorf("expected exit %d, got %d (err=%v)", wantCode, code, err) } @@ -194,6 +211,7 @@ func TestValidate_SelectVariable(t *testing.T) { if err != nil { t.Fatalf("expected exit 0, got error: %v", err) } + if !strings.Contains(out, "template is valid") { t.Errorf("expected 'template is valid', got: %q", out) } @@ -211,6 +229,7 @@ func TestValidate_NoIssues(t *testing.T) { if err != nil { t.Fatalf("expected exit 0, got error: %v", err) } + if !strings.Contains(out, "template is valid") { t.Errorf("expected 'template is valid', got: %q", out) } diff --git a/internal/cmd/testhelpers_test.go b/internal/cmd/testhelpers_test.go index b0371a7..bfcf671 100644 --- a/internal/cmd/testhelpers_test.go +++ b/internal/cmd/testhelpers_test.go @@ -15,5 +15,6 @@ func withTempRegistry(t *testing.T) string { t.Setenv("XDG_CONFIG_HOME", tmp) xdg.Reload() t.Cleanup(func() { xdg.Reload() }) + return specs.TemplateDir() } diff --git a/internal/cmd/use.go b/internal/cmd/use.go index 364110f..45d7cf3 100644 --- a/internal/cmd/use.go +++ b/internal/cmd/use.go @@ -54,7 +54,11 @@ func runUse(app *App, rawSource, targetDir string, opts executeOpts) error { if err != nil { return err } - defer os.RemoveAll(tmp) + defer func() { + if err := os.RemoveAll(tmp); err != nil { + app.Output.Warn("failed to remove temp dir %s: %v", tmp, err) + } + }() var templateRoot string @@ -62,6 +66,7 @@ func runUse(app *App, rawSource, targetDir string, opts executeOpts) error { if err := osutil.CopyDir(src.LocalPath, tmp); err != nil { return fmt.Errorf("copying local template: %w", err) } + templateRoot = tmp } else { app.Output.Info("cloning %s…", src.CloneURL) @@ -70,6 +75,7 @@ func runUse(app *App, rawSource, targetDir string, opts executeOpts) error { if err != nil { return err } + templateRoot = cloneDir opts.remote = true } diff --git a/internal/cmd/use_test.go b/internal/cmd/use_test.go index 3c2f548..3b8e180 100644 --- a/internal/cmd/use_test.go +++ b/internal/cmd/use_test.go @@ -14,13 +14,16 @@ import ( // content and a single template file. func buildMinimalTemplate(t *testing.T, dir, yamlContent, filename, fileContent string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, specs.ProjectYAMLFile), []byte(yamlContent), 0644); err != nil { t.Fatal(err) } + tplDir := filepath.Join(dir, specs.TemplateDirFile) if err := os.MkdirAll(tplDir, 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tplDir, filename), []byte(fileContent), 0644); err != nil { t.Fatal(err) } @@ -40,6 +43,7 @@ func TestUse_LocalPath(t *testing.T) { if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "Hello world" { t.Errorf("got %q, want %q", string(got), "Hello world") } @@ -59,6 +63,7 @@ func TestUse_RelativePath(t *testing.T) { if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "relative" { t.Errorf("got %q, want %q", string(got), "relative") } @@ -78,6 +83,7 @@ func TestUse_UseDefaults(t *testing.T) { if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "default-val" { t.Errorf("got %q, want %q", string(got), "default-val") } @@ -97,6 +103,7 @@ func TestUse_ArgOverride(t *testing.T) { if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "test" { t.Errorf("got %q, want %q", string(got), "test") } @@ -108,11 +115,13 @@ func TestUse_ValuesFile(t *testing.T) { vf := filepath.Join(t.TempDir(), "vals.json") data, _ := json.Marshal(map[string]string{"Name": "from-file"}) + if err := os.WriteFile(vf, data, 0644); err != nil { t.Fatal(err) } targetDir := t.TempDir() + _, err := executeCmd("use", "--use-defaults", "--values", vf, "file:"+srcDir, targetDir) if err != nil { t.Fatalf("use: %v", err) @@ -122,6 +131,7 @@ func TestUse_ValuesFile(t *testing.T) { if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "from-file" { t.Errorf("got %q, want %q", string(got), "from-file") } @@ -195,23 +205,28 @@ func TestUse_ProjectYMLFile(t *testing.T) { if err := os.WriteFile(filepath.Join(srcDir, specs.ProjectYMLFile), []byte("Name: from-yml\n"), 0644); err != nil { t.Fatal(err) } + tplDir := filepath.Join(srcDir, specs.TemplateDirFile) if err := os.MkdirAll(tplDir, 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(tplDir, "out.txt"), []byte("{{.Name}}"), 0644); err != nil { t.Fatal(err) } + targetDir := t.TempDir() _, err := executeCmd("use", "--use-defaults", "file:"+srcDir, targetDir) if err != nil { t.Fatalf("use with project.yml: %v", err) } + got, err := os.ReadFile(filepath.Join(targetDir, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "from-yml" { t.Errorf("got %q, want %q", string(got), "from-yml") } @@ -228,6 +243,7 @@ func TestUse_SafeMode_SkipsHooks(t *testing.T) { if err != nil { t.Fatalf("use --safe-mode: %v", err) } + if _, err := os.Stat(sentinel); err == nil { t.Error("hook ran despite --safe-mode") } @@ -242,10 +258,12 @@ func TestUse_YesFlag_IsValidFlag(t *testing.T) { if err != nil { t.Fatalf("use --yes: %v", err) } + got, err := os.ReadFile(filepath.Join(targetDir, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "world" { t.Errorf("got %q, want %q", string(got), "world") } @@ -260,6 +278,7 @@ func TestUse_ContinueOnError_CopiesVerbatim(t *testing.T) { if err != nil { t.Fatalf("use --continue-on-error: %v", err) } + if _, err := os.Stat(filepath.Join(targetDir, "bad.txt")); err != nil { t.Error("expected file to be copied verbatim with --continue-on-error, but it was not written") } @@ -281,6 +300,7 @@ func TestUse_SpecsVersionUnsatisfied(t *testing.T) { // (the default "dev" build would skip the check). prev := Version Version = "0.1.0" + t.Cleanup(func() { Version = prev }) srcDir := t.TempDir() @@ -296,6 +316,7 @@ func TestUse_SpecsVersionUnsatisfied(t *testing.T) { func TestUse_SpecsVersionSatisfied(t *testing.T) { prev := Version Version = "0.1.5" + t.Cleanup(func() { Version = prev }) srcDir := t.TempDir() @@ -306,10 +327,12 @@ func TestUse_SpecsVersionSatisfied(t *testing.T) { if err != nil { t.Fatalf("use: expected success when version satisfies constraint, got %v", err) } + got, err := os.ReadFile(filepath.Join(targetDir, "out.txt")) if err != nil { t.Fatalf("output file missing: %v", err) } + if string(got) != "world" { t.Errorf("got %q, want %q", string(got), "world") } @@ -320,9 +343,11 @@ func TestUse_AmbiguousProjectFiles(t *testing.T) { if err := os.WriteFile(filepath.Join(srcDir, specs.ProjectYAMLFile), []byte("Name: yaml\n"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(srcDir, specs.ProjectYMLFile), []byte("Name: yml\n"), 0644); err != nil { t.Fatal(err) } + tplDir := filepath.Join(srcDir, specs.TemplateDirFile) if err := os.MkdirAll(tplDir, 0755); err != nil { t.Fatal(err) diff --git a/internal/cmd/version_test.go b/internal/cmd/version_test.go index 5bd8d5c..cc667ac 100644 --- a/internal/cmd/version_test.go +++ b/internal/cmd/version_test.go @@ -10,6 +10,7 @@ func TestVersion_PrintsVersion(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, "specs version") { t.Errorf("expected output to contain 'specs version', got: %q", out) } @@ -20,9 +21,11 @@ func TestVersion_JSONOutput(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, `"level":"info"`) { t.Errorf("expected JSON level field, got: %q", out) } + if !strings.Contains(out, Version) { t.Errorf("expected output to contain version %q, got: %q", Version, out) } @@ -33,9 +36,11 @@ func TestVersionFlag_LongForm(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, Version) { t.Errorf("expected output to contain %q, got: %q", Version, out) } + if strings.Contains(out, "specs version") { t.Errorf("expected plain output without 'specs version' prefix, got: %q", out) } @@ -46,9 +51,11 @@ func TestVersionFlag_ShortForm(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if !strings.Contains(out, Version) { t.Errorf("expected output to contain %q, got: %q", Version, out) } + if strings.Contains(out, "specs version") { t.Errorf("expected plain output without 'specs version' prefix, got: %q", out) } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 766122c..0f5e1dc 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -36,6 +36,7 @@ func Load(templateRoot string, projectConfig map[string]any, envPrefix string) ( if err := h.parseInline(raw); err != nil { return nil, fmt.Errorf("parsing inline hooks: %w", err) } + hasInline = true } @@ -62,6 +63,7 @@ func Load(templateRoot string, projectConfig map[string]any, envPrefix string) ( // Returns immediately on the first non-zero exit. func (h *Hooks) Run(trigger, cwd string, ctx map[string]any, funcMap template.FuncMap, delims specs.Delimiters) error { var commands []string + switch trigger { case "pre-use": commands = h.PreUse @@ -93,6 +95,7 @@ func (h *Hooks) Run(trigger, cwd string, ctx map[string]any, funcMap template.Fu cmd := exec.Command("bash", "-c", rendered) cmd.Dir = cwd + cmd.Env = append(os.Environ(), env...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -105,6 +108,7 @@ func (h *Hooks) Run(trigger, cwd string, ctx map[string]any, funcMap template.Fu slog.Debug("hook command completed", "trigger", trigger, "command", firstLine(rendered)) } + return nil } @@ -112,6 +116,7 @@ func (h *Hooks) Run(trigger, cwd string, ctx map[string]any, funcMap template.Fu // It is used to preview hook contents before asking for user confirmation. func (h *Hooks) Rendered(trigger string, ctx map[string]any, funcMap template.FuncMap, delims specs.Delimiters) ([]string, error) { var cmds []string + switch trigger { case "pre-use": cmds = h.PreUse @@ -120,14 +125,18 @@ func (h *Hooks) Rendered(trigger string, ctx map[string]any, funcMap template.Fu default: return nil, fmt.Errorf("unknown hook trigger: %q", trigger) } + result := make([]string, len(cmds)) + for i, cmd := range cmds { rendered, err := renderCommand(cmd, ctx, funcMap, delims) if err != nil { return nil, err } + result[i] = rendered } + return result, nil } @@ -143,20 +152,25 @@ func (h *Hooks) parseInline(raw any) error { if !ok { return fmt.Errorf("hooks must be a mapping, got %T", raw) } + if pre, ok := m["pre-use"]; ok { cmds, err := toStringSlice(pre) if err != nil { return fmt.Errorf("pre-use: %w", err) } + h.PreUse = cmds } + if post, ok := m["post-use"]; ok { cmds, err := toStringSlice(post) if err != nil { return fmt.Errorf("post-use: %w", err) } + h.PostUse = cmds } + return nil } @@ -171,15 +185,19 @@ func (h *Hooks) loadFromDir(hooksDir string) error { {"post-use.sh", &h.PostUse}, } { path := filepath.Join(hooksDir, name.file) + data, err := os.ReadFile(path) if os.IsNotExist(err) { continue } + if err != nil { return err } + *name.target = []string{string(data)} } + return nil } @@ -190,6 +208,7 @@ func buildEnv(ctx map[string]any, prefix string) []string { for k, v := range ctx { env = append(env, fmt.Sprintf("%s%s=%v", prefix, strings.ToUpper(k), v)) } + return env } @@ -198,14 +217,17 @@ func renderCommand(cmdTpl string, ctx map[string]any, funcMap template.FuncMap, if !strings.Contains(cmdTpl, delims.Left) { return cmdTpl, nil // fast path: no template expressions } + tmpl, err := template.New("").Delims(delims.Left, delims.Right).Funcs(funcMap).Parse(cmdTpl) if err != nil { return "", err } + var buf bytes.Buffer if err := tmpl.Execute(&buf, ctx); err != nil { return "", err } + return buf.String(), nil } @@ -215,14 +237,18 @@ func toStringSlice(v any) ([]string, error) { if !ok { return nil, fmt.Errorf("expected a list, got %T", v) } + result := make([]string, len(list)) + for i, item := range list { s, ok := item.(string) if !ok { return nil, fmt.Errorf("item %d is not a string: %T", i, item) } + result[i] = s } + return result, nil } @@ -237,5 +263,6 @@ func firstLine(s string) string { if firstLine, _, found := strings.Cut(s, "\n"); found { return firstLine + " …" } + return s } diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index e04957f..3dbadfd 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -34,6 +34,7 @@ func TestLoad_InlinePreUse(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PreUse) != 1 || h.PreUse[0] != "echo hi" { t.Errorf("PreUse = %v, want [echo hi]", h.PreUse) } @@ -48,6 +49,7 @@ func TestLoad_InlinePostUse(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PostUse) != 1 || h.PostUse[0] != "npm install" { t.Errorf("PostUse = %v, want [npm install]", h.PostUse) } @@ -63,9 +65,11 @@ func TestLoad_InlineBothTriggers(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PreUse) != 1 || h.PreUse[0] != "echo pre" { t.Errorf("PreUse = %v", h.PreUse) } + if len(h.PostUse) != 2 { t.Errorf("PostUse = %v, want 2 items", h.PostUse) } @@ -76,9 +80,11 @@ func TestLoad_NoHooks(t *testing.T) { if err != nil { t.Fatal(err) } + if h == nil { t.Fatal("expected non-nil *Hooks") } + if len(h.PreUse) != 0 || len(h.PostUse) != 0 { t.Errorf("expected empty hooks, got %+v", h) } @@ -91,6 +97,7 @@ func TestLoad_EmptyHooks(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PreUse) != 0 || len(h.PostUse) != 0 { t.Errorf("expected empty hooks, got %+v", h) } @@ -100,10 +107,12 @@ func TestLoad_EmptyHooks(t *testing.T) { func TestLoad_DirPreUse(t *testing.T) { dir := t.TempDir() + hooksDir := filepath.Join(dir, "hooks") if err := os.Mkdir(hooksDir, 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(hooksDir, "pre-use.sh"), []byte("echo from file"), 0o644); err != nil { t.Fatal(err) } @@ -112,6 +121,7 @@ func TestLoad_DirPreUse(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PreUse) != 1 || h.PreUse[0] != "echo from file" { t.Errorf("PreUse = %v", h.PreUse) } @@ -119,10 +129,12 @@ func TestLoad_DirPreUse(t *testing.T) { func TestLoad_DirPostUse(t *testing.T) { dir := t.TempDir() + hooksDir := filepath.Join(dir, "hooks") if err := os.Mkdir(hooksDir, 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(hooksDir, "post-use.sh"), []byte("npm install"), 0o644); err != nil { t.Fatal(err) } @@ -131,6 +143,7 @@ func TestLoad_DirPostUse(t *testing.T) { if err != nil { t.Fatal(err) } + if len(h.PostUse) != 1 || h.PostUse[0] != "npm install" { t.Errorf("PostUse = %v", h.PostUse) } @@ -146,6 +159,7 @@ func TestLoad_DirMissingFile(t *testing.T) { if err != nil { t.Fatal(err) } + if h.PreUse != nil { t.Errorf("expected nil PreUse, got %v", h.PreUse) } @@ -191,6 +205,7 @@ func TestRun_StopsOnFirstFailure(t *testing.T) { sentinel := t.TempDir() + "/sentinel" h := &Hooks{PostUse: []string{"exit 1", "touch " + sentinel}} _ = h.Run("post-use", t.TempDir(), map[string]any{}, emptyFuncMap, specs.DefaultDelimiters) + if _, err := os.Stat(sentinel); err == nil { t.Error("second command ran after first failure") } @@ -199,6 +214,7 @@ func TestRun_StopsOnFirstFailure(t *testing.T) { func TestRun_InjectsEnvVarsWithPrefix(t *testing.T) { h := &Hooks{PostUse: []string{`test "$SPECS_PROJECTNAME" = acme`}, EnvPrefix: specs.HookEnvPrefix} ctx := map[string]any{"ProjectName": "acme"} + if err := h.Run("post-use", t.TempDir(), ctx, emptyFuncMap, specs.DefaultDelimiters); err != nil { t.Errorf("env var not injected: %v", err) } @@ -207,6 +223,7 @@ func TestRun_InjectsEnvVarsWithPrefix(t *testing.T) { func TestRun_InjectsEnvVarsNoPrefix(t *testing.T) { h := &Hooks{PostUse: []string{`test "$PROJECTNAME" = acme`}, EnvPrefix: ""} ctx := map[string]any{"ProjectName": "acme"} + if err := h.Run("post-use", t.TempDir(), ctx, emptyFuncMap, specs.DefaultDelimiters); err != nil { t.Errorf("env var not injected without prefix: %v", err) } @@ -215,6 +232,7 @@ func TestRun_InjectsEnvVarsNoPrefix(t *testing.T) { func TestRun_RendersTemplateInCommand(t *testing.T) { h := &Hooks{PostUse: []string{`test "{{.Name}}" = world`}} ctx := map[string]any{"Name": "world"} + if err := h.Run("post-use", t.TempDir(), ctx, emptyFuncMap, specs.DefaultDelimiters); err != nil { t.Errorf("template not rendered: %v", err) } @@ -236,11 +254,14 @@ func TestRun_EmptyHooks(t *testing.T) { func TestRun_MissingBash_ReturnsError(t *testing.T) { h := &Hooks{PostUse: []string{"echo ok"}} + t.Setenv("PATH", "") + err := h.Run("post-use", t.TempDir(), map[string]any{}, emptyFuncMap, specs.DefaultDelimiters) if err == nil { t.Fatal("expected error when bash is absent from PATH") } + if !strings.Contains(err.Error(), "bash") { t.Errorf("error should mention bash, got: %v", err) } @@ -251,10 +272,12 @@ func TestRun_MissingBash_ReturnsError(t *testing.T) { func TestRendered_PreUse(t *testing.T) { h := &Hooks{PreUse: []string{`echo {{ .Name }}`}} ctx := map[string]any{"Name": "world"} + got, err := h.Rendered("pre-use", ctx, emptyFuncMap, specs.DefaultDelimiters) if err != nil { t.Fatal(err) } + if len(got) != 1 || got[0] != "echo world" { t.Errorf("Rendered = %v, want [echo world]", got) } @@ -262,10 +285,12 @@ func TestRendered_PreUse(t *testing.T) { func TestRendered_PostUse(t *testing.T) { h := &Hooks{PostUse: []string{"npm install", "git init"}} + got, err := h.Rendered("post-use", map[string]any{}, emptyFuncMap, specs.DefaultDelimiters) if err != nil { t.Fatal(err) } + if len(got) != 2 || got[0] != "npm install" || got[1] != "git init" { t.Errorf("Rendered = %v", got) } diff --git a/internal/host/source.go b/internal/host/source.go index a03acf1..bd65885 100644 --- a/internal/host/source.go +++ b/internal/host/source.go @@ -69,6 +69,7 @@ func parseGitHub(input string) (*Source, error) { parts := strings.SplitN(rest, ":", 2) repoPart := parts[0] + before, after, ok := strings.Cut(repoPart, "/") if !ok { return nil, fmt.Errorf("invalid github source: missing owner/repo separator in %q", input) @@ -84,6 +85,7 @@ func parseGitHub(input string) (*Source, error) { if err := validateGitHubName("owner", owner, maxOwnerLen); err != nil { return nil, fmt.Errorf("invalid github source: %w", err) } + if err := validateGitHubName("repo", repo, maxRepoLen); err != nil { return nil, fmt.Errorf("invalid github source: %w", err) } @@ -91,13 +93,16 @@ func parseGitHub(input string) (*Source, error) { s := &Source{ CloneURL: "https://github.com/" + owner + "/" + repo, } + if len(parts) == 2 { branch := parts[1] if err := validateBranch(branch); err != nil { return nil, fmt.Errorf("invalid github source: %w", err) } + s.Branch = branch } + return s, nil } @@ -106,12 +111,15 @@ func validateGitHubName(kind, name string, maxLen int) error { if name == "" { return fmt.Errorf("%s is empty", kind) } + if len(name) > maxLen { return fmt.Errorf("%s %q exceeds %d characters", kind, name, maxLen) } + if !nameRe.MatchString(name) { return fmt.Errorf("%s name %q is not a valid GitHub name (use letters, numbers, dots, hyphens, underscores)", kind, name) } + return nil } @@ -120,12 +128,15 @@ func validateBranch(branch string) error { if branch == "" { return fmt.Errorf("branch is empty") } + if strings.ContainsAny(branch, " \t\n\r") { return fmt.Errorf("branch %q contains whitespace", branch) } + if strings.Contains(branch, "..") { return fmt.Errorf("branch %q contains \"..\"", branch) } + return nil } @@ -135,12 +146,15 @@ func parseHTTPS(input string) (*Source, error) { if err != nil { return nil, fmt.Errorf("invalid HTTPS URL %q: %w", input, err) } + if u.Host == "" { return nil, fmt.Errorf("invalid HTTPS URL %q: missing host", input) } + if err := validateURLPath(u.Path, input); err != nil { return nil, err } + return &Source{CloneURL: strings.TrimSuffix(input, ".git")}, nil } @@ -151,20 +165,24 @@ func parseSSH(input string) (*Source, error) { if err != nil { return nil, fmt.Errorf("invalid SSH URL %q: %w", input, err) } + if u.Host == "" { return nil, fmt.Errorf("invalid SSH URL %q: missing host", input) } + if err := validateURLPath(u.Path, input); err != nil { return nil, err } } else { // SCP-style: user@host:path colonIdx := strings.Index(input, ":") + path := input[colonIdx+1:] if err := validateScpPath(path, input); err != nil { return nil, err } } + return &Source{CloneURL: strings.TrimSuffix(input, ".git")}, nil } @@ -172,9 +190,11 @@ func parseSSH(input string) (*Source, error) { func validateURLPath(path, input string) error { p := strings.TrimPrefix(path, "/") p = strings.TrimSuffix(p, ".git") + if countNonEmptySegments(p) < 2 { return fmt.Errorf("invalid URL %q: path must have at least two non-empty segments (owner/repo)", input) } + return nil } @@ -184,16 +204,19 @@ func validateScpPath(path, input string) error { if countNonEmptySegments(p) < 2 { return fmt.Errorf("invalid SSH URL %q: path must have at least two non-empty segments (owner/repo)", input) } + return nil } func countNonEmptySegments(path string) int { n := 0 + for seg := range strings.SplitSeq(path, "/") { if seg != "" { n++ } } + return n } @@ -201,5 +224,6 @@ func countNonEmptySegments(path string) int { func isScpStyle(input string) bool { atIdx := strings.Index(input, "@") colonIdx := strings.Index(input, ":") + return atIdx > 0 && colonIdx > atIdx && !strings.Contains(input, "://") } diff --git a/internal/host/source_test.go b/internal/host/source_test.go index 5af3af4..9ef851a 100644 --- a/internal/host/source_test.go +++ b/internal/host/source_test.go @@ -187,17 +187,22 @@ func TestParse(t *testing.T) { if err == nil { t.Fatalf("Parse(%q) = nil error, want error", tt.input) } + return } + if err != nil { t.Fatalf("Parse(%q) error: %v", tt.input, err) } + if src.CloneURL != tt.wantURL { t.Errorf("CloneURL = %q, want %q", src.CloneURL, tt.wantURL) } + if src.Branch != tt.wantBranch { t.Errorf("Branch = %q, want %q", src.Branch, tt.wantBranch) } + if src.LocalPath != tt.wantLocal { t.Errorf("LocalPath = %q, want %q", src.LocalPath, tt.wantLocal) } diff --git a/internal/registry/local_upgrade_test.go b/internal/registry/local_upgrade_test.go index 1e161d7..bfe173e 100644 --- a/internal/registry/local_upgrade_test.go +++ b/internal/registry/local_upgrade_test.go @@ -23,37 +23,47 @@ var upgradeSig = &object.Signature{Name: "Test", Email: "test@example.com", When func newSourceRepo(t *testing.T) (string, *gogit.Repository) { t.Helper() dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) if err != nil { t.Fatalf("PlainInit: %v", err) } + commitFile(t, repo, dir, "init") + head, err := repo.Head() if err != nil { t.Fatalf("Head: %v", err) } + if _, err := repo.CreateTag("1.0.0", head.Hash(), nil); err != nil { t.Fatalf("CreateTag: %v", err) } + return dir, repo } func commitFile(t *testing.T, repo *gogit.Repository, dir, label string) plumbing.Hash { t.Helper() + wt, err := repo.Worktree() if err != nil { t.Fatalf("Worktree: %v", err) } + if err := os.WriteFile(filepath.Join(dir, label+".txt"), []byte(label), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } + if _, err := wt.Add(label + ".txt"); err != nil { t.Fatalf("Add: %v", err) } + hash, err := wt.Commit(label, &gogit.CommitOptions{Author: upgradeSig}) if err != nil { t.Fatalf("Commit: %v", err) } + return hash } @@ -61,18 +71,22 @@ func commitFile(t *testing.T, repo *gogit.Repository, dir, label string) plumbin // writes metadata with a "local:" repository and the source's describe output. func saveLocalTemplate(t *testing.T, registryDir, name, src string) string { t.Helper() + dest := filepath.Join(registryDir, name) if err := osutil.CopyDir(src, dest); err != nil { t.Fatalf("CopyDir: %v", err) } + desc, err := pkggit.Describe(src) if err != nil { t.Fatalf("Describe: %v", err) } + now := time.Now().UTC() if err := pkgtemplate.SaveMetadata(dest, name, "local:"+src, "", desc.Commit, desc.Version, now, now); err != nil { t.Fatalf("SaveMetadata: %v", err) } + return dest } @@ -85,9 +99,11 @@ func TestUpgrade_LocalGitSource_UpToDate(t *testing.T) { if err != nil { t.Fatalf("Upgrade: %v", err) } + if !res.AlreadyUpToDate { t.Errorf("expected AlreadyUpToDate=true when source is unchanged, got %+v", res) } + if res.IsLocal { t.Error("expected IsLocal=false for a git-tracked local source") } @@ -108,6 +124,7 @@ func TestUpgrade_LocalGitSource_Advanced(t *testing.T) { if err != nil { t.Fatalf("Upgrade: %v", err) } + if res.AlreadyUpToDate || res.IsLocal { t.Fatalf("expected an actual upgrade, got %+v", res) } @@ -116,10 +133,12 @@ func TestUpgrade_LocalGitSource_Advanced(t *testing.T) { if after.Commit == before.Commit { t.Error("expected Commit to change after upgrade") } + if after.Commit != newDesc.Commit { t.Errorf("Commit = %q, want %q", after.Commit, newDesc.Commit) } - if !after.Created.Time.Equal(before.Created.Time) { + + if !after.Created.Equal(before.Created.Time) { t.Error("Created should be preserved across upgrade") } // The freshly copied file must be present in the registry. diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 64701ab..59c2b58 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -40,10 +40,12 @@ func Load(name string) (*Entry, error) { if _, err := os.Stat(root); os.IsNotExist(err) { return nil, nil } + meta, err := pkgtemplate.LoadMetadata(root) if err != nil { slog.Debug("failed to parse template metadata", "template", name, "error", err) } + var status *pkgtemplate.TemplateStatus if meta != nil && meta.Repository != "" && meta.Branch != "" { status, err = pkgtemplate.LoadStatus(root) @@ -51,6 +53,7 @@ func Load(name string) (*Entry, error) { slog.Debug("failed to load template status", "template", name, "error", err) } } + return &Entry{Name: name, Root: root, Metadata: meta, Status: status}, nil } @@ -73,9 +76,11 @@ func Upgrade(name string) (UpgradeResult, error) { if err != nil { slog.Debug("failed to parse template metadata", "template", name, "error", err) } + if meta == nil || meta.Repository == "" { return UpgradeResult{IsLocal: true}, nil } + if strings.HasPrefix(meta.Repository, "local:") { return upgradeLocal(name, root, meta) } @@ -87,14 +92,17 @@ func Upgrade(name string) (UpgradeResult, error) { slog.Debug("could not resolve branch from local HEAD, treating as local", "template", name, "error", err) return UpgradeResult{IsLocal: true}, nil } + branch = b } targetRef := branch + result := pkggit.CheckRemote(root, meta.Repository, branch) if err := result.Err(); err != nil { return UpgradeResult{}, err } + if result.IsUpToDate && result.LatestVersion == "" { return UpgradeResult{AlreadyUpToDate: true}, nil } @@ -108,6 +116,7 @@ func Upgrade(name string) (UpgradeResult, error) { ) newBranch := branch + if result.LatestVersion != "" { targetRef = result.LatestVersion newBranch = result.LatestVersion @@ -116,6 +125,7 @@ func Upgrade(name string) (UpgradeResult, error) { if err := os.RemoveAll(root); err != nil { return UpgradeResult{}, err } + if err := pkggit.Clone(meta.Repository, root, pkggit.CloneOptions{Branch: targetRef}); err != nil { return UpgradeResult{}, err } @@ -131,6 +141,7 @@ func Upgrade(name string) (UpgradeResult, error) { } slog.Debug("upgrade complete", "template", name, "target_ref", targetRef) + return UpgradeResult{Repository: meta.Repository, TargetRef: targetRef}, nil } @@ -149,9 +160,11 @@ func upgradeLocal(name, root string, meta *pkgtemplate.Metadata) (UpgradeResult, src := strings.TrimPrefix(meta.Repository, "local:") check := pkggit.CheckLocalSource(src, meta.Commit, meta.Version) + if check.ErrorKind == pkggit.CheckErrorSourceMissing { return UpgradeResult{}, fmt.Errorf("%w: %s", specs.ErrLocalSourceMissing, src) } + if check.IsUpToDate { return UpgradeResult{AlreadyUpToDate: true}, nil } @@ -161,6 +174,7 @@ func upgradeLocal(name, root string, meta *pkgtemplate.Metadata) (UpgradeResult, if err := os.RemoveAll(root); err != nil { return UpgradeResult{}, err } + if err := osutil.CopyDir(src, root); err != nil { return UpgradeResult{}, err } @@ -176,5 +190,6 @@ func upgradeLocal(name, root string, meta *pkgtemplate.Metadata) (UpgradeResult, } slog.Debug("upgrade local complete", "template", name, "source", src) + return UpgradeResult{Repository: meta.Repository, TargetRef: src}, nil } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 8d5fa64..285ebd1 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -21,9 +21,11 @@ func withTempRegistry(t *testing.T) string { t.Setenv("XDG_CONFIG_HOME", tmp) xdg.Reload() t.Cleanup(func() { xdg.Reload() }) + if err := specs.EnsureRegistry(); err != nil { t.Fatalf("EnsureRegistry: %v", err) } + return specs.TemplateDir() } @@ -34,6 +36,7 @@ func TestLoad_NonExistent(t *testing.T) { if err != nil { t.Fatalf("Load: unexpected error: %v", err) } + if entry != nil { t.Errorf("expected nil entry for non-existent template, got %+v", entry) } @@ -51,15 +54,19 @@ func TestLoad_ExistsNoMetadata(t *testing.T) { if err != nil { t.Fatalf("Load: unexpected error: %v", err) } + if entry == nil { t.Fatal("expected non-nil entry for existing template") } + if entry.Name != "bare-tpl" { t.Errorf("Name = %q, want %q", entry.Name, "bare-tpl") } + if entry.Metadata != nil { t.Errorf("expected nil Metadata for template without __metadata.json, got %+v", entry.Metadata) } + if entry.Status != nil { t.Errorf("expected nil Status for template without remote metadata, got %+v", entry.Status) } @@ -72,6 +79,7 @@ func TestLoad_WithMetadata(t *testing.T) { if err := os.MkdirAll(tmplDir, 0755); err != nil { t.Fatal(err) } + created := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) if err := pkgtemplate.SaveMetadata(tmplDir, "my-tpl", "https://example.com/repo", "main", "abc123", "v1.0.0", created, created); err != nil { t.Fatalf("SaveMetadata: %v", err) @@ -81,15 +89,19 @@ func TestLoad_WithMetadata(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } + if entry == nil { t.Fatal("expected non-nil entry") } + if entry.Metadata == nil { t.Fatal("expected non-nil Metadata") } + if entry.Metadata.Repository != "https://example.com/repo" { t.Errorf("Repository = %q, want %q", entry.Metadata.Repository, "https://example.com/repo") } + if entry.Metadata.Branch != "main" { t.Errorf("Branch = %q, want %q", entry.Metadata.Branch, "main") } @@ -102,6 +114,7 @@ func TestUpgrade_NonExistent(t *testing.T) { if err == nil { t.Fatal("expected error for non-existent template") } + if !errors.Is(err, specs.ErrTemplateNotFound) { t.Errorf("expected ErrTemplateNotFound, got %v", err) } @@ -123,6 +136,7 @@ func TestUpgrade_LocalTemplate(t *testing.T) { if err != nil { t.Fatalf("Upgrade: unexpected error: %v", err) } + if !result.IsLocal { t.Error("expected IsLocal=true for template with local: repository") } @@ -151,6 +165,7 @@ func TestUpgrade_LocalTemplate_WithGitHistory(t *testing.T) { if err != nil { t.Fatalf("Upgrade: unexpected error: %v", err) } + if !result.IsLocal { t.Error("expected IsLocal=true — must not attempt network clone of local: repository") } @@ -168,6 +183,7 @@ func TestUpgrade_NoMetadata_TreatedAsLocal(t *testing.T) { if err != nil { t.Fatalf("Upgrade: unexpected error: %v", err) } + if !result.IsLocal { t.Error("expected IsLocal=true for template without metadata") } diff --git a/internal/specs/configuration_test.go b/internal/specs/configuration_test.go index 9016418..a33a4c4 100644 --- a/internal/specs/configuration_test.go +++ b/internal/specs/configuration_test.go @@ -16,6 +16,7 @@ func TestConfigDir_XDGOverride(t *testing.T) { got := specs.ConfigDir() want := filepath.Join(tmp, "specs") + if got != want { t.Errorf("ConfigDir() = %q, want %q", got, want) } @@ -29,6 +30,7 @@ func TestTemplateDir_XDGOverride(t *testing.T) { got := specs.TemplateDir() want := filepath.Join(tmp, "specs", "templates") + if got != want { t.Errorf("TemplateDir() = %q, want %q", got, want) } @@ -53,4 +55,3 @@ func TestIsReservedName(t *testing.T) { } } } - diff --git a/internal/specs/errors_test.go b/internal/specs/errors_test.go index a22c4c4..243e20d 100644 --- a/internal/specs/errors_test.go +++ b/internal/specs/errors_test.go @@ -57,6 +57,7 @@ func TestKindOf_NilPanics(t *testing.T) { t.Errorf("KindOf(nil) panicked: %v", r) } }() + if got := specs.KindOf(nil); got != "" { t.Errorf("KindOf(nil) = %q, want %q", got, "") } diff --git a/internal/template/analysis.go b/internal/template/analysis.go index b030611..1b084ec 100644 --- a/internal/template/analysis.go +++ b/internal/template/analysis.go @@ -38,6 +38,7 @@ func AnalyzeConditionals( if err != nil { return err } + rel, _ := filepath.Rel(srcRoot, path) if rel == "." { return nil @@ -52,8 +53,10 @@ func AnalyzeConditionals( if err != nil { return err } + analyseExpr(string(data), nil, funcMap, conds, always, delims) } + return nil }) if err != nil { @@ -65,6 +68,7 @@ func AnalyzeConditionals( for k := range always { referenced[k] = true } + for k := range conds { referenced[k] = true } @@ -73,6 +77,7 @@ func AnalyzeConditionals( for k := range always { delete(conds, k) } + return conds, referenced, nil } @@ -90,14 +95,17 @@ func analyseExpr( if !strings.Contains(src, delims.Left) { return } + tmpl, err := texttemplate.New("").Delims(delims.Left, delims.Right).Funcs(funcMap).Parse(src) - if err != nil || tmpl == nil || tmpl.Tree == nil || tmpl.Tree.Root == nil { + if err != nil || tmpl == nil || tmpl.Tree == nil || tmpl.Root == nil { if err != nil { slog.Debug("analyseExpr: failed to parse expression; conditional analysis skipped", "err", err) } + return } - walkNode(tmpl.Tree.Root, outerGate, funcMap, conds, always) + + walkNode(tmpl.Root, outerGate, funcMap, conds, always) } // walkNode recursively walks a template AST node, tracking conditional gates. @@ -127,7 +135,9 @@ func walkNode( if ok { thenGate := andGates(gate, innerCond) elseGate := andGates(gate, condNot{innerCond}) + walkNode(n.List, thenGate, funcMap, conds, always) + if n.ElseList != nil { walkNode(n.ElseList, elseGate, funcMap, conds, always) } @@ -135,6 +145,7 @@ func walkNode( // Unrecognised condition — walk bodies under the current gate unchanged // (conservative fallback: treat as unconditional). walkNode(n.List, gate, funcMap, conds, always) + if n.ElseList != nil { walkNode(n.ElseList, gate, funcMap, conds, always) } @@ -157,7 +168,9 @@ func walkNode( if len(n.Ident) == 0 { return } + key := n.Ident[0] + if gate == nil { always[key] = true } else if !always[key] { @@ -165,6 +178,7 @@ func walkNode( // Seen under a different condition — treat as always needed. if fmt.Sprint(existing) != fmt.Sprint(gate) { always[key] = true + delete(conds, key) } } else { @@ -175,6 +189,7 @@ func walkNode( case *parse.RangeNode: walkNode(n.Pipe, gate, funcMap, conds, always) walkNode(n.List, gate, funcMap, conds, always) + if n.ElseList != nil { walkNode(n.ElseList, gate, funcMap, conds, always) } @@ -182,6 +197,7 @@ func walkNode( case *parse.WithNode: walkNode(n.Pipe, gate, funcMap, conds, always) walkNode(n.List, gate, funcMap, conds, always) + if n.ElseList != nil { walkNode(n.ElseList, gate, funcMap, conds, always) } @@ -194,6 +210,7 @@ func andGates(outer Cond, inner Cond) Cond { if outer == nil { return inner } + return condAnd{subs: []Cond{outer, inner}} } @@ -203,6 +220,7 @@ func parsePipeCond(pipe *parse.PipeNode) (Cond, bool) { if pipe == nil || len(pipe.Cmds) != 1 { return nil, false } + return parseCmdCond(pipe.Cmds[0]) } @@ -217,6 +235,7 @@ func parseCmdCond(cmd *parse.CommandNode) (Cond, bool) { if f, ok := args[0].(*parse.FieldNode); ok && len(f.Ident) == 1 { return condField{f.Ident[0]}, true } + return nil, false } @@ -231,44 +250,55 @@ func parseCmdCond(cmd *parse.CommandNode) (Cond, bool) { if len(args) != 2 { return nil, false } + sub, ok := parseArgCond(args[1]) if !ok { return nil, false } + return condNot{sub}, true case "eq", "ne": if len(args) != 3 { return nil, false } + field, ok := args[1].(*parse.FieldNode) if !ok || len(field.Ident) != 1 { return nil, false } + lit, ok := parseLiteral(args[2]) if !ok { return nil, false } + if fn.Ident == "eq" { return condEq{field.Ident[0], lit}, true } + return condNe{field.Ident[0], lit}, true case "and", "or": if len(args) < 3 { return nil, false } + subs := make([]Cond, 0, len(args)-1) + for _, arg := range args[1:] { sub, ok := parseArgCond(arg) if !ok { return nil, false } + subs = append(subs, sub) } + if fn.Ident == "and" { return condAnd{subs}, true } + return condOr{subs}, true } @@ -286,6 +316,7 @@ func parseArgCond(arg parse.Node) (Cond, bool) { case *parse.PipeNode: return parsePipeCond(n) } + return nil, false } @@ -300,9 +331,11 @@ func parseLiteral(node parse.Node) (any, bool) { if n.IsInt { return n.Int64, true } + if n.IsFloat { return n.Float64, true } } + return nil, false } diff --git a/internal/template/analysis_test.go b/internal/template/analysis_test.go index 97cae00..d702a93 100644 --- a/internal/template/analysis_test.go +++ b/internal/template/analysis_test.go @@ -12,32 +12,39 @@ import ( // yaml is the project.yaml content, files maps template/-relative paths to content. func buildAnalysisTemplate(t *testing.T, yaml string, files map[string]string) string { t.Helper() + root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "project.yaml"), []byte(yaml), 0644); err != nil { t.Fatalf("writing project.yaml: %v", err) } + templateDir := filepath.Join(root, "template") if err := os.MkdirAll(templateDir, 0755); err != nil { t.Fatalf("creating template dir: %v", err) } + for relPath, content := range files { abs := filepath.Join(templateDir, filepath.FromSlash(relPath)) if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { t.Fatalf("creating parent dir for %s: %v", relPath, err) } + if err := os.WriteFile(abs, []byte(content), 0644); err != nil { t.Fatalf("writing %s: %v", relPath, err) } } + return root } func analyzeTemplate(t *testing.T, root string) *pkgtemplate.Template { t.Helper() + tmpl, err := pkgtemplate.Get(root, pkgtemplate.Config{}) if err != nil { t.Fatalf("Get: %v", err) } + return tmpl } @@ -47,6 +54,7 @@ func TestAnalysis_Unconditional(t *testing.T) { map[string]string{"file.txt": "{{.Name}}"}, ) tmpl := analyzeTemplate(t, root) + conds := tmpl.Conditionals if _, ok := conds["Name"]; ok { t.Error("Name should not be in conditionals (it is used unconditionally)") @@ -71,9 +79,11 @@ func TestAnalysis_SimpleGate(t *testing.T) { if !ok { t.Fatal("DbName should be in conditionals") } + if !cond.Eval(map[string]any{"UseDB": true}) { t.Error("DbName condition should be satisfied when UseDB=true") } + if cond.Eval(map[string]any{"UseDB": false}) { t.Error("DbName condition should not be satisfied when UseDB=false") } @@ -95,6 +105,7 @@ func TestAnalysis_ElseBranch(t *testing.T) { if cond.Eval(map[string]any{"UseDB": false}) != true { t.Error("NoDbMsg condition should be satisfied when UseDB=false") } + if cond.Eval(map[string]any{"UseDB": true}) != false { t.Error("NoDbMsg condition should not be satisfied when UseDB=true") } @@ -112,9 +123,11 @@ func TestAnalysis_Not(t *testing.T) { if !ok { t.Fatal("Fallback should be in conditionals") } + if !cond.Eval(map[string]any{"UseDB": false}) { t.Error("Fallback condition should be satisfied when UseDB=false") } + if cond.Eval(map[string]any{"UseDB": true}) { t.Error("Fallback condition should not be satisfied when UseDB=true") } @@ -132,9 +145,11 @@ func TestAnalysis_Eq(t *testing.T) { if !ok { t.Fatal("PgPort should be in conditionals") } + if !cond.Eval(map[string]any{"DbType": "pg"}) { t.Error("PgPort condition should be satisfied when DbType=pg") } + if cond.Eval(map[string]any{"DbType": "mysql"}) { t.Error("PgPort condition should not be satisfied when DbType=mysql") } @@ -152,12 +167,15 @@ func TestAnalysis_And(t *testing.T) { if !ok { t.Fatal("Cert should be in conditionals") } + if !cond.Eval(map[string]any{"UseDB": true, "UseSSL": true}) { t.Error("Cert condition should be satisfied when UseDB=true AND UseSSL=true") } + if cond.Eval(map[string]any{"UseDB": true, "UseSSL": false}) { t.Error("Cert condition should not be satisfied when UseSSL=false") } + if cond.Eval(map[string]any{"UseDB": false, "UseSSL": true}) { t.Error("Cert condition should not be satisfied when UseDB=false") } @@ -175,12 +193,15 @@ func TestAnalysis_Nested(t *testing.T) { if !ok { t.Fatal("PgCfg should be in conditionals (nested if)") } + if !cond.Eval(map[string]any{"UseDB": true, "DbType": "pg"}) { t.Error("PgCfg condition should be satisfied when UseDB=true and DbType=pg") } + if cond.Eval(map[string]any{"UseDB": false, "DbType": "pg"}) { t.Error("PgCfg condition should not be satisfied when UseDB=false") } + if cond.Eval(map[string]any{"UseDB": true, "DbType": "mysql"}) { t.Error("PgCfg condition should not be satisfied when DbType!=pg") } @@ -193,6 +214,7 @@ func TestAnalysis_BothBranches_AlwaysNeeded(t *testing.T) { map[string]string{"file.txt": "{{.Name}}\n{{if .UseDB}}also: {{.Name}}{{end}}"}, ) tmpl := analyzeTemplate(t, root) + conds := tmpl.Conditionals if _, ok := conds["Name"]; ok { t.Error("Name should not be conditional (referenced both inside and outside if)") @@ -209,6 +231,7 @@ func TestAnalysis_UnknownFn_FallsBackToAlways(t *testing.T) { // Since Get uses the real FuncMap from FuncMap(), myFunc is not in it, // so the template file will fail to parse — the analyser treats it as always-needed. tmpl := analyzeTemplate(t, root) + conds := tmpl.Conditionals if _, ok := conds["Y"]; ok { t.Error("Y should not be in conditionals when condition function is unrecognised") @@ -226,6 +249,7 @@ func TestAnalysis_MultiFile_ConflictBecomesAlways(t *testing.T) { }, ) tmpl := analyzeTemplate(t, root) + conds := tmpl.Conditionals if _, ok := conds["DbName"]; ok { t.Error("DbName should not be conditional when used unconditionally in another file") @@ -239,6 +263,7 @@ func TestAnalysis_Filename_GateVarAlwaysNeeded(t *testing.T) { map[string]string{"{{if .UseDB}}db.env{{end}}": "content"}, ) tmpl := analyzeTemplate(t, root) + conds := tmpl.Conditionals if _, ok := conds["UseDB"]; ok { t.Error("UseDB should not be conditional when it is used as a gate in a filename") @@ -257,12 +282,15 @@ func TestAnalysis_Or(t *testing.T) { if !ok { t.Fatal("Secret should be in conditionals") } + if !cond.Eval(map[string]any{"UseA": true, "UseB": false}) { t.Error("Secret condition should be satisfied when UseA=true") } + if !cond.Eval(map[string]any{"UseA": false, "UseB": true}) { t.Error("Secret condition should be satisfied when UseB=true") } + if cond.Eval(map[string]any{"UseA": false, "UseB": false}) { t.Error("Secret condition should not be satisfied when both false") } @@ -276,10 +304,12 @@ func TestReferenced_UnusedVarAbsent(t *testing.T) { "Name: \"\"\nUnused: \"\"\n", map[string]string{"file.txt": "{{.Name}}"}, ) + tmpl := analyzeTemplate(t, root) if tmpl.Referenced["Unused"] { t.Error("Unused should not be in Referenced") } + if !tmpl.Referenced["Name"] { t.Error("Name should be in Referenced") } @@ -291,6 +321,7 @@ func TestReferenced_ConditionalVarPresent(t *testing.T) { "UseDB: false\nDbName: \"\"\n", map[string]string{"file.txt": "{{if .UseDB}}DB={{.DbName}}{{end}}"}, ) + tmpl := analyzeTemplate(t, root) if !tmpl.Referenced["DbName"] { t.Error("DbName should be in Referenced (conditional but still referenced)") @@ -303,6 +334,7 @@ func TestReferenced_ComputedOnlyVar(t *testing.T) { "Name: \"\"\ncomputed:\n Upper: \"{{ toUpper .Name }}\"\n", map[string]string{"file.txt": "{{.Upper}}"}, ) + tmpl := analyzeTemplate(t, root) if !tmpl.Referenced["Name"] { t.Error("Name should be in Referenced (used in computed expression)") diff --git a/internal/template/cond.go b/internal/template/cond.go index a0f51b7..49dbb93 100644 --- a/internal/template/cond.go +++ b/internal/template/cond.go @@ -49,6 +49,7 @@ func (c condAnd) Eval(ctx map[string]any) bool { return false } } + return true } func (c condOr) Eval(ctx map[string]any) bool { @@ -57,6 +58,7 @@ func (c condOr) Eval(ctx map[string]any) bool { return true } } + return false } @@ -72,6 +74,7 @@ func collectKeys(subs []Cond) []string { for _, s := range subs { keys = append(keys, s.Keys()...) } + return keys } diff --git a/internal/template/cond_test.go b/internal/template/cond_test.go index 489023e..5ed2f22 100644 --- a/internal/template/cond_test.go +++ b/internal/template/cond_test.go @@ -109,6 +109,7 @@ func TestCondOr_AllFalse(t *testing.T) { func TestCondField_Keys(t *testing.T) { c := condField{"X"} + keys := c.Keys() if len(keys) != 1 || keys[0] != "X" { t.Errorf("Keys() = %v, want [X]", keys) @@ -117,6 +118,7 @@ func TestCondField_Keys(t *testing.T) { func TestCondNot_Keys(t *testing.T) { c := condNot{condField{"X"}} + keys := c.Keys() if len(keys) != 1 || keys[0] != "X" { t.Errorf("Keys() = %v, want [X]", keys) @@ -125,6 +127,7 @@ func TestCondNot_Keys(t *testing.T) { func TestCondAnd_Keys(t *testing.T) { c := condAnd{[]Cond{condField{"A"}, condField{"B"}}} + keys := c.Keys() if len(keys) != 2 { t.Errorf("Keys() = %v, want 2 keys", keys) @@ -133,6 +136,7 @@ func TestCondAnd_Keys(t *testing.T) { func TestCondOr_Keys(t *testing.T) { c := condOr{[]Cond{condField{"A"}, condField{"B"}}} + keys := c.Keys() if len(keys) != 2 { t.Errorf("Keys() = %v, want 2 keys", keys) diff --git a/internal/template/context.go b/internal/template/context.go index b35563f..ad6dd22 100644 --- a/internal/template/context.go +++ b/internal/template/context.go @@ -43,6 +43,7 @@ func LoadUserContext(templateRoot string, funcMap texttemplate.FuncMap, delims s delete(raw, specs.ProjectSpecsVersionKey) // consumed by Get(); must not appear as a user variable userCtx, err = resolveReferencedDefaults(raw, funcMap, delims) + return userCtx, computedDefs, err } @@ -54,19 +55,24 @@ func ExtractProjectDelimiters(templateRoot string, fallback specs.Delimiters) (s if err != nil { return fallback, err } + v, ok := raw[specs.ProjectDelimitersKey] if !ok { return fallback, nil } + m, ok := v.(map[string]any) if !ok { return fallback, specs.ErrInvalidDelimiters } + left, leftOK := m["left"].(string) right, rightOK := m["right"].(string) + if !leftOK || !rightOK || left == "" || right == "" { return fallback, specs.ErrInvalidDelimiters } + return specs.Delimiters{Left: left, Right: right}, nil } @@ -79,18 +85,22 @@ func ExtractSpecsVersion(templateRoot string) (constraint *semver.Constraints, r if err != nil { return nil, "", err } + v, ok := rawCtx[specs.ProjectSpecsVersionKey] if !ok { return nil, "", nil } + s, ok := v.(string) if !ok { return nil, "", fmt.Errorf("%w: got %T", specs.ErrInvalidSpecsVersion, v) } + c, err := semver.NewConstraint(s) if err != nil { return nil, "", fmt.Errorf("%w: %q: %v", specs.ErrInvalidSpecsVersion, s, err) } + return c, s, nil } @@ -104,12 +114,15 @@ func ReservedValues(templateRoot string) (map[string]any, error) { if err != nil { return nil, err } + reserved := make(map[string]any) + for k := range specs.ReservedConfigKeys { if v, ok := raw[k]; ok { reserved[k] = v } } + return reserved, nil } @@ -143,11 +156,14 @@ func ApplyComputed(ctx map[string]any, defs map[string]string, funcMap texttempl for _, k := range sorted { expr := defs[k] + val, err := renderExpr(expr, result, funcMap, delims) if err != nil { return nil, fmt.Errorf("computed %q: %w", k, err) } + result[k] = val + slog.Debug("context key resolved", "key", k, "source", "computed") } @@ -173,30 +189,37 @@ func LoadProjectFile(templateRoot string) (map[string]any, error) { if hasYAML || hasYML { chosen := yamlPath chosenName := specs.ProjectYAMLFile + if hasYML { chosen = ymlPath chosenName = specs.ProjectYMLFile } + data, err := os.ReadFile(chosen) if err != nil { return nil, fmt.Errorf("reading %s: %w", chosenName, err) } + var ctx map[string]any if err := yaml.Unmarshal(data, &ctx); err != nil { return nil, fmt.Errorf("parsing %s: %w", chosenName, err) } + return ctx, nil } jsonPath := filepath.Join(templateRoot, specs.ProjectJSONFile) + data, err := os.ReadFile(jsonPath) if err != nil { return nil, fmt.Errorf("%w in %s", specs.ErrProjectFileMissing, templateRoot) } + var ctx map[string]any if err := json.Unmarshal(data, &ctx); err != nil { return nil, fmt.Errorf("parsing %s: %w", specs.ProjectJSONFile, err) } + return ctx, nil } @@ -207,16 +230,20 @@ func LoadProjectFile(templateRoot string) (map[string]any, error) { // configuration keys can be added without clashing with existing template variables. func CheckReservedNames(raw map[string]any) error { seen := make(map[string]bool) + var offending []string + collect := func(name string) { if specs.IsReservedName(name) && !seen[name] { seen[name] = true + offending = append(offending, name) } } for k, v := range raw { collect(k) + if k == "computed" { if m, ok := v.(map[string]any); ok { for ck := range m { @@ -229,7 +256,9 @@ func CheckReservedNames(raw map[string]any) error { if len(offending) == 0 { return nil } + sort.Strings(offending) + return fmt.Errorf("%w: %s", specs.ErrReservedVariableName, strings.Join(offending, ", ")) } @@ -240,6 +269,7 @@ func extractComputed(raw map[string]any) (map[string]string, error) { if !ok { return nil, nil } + delete(raw, "computed") m, ok := v.(map[string]any) @@ -248,16 +278,20 @@ func extractComputed(raw map[string]any) (map[string]string, error) { } defs := make(map[string]string, len(m)) + for k, val := range m { if _, conflict := raw[k]; conflict { return nil, fmt.Errorf("%w: key %q conflicts with a user input key", specs.ErrInvalidComputedDef, k) } + s, ok := val.(string) if !ok { return nil, fmt.Errorf("%w: value for %q must be a string, got %T", specs.ErrInvalidComputedDef, k, val) } + defs[k] = s } + return defs, nil } @@ -267,11 +301,13 @@ func extractComputed(raw map[string]any) (map[string]string, error) { func resolveReferencedDefaults(ctx map[string]any, funcMap texttemplate.FuncMap, delims specs.Delimiters) (map[string]any, error) { // Find keys whose string value is a template expression. var refKeys []string + for k, v := range ctx { if s, ok := v.(string); ok && strings.Contains(s, delims.Left) { refKeys = append(refKeys, k) } } + if len(refKeys) == 0 { return ctx, nil } @@ -295,6 +331,7 @@ func resolveReferencedDefaults(ctx map[string]any, funcMap texttemplate.FuncMap, if err != nil { return nil, fmt.Errorf("referenced default %q: %w", k, err) } + result[k] = val } @@ -313,9 +350,11 @@ func topoSort(keys []string, deps map[string][]string) ([]string, error) { inDegree := make(map[string]int, len(keys)) dependents := make(map[string][]string, len(keys)) + for _, k := range keys { inDegree[k] = 0 } + for _, k := range keys { for _, dep := range deps[k] { if inSet[dep] { @@ -326,6 +365,7 @@ func topoSort(keys []string, deps map[string][]string) ([]string, error) { } queue := make([]string, 0, len(keys)) + for _, k := range keys { if inDegree[k] == 0 { queue = append(queue, k) @@ -333,10 +373,13 @@ func topoSort(keys []string, deps map[string][]string) ([]string, error) { } sorted := make([]string, 0, len(keys)) + for len(queue) > 0 { n := queue[0] queue = queue[1:] + sorted = append(sorted, n) + for _, dep := range dependents[n] { inDegree[dep]-- if inDegree[dep] == 0 { @@ -347,11 +390,13 @@ func topoSort(keys []string, deps map[string][]string) ([]string, error) { if len(sorted) != len(keys) { var cycle []string + for _, k := range keys { if inDegree[k] > 0 { cycle = append(cycle, k) } } + return nil, fmt.Errorf("%w: %s", specs.ErrCyclicDependency, strings.Join(cycle, ", ")) } @@ -366,17 +411,22 @@ func extractRefs(expr string, funcMap texttemplate.FuncMap, delims specs.Delimit if !strings.Contains(expr, delims.Left) { return nil } + tmpl, err := texttemplate.New(""). Delims(delims.Left, delims.Right). Funcs(funcMap). Parse(expr) - if err != nil || tmpl == nil || tmpl.Tree == nil || tmpl.Tree.Root == nil { + if err != nil || tmpl == nil || tmpl.Tree == nil || tmpl.Root == nil { slog.Debug("extractRefs: failed to parse expression; dependency detection skipped", "err", err) return nil // parse errors surface during actual rendering } + seen := make(map[string]bool) + var refs []string - walkForRefs(tmpl.Tree.Root, seen, &refs) + + walkForRefs(tmpl.Root, seen, &refs) + return refs } @@ -385,6 +435,7 @@ func walkForRefs(node parse.Node, seen map[string]bool, refs *[]string) { if node == nil { return } + switch n := node.(type) { case *parse.ListNode: for _, child := range n.Nodes { @@ -405,24 +456,28 @@ func walkForRefs(node parse.Node, seen map[string]bool, refs *[]string) { key := n.Ident[0] if !seen[key] { seen[key] = true + *refs = append(*refs, key) } } case *parse.IfNode: walkForRefs(n.Pipe, seen, refs) walkForRefs(n.List, seen, refs) + if n.ElseList != nil { walkForRefs(n.ElseList, seen, refs) } case *parse.RangeNode: walkForRefs(n.Pipe, seen, refs) walkForRefs(n.List, seen, refs) + if n.ElseList != nil { walkForRefs(n.ElseList, seen, refs) } case *parse.WithNode: walkForRefs(n.Pipe, seen, refs) walkForRefs(n.List, seen, refs) + if n.ElseList != nil { walkForRefs(n.ElseList, seen, refs) } @@ -439,9 +494,11 @@ func renderExpr(expr string, ctx map[string]any, funcMap texttemplate.FuncMap, d if err != nil { return "", err } + var buf bytes.Buffer if err := tmpl.Execute(&buf, ctx); err != nil { return "", err } + return buf.String(), nil } diff --git a/internal/template/context_internal_test.go b/internal/template/context_internal_test.go index 038431e..d521036 100644 --- a/internal/template/context_internal_test.go +++ b/internal/template/context_internal_test.go @@ -15,6 +15,7 @@ func TestResolveReferencedDefaults_DoesNotMutateInput(t *testing.T) { originalSlug := input["Slug"] fm := FuncMap(Config{}) + result, err := resolveReferencedDefaults(input, fm, specs.DefaultDelimiters) if err != nil { t.Fatalf("resolveReferencedDefaults: %v", err) @@ -34,6 +35,7 @@ func TestResolveReferencedDefaults_DoesNotMutateInput(t *testing.T) { func TestResolveReferencedDefaults_NoRefs_ReturnsSameMap(t *testing.T) { input := map[string]any{"Name": "plain"} fm := FuncMap(Config{}) + result, err := resolveReferencedDefaults(input, fm, specs.DefaultDelimiters) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/template/context_test.go b/internal/template/context_test.go index f2b0ea4..d7946ff 100644 --- a/internal/template/context_test.go +++ b/internal/template/context_test.go @@ -12,6 +12,7 @@ import ( func writeProjectYAML(t *testing.T, dir, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, "project.yaml"), []byte(content), 0644); err != nil { t.Fatalf("writeProjectYAML: %v", err) } @@ -19,6 +20,7 @@ func writeProjectYAML(t *testing.T, dir, content string) { func writeProjectYML(t *testing.T, dir, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte(content), 0644); err != nil { t.Fatalf("writeProjectYML: %v", err) } @@ -26,6 +28,7 @@ func writeProjectYML(t *testing.T, dir, content string) { func writeProjectJSON(t *testing.T, dir, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, "project.json"), []byte(content), 0644); err != nil { t.Fatalf("writeProjectJSON: %v", err) } @@ -39,6 +42,7 @@ func TestLoadUserContext_String(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if ctx["Name"] != "my-project" { t.Errorf("ctx[Name] = %q, want %q", ctx["Name"], "my-project") } @@ -52,6 +56,7 @@ func TestLoadUserContext_Bool(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if ctx["UseSonar"] != false { t.Errorf("ctx[UseSonar] = %v, want false", ctx["UseSonar"]) } @@ -69,10 +74,12 @@ License: if err != nil { t.Fatalf("unexpected error: %v", err) } + list, ok := ctx["License"].([]any) if !ok { t.Fatalf("ctx[License] is %T, want []any", ctx["License"]) } + if len(list) != 2 || list[0] != "MIT" || list[1] != "GPL" { t.Errorf("ctx[License] = %v, want [MIT GPL]", list) } @@ -86,6 +93,7 @@ func TestLoadUserContext_JSONFallback(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if ctx["Name"] != "from-json" { t.Errorf("ctx[Name] = %q, want %q", ctx["Name"], "from-json") } @@ -102,6 +110,7 @@ Slug: "{{toKebabCase .Name}}" if err != nil { t.Fatalf("unexpected error: %v", err) } + if ctx["Slug"] != "my-app" { t.Errorf("ctx[Slug] = %q, want %q", ctx["Slug"], "my-app") } @@ -133,6 +142,7 @@ hooks: if err != nil { t.Fatalf("unexpected error: %v", err) } + if _, ok := ctx["hooks"]; ok { t.Error("hooks key should be stripped from user context") } @@ -150,12 +160,15 @@ computed: if err != nil { t.Fatalf("unexpected error: %v", err) } + if _, ok := ctx["computed"]; ok { t.Error("computed key should be stripped from user context") } + if _, ok := ctx["Env"]; ok { t.Error("Env computed key should not be in user context") } + if computedDefs["Env"] != "prod" { t.Errorf("computedDefs[Env] = %q, want %q", computedDefs["Env"], "prod") } @@ -192,6 +205,7 @@ computed: if err != nil { t.Fatalf("ApplyComputed: %v", err) } + if result["Env"] != "ACME" { t.Errorf("result[Env] = %q, want %q", result["Env"], "ACME") } @@ -218,9 +232,11 @@ computed: if err != nil { t.Fatalf("ApplyComputed: %v", err) } + if result["Slug"] != "acme" { t.Errorf("result[Slug] = %q, want %q", result["Slug"], "acme") } + if result["DbName"] != "acme_production" { t.Errorf("result[DbName] = %q, want %q", result["DbName"], "acme_production") } @@ -264,9 +280,11 @@ func TestApplyComputed_ChainWithCustomDelimitersAndBuiltins(t *testing.T) { if err != nil { t.Fatalf("ApplyComputed: %v", err) } + if result["PhpDockerTag"] != "0.5.3" { t.Errorf("PhpDockerTag = %q, want %q", result["PhpDockerTag"], "0.5.3") } + if result["Php84DockerTag"] != "1.5.3" { t.Errorf("Php84DockerTag = %q, want %q", result["Php84DockerTag"], "1.5.3") } @@ -274,10 +292,12 @@ func TestApplyComputed_ChainWithCustomDelimitersAndBuiltins(t *testing.T) { func TestApplyComputed_NoDefs(t *testing.T) { ctx := map[string]any{"Name": "test"} + result, err := pkgtemplate.ApplyComputed(ctx, nil, pkgtemplate.FuncMap(pkgtemplate.Config{}), specs.DefaultDelimiters) if err != nil { t.Fatalf("unexpected error: %v", err) } + if result["Name"] != "test" { t.Errorf("result[Name] = %q, want %q", result["Name"], "test") } @@ -291,6 +311,7 @@ func TestLoadUserContext_YMLExtension(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if ctx["Name"] != "from-yml" { t.Errorf("ctx[Name] = %q, want %q", ctx["Name"], "from-yml") } @@ -304,9 +325,11 @@ func TestLoadUserContext_DelimitersKeyStripped(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if _, ok := ctx["__delimiters"]; ok { t.Error("__delimiters should be stripped from user context") } + if ctx["Name"] != "test" { t.Errorf("ctx[Name] = %q, want %q", ctx["Name"], "test") } @@ -320,9 +343,11 @@ func TestExtractSpecsVersion_Missing(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if constraint != nil { t.Errorf("constraint = %v, want nil when key absent", constraint) } + if raw != "" { t.Errorf("raw = %q, want empty when key absent", raw) } @@ -336,9 +361,11 @@ func TestExtractSpecsVersion_Valid(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if constraint == nil { t.Fatal("constraint = nil, want a parsed constraint") } + if raw != "^0.1.0" { t.Errorf("raw = %q, want %q", raw, "^0.1.0") } @@ -372,9 +399,11 @@ func TestLoadUserContext_SpecsVersionKeyStripped(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if _, ok := ctx["__specs__version"]; ok { t.Error("__specs__version should be stripped from user context") } + if ctx["Name"] != "test" { t.Errorf("ctx[Name] = %q, want %q", ctx["Name"], "test") } @@ -417,12 +446,15 @@ func TestReservedValues(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if reserved["__specs__version"] != ">=0.0.1" { t.Errorf("reserved[__specs__version] = %v, want %q", reserved["__specs__version"], ">=0.0.1") } + if _, ok := reserved["__delimiters"]; !ok { t.Error("reserved should contain __delimiters") } + if _, ok := reserved["Name"]; ok { t.Error("reserved must not contain non-reserved user keys") } @@ -436,6 +468,7 @@ func TestReservedValues_None(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if len(reserved) != 0 { t.Errorf("reserved = %v, want empty", reserved) } @@ -461,6 +494,7 @@ func TestCheckReservedNames(t *testing.T) { if tc.wantErr && !errors.Is(err, specs.ErrReservedVariableName) { t.Fatalf("expected ErrReservedVariableName, got %v", err) } + if !tc.wantErr && err != nil { t.Fatalf("unexpected error: %v", err) } @@ -489,6 +523,7 @@ func TestLoadProjectFile_ReturnsRawMap(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if raw["Name"] != "raw" { t.Errorf("raw[Name] = %v, want %q", raw["Name"], "raw") } @@ -506,6 +541,7 @@ func TestLoadProjectFile_JSONFallback(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if raw["Name"] != "json-raw" { t.Errorf("raw[Name] = %v, want %q", raw["Name"], "json-raw") } diff --git a/internal/template/functions.go b/internal/template/functions.go index 88b4f45..9838c33 100644 --- a/internal/template/functions.go +++ b/internal/template/functions.go @@ -66,6 +66,7 @@ func FuncMap(cfg Config) texttemplate.FuncMap { handler := sprout.New(sprout.WithLogger(slog.Default())) mustAddRegistries(handler, registries...) + return handler.Build() } diff --git a/internal/template/functions_test.go b/internal/template/functions_test.go index af1ef80..d56256b 100644 --- a/internal/template/functions_test.go +++ b/internal/template/functions_test.go @@ -104,6 +104,7 @@ func TestFuncMap_NoPanic(t *testing.T) { t.Errorf("FuncMap() panicked: %v", r) } }() + pkgtemplate.FuncMap(pkgtemplate.Config{}) pkgtemplate.FuncMap(pkgtemplate.Config{SafeMode: true}) } diff --git a/internal/template/logging_test.go b/internal/template/logging_test.go index 077cd47..e3d457a 100644 --- a/internal/template/logging_test.go +++ b/internal/template/logging_test.go @@ -20,8 +20,11 @@ func TestExecute_SummaryLogIsDebugLevel(t *testing.T) { runExecute := func(t *testing.T, level slog.Level) string { t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))) t.Cleanup(func() { slog.SetDefault(prev) }) @@ -29,9 +32,11 @@ func TestExecute_SummaryLogIsDebugLevel(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if err := tmpl.Execute(t.TempDir()); err != nil { t.Fatalf("Execute: %v", err) } + return buf.String() } @@ -45,6 +50,7 @@ func TestExecute_SummaryLogIsDebugLevel(t *testing.T) { if !strings.Contains(out, "template execution complete") { t.Fatalf("summary log missing at Debug level:\n%s", out) } + for _, attr := range []string{"rendered=", "verbatim=", "skipped="} { if !strings.Contains(out, attr) { t.Errorf("summary log missing attribute %q at Debug level:\n%s", attr, out) diff --git a/internal/template/metadata.go b/internal/template/metadata.go index b867268..bf9e0a1 100644 --- a/internal/template/metadata.go +++ b/internal/template/metadata.go @@ -27,7 +27,7 @@ type JSONTime struct { } func (t JSONTime) MarshalJSON() ([]byte, error) { - return json.Marshal(t.Time.Format(time.RFC1123Z)) + return json.Marshal(t.Format(time.RFC1123Z)) } func (t *JSONTime) UnmarshalJSON(data []byte) error { @@ -35,17 +35,21 @@ func (t *JSONTime) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &s); err != nil { return err } + parsed, err := time.Parse(time.RFC1123Z, s) if err != nil { return err } + t.Time = parsed + return nil } // String returns a human-readable relative time string ("3 days ago"). func (t JSONTime) String() string { d := time.Since(t.Time) + switch { case d < time.Minute: return "just now" @@ -63,19 +67,22 @@ func (t JSONTime) String() string { // A malformed file returns an error so callers can log the diagnostic. func LoadMetadata(templateRoot string) (*Metadata, error) { path := filepath.Join(templateRoot, specs.MetadataFile) + data, err := os.ReadFile(path) if err != nil { return nil, nil //nolint:nilerr // missing or unreadable metadata is not an error } + var m Metadata if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("parsing %s: %w", specs.MetadataFile, err) } // Metadata written before the Updated field existed has no Updated timestamp. // Fall back to Created so pre-existing templates display a sensible value. - if m.Updated.Time.IsZero() { + if m.Updated.IsZero() { m.Updated = m.Created } + return &m, nil } @@ -93,9 +100,11 @@ func SaveMetadata(templateRoot, name, repository, branch, commit, version string Commit: commit, Version: version, } + data, err := json.MarshalIndent(m, "", " ") if err != nil { return err } + return os.WriteFile(filepath.Join(templateRoot, specs.MetadataFile), data, 0644) } diff --git a/internal/template/metadata_test.go b/internal/template/metadata_test.go index 7ecdc93..e88715d 100644 --- a/internal/template/metadata_test.go +++ b/internal/template/metadata_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - pkgtemplate "github.com/specsnl/specs-cli/internal/template" "github.com/specsnl/specs-cli/internal/specs" + pkgtemplate "github.com/specsnl/specs-cli/internal/template" ) // --- JSONTime.MarshalJSON / UnmarshalJSON --- @@ -27,13 +27,14 @@ func TestJSONTime_RoundTrip(t *testing.T) { t.Fatalf("UnmarshalJSON: %v", err) } - if !original.Time.Equal(restored.Time) { + if !original.Equal(restored.Time) { t.Errorf("round-trip mismatch: got %v, want %v", restored.Time, original.Time) } } func TestJSONTime_MarshalJSON_UsesRFC1123Z(t *testing.T) { jt := pkgtemplate.JSONTime{Time: time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC)} + data, err := json.Marshal(jt) if err != nil { t.Fatalf("MarshalJSON: %v", err) @@ -43,7 +44,8 @@ func TestJSONTime_MarshalJSON_UsesRFC1123Z(t *testing.T) { if err := json.Unmarshal(data, &s); err != nil { t.Fatalf("unexpected format: %v", err) } - want := jt.Time.Format(time.RFC1123Z) + + want := jt.Format(time.RFC1123Z) if s != want { t.Errorf("MarshalJSON = %q, want %q", s, want) } @@ -74,6 +76,7 @@ func TestJSONTime_String_JustNow(t *testing.T) { func TestJSONTime_String_MinutesAgo(t *testing.T) { jt := pkgtemplate.JSONTime{Time: time.Now().Add(-30 * time.Minute)} + got := jt.String() if !strings.HasSuffix(got, "minutes ago") { t.Errorf("String() = %q, want suffix 'minutes ago'", got) @@ -82,6 +85,7 @@ func TestJSONTime_String_MinutesAgo(t *testing.T) { func TestJSONTime_String_HoursAgo(t *testing.T) { jt := pkgtemplate.JSONTime{Time: time.Now().Add(-3 * time.Hour)} + got := jt.String() if !strings.HasSuffix(got, "hours ago") { t.Errorf("String() = %q, want suffix 'hours ago'", got) @@ -90,6 +94,7 @@ func TestJSONTime_String_HoursAgo(t *testing.T) { func TestJSONTime_String_DaysAgo(t *testing.T) { jt := pkgtemplate.JSONTime{Time: time.Now().Add(-48 * time.Hour)} + got := jt.String() if !strings.HasSuffix(got, "days ago") { t.Errorf("String() = %q, want suffix 'days ago'", got) @@ -107,10 +112,12 @@ func TestGet_LoadsMetadata(t *testing.T) { Repository: "user/repo", Created: pkgtemplate.JSONTime{Time: created}, } + data, err := json.Marshal(meta) if err != nil { t.Fatalf("marshal metadata: %v", err) } + if err := os.WriteFile(filepath.Join(root, "__metadata.json"), data, 0644); err != nil { t.Fatalf("write metadata: %v", err) } @@ -119,16 +126,20 @@ func TestGet_LoadsMetadata(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if tmpl.Metadata == nil { t.Fatal("Metadata is nil, expected it to be loaded") } + if tmpl.Metadata.Name != "my-tpl" { t.Errorf("Name = %q, want %q", tmpl.Metadata.Name, "my-tpl") } + if tmpl.Metadata.Repository != "user/repo" { t.Errorf("Repository = %q, want %q", tmpl.Metadata.Repository, "user/repo") } - if !tmpl.Metadata.Created.Time.Equal(created) { + + if !tmpl.Metadata.Created.Equal(created) { t.Errorf("Created = %v, want %v", tmpl.Metadata.Created.Time, created) } } @@ -140,6 +151,7 @@ func TestGet_MissingMetadata_ReturnsNil(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if tmpl.Metadata != nil { t.Errorf("expected nil Metadata when __metadata.json is absent, got %+v", tmpl.Metadata) } @@ -157,6 +169,7 @@ func TestGet_MalformedMetadata_ReturnsError(t *testing.T) { if err != nil { t.Fatalf("Get: unexpected error for malformed metadata: %v", err) } + if tmpl.Metadata != nil { t.Errorf("expected nil Metadata for malformed file, got %+v", tmpl.Metadata) } @@ -171,6 +184,7 @@ func TestLoadMetadata_Missing(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata: unexpected error for missing file: %v", err) } + if m != nil { t.Errorf("expected nil for missing __metadata.json, got %+v", m) } @@ -186,6 +200,7 @@ func TestLoadMetadata_Malformed(t *testing.T) { if err == nil { t.Fatal("LoadMetadata: expected error for malformed file, got nil") } + if m != nil { t.Errorf("expected nil for malformed __metadata.json, got %+v", m) } @@ -207,28 +222,36 @@ func TestSaveMetadata_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata: %v", err) } + if m == nil { t.Fatal("LoadMetadata returned nil after SaveMetadata") } + if m.Name != "my-tpl" { t.Errorf("Name = %q, want %q", m.Name, "my-tpl") } + if m.Repository != "https://example.com/repo" { t.Errorf("Repository = %q, want %q", m.Repository, "https://example.com/repo") } + if m.Branch != "main" { t.Errorf("Branch = %q, want %q", m.Branch, "main") } + if m.Commit != "abc123" { t.Errorf("Commit = %q, want %q", m.Commit, "abc123") } + if m.Version != "v1.2.3" { t.Errorf("Version = %q, want %q", m.Version, "v1.2.3") } - if !m.Created.Time.Equal(created) { + + if !m.Created.Equal(created) { t.Errorf("Created = %v, want %v", m.Created.Time, created) } - if !m.Updated.Time.Equal(updated) { + + if !m.Updated.Equal(updated) { t.Errorf("Updated = %v, want %v", m.Updated.Time, updated) } } @@ -257,12 +280,15 @@ func TestSaveMetadata_PreservesCreatedOnUpgrade(t *testing.T) { if upgraded == nil { t.Fatal("LoadMetadata returned nil after upgrade") } - if !upgraded.Created.Time.Equal(original) { + + if !upgraded.Created.Equal(original) { t.Errorf("Created after upgrade = %v, want %v", upgraded.Created.Time, original) } - if !upgraded.Updated.Time.Equal(upgradedAt) { + + if !upgraded.Updated.Equal(upgradedAt) { t.Errorf("Updated after upgrade = %v, want %v", upgraded.Updated.Time, upgradedAt) } + if upgraded.Commit != "new-sha" { t.Errorf("Commit = %q, want %q", upgraded.Commit, "new-sha") } @@ -291,10 +317,12 @@ func TestLoadMetadata_MissingUpdated_FallsBackToCreated(t *testing.T) { if err != nil { t.Fatalf("LoadMetadata: %v", err) } + if m == nil { t.Fatal("LoadMetadata returned nil") } - if !m.Updated.Time.Equal(created) { + + if !m.Updated.Equal(created) { t.Errorf("Updated = %v, want fallback to Created %v", m.Updated.Time, created) } } diff --git a/internal/template/specsregistry.go b/internal/template/specsregistry.go index 5f07448..391ac54 100644 --- a/internal/template/specsregistry.go +++ b/internal/template/specsregistry.go @@ -38,6 +38,7 @@ func (r *SpecsRegistry) RegisterFunctions(funcsMap sprout.FunctionMap) error { sprout.AddFunction(funcsMap, "toBinary", r.ToBinary) sprout.AddFunction(funcsMap, "formatFilesize", r.FormatFilesize) sprout.AddFunction(funcsMap, "password", r.Password) + return nil } @@ -47,13 +48,22 @@ func (r *SpecsRegistry) Hostname() string { return h } -// Username returns the current OS username. +// Username returns the current OS username. When the user database is +// unavailable (e.g. a container running as a UID without an /etc/passwd +// entry), it falls back to the USER/LOGNAME environment variables and, +// finally, the numeric user ID. func (r *SpecsRegistry) Username() string { - u, _ := user.Current() - if u != nil { + if u, err := user.Current(); err == nil && u.Username != "" { return u.Username } - return "" + + for _, key := range []string{"USER", "LOGNAME", "USERNAME"} { + if name := os.Getenv(key); name != "" { + return name + } + } + + return strconv.Itoa(os.Getuid()) } // ToBinary formats an integer as a binary string. diff --git a/internal/template/specsregistry_test.go b/internal/template/specsregistry_test.go index c431db1..ced3a03 100644 --- a/internal/template/specsregistry_test.go +++ b/internal/template/specsregistry_test.go @@ -72,6 +72,7 @@ func TestSpecsRegistry_ToBinary(t *testing.T) { {-1, "-1"}, } r := newRegistry() + for _, tt := range tests { if got := r.ToBinary(tt.input); got != tt.want { t.Errorf("ToBinary(%d) = %q, want %q", tt.input, got, tt.want) @@ -92,6 +93,7 @@ func TestSpecsRegistry_FormatFilesize(t *testing.T) { {1000 * 1000 * 1000, "1GB"}, } r := newRegistry() + for _, tt := range tests { if got := r.FormatFilesize(tt.input); got != tt.want { t.Errorf("FormatFilesize(%v) = %q, want %q", tt.input, got, tt.want) diff --git a/internal/template/status.go b/internal/template/status.go index 95eeab2..db00b24 100644 --- a/internal/template/status.go +++ b/internal/template/status.go @@ -45,13 +45,16 @@ func LoadStatus(templateRoot string) (*TemplateStatus, error) { if os.IsNotExist(err) { return nil, nil } + if err != nil { return nil, err } + var s TemplateStatus if err := json.Unmarshal(data, &s); err != nil { return nil, err } + return &s, nil } @@ -61,5 +64,6 @@ func SaveStatus(templateRoot string, s *TemplateStatus) error { if err != nil { return err } + return os.WriteFile(filepath.Join(templateRoot, specs.StatusFile), data, 0644) } diff --git a/internal/template/status_test.go b/internal/template/status_test.go index c79daf1..639b0f1 100644 --- a/internal/template/status_test.go +++ b/internal/template/status_test.go @@ -58,13 +58,16 @@ func TestStatus_SpecsVersionRoundtrip(t *testing.T) { IsUpToDate: true, SpecsVersion: "1.2.3", } + if err := SaveStatus(dir, original); err != nil { t.Fatalf("SaveStatus: %v", err) } + loaded, err := LoadStatus(dir) if err != nil { t.Fatalf("LoadStatus: %v", err) } + if loaded.SpecsVersion != "1.2.3" { t.Errorf("SpecsVersion: got %q, want %q", loaded.SpecsVersion, "1.2.3") } @@ -72,10 +75,12 @@ func TestStatus_SpecsVersionRoundtrip(t *testing.T) { func TestLoadStatus_Missing(t *testing.T) { dir := t.TempDir() + s, err := LoadStatus(dir) if err != nil { t.Fatalf("LoadStatus: expected nil error, got %v", err) } + if s != nil { t.Errorf("LoadStatus: expected nil status for missing file, got %+v", s) } @@ -99,19 +104,24 @@ func TestStatusRoundtrip(t *testing.T) { if err != nil { t.Fatalf("LoadStatus: %v", err) } + if loaded == nil { t.Fatal("LoadStatus: expected non-nil status") } + if loaded.IsUpToDate != original.IsUpToDate { t.Errorf("IsUpToDate: got %v, want %v", loaded.IsUpToDate, original.IsUpToDate) } + if loaded.LatestVersion != original.LatestVersion { t.Errorf("LatestVersion: got %q, want %q", loaded.LatestVersion, original.LatestVersion) } + if loaded.ErrorKind != original.ErrorKind { t.Errorf("ErrorKind: got %q, want %q", loaded.ErrorKind, original.ErrorKind) } - if !loaded.CheckedAt.Time.Equal(original.CheckedAt.Time) { + + if !loaded.CheckedAt.Equal(original.CheckedAt.Time) { t.Errorf("CheckedAt: got %v, want %v", loaded.CheckedAt.Time, original.CheckedAt.Time) } diff --git a/internal/template/template.go b/internal/template/template.go index 746c235..c86234e 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -51,6 +51,7 @@ func (c Config) delims() specs.Delimiters { if c.Delims.Left == "" { return specs.DefaultDelimiters } + return c.Delims } @@ -110,6 +111,7 @@ func Get(templateRoot string, cfg Config) (*Template, error) { if err != nil { return nil, fmt.Errorf("reading delimiters from project file: %w", err) } + cfg.Delims = delims // Enforce a template-declared __specs__version constraint against the running CLI. @@ -181,6 +183,7 @@ func checkSpecsVersion(templateRoot, cliVersion string) error { if err != nil { return err } + if constraint == nil { return nil // no constraint declared — nothing to enforce } @@ -189,6 +192,7 @@ func checkSpecsVersion(templateRoot, cliVersion string) error { if err != nil { slog.Debug("skipping specs version check: CLI version is not a parseable semver", "version", cliVersion, "constraint", raw) + return nil } @@ -196,6 +200,7 @@ func checkSpecsVersion(templateRoot, cliVersion string) error { return fmt.Errorf("%w: template requires specs %s, but this binary is %s", specs.ErrSpecsVersionUnsatisfied, raw, cliVersion) } + return nil } @@ -236,6 +241,7 @@ func (t *Template) Execute(targetDir string) error { if d.IsDir() { return filepath.SkipDir } + return nil } @@ -243,12 +249,15 @@ func (t *Template) Execute(targetDir string) error { destRel, err := t.renderName(rel, ctx) if err != nil || strings.TrimSpace(destRel) == "" { slog.Debug("skipping path", "path", rel, "error", err) + if !d.IsDir() { skipped++ } + if d.IsDir() { return filepath.SkipDir } + return nil } @@ -257,9 +266,11 @@ func (t *Template) Execute(targetDir string) error { if !d.IsDir() { skipped++ } + if d.IsDir() { return filepath.SkipDir } + return nil } @@ -274,14 +285,18 @@ func (t *Template) Execute(targetDir string) error { relForward := filepath.ToSlash(rel) if t.verbatim.Matches(relForward) || isBinary(srcPath) { slog.Debug("file decision", "path", rel, "dest", destPath, "action", "verbatim") + verbatim++ + return copyFile(srcPath, destPath) } + slog.Debug("file decision", "path", rel, "dest", destPath, "action", "render") + rendered++ + return t.renderFile(srcPath, destPath, relForward, ctx) }) - if walkErr != nil { return walkErr } @@ -293,20 +308,24 @@ func (t *Template) Execute(targetDir string) error { "verbatim", verbatim, "skipped", skipped, ) + return nil } // renderName renders a file/directory path template using the configured delimiters. func (t *Template) renderName(name string, ctx map[string]any) (string, error) { d := t.cfg.delims() + tmpl, err := texttemplate.New("").Delims(d.Left, d.Right).Funcs(t.funcMap).Parse(name) if err != nil { return "", err } + var buf strings.Builder if err := tmpl.Execute(&buf, ctx); err != nil { return "", err } + return buf.String(), nil } @@ -327,6 +346,7 @@ func (t *Template) renderFile(srcPath, destPath, rel string, ctx map[string]any) } d := t.cfg.delims() + tmpl, err := texttemplate.New(""). Delims(d.Left, d.Right). Funcs(t.funcMap). @@ -337,6 +357,7 @@ func (t *Template) renderFile(srcPath, destPath, rel string, ctx map[string]any) t.Warnings = append(t.Warnings, RenderWarning{Path: rel, Err: err, Preview: contentPreview(data)}) return copyFile(srcPath, destPath) } + return fmt.Errorf("parsing template %s: %w", rel, err) } @@ -346,6 +367,7 @@ func (t *Template) renderFile(srcPath, destPath, rel string, ctx map[string]any) t.Warnings = append(t.Warnings, RenderWarning{Path: rel, Err: err, Preview: contentPreview(data)}) return copyFile(srcPath, destPath) } + return fmt.Errorf("rendering template %s: %w", rel, err) } @@ -360,10 +382,12 @@ func (t *Template) renderFile(srcPath, destPath, rel string, ctx map[string]any) // contentPreview returns the first ~80 characters of data for display in warnings. func contentPreview(data []byte) string { const max = 80 + s := string(data) if len(s) > max { return s[:max] } + return s } @@ -379,7 +403,7 @@ func isBinary(path string) bool { if err != nil { return false } - defer f.Close() + defer func() { _ = f.Close() }() buf := make([]byte, 512) n, _ := f.Read(buf) @@ -392,6 +416,7 @@ func isBinary(path string) bool { if slices.Contains(buf, 0) { return true } + return !utf8.Valid(buf) } @@ -402,30 +427,44 @@ func hasEmptySegment(path string) bool { return true } } + return false } -func copyFile(src, dst string) error { +func copyFile(src, dst string) (err error) { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } + info, err := os.Stat(src) if err != nil { return err } + in, err := os.Open(src) if err != nil { return err } - defer in.Close() + + defer func() { _ = in.Close() }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } - defer out.Close() + + // A failed Close on a writable file can mean buffered data was not flushed, + // so surface it when no earlier error already took precedence. + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() + if _, err = io.Copy(out, in); err != nil { return err } + return os.Chmod(dst, info.Mode()) } @@ -433,8 +472,10 @@ func writeFile(path string, data []byte, mode fs.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } + if err := os.WriteFile(path, data, mode); err != nil { return err } + return os.Chmod(path, mode) } diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 206daab..6e63853 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -31,27 +31,33 @@ func buildTemplate(t *testing.T, yaml string, files map[string][]byte) string { if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { t.Fatalf("creating dir for %s: %v", relPath, err) } + if err := os.WriteFile(abs, content, 0644); err != nil { t.Fatalf("writing %s: %v", relPath, err) } } + return root } // readFile reads the content of a file in the target dir; fails the test if absent. func readFile(t *testing.T, targetDir, relPath string) string { t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, filepath.FromSlash(relPath))) if err != nil { t.Fatalf("reading %s: %v", relPath, err) } + return string(data) } // fileExists reports whether a file or directory exists at relPath inside targetDir. func fileExists(t *testing.T, targetDir, relPath string) bool { t.Helper() + _, err := os.Stat(filepath.Join(targetDir, filepath.FromSlash(relPath))) + return err == nil } @@ -183,6 +189,7 @@ func TestExecute_BinaryFile(t *testing.T) { if err != nil { t.Fatalf("reading image.bin: %v", err) } + if string(got) != string(content) { t.Errorf("image.bin content mismatch (binary file not copied verbatim)") } @@ -246,6 +253,7 @@ func TestExecute_ComputedValueInTemplate(t *testing.T) { if err != nil { t.Fatalf("ApplyComputed: %v", err) } + tmpl.Context = ctx target := t.TempDir() @@ -300,6 +308,7 @@ func TestExecute_PassthroughDoubleBrace(t *testing.T) { got := readFile(t, target, "ci.yml") want := "group: ${{ github.ref }}\nname: ci" + if got != want { t.Errorf("ci.yml = %q, want %q", got, want) } @@ -316,6 +325,7 @@ func TestExecute_CustomDelimiters_NotInContext(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if _, ok := tmpl.Context["__delimiters"]; ok { t.Error("__delimiters should not appear in the template context") } @@ -324,6 +334,7 @@ func TestExecute_CustomDelimiters_NotInContext(t *testing.T) { if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) } + if got := readFile(t, target, "out.txt"); got != "test" { t.Errorf("out.txt = %q, want %q", got, "test") } @@ -341,9 +352,11 @@ func TestExecute_ReservedSpecsVersionAvailable(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + if _, ok := tmpl.Context["__specs__version"]; ok { t.Error("__specs__version should not appear in the schema context") } + if tmpl.Reserved["__specs__version"] != ">=0.0.1" { t.Errorf("Reserved[__specs__version] = %v, want %q", tmpl.Reserved["__specs__version"], ">=0.0.1") } @@ -352,6 +365,7 @@ func TestExecute_ReservedSpecsVersionAvailable(t *testing.T) { if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) } + if got := readFile(t, target, "out.txt"); got != "v=>=0.0.1" { t.Errorf("out.txt = %q, want %q", got, "v=>=0.0.1") } @@ -374,6 +388,7 @@ func TestExecute_ReservedDelimitersAvailable(t *testing.T) { if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) } + if got := readFile(t, target, "out.txt"); got != "d=[[]]" { t.Errorf("out.txt = %q, want %q", got, "d=[[]]") } @@ -411,10 +426,12 @@ func TestExecute_ParseError_ContinueOnError(t *testing.T) { if len(tmpl.Warnings) == 0 { t.Fatal("expected at least one RenderWarning, got none") } + w := tmpl.Warnings[0] if w.Path != "compose.yml" { t.Errorf("warning path = %q, want %q", w.Path, "compose.yml") } + if w.Preview == "" { t.Error("expected Preview to be set on RenderWarning") } @@ -446,6 +463,7 @@ func TestExecute_ParseError_FailFast(t *testing.T) { if fileExists(t, target, "compose.yml") { t.Error("compose.yml must not be written when Execute aborts due to a parse error") } + if len(tmpl.Warnings) != 0 { t.Errorf("expected no Warnings in fail-fast mode, got %d", len(tmpl.Warnings)) } @@ -493,10 +511,12 @@ func TestExecute_ExecuteError_ContinueOnError(t *testing.T) { if len(tmpl.Warnings) == 0 { t.Fatal("expected at least one RenderWarning, got none") } + w := tmpl.Warnings[0] if w.Path != "out.txt" { t.Errorf("warning path = %q, want %q", w.Path, "out.txt") } + if w.Preview == "" { t.Error("expected Preview to be set on RenderWarning") } @@ -512,14 +532,17 @@ func TestExecute_PreservesPermissions_TextFile(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "project.yaml"), []byte("Name: test\n"), 0644); err != nil { t.Fatal(err) } + templateDir := filepath.Join(root, "template") if err := os.MkdirAll(templateDir, 0755); err != nil { t.Fatal(err) } + scriptPath := filepath.Join(templateDir, "run.sh") if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho {{.Name}}\n"), 0755); err != nil { t.Fatal(err) } + if err := os.Chmod(scriptPath, 0755); err != nil { t.Fatal(err) } @@ -528,6 +551,7 @@ func TestExecute_PreservesPermissions_TextFile(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -537,6 +561,7 @@ func TestExecute_PreservesPermissions_TextFile(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } + if got := info.Mode().Perm(); got != 0755 { t.Errorf("run.sh permissions = %04o, want 0755", got) } @@ -547,15 +572,18 @@ func TestExecute_PreservesPermissions_BinaryFile(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "project.yaml"), []byte("Name: test\n"), 0644); err != nil { t.Fatal(err) } + templateDir := filepath.Join(root, "template") if err := os.MkdirAll(templateDir, 0755); err != nil { t.Fatal(err) } + binPath := filepath.Join(templateDir, "tool") // null byte makes it detected as binary if err := os.WriteFile(binPath, []byte{0x7f, 0x45, 0x4c, 0x46, 0x00}, 0755); err != nil { t.Fatal(err) } + if err := os.Chmod(binPath, 0755); err != nil { t.Fatal(err) } @@ -564,6 +592,7 @@ func TestExecute_PreservesPermissions_BinaryFile(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -573,6 +602,7 @@ func TestExecute_PreservesPermissions_BinaryFile(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } + if got := info.Mode().Perm(); got != 0755 { t.Errorf("tool permissions = %04o, want 0755", got) } @@ -593,6 +623,7 @@ func TestExecute_BinaryDetection_JPEG(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -602,6 +633,7 @@ func TestExecute_BinaryDetection_JPEG(t *testing.T) { if err != nil { t.Fatalf("reading photo.jpg: %v", err) } + if string(got) != string(content) { t.Error("photo.jpg not copied verbatim (JPEG magic bytes should be detected as binary)") } @@ -619,6 +651,7 @@ func TestExecute_BinaryDetection_Gzip(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -628,6 +661,7 @@ func TestExecute_BinaryDetection_Gzip(t *testing.T) { if err != nil { t.Fatalf("reading archive.tar.gz: %v", err) } + if string(got) != string(content) { t.Error("archive.tar.gz not copied verbatim (gzip magic bytes should be detected as binary)") } @@ -646,6 +680,7 @@ func TestExecute_BinaryDetection_UTF16BOM(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -655,6 +690,7 @@ func TestExecute_BinaryDetection_UTF16BOM(t *testing.T) { if err != nil { t.Fatalf("reading utf16.txt: %v", err) } + if string(got) != string(content) { t.Error("utf16.txt not copied verbatim (UTF-16 BOM should be detected as binary)") } @@ -675,6 +711,7 @@ func TestExecute_BinaryDetection_PDFHeader(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -684,6 +721,7 @@ func TestExecute_BinaryDetection_PDFHeader(t *testing.T) { if err != nil { t.Fatalf("reading document.pdf: %v", err) } + if string(got) != string(content) { t.Errorf("document.pdf = %q, want verbatim %q (PDF header should be detected as binary even though bytes are valid UTF-8)", got, string(content)) } @@ -757,10 +795,12 @@ func TestGet_CustomDelimiters_ConditionalFilename(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) } + if !fileExists(t, target, "feature.txt") { t.Error("feature.txt should exist when UseX is true and custom delimiters are set") } diff --git a/internal/template/validate.go b/internal/template/validate.go index 25c8066..74257c4 100644 --- a/internal/template/validate.go +++ b/internal/template/validate.go @@ -30,6 +30,7 @@ func (v ValidationIssue) Error() string { if v.File != "" { return fmt.Sprintf("%s: %q in %s", v.Kind, v.Name, v.File) } + return fmt.Sprintf("%s: %q", v.Kind, v.Name) } @@ -43,6 +44,7 @@ func HasUnknown(issues []ValidationIssue) bool { return true } } + return false } @@ -53,6 +55,7 @@ func HasUnused(issues []ValidationIssue) bool { return true } } + return false } @@ -67,6 +70,7 @@ func (t *Template) Validate() ([]ValidationIssue, error) { for k := range t.Context { allDefined[k] = true } + for k := range t.ComputedDefs { allDefined[k] = true } @@ -80,27 +84,33 @@ func (t *Template) Validate() ([]ValidationIssue, error) { kind error name, file string } + seen := make(map[issueKey]bool) + var issues []ValidationIssue addIssue := func(kind error, name, file string) { k := issueKey{kind, name, file} if !seen[k] { seen[k] = true + issues = append(issues, ValidationIssue{Kind: kind, Name: name, File: file}) } } // Scan template files and path expressions for unknown variable references. srcRoot := filepath.Join(t.Root, specs.TemplateDirFile) + err := filepath.WalkDir(srcRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } + relFromRoot, _ := filepath.Rel(t.Root, path) if relFromRoot == "." { return nil } + reportPath := filepath.ToSlash(relFromRoot) // Check the entry's name as a path template expression. @@ -119,11 +129,13 @@ func (t *Template) Validate() ([]ValidationIssue, error) { if readErr != nil { return readErr } + for _, k := range extractRefs(string(data), t.funcMap, t.cfg.delims()) { if !allDefined[k] { addIssue(ErrUnknownVariable, k, reportPath) } } + return nil }) if err != nil { @@ -146,14 +158,17 @@ func (t *Template) Validate() ([]ValidationIssue, error) { } kindRank := map[error]int{ErrUnknownVariable: 0, ErrUnusedVariable: 1, ErrUnusedComputed: 2} + sort.Slice(issues, func(i, j int) bool { a, b := issues[i], issues[j] if kindRank[a.Kind] != kindRank[b.Kind] { return kindRank[a.Kind] < kindRank[b.Kind] } + if a.Name != b.Name { return a.Name < b.Name } + return a.File < b.File }) diff --git a/internal/template/verbatim.go b/internal/template/verbatim.go index a914553..96e1993 100644 --- a/internal/template/verbatim.go +++ b/internal/template/verbatim.go @@ -20,24 +20,30 @@ type VerbatimRules struct { // (no patterns, no error) if the file does not exist. func LoadVerbatim(templateRoot string) (*VerbatimRules, error) { path := filepath.Join(templateRoot, specs.VerbatimFile) + f, err := os.Open(path) if os.IsNotExist(err) { return &VerbatimRules{}, nil } + if err != nil { return nil, err } - defer f.Close() + + defer func() { _ = f.Close() }() var patterns []string + scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } + patterns = append(patterns, line) } + return &VerbatimRules{patterns: patterns}, scanner.Err() } @@ -49,5 +55,6 @@ func (r *VerbatimRules) Matches(path string) bool { return true } } + return false } diff --git a/internal/template/verbatim_test.go b/internal/template/verbatim_test.go index 8272e78..2c46772 100644 --- a/internal/template/verbatim_test.go +++ b/internal/template/verbatim_test.go @@ -10,10 +10,12 @@ import ( func TestLoadVerbatim_MissingFile(t *testing.T) { dir := t.TempDir() + rules, err := pkgtemplate.LoadVerbatim(dir) if err != nil { t.Fatalf("unexpected error for missing .specsverbatim: %v", err) } + if rules.Matches("anything.txt") { t.Error("empty rules should not match anything") } @@ -21,6 +23,7 @@ func TestLoadVerbatim_MissingFile(t *testing.T) { func writeVerbatimFile(t *testing.T, dir, content string) { t.Helper() + if err := os.WriteFile(filepath.Join(dir, ".specsverbatim"), []byte(content), 0644); err != nil { t.Fatalf("writeVerbatimFile: %v", err) } @@ -47,10 +50,12 @@ func TestVerbatimMatches(t *testing.T) { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() writeVerbatimFile(t, dir, tt.pattern+"\n") + rules, err := pkgtemplate.LoadVerbatim(dir) if err != nil { t.Fatalf("LoadVerbatim: %v", err) } + got := rules.Matches(tt.path) if got != tt.want { t.Errorf("Matches(%q) with pattern %q = %v, want %v", tt.path, tt.pattern, got, tt.want) @@ -111,6 +116,7 @@ config/secrets/credentials.json t.Errorf("expected %q to be matched, but it was not", path) } } + for _, path := range shouldNotMatch { if rules.Matches(path) { t.Errorf("expected %q not to be matched, but it was", path) @@ -121,16 +127,20 @@ config/secrets/credentials.json func TestLoadVerbatim_MultiplePatterns(t *testing.T) { dir := t.TempDir() writeVerbatimFile(t, dir, "composer.lock\npackage-lock.json\n") + rules, err := pkgtemplate.LoadVerbatim(dir) if err != nil { t.Fatalf("LoadVerbatim: %v", err) } + if !rules.Matches("composer.lock") { t.Error("should match composer.lock") } + if !rules.Matches("package-lock.json") { t.Error("should match package-lock.json") } + if rules.Matches("composer.json") { t.Error("should not match composer.json") } diff --git a/internal/util/git/check_local_test.go b/internal/util/git/check_local_test.go index cacef6c..14654a3 100644 --- a/internal/util/git/check_local_test.go +++ b/internal/util/git/check_local_test.go @@ -10,6 +10,7 @@ import ( func TestCheckLocalSource_Missing(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist") + got := pkggit.CheckLocalSource(missing, "abc", "v1.0.0") if got.ErrorKind != pkggit.CheckErrorSourceMissing { t.Errorf("ErrorKind = %q, want %q", got.ErrorKind, pkggit.CheckErrorSourceMissing) @@ -37,9 +38,11 @@ func TestCheckLocalSource_UpToDate(t *testing.T) { if got.ErrorKind != pkggit.CheckErrorNone { t.Fatalf("ErrorKind = %q, want none", got.ErrorKind) } + if !got.IsUpToDate { t.Errorf("IsUpToDate = false, want true when source matches saved commit/version") } + if got.LatestVersion != "" { t.Errorf("LatestVersion = %q, want empty when up-to-date", got.LatestVersion) } @@ -57,6 +60,7 @@ func TestCheckLocalSource_SourceAdvanced(t *testing.T) { // Source moves forward after the template was saved. addCommit(t, repo, dir, "second") + newDesc, err := pkggit.Describe(dir) if err != nil { t.Fatalf("Describe (new): %v", err) @@ -66,9 +70,11 @@ func TestCheckLocalSource_SourceAdvanced(t *testing.T) { if got.ErrorKind != pkggit.CheckErrorNone { t.Fatalf("ErrorKind = %q, want none", got.ErrorKind) } + if got.IsUpToDate { t.Errorf("IsUpToDate = true, want false when source advanced") } + if got.LatestVersion != newDesc.Version { t.Errorf("LatestVersion = %q, want %q (current source version)", got.LatestVersion, newDesc.Version) } @@ -91,10 +97,12 @@ func TestCheckLocalSource_DirtyAfterSaveIsUpToDate(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("modified"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } + dirtyDesc, err := pkggit.Describe(dir) if err != nil { t.Fatalf("Describe (dirty): %v", err) } + if dirtyDesc.Version == saved.Version { t.Fatalf("precondition: dirty version %q should differ from saved %q", dirtyDesc.Version, saved.Version) } @@ -103,9 +111,11 @@ func TestCheckLocalSource_DirtyAfterSaveIsUpToDate(t *testing.T) { if got.ErrorKind != pkggit.CheckErrorNone { t.Fatalf("ErrorKind = %q, want none", got.ErrorKind) } + if !got.IsUpToDate { t.Errorf("IsUpToDate = false, want true when only the working tree turned dirty on the saved commit") } + if got.LatestVersion != "" { t.Errorf("LatestVersion = %q, want empty when up-to-date", got.LatestVersion) } @@ -123,6 +133,7 @@ func TestCheckLocalSource_SavedDirtyNowCleanIsUpToDate(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("modified"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } + savedDirty, err := pkggit.Describe(dir) if err != nil { t.Fatalf("Describe (dirty): %v", err) @@ -137,6 +148,7 @@ func TestCheckLocalSource_SavedDirtyNowCleanIsUpToDate(t *testing.T) { if got.ErrorKind != pkggit.CheckErrorNone { t.Fatalf("ErrorKind = %q, want none", got.ErrorKind) } + if !got.IsUpToDate { t.Errorf("IsUpToDate = false, want true when the source returned clean on the saved commit") } diff --git a/internal/util/git/describe_test.go b/internal/util/git/describe_test.go index 0d9866f..29379ba 100644 --- a/internal/util/git/describe_test.go +++ b/internal/util/git/describe_test.go @@ -22,40 +22,49 @@ var testSig = &object.Signature{ func initRepo(t *testing.T) (string, *gogit.Repository) { t.Helper() dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) if err != nil { t.Fatalf("PlainInit: %v", err) } + return dir, repo } // addCommit writes a uniquely-named file, stages it, and commits. func addCommit(t *testing.T, repo *gogit.Repository, dir, label string) plumbing.Hash { t.Helper() + wt, err := repo.Worktree() if err != nil { t.Fatalf("Worktree: %v", err) } + if err := os.WriteFile(filepath.Join(dir, label+".txt"), []byte(label), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } + if _, err := wt.Add(label + ".txt"); err != nil { t.Fatalf("Add: %v", err) } + hash, err := wt.Commit(label, &gogit.CommitOptions{Author: testSig}) if err != nil { t.Fatalf("Commit(%q): %v", label, err) } + return hash } // tagCommit creates a lightweight tag when annotated is false, or an annotated tag otherwise. func tagCommit(t *testing.T, repo *gogit.Repository, name string, hash plumbing.Hash, annotated bool) { t.Helper() + var opts *gogit.CreateTagOptions if annotated { opts = &gogit.CreateTagOptions{Tagger: testSig, Message: name} } + if _, err := repo.CreateTag(name, hash, opts); err != nil { t.Fatalf("CreateTag(%q): %v", name, err) } @@ -69,6 +78,7 @@ func TestCurrentBranch_OnBranch(t *testing.T) { if err != nil { t.Fatalf("CurrentBranch: %v", err) } + if got == "" { t.Error("CurrentBranch: expected non-empty branch name") } @@ -83,6 +93,7 @@ func TestCurrentBranch_DetachedHead(t *testing.T) { if err != nil { t.Fatalf("Worktree: %v", err) } + if err := wt.Checkout(&gogit.CheckoutOptions{Hash: hash}); err != nil { t.Fatalf("Checkout (detach): %v", err) } @@ -100,7 +111,6 @@ func TestCurrentBranch_NotARepo(t *testing.T) { } } - func TestDescribe_ExactLightweightTag(t *testing.T) { dir, repo := initRepo(t) hash := addCommit(t, repo, dir, "init") @@ -110,9 +120,11 @@ func TestDescribe_ExactLightweightTag(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Commit != hash.String() { t.Errorf("Commit = %q, want %q", got.Commit, hash.String()) } + if got.Version != "v1.0.0" { t.Errorf("Version = %q, want %q", got.Version, "v1.0.0") } @@ -127,6 +139,7 @@ func TestDescribe_ExactAnnotatedTag(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Version != "v2.0.0" { t.Errorf("Version = %q, want %q", got.Version, "v2.0.0") } @@ -143,9 +156,11 @@ func TestDescribe_AheadOfTag(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Commit != head.String() { t.Errorf("Commit = %q, want %q", got.Commit, head.String()) } + want := fmt.Sprintf("v1.0.0-2-g%s", got.Commit[:7]) if got.Version != want { t.Errorf("Version = %q, want %q", got.Version, want) @@ -160,6 +175,7 @@ func TestDescribe_NoTags(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Version != hash.String()[:7] { t.Errorf("Version = %q, want %q", got.Version, hash.String()[:7]) } @@ -178,6 +194,7 @@ func TestDescribe_DirtyWorktree(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Version != "v1.0.0-dirty" { t.Errorf("Version = %q, want %q", got.Version, "v1.0.0-dirty") } @@ -196,6 +213,7 @@ func TestDescribe_UntrackedFileIsNotDirty(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + if got.Version != "v1.0.0" { t.Errorf("Version = %q, want %q", got.Version, "v1.0.0") } @@ -206,6 +224,7 @@ func TestDescribe_AheadAndDirty(t *testing.T) { base := addCommit(t, repo, dir, "base") tagCommit(t, repo, "v1.0.0", base, false) addCommit(t, repo, dir, "second") + if err := os.WriteFile(filepath.Join(dir, "base.txt"), []byte("modified"), 0644); err != nil { t.Fatalf("WriteFile: %v", err) } @@ -214,6 +233,7 @@ func TestDescribe_AheadAndDirty(t *testing.T) { if err != nil { t.Fatalf("Describe: %v", err) } + want := fmt.Sprintf("v1.0.0-1-g%s-dirty", got.Commit[:7]) if got.Version != want { t.Errorf("Version = %q, want %q", got.Version, want) diff --git a/internal/util/git/git.go b/internal/util/git/git.go index f8b5095..1d3fad7 100644 --- a/internal/util/git/git.go +++ b/internal/util/git/git.go @@ -37,6 +37,7 @@ func CloneInto(parent, name, url string, opts CloneOptions) (string, error) { if err := Clone(url, dir, opts); err != nil { return "", err } + return dir, nil } @@ -62,6 +63,7 @@ func Clone(url, dir string, opts CloneOptions) error { if err != nil { return err } + cloneOpts.Auth = auth } @@ -78,6 +80,7 @@ func Clone(url, dir string, opts CloneOptions) error { } slog.Debug("git clone complete", "repo", url, "dest", dir, "branch", opts.Branch) + return nil } @@ -86,21 +89,29 @@ func Clone(url, dir string, opts CloneOptions) error { // ("main") without needing to know which kind of ref it is. func cloneWithRef(url, dir string, cloneOpts *gogit.CloneOptions, ref string) error { cloneOpts.ReferenceName = plumbing.NewTagReferenceName(ref) + _, err := gogit.PlainClone(dir, false, cloneOpts) if err == nil { return nil } + if !strings.Contains(err.Error(), "couldn't find remote ref") { return fmt.Errorf("cloning %s: %w", url, err) } - // Tag ref not found — retry as a branch. - os.RemoveAll(dir) + // Tag ref not found — retry as a branch. The dir must be emptied first, + // otherwise the retry clone fails on a non-empty target. + if rmErr := os.RemoveAll(dir); rmErr != nil { + return fmt.Errorf("cleaning up %s before branch retry: %w", dir, rmErr) + } + cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(ref) + _, err = gogit.PlainClone(dir, false, cloneOpts) if err != nil { return fmt.Errorf("cloning %s: %w", url, err) } + return nil } @@ -134,6 +145,7 @@ func Describe(dir string) (DescribeResult, error) { shortHash := commit[:7] dirty := false + if wt, err := repo.Worktree(); err == nil { if st, err := wt.Status(); err == nil { for _, s := range st { @@ -141,7 +153,9 @@ func Describe(dir string) (DescribeResult, error) { if s.Staging == gogit.Untracked && s.Worktree == gogit.Untracked { continue } + dirty = true + break } } @@ -152,6 +166,7 @@ func Describe(dir string) (DescribeResult, error) { Version: buildVersion(repo, head.Hash(), shortHash, dirty), } slog.Debug("git describe", "dest", dir, "commit", result.Commit, "version", result.Version) + return result, nil } @@ -159,31 +174,38 @@ func Describe(dir string) (DescribeResult, error) { func buildVersion(repo *gogit.Repository, headHash plumbing.Hash, shortHash string, dirty bool) string { // Map each tagged commit hash to its tag name (dereference annotated tags). tagMap := make(map[plumbing.Hash]string) + if tags, err := repo.Tags(); err == nil { _ = tags.ForEach(func(ref *plumbing.Reference) error { h := ref.Hash() if obj, err := repo.TagObject(h); err == nil { h = obj.Target } + tagMap[h] = ref.Name().Short() + return nil }) } // Walk commits from HEAD to find the nearest tagged ancestor. foundTag, distance := "", 0 + if iter, err := repo.Log(&gogit.LogOptions{From: headHash}); err == nil { _ = iter.ForEach(func(c *object.Commit) error { if tag, ok := tagMap[c.Hash]; ok { foundTag = tag return storer.ErrStop } + distance++ + return nil }) } var v string + switch { case foundTag == "": v = shortHash @@ -192,9 +214,11 @@ func buildVersion(repo *gogit.Repository, headHash plumbing.Hash, shortHash stri default: v = fmt.Sprintf("%s-%d-g%s", foundTag, distance, shortHash) } + if dirty { v += "-dirty" } + return v } @@ -215,6 +239,7 @@ func sshUser(url string) string { return url[:at] } } + return "git" } @@ -230,6 +255,7 @@ func sshAuth(url string) (transport.AuthMethod, error) { } khPath := filepath.Join(home, ".ssh", "known_hosts") + hostKeyCallback, err := knownhosts.New(khPath) if err != nil { return nil, fmt.Errorf("reading ~/.ssh/known_hosts: %w", err) @@ -246,11 +272,14 @@ func sshAuth(url string) (transport.AuthMethod, error) { // 2. Standard key files for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} { keyPath := filepath.Join(home, ".ssh", name) + auth, err := gogitssh.NewPublicKeysFromFile(user, keyPath, "") if err != nil { continue } + auth.HostKeyCallback = hostKeyCallback + return auth, nil } @@ -265,13 +294,16 @@ func CurrentBranch(dir string) (string, error) { if err != nil { return "", fmt.Errorf("opening repository at %s: %w", dir, err) } + head, err := repo.Head() if err != nil { return "", fmt.Errorf("reading HEAD: %w", err) } + if !head.Name().IsBranch() { return "", fmt.Errorf("HEAD is not on a branch (detached HEAD or tag checkout)") } + return head.Name().Short(), nil } @@ -335,6 +367,7 @@ func (r RemoteCheckResult) Err() error { // On failure, ErrorKind is set in the result; errors are never returned. func CheckRemoteContext(ctx context.Context, dir, url, branch string) (result RemoteCheckResult) { slog.Debug("git check-remote start", "repo", url, "branch", branch, "dest", dir) + defer func() { slog.Debug("git check-remote result", "repo", url, "branch", branch, "dest", dir, @@ -355,11 +388,13 @@ func CheckRemoteContext(ctx context.Context, dir, url, branch string) (result Re } listOpts := &gogit.ListOptions{} + if isSSHURL(url) { auth, err := sshAuth(url) if err != nil { return RemoteCheckResult{ErrorKind: CheckErrorAuth} } + listOpts.Auth = auth } @@ -368,6 +403,7 @@ func CheckRemoteContext(ctx context.Context, dir, url, branch string) (result Re if ctx.Err() != nil { return RemoteCheckResult{ErrorKind: CheckErrorNetwork} } + return RemoteCheckResult{ErrorKind: classifyRemoteError(err)} } @@ -395,26 +431,34 @@ func semverTagAtCommit(repo *gogit.Repository, hash plumbing.Hash) string { if err != nil { return "" } + var best *semver.Version + var bestOrig string + _ = tags.ForEach(func(ref *plumbing.Reference) error { h := ref.Hash() if obj, err := repo.TagObject(h); err == nil { h = obj.Target // annotated tag: resolve to the commit it points at } + if h != hash { return nil } + v, err := semver.NewVersion(ref.Name().Short()) if err != nil { return nil } + if best == nil || v.GreaterThan(best) { best = v bestOrig = v.Original() } + return nil }) + return bestOrig } @@ -441,6 +485,7 @@ func CheckRemote(dir, url, branch string) RemoteCheckResult { // - otherwise → not up-to-date, with LatestVersion set to the path's current version. func CheckLocalSource(sourcePath, savedCommit, savedVersion string) (result RemoteCheckResult) { slog.Debug("git check-local start", "source", sourcePath, "saved_commit", savedCommit, "saved_version", savedVersion) + defer func() { slog.Debug("git check-local result", "source", sourcePath, @@ -466,6 +511,7 @@ func CheckLocalSource(sourcePath, savedCommit, savedVersion string) (result Remo if desc.Commit == savedCommit && stripDirty(desc.Version) == stripDirty(savedVersion) { return RemoteCheckResult{IsUpToDate: true} } + return RemoteCheckResult{IsUpToDate: false, LatestVersion: desc.Version} } @@ -477,10 +523,10 @@ func stripDirty(version string) string { // classifyRemoteError maps a remote.List error to a CheckErrorKind. func classifyRemoteError(err error) CheckErrorKind { - var netErr *net.OpError - if errors.As(err, &netErr) { + if _, ok := errors.AsType[*net.OpError](err); ok { return CheckErrorNetwork } + switch { case errors.Is(err, transport.ErrAuthenticationRequired), errors.Is(err, transport.ErrAuthorizationFailed): @@ -488,6 +534,7 @@ func classifyRemoteError(err error) CheckErrorKind { case errors.Is(err, transport.ErrRepositoryNotFound): return CheckErrorNotFound } + return CheckErrorUnknown } @@ -502,6 +549,7 @@ func resolveStatus(refs []*plumbing.Reference, localHead plumbing.Hash, ref, cur branchRef := plumbing.NewBranchReferenceName(ref) remoteTags := map[string]struct{}{} + for _, r := range refs { if r.Name().IsTag() { remoteTags[r.Name().Short()] = struct{}{} @@ -515,6 +563,7 @@ func resolveStatus(refs []*plumbing.Reference, localHead plumbing.Hash, ref, cur if latest == "" || latest == ref { return RemoteCheckResult{IsUpToDate: true} } + return RemoteCheckResult{IsUpToDate: false, LatestVersion: latest} } } @@ -532,8 +581,10 @@ func resolveStatus(refs []*plumbing.Reference, localHead plumbing.Hash, ref, cur if latest == "" || latest == currentVersion { return RemoteCheckResult{IsUpToDate: true} } + return RemoteCheckResult{IsUpToDate: false, LatestVersion: latest} } + return RemoteCheckResult{IsUpToDate: r.Hash() == localHead} } } @@ -548,18 +599,23 @@ func latestSemverTag(tags map[string]struct{}, current string) string { if err != nil { return "" } + var latest *semver.Version + for tag := range tags { v, err := semver.NewVersion(tag) if err != nil { continue } + if v.GreaterThan(cur) && (latest == nil || v.GreaterThan(latest)) { latest = v } } + if latest == nil { return "" } + return latest.Original() } diff --git a/internal/util/git/remote_check_test.go b/internal/util/git/remote_check_test.go index 2fc07e7..a925890 100644 --- a/internal/util/git/remote_check_test.go +++ b/internal/util/git/remote_check_test.go @@ -17,6 +17,7 @@ import ( func TestClassifyRemoteError_Network(t *testing.T) { err := &net.OpError{Op: "dial", Err: fmt.Errorf("connection refused")} + got := classifyRemoteError(err) if got != CheckErrorNetwork { t.Errorf("classifyRemoteError(&net.OpError): got %q, want %q", got, CheckErrorNetwork) @@ -60,10 +61,12 @@ func TestResolveStatus_BranchUpToDate(t *testing.T) { refs := []*plumbing.Reference{ plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), hashA), } + result := resolveStatus(refs, hashA, "main", "") if !result.IsUpToDate { t.Error("expected IsUpToDate = true when branch hash matches local HEAD") } + if result.ErrorKind != CheckErrorNone { t.Errorf("expected no error, got %q", result.ErrorKind) } @@ -73,10 +76,12 @@ func TestResolveStatus_BranchBehind(t *testing.T) { refs := []*plumbing.Reference{ plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), hashB), } + result := resolveStatus(refs, hashA, "main", "") if result.IsUpToDate { t.Error("expected IsUpToDate = false when branch hash differs from local HEAD") } + if result.ErrorKind != CheckErrorNone { t.Errorf("expected no error, got %q", result.ErrorKind) } @@ -92,10 +97,12 @@ func TestResolveStatus_BranchOnSemverTagNotOutdatedByLowerTag(t *testing.T) { plumbing.NewHashReference(plumbing.NewTagReferenceName("1.1.0"), hashA), plumbing.NewHashReference(plumbing.NewTagReferenceName("1.0.1"), hashB), } + result := resolveStatus(refs, hashA, "main", "1.1.0") if !result.IsUpToDate { t.Error("expected IsUpToDate = true: 1.0.1 is not a semver upgrade over 1.1.0") } + if result.LatestVersion != "" { t.Errorf("expected empty LatestVersion, got %q", result.LatestVersion) } @@ -109,10 +116,12 @@ func TestResolveStatus_BranchOnSemverTagUpgradesToHigherTag(t *testing.T) { plumbing.NewHashReference(plumbing.NewTagReferenceName("1.1.0"), hashA), plumbing.NewHashReference(plumbing.NewTagReferenceName("1.2.0"), hashB), } + result := resolveStatus(refs, hashA, "main", "1.1.0") if result.IsUpToDate { t.Error("expected IsUpToDate = false when a higher semver tag exists") } + if result.LatestVersion != "1.2.0" { t.Errorf("LatestVersion: got %q, want %q", result.LatestVersion, "1.2.0") } @@ -124,6 +133,7 @@ func TestResolveStatus_BranchNonSemverFallsBackToCommit(t *testing.T) { refs := []*plumbing.Reference{ plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), hashB), } + result := resolveStatus(refs, hashA, "main", "") if result.IsUpToDate { t.Error("expected IsUpToDate = false: non-semver checkout falls back to commit comparison") @@ -134,10 +144,12 @@ func TestResolveStatus_TagAlreadyLatest(t *testing.T) { refs := []*plumbing.Reference{ plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0.0"), hashA), } + result := resolveStatus(refs, hashA, "v1.0.0", "v1.0.0") if !result.IsUpToDate { t.Error("expected IsUpToDate = true when on latest semver tag") } + if result.LatestVersion != "" { t.Errorf("expected empty LatestVersion, got %q", result.LatestVersion) } @@ -148,10 +160,12 @@ func TestResolveStatus_TagNewerExists(t *testing.T) { plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.0.0"), hashA), plumbing.NewHashReference(plumbing.NewTagReferenceName("v2.0.0"), hashB), } + result := resolveStatus(refs, hashA, "v1.0.0", "v1.0.0") if result.IsUpToDate { t.Error("expected IsUpToDate = false when newer tag exists") } + if result.LatestVersion != "v2.0.0" { t.Errorf("LatestVersion: got %q, want %q", result.LatestVersion, "v2.0.0") } @@ -167,26 +181,33 @@ func TestResolveStatus_RefNotFound(t *testing.T) { // commitFile stages a file and commits it, returning the new commit hash. func commitFile(t *testing.T, repo *gogit.Repository, dir, name, msg string) plumbing.Hash { t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0644); err != nil { t.Fatalf("write file: %v", err) } + wt, err := repo.Worktree() if err != nil { t.Fatalf("worktree: %v", err) } + if _, err := wt.Add(name); err != nil { t.Fatalf("add: %v", err) } + sig := &object.Signature{Name: "T", Email: "t@example.com", When: time.Unix(0, 0).UTC()} + h, err := wt.Commit(msg, &gogit.CommitOptions{Author: sig, Committer: sig}) if err != nil { t.Fatalf("commit: %v", err) } + return h } func TestSemverTagAtCommit(t *testing.T) { dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) if err != nil { t.Fatalf("init: %v", err) @@ -197,6 +218,7 @@ func TestSemverTagAtCommit(t *testing.T) { if _, err := repo.CreateTag("1.1.0", tagged, nil); err != nil { t.Fatalf("lightweight tag: %v", err) } + sig := &object.Signature{Name: "T", Email: "t@example.com", When: time.Unix(0, 0).UTC()} if _, err := repo.CreateTag("v1.2.0", tagged, &gogit.CreateTagOptions{Message: "release", Tagger: sig}); err != nil { t.Fatalf("annotated tag: %v", err) @@ -221,6 +243,7 @@ func TestLatestSemverTag_NewerExists(t *testing.T) { "v2.0.0": {}, "not-semver": {}, } + got := latestSemverTag(tags, "v1.1.0") if got != "v2.0.0" { t.Errorf("latestSemverTag: got %q, want %q", got, "v2.0.0") @@ -232,6 +255,7 @@ func TestLatestSemverTag_AlreadyLatest(t *testing.T) { "v1.0.0": {}, "v1.1.0": {}, } + got := latestSemverTag(tags, "v1.1.0") if got != "" { t.Errorf("latestSemverTag: got %q, want empty string (already latest)", got) @@ -240,6 +264,7 @@ func TestLatestSemverTag_AlreadyLatest(t *testing.T) { func TestLatestSemverTag_InvalidCurrent(t *testing.T) { tags := map[string]struct{}{"v1.0.0": {}} + got := latestSemverTag(tags, "not-a-version") if got != "" { t.Errorf("latestSemverTag: got %q, want empty string for invalid current", got) diff --git a/internal/util/osutil/osutil.go b/internal/util/osutil/osutil.go index 883b283..8116425 100644 --- a/internal/util/osutil/osutil.go +++ b/internal/util/osutil/osutil.go @@ -14,36 +14,51 @@ func CopyDir(src, dst string) error { if err != nil { return err } + rel, _ := filepath.Rel(src, path) target := filepath.Join(dst, rel) if d.IsDir() { return os.MkdirAll(target, 0755) } + return copyFile(path, target) }) } -func copyFile(src, dst string) error { +func copyFile(src, dst string) (err error) { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } + info, err := os.Stat(src) if err != nil { return err } + in, err := os.Open(src) if err != nil { return err } - defer in.Close() + + defer func() { _ = in.Close() }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) if err != nil { return err } - defer out.Close() + + // A failed Close on a writable file can mean buffered data was not flushed, + // so surface it when no earlier error already took precedence. + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() + if _, err = io.Copy(out, in); err != nil { return err } + return os.Chmod(dst, info.Mode()) } diff --git a/internal/util/osutil/osutil_test.go b/internal/util/osutil/osutil_test.go index de606a3..373e10b 100644 --- a/internal/util/osutil/osutil_test.go +++ b/internal/util/osutil/osutil_test.go @@ -16,9 +16,11 @@ func TestCopyDir_PreservesStructure(t *testing.T) { if err := os.MkdirAll(filepath.Join(src, "a", "b"), 0755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(src, "a", "b", "file.txt"), []byte("hello"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(src, "root.txt"), []byte("root"), 0644); err != nil { t.Fatal(err) } @@ -51,6 +53,7 @@ func TestCopyDir_PreservesContent(t *testing.T) { if err != nil { t.Fatalf("reading dst file: %v", err) } + if string(got) != string(content) { t.Errorf("content = %q, want %q", got, content) } @@ -64,6 +67,7 @@ func TestCopyDir_PreservesPermissions(t *testing.T) { if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatal(err) } + if err := os.Chmod(path, 0755); err != nil { t.Fatal(err) } @@ -76,6 +80,7 @@ func TestCopyDir_PreservesPermissions(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } + if got := info.Mode().Perm(); got != 0755 { t.Errorf("permissions = %04o, want 0755", got) } @@ -88,6 +93,7 @@ func TestCopyDir_OverwritesExisting(t *testing.T) { if err := os.WriteFile(filepath.Join(src, "file.txt"), []byte("new"), 0644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dst, "file.txt"), []byte("old"), 0644); err != nil { t.Fatal(err) } diff --git a/internal/util/output/log_test.go b/internal/util/output/log_test.go index 3d4dd27..f6456e7 100644 --- a/internal/util/output/log_test.go +++ b/internal/util/output/log_test.go @@ -12,8 +12,10 @@ import ( func TestHumanWriter_Info_NonEmpty(t *testing.T) { var buf bytes.Buffer + w := output.NewHumanWriter(&buf, &bytes.Buffer{}) w.Info("hello %s", "world") + if buf.Len() == 0 { t.Error("HumanWriter.Info produced no output") } @@ -21,8 +23,10 @@ func TestHumanWriter_Info_NonEmpty(t *testing.T) { func TestHumanWriter_Warn_NonEmpty(t *testing.T) { var errBuf bytes.Buffer + w := output.NewHumanWriter(&bytes.Buffer{}, &errBuf) w.Warn("something wrong") + if errBuf.Len() == 0 { t.Error("HumanWriter.Warn produced no output") } @@ -30,8 +34,10 @@ func TestHumanWriter_Warn_NonEmpty(t *testing.T) { func TestHumanWriter_Error_NonEmpty(t *testing.T) { var errBuf bytes.Buffer + w := output.NewHumanWriter(&bytes.Buffer{}, &errBuf) w.Error("fatal error") + if errBuf.Len() == 0 { t.Error("HumanWriter.Error produced no output") } @@ -39,12 +45,15 @@ func TestHumanWriter_Error_NonEmpty(t *testing.T) { func TestJSONWriter_Info(t *testing.T) { var buf bytes.Buffer + w := output.NewJSONWriter(&buf, &bytes.Buffer{}) w.Info("hello %s", "world") + out := buf.String() if !strings.Contains(out, `"level":"info"`) { t.Errorf("JSONWriter.Info missing level field, got: %q", out) } + if !strings.Contains(out, `"message":"hello world"`) { t.Errorf("JSONWriter.Info missing message field, got: %q", out) } @@ -52,8 +61,10 @@ func TestJSONWriter_Info(t *testing.T) { func TestJSONWriter_Warn(t *testing.T) { var errBuf bytes.Buffer + w := output.NewJSONWriter(&bytes.Buffer{}, &errBuf) w.Warn("something wrong") + out := errBuf.String() if !strings.Contains(out, `"level":"warn"`) { t.Errorf("JSONWriter.Warn missing level field, got: %q", out) @@ -62,8 +73,10 @@ func TestJSONWriter_Warn(t *testing.T) { func TestJSONWriter_Error(t *testing.T) { var errBuf bytes.Buffer + w := output.NewJSONWriter(&bytes.Buffer{}, &errBuf) w.Error("fatal error") + out := errBuf.String() if !strings.Contains(out, `"level":"error"`) { t.Errorf("JSONWriter.Error missing level field, got: %q", out) @@ -72,15 +85,19 @@ func TestJSONWriter_Error(t *testing.T) { func TestJSONWriter_WriteErr_WithKnownSentinel(t *testing.T) { var errBuf bytes.Buffer + w := output.NewJSONWriter(&bytes.Buffer{}, &errBuf) w.WriteErr(specs.ErrTemplateNotFound) + out := errBuf.String() if !strings.Contains(out, `"level":"error"`) { t.Errorf("JSONWriter.WriteErr missing level field, got: %q", out) } + if !strings.Contains(out, `"error_kind":"template_not_found"`) { t.Errorf("JSONWriter.WriteErr missing error_kind field, got: %q", out) } + if !strings.Contains(out, `"message"`) { t.Errorf("JSONWriter.WriteErr missing message field, got: %q", out) } @@ -88,8 +105,10 @@ func TestJSONWriter_WriteErr_WithKnownSentinel(t *testing.T) { func TestJSONWriter_WriteErr_UnknownError(t *testing.T) { var errBuf bytes.Buffer + w := output.NewJSONWriter(&bytes.Buffer{}, &errBuf) w.WriteErr(errors.New("something unexpected")) + out := errBuf.String() if strings.Contains(out, `"error_kind"`) { t.Errorf("JSONWriter.WriteErr should not include error_kind for unknown error, got: %q", out) @@ -98,8 +117,10 @@ func TestJSONWriter_WriteErr_UnknownError(t *testing.T) { func TestHumanWriter_WriteErr_NonEmpty(t *testing.T) { var errBuf bytes.Buffer + w := output.NewHumanWriter(&bytes.Buffer{}, &errBuf) w.WriteErr(specs.ErrTemplateNotFound) + if errBuf.Len() == 0 { t.Error("HumanWriter.WriteErr produced no output") } @@ -107,15 +128,18 @@ func TestHumanWriter_WriteErr_NonEmpty(t *testing.T) { func TestJSONWriter_Table(t *testing.T) { var buf bytes.Buffer + w := output.NewJSONWriter(&buf, &bytes.Buffer{}) w.Table( []string{"Name", "Version"}, [][]string{{"my-tpl", "1.0.0"}, {"other", "2.0.0"}}, ) + out := buf.String() if !strings.Contains(out, `"Name":"my-tpl"`) { t.Errorf("JSONWriter.Table missing Name field, got: %q", out) } + if !strings.Contains(out, `"Version":"1.0.0"`) { t.Errorf("JSONWriter.Table missing Version field, got: %q", out) } diff --git a/internal/util/output/table.go b/internal/util/output/table.go index c9a18f4..72f273f 100644 --- a/internal/util/output/table.go +++ b/internal/util/output/table.go @@ -23,12 +23,14 @@ func RenderTable(headers []string, rows [][]string) string { for i, h := range headers { sb.WriteString(tableHeaderStyle.Width(widths[i] + 2).Render(h)) } + sb.WriteString("\n") // Separator for _, w := range widths { sb.WriteString(tableBorderStyle.Render(strings.Repeat("─", w+2))) // +2 for padding } + sb.WriteString("\n") // Data rows @@ -37,8 +39,10 @@ func RenderTable(headers []string, rows [][]string) string { if i >= len(widths) { break } + sb.WriteString(tableCellStyle.Width(widths[i] + 2).Render(cell)) } + sb.WriteString("\n") } @@ -53,6 +57,7 @@ func columnWidths(headers []string, rows [][]string) []int { for i, h := range headers { widths[i] = len(h) } + for _, row := range rows { for i, cell := range row { if i < len(widths) && len(cell) > widths[i] { @@ -60,5 +65,6 @@ func columnWidths(headers []string, rows [][]string) []int { } } } + return widths } diff --git a/internal/util/output/table_test.go b/internal/util/output/table_test.go index bf7204a..2cf5938 100644 --- a/internal/util/output/table_test.go +++ b/internal/util/output/table_test.go @@ -15,6 +15,7 @@ func TestRenderTable_ContainsHeaders(t *testing.T) { if !strings.Contains(out, "Tag") { t.Error("table output does not contain header 'Tag'") } + if !strings.Contains(out, "my-tag") { t.Error("table output does not contain row value 'my-tag'") } @@ -31,6 +32,7 @@ func TestRenderTable_MultipleRows(t *testing.T) { if !strings.Contains(out, "alpha") { t.Error("table output does not contain 'alpha'") } + if !strings.Contains(out, "beta") { t.Error("table output does not contain 'beta'") } diff --git a/internal/util/output/writer.go b/internal/util/output/writer.go index 8c5bc62..fa40e83 100644 --- a/internal/util/output/writer.go +++ b/internal/util/output/writer.go @@ -100,6 +100,7 @@ func (w *JSONWriter) WriteErr(err error) { if kind := specs.KindOf(err); kind != "" { payload["error_kind"] = kind } + data, _ := json.Marshal(payload) fmt.Fprintln(w.stderr, string(data)) } @@ -107,15 +108,19 @@ func (w *JSONWriter) WriteErr(err error) { // Table outputs an array of JSON objects, one per row, keyed by column header. func (w *JSONWriter) Table(headers []string, rows [][]string) { records := make([]map[string]string, len(rows)) + for i, row := range rows { record := make(map[string]string, len(headers)) + for j, header := range headers { if j < len(row) { record[header] = row[j] } } + records[i] = record } + data, _ := json.Marshal(records) fmt.Fprintln(w.stdout, string(data)) } diff --git a/internal/util/validate/validate.go b/internal/util/validate/validate.go index e0f6af2..a362fd6 100644 --- a/internal/util/validate/validate.go +++ b/internal/util/validate/validate.go @@ -13,8 +13,10 @@ func Name(name string) error { if name == "" { return fmt.Errorf("name must not be empty") } + if !namePattern.MatchString(name) { return fmt.Errorf("name %q contains invalid characters (allowed: a-z A-Z 0-9 _ -)", name) } + return nil } diff --git a/internal/util/values/values.go b/internal/util/values/values.go index 6e4f01b..0d560c2 100644 --- a/internal/util/values/values.go +++ b/internal/util/values/values.go @@ -18,7 +18,9 @@ func LoadFile(path string) (map[string]any, error) { if err != nil { return nil, fmt.Errorf("reading values file %q: %w", path, err) } + var m map[string]any + ext := strings.ToLower(filepath.Ext(path)) if ext == ".yaml" || ext == ".yml" { if err := yaml.Unmarshal(data, &m); err != nil { @@ -29,6 +31,7 @@ func LoadFile(path string) (map[string]any, error) { return nil, fmt.Errorf("parsing values file %q: %w", path, err) } } + return m, nil } @@ -38,6 +41,7 @@ func ParseArg(arg string) (key, value string, err error) { if len(parts) != 2 { return "", "", fmt.Errorf("--arg %q must be in Key=Value form", arg) } + return parts[0], parts[1], nil } @@ -46,5 +50,6 @@ func Merge(base, overrides map[string]any) map[string]any { result := make(map[string]any, len(base)) maps.Copy(result, base) maps.Copy(result, overrides) + return result } diff --git a/internal/util/values/values_test.go b/internal/util/values/values_test.go index f9f6cd7..0615b03 100644 --- a/internal/util/values/values_test.go +++ b/internal/util/values/values_test.go @@ -14,10 +14,12 @@ func TestLoadFile_Valid(t *testing.T) { if err := os.WriteFile(f, []byte(`{"Name":"acme"}`), 0644); err != nil { t.Fatal(err) } + m, err := values.LoadFile(f) if err != nil { t.Fatalf("unexpected error: %v", err) } + if m["Name"] != "acme" { t.Errorf("Name = %v, want %q", m["Name"], "acme") } @@ -35,6 +37,7 @@ func TestLoadFile_InvalidJSON(t *testing.T) { if err := os.WriteFile(f, []byte(`{not valid`), 0644); err != nil { t.Fatal(err) } + _, err := values.LoadFile(f) if err == nil { t.Fatal("expected error for invalid JSON") @@ -48,10 +51,12 @@ func TestLoadFile_ValidYAML(t *testing.T) { if err := os.WriteFile(f, []byte("Name: acme\n"), 0644); err != nil { t.Fatal(err) } + m, err := values.LoadFile(f) if err != nil { t.Fatalf("unexpected error: %v", err) } + if m["Name"] != "acme" { t.Errorf("Name = %v, want %q", m["Name"], "acme") } @@ -64,6 +69,7 @@ func TestLoadFile_InvalidYAML(t *testing.T) { if err := os.WriteFile(f, []byte(":\tinvalid: yaml: [\n"), 0644); err != nil { t.Fatal(err) } + _, err := values.LoadFile(f) if err == nil { t.Fatal("expected error for invalid YAML") @@ -75,6 +81,7 @@ func TestParseArg_Valid(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if k != "Name" || v != "acme" { t.Errorf("got key=%q value=%q, want key=%q value=%q", k, v, "Name", "acme") } @@ -85,6 +92,7 @@ func TestParseArg_WithEquals(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } + if k != "Url" || v != "http://x.com" { t.Errorf("got key=%q value=%q", k, v) } @@ -100,10 +108,12 @@ func TestParseArg_NoEquals(t *testing.T) { func TestMerge_OverridesBase(t *testing.T) { base := map[string]any{"A": json.Number("1")} overrides := map[string]any{"A": json.Number("2"), "B": json.Number("3")} + result := values.Merge(base, overrides) if result["A"] != json.Number("2") { t.Errorf("A = %v, want 2", result["A"]) } + if result["B"] != json.Number("3") { t.Errorf("B = %v, want 3", result["B"]) } @@ -113,6 +123,7 @@ func TestMerge_DoesNotMutateBase(t *testing.T) { base := map[string]any{"A": json.Number("1")} overrides := map[string]any{"A": json.Number("2")} values.Merge(base, overrides) + if base["A"] != json.Number("1") { t.Errorf("base mutated: A = %v, want 1", base["A"]) } diff --git a/main.go b/main.go index 53e9b98..be8e9dc 100644 --- a/main.go +++ b/main.go @@ -17,10 +17,10 @@ func main() { app := cmd.NewApp() if err := cmd.ExecuteContext(ctx, app); err != nil { - var exitErr *exit.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exit.ExitError](err); ok { os.Exit(exitErr.Code) } + app.Output.WriteErr(err) os.Exit(exit.Error) } diff --git a/taskfiles/Taskfile.docs.yml b/taskfiles/Taskfile.docs.yml new file mode 100644 index 0000000..ee8df09 --- /dev/null +++ b/taskfiles/Taskfile.docs.yml @@ -0,0 +1,27 @@ +# https://taskfile.dev +version: "3" + +tasks: + + check: + desc: Check the style of Markdown files with markdownlint + cmds: + - task: :dc:run:node + vars: + SUB_CMD: "npx --yes markdownlint-cli2 {{.SUB_CMD}}" + + fix: + desc: Fix Markdown style — align tables, then apply autofixable lint rules + cmds: + - task: fix-tables + - task: check + vars: + SUB_CMD: --fix + + fix-tables: + desc: Align table spacing in Markdown files + cmds: + # dotglob so hidden directories (e.g. .github) are covered too. + - task: :dc:run:node + vars: + SUB_CMD: bash -c "shopt -s globstar dotglob && npx --yes markdown-table-formatter **/*.md" diff --git a/taskfiles/Taskfile.lint.yml b/taskfiles/Taskfile.lint.yml new file mode 100644 index 0000000..9cb7c98 --- /dev/null +++ b/taskfiles/Taskfile.lint.yml @@ -0,0 +1,17 @@ +# https://taskfile.dev +version: "3" + +tasks: + lint: + desc: Run golangci-lint + cmds: + - task: dc:run:golangci-lint + vars: + SUB_CMD: "golangci-lint run" + + lint:fix: + desc: Run golangci-lint + cmds: + - task: dc:run:golangci-lint + vars: + SUB_CMD: "golangci-lint run --fix" diff --git a/taskfiles/Taskfile.md.yml b/taskfiles/Taskfile.md.yml new file mode 100644 index 0000000..9a97c4d --- /dev/null +++ b/taskfiles/Taskfile.md.yml @@ -0,0 +1,35 @@ +# https://taskfile.dev +version: "3" + +tasks: + + build: + desc: Build the static documentation site into docs/public/ + cmds: + - task: :dc:run:hugo + vars: + SUB_CMD: --minify + + serve: + desc: Start Hugo development server with live reload (http://localhost:1313) + cmds: + - task: :dc + vars: + SUB_CMD: "run --rm --service-ports hugo server --bind 0.0.0.0" + + preview: + desc: Build and serve the static docs site locally (http://localhost:8080) + cmds: + - task: :dc:run:hugo + vars: + SUB_CMD: --minify --baseURL http://localhost:8080 + - task: :dc + vars: + SUB_CMD: "run --rm --service-ports docs-preview" + + mod:tidy: + desc: Tidy Hugo module dependencies and regenerate go.sum + cmds: + - task: :dc:run:hugo + vars: + SUB_CMD: mod tidy