diff --git a/internal/alpha/command/alpha.go b/internal/alpha/command/alpha.go
index 723548c6..016137af 100644
--- a/internal/alpha/command/alpha.go
+++ b/internal/alpha/command/alpha.go
@@ -2,7 +2,6 @@ package command
import (
"github.com/nais/cli/internal/alpha/command/flag"
- apply "github.com/nais/cli/internal/apply/command"
"github.com/nais/cli/internal/flags"
krakend "github.com/nais/cli/internal/krakend/command"
mcpcmd "github.com/nais/cli/internal/mcp/command"
@@ -17,7 +16,6 @@ func Alpha(parentFlags *flags.GlobalFlags) *naistrix.Command {
Description: "These commands are usually fully functional and ready to use, but the API might evolve based on your feedback.",
StickyFlags: flags,
SubCommands: []*naistrix.Command{
- apply.Apply(flags),
krakend.Krakend(flags),
mcpcmd.MCP(flags),
},
diff --git a/internal/application/application.go b/internal/application/application.go
index dd4ccc76..2c3d90ff 100644
--- a/internal/application/application.go
+++ b/internal/application/application.go
@@ -10,6 +10,7 @@ import (
activityCommand "github.com/nais/cli/internal/activity/command"
alphaCommand "github.com/nais/cli/internal/alpha/command"
appCommand "github.com/nais/cli/internal/app/command"
+ applyCommand "github.com/nais/cli/internal/apply/command"
authCommand "github.com/nais/cli/internal/auth/command"
configCommand "github.com/nais/cli/internal/config/command"
debugCommand "github.com/nais/cli/internal/debug/command"
@@ -71,6 +72,7 @@ func New(w io.Writer) (*Application, *flags.GlobalFlags, error) {
activityCommand.Activity(globalFlags),
alphaCommand.Alpha(globalFlags),
appCommand.App(globalFlags),
+ applyCommand.Apply(globalFlags),
authCommand.Auth(globalFlags),
configCommand.Config(globalFlags),
debugCommand.Debug(globalFlags),
diff --git a/internal/apply/apply.go b/internal/apply/apply.go
index e2c6d738..4f4c30b8 100644
--- a/internal/apply/apply.go
+++ b/internal/apply/apply.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
@@ -31,11 +32,37 @@ func Run(ctx context.Context, filePath string, flags *flag.Apply, out *naistrix.
return err
}
- data, err := render(filePath, string(flags.Mixin), environment, flags.Set, out)
+ isDir, err := isDirectory(filePath)
if err != nil {
return err
}
+ if isDir {
+ if len(flags.Set) > 0 {
+ return fmt.Errorf("--set cannot be used when applying a directory (ambiguous target manifest)")
+ }
+ if flags.Mixin != "" {
+ return fmt.Errorf("--mixin cannot be used when applying a directory (mixins are auto-loaded per file)")
+ }
+ }
+
+ var data []byte
+ if isDir {
+ envs, err := naisapi.GetAllEnvironments(ctx)
+ if err != nil {
+ out.Warnf("unable to fetch environment list: %v; mixin files will be identified heuristically\n", err)
+ }
+ data, err = renderDir(filePath, environment, envs, out)
+ if err != nil {
+ return err
+ }
+ } else {
+ data, err = render(filePath, string(flags.Mixin), environment, flags.Set, out)
+ if err != nil {
+ return err
+ }
+ }
+
docs, err := resource.Documents(data)
if err != nil {
return err
@@ -44,6 +71,10 @@ func Run(ctx context.Context, filePath string, flags *flag.Apply, out *naistrix.
return fmt.Errorf("no resources found in %s", filePath)
}
+ // Sort documents so workloads (Application, Naisjob) are applied last,
+ // ensuring their dependencies (e.g. Config, Valkey, OpenSearch) exist first.
+ sortDocsWorkloadsLast(docs)
+
var (
crds []unstructured.Unstructured
errs []string
@@ -340,3 +371,44 @@ func printDryRunYAML(doc *yaml.Node, out *naistrix.OutputWriter) {
out.Printf("\n")
}
}
+
+// isDirectory reports whether the given path is a directory.
+func isDirectory(path string) (bool, error) {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return false, fmt.Errorf("failed to stat %s: %w", path, err)
+ }
+ return fi.IsDir(), nil
+}
+
+// sortDocsWorkloadsLast reorders documents so that workload kinds (Application,
+// Naisjob) appear after all other resources. This ensures dependencies like
+// Config, Valkey, and OpenSearch are created before the workloads that reference
+// them.
+func sortDocsWorkloadsLast(docs []*yaml.Node) {
+ sort.SliceStable(docs, func(i, j int) bool {
+ iIsWorkload := isWorkloadDoc(docs[i])
+ jIsWorkload := isWorkloadDoc(docs[j])
+ // Non-workloads before workloads; preserve relative order otherwise.
+ return !iIsWorkload && jIsWorkload
+ })
+}
+
+// isWorkloadDoc checks if a YAML document node represents a workload kind.
+func isWorkloadDoc(doc *yaml.Node) bool {
+ kind := docKind(doc)
+ return workloadKinds[kind]
+}
+
+// docKind extracts the "kind" value from a YAML mapping node.
+func docKind(doc *yaml.Node) string {
+ if doc.Kind != yaml.MappingNode {
+ return ""
+ }
+ for i := 0; i+1 < len(doc.Content); i += 2 {
+ if doc.Content[i].Value == "kind" {
+ return doc.Content[i+1].Value
+ }
+ }
+ return ""
+}
diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go
index e2a30407..d98a0725 100644
--- a/internal/apply/apply_test.go
+++ b/internal/apply/apply_test.go
@@ -10,11 +10,11 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
- alphaflag "github.com/nais/cli/internal/alpha/command/flag"
applyflag "github.com/nais/cli/internal/apply/command/flag"
"github.com/nais/cli/internal/apply/resource"
flagspkg "github.com/nais/cli/internal/flags"
"github.com/nais/naistrix"
+ "gopkg.in/yaml.v3"
)
func TestReadManifestFile_Validation(t *testing.T) {
@@ -157,12 +157,10 @@ spec:
var out bytes.Buffer
flags := &applyflag.Apply{
- Alpha: &alphaflag.Alpha{
- GlobalFlags: &flagspkg.GlobalFlags{
- AdditionalFlags: &flagspkg.AdditionalFlags{
- Team: "my-team",
- Environment: "dev",
- },
+ GlobalFlags: &flagspkg.GlobalFlags{
+ AdditionalFlags: &flagspkg.AdditionalFlags{
+ Team: "my-team",
+ Environment: "dev",
},
},
DryRun: true,
@@ -202,12 +200,10 @@ spec:
}
flags := &applyflag.Apply{
- Alpha: &alphaflag.Alpha{
- GlobalFlags: &flagspkg.GlobalFlags{
- AdditionalFlags: &flagspkg.AdditionalFlags{
- Team: "my-team",
- Environment: "dev",
- },
+ GlobalFlags: &flagspkg.GlobalFlags{
+ AdditionalFlags: &flagspkg.AdditionalFlags{
+ Team: "my-team",
+ Environment: "dev",
},
},
DryRun: true,
@@ -226,3 +222,110 @@ func mustErrorContains(t *testing.T, err error, want string) {
t.Errorf("error %q does not contain %q", err.Error(), want)
}
}
+
+func TestSortDocsWorkloadsLast(t *testing.T) {
+ mkDoc := func(kind string) *yaml.Node {
+ return &yaml.Node{
+ Kind: yaml.MappingNode,
+ Content: []*yaml.Node{
+ {Kind: yaml.ScalarNode, Value: "kind"},
+ {Kind: yaml.ScalarNode, Value: kind},
+ },
+ }
+ }
+
+ docs := []*yaml.Node{
+ mkDoc("Application"),
+ mkDoc("Valkey"),
+ mkDoc("Naisjob"),
+ mkDoc("OpenSearch"),
+ mkDoc("Config"),
+ }
+
+ sortDocsWorkloadsLast(docs)
+
+ kinds := make([]string, len(docs))
+ for i, d := range docs {
+ kinds[i] = docKind(d)
+ }
+
+ // Workloads should be at the end
+ for i, kind := range kinds {
+ if workloadKinds[kind] && i < 3 {
+ t.Errorf("workload %q at index %d, expected at index >= 3", kind, i)
+ }
+ if !workloadKinds[kind] && i >= 3 {
+ t.Errorf("non-workload %q at index %d, expected at index < 3", kind, i)
+ }
+ }
+
+ // Non-workloads preserve relative order
+ nonWorkloads := []string{}
+ for _, k := range kinds {
+ if !workloadKinds[k] {
+ nonWorkloads = append(nonWorkloads, k)
+ }
+ }
+ if want := []string{"Valkey", "OpenSearch", "Config"}; strings.Join(nonWorkloads, ",") != strings.Join(want, ",") {
+ t.Errorf("non-workload order = %v, want %v", nonWorkloads, want)
+ }
+
+ // Workloads preserve relative order
+ workloads := []string{}
+ for _, k := range kinds {
+ if workloadKinds[k] {
+ workloads = append(workloads, k)
+ }
+ }
+ if want := []string{"Application", "Naisjob"}; strings.Join(workloads, ",") != strings.Join(want, ",") {
+ t.Errorf("workload order = %v, want %v", workloads, want)
+ }
+}
+
+func TestRun_DirectoryWithSetFails(t *testing.T) {
+ dir := t.TempDir()
+ manifestPath := filepath.Join(dir, "manifests")
+ if err := os.Mkdir(manifestPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(manifestPath, "app.yaml"), []byte("kind: Application\nmetadata:\n name: myapp\nspec:\n image: test\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ flags := &applyflag.Apply{
+ GlobalFlags: &flagspkg.GlobalFlags{
+ AdditionalFlags: &flagspkg.AdditionalFlags{
+ Team: "my-team",
+ Environment: "dev",
+ },
+ },
+ Set: []string{"spec.image=override"},
+ }
+
+ err := Run(context.Background(), manifestPath, flags, naistrix.NewOutputWriter(io.Discard, new(naistrix.Count)))
+ mustErrorContains(t, err, "--set cannot be used when applying a directory")
+}
+
+func TestRun_DirectoryWithMixinFlagFails(t *testing.T) {
+ dir := t.TempDir()
+ manifestPath := filepath.Join(dir, "manifests")
+ if err := os.Mkdir(manifestPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(manifestPath, "app.yaml"), []byte("kind: Application\nmetadata:\n name: myapp\nspec:\n image: test\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ flags := &applyflag.Apply{
+ GlobalFlags: &flagspkg.GlobalFlags{
+ AdditionalFlags: &flagspkg.AdditionalFlags{
+ Team: "my-team",
+ Environment: "dev",
+ },
+ },
+ Mixin: "some-mixin.yaml",
+ }
+
+ err := Run(context.Background(), manifestPath, flags, naistrix.NewOutputWriter(io.Discard, new(naistrix.Count)))
+ mustErrorContains(t, err, "--mixin cannot be used when applying a directory")
+}
diff --git a/internal/apply/command/apply.go b/internal/apply/command/apply.go
index a3a8f622..650e4ce9 100644
--- a/internal/apply/command/apply.go
+++ b/internal/apply/command/apply.go
@@ -5,35 +5,35 @@ import (
"fmt"
"time"
- alpha "github.com/nais/cli/internal/alpha/command/flag"
"github.com/nais/cli/internal/apply"
"github.com/nais/cli/internal/apply/command/flag"
+ "github.com/nais/cli/internal/flags"
"github.com/nais/cli/internal/validation"
"github.com/nais/naistrix"
)
-func Apply(parentFlags *alpha.Alpha) *naistrix.Command {
- flags := &flag.Apply{Alpha: parentFlags, Timeout: 10 * time.Minute}
+func Apply(parentFlags *flags.GlobalFlags) *naistrix.Command {
+ flags := &flag.Apply{GlobalFlags: parentFlags, Timeout: 10 * time.Minute}
return &naistrix.Command{
Name: "apply",
Title: "Apply resources.",
- Description: "Apply a Nais resource manifest (YAML) to a specific team and environment. The manifest file, team, and environment are required.",
+ Description: "Apply Nais resource manifests (YAML) to a specific team and environment. Accepts a single file or a directory containing multiple manifests. When a directory is given, mixin files (..yaml) are auto-loaded and --set/--mixin flags are disabled.",
Args: []naistrix.Argument{
- {Name: "file"},
+ {Name: "path"},
},
AutoCompleteExtensions: []string{"yaml", "yml"},
Flags: flags,
ValidateFunc: naistrix.ValidateFuncs(
validation.RequireTeam(flags),
func(ctx context.Context, args *naistrix.Arguments) error {
- if args.Get("file") == "" {
- return fmt.Errorf("file cannot be empty")
+ if args.Get("path") == "" {
+ return fmt.Errorf("path cannot be empty")
}
return nil
},
),
RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error {
- return apply.Run(ctx, args.Get("file"), flags, out)
+ return apply.Run(ctx, args.Get("path"), flags, out)
},
}
}
diff --git a/internal/apply/command/flag/flag.go b/internal/apply/command/flag/flag.go
index 714315cf..d76509a7 100644
--- a/internal/apply/command/flag/flag.go
+++ b/internal/apply/command/flag/flag.go
@@ -3,12 +3,12 @@ package flag
import (
"time"
- alpha "github.com/nais/cli/internal/alpha/command/flag"
+ "github.com/nais/cli/internal/flags"
"github.com/nais/naistrix"
)
type Apply struct {
- *alpha.Alpha
+ *flags.GlobalFlags
AllowIgnoredFields bool `name:"allow-ignored-fields" usage:"Warn instead of failing when a manifest contains fields that nais apply ignores (e.g. |metadata.namespace| or |metadata.annotations|)."`
DryRun bool `name:"dry-run" usage:"Preview which resources would be applied without making any changes."`
Mixin mixinFile `name:"mixin" usage:"YAML |FILE| deep-merged over the base manifest (mixin values win). If omitted, an adjacent ..yaml is auto-loaded when present."`
diff --git a/internal/apply/render.go b/internal/apply/render.go
index 016368b0..f6721e79 100644
--- a/internal/apply/render.go
+++ b/internal/apply/render.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "sort"
"strings"
"github.com/nais/naistrix"
@@ -142,3 +143,112 @@ func decodeSingleDocument(data []byte, path string) (map[string]any, error) {
return doc, nil
}
+
+// renderDir collects all YAML base files in a directory (excluding mixin files),
+// renders each with its environment-specific mixin (if present), and returns the
+// concatenated YAML output.
+//
+// knownEnvs is an optional list of known Nais environment names used to identify
+// mixin files: a file "..yaml" is treated as a mixin when the
+// corresponding base file exists and env is a known environment. When knownEnvs
+// is empty or nil, any "..yaml" file with a matching base is
+// treated as a mixin (heuristic fallback).
+func renderDir(dirPath, environment string, knownEnvs []string, out *naistrix.OutputWriter) ([]byte, error) {
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read directory %s: %w", dirPath, err)
+ }
+
+ // Collect all YAML files and identify which are mixins.
+ var yamlFiles []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ ext := filepath.Ext(name)
+ if ext != ".yaml" && ext != ".yml" {
+ continue
+ }
+ yamlFiles = append(yamlFiles, name)
+ }
+
+ mixins := mixinSet(yamlFiles, knownEnvs)
+
+ // Collect base files (non-mixins) sorted alphabetically.
+ var baseFiles []string
+ for _, name := range yamlFiles {
+ if mixins[name] {
+ continue
+ }
+ baseFiles = append(baseFiles, name)
+ }
+ sort.Strings(baseFiles)
+
+ if len(baseFiles) == 0 {
+ return nil, fmt.Errorf("no YAML resource files found in %s", dirPath)
+ }
+
+ var combined []byte
+ for _, name := range baseFiles {
+ basePath := filepath.Join(dirPath, name)
+ data, err := render(basePath, "", environment, nil, out)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+ if len(combined) > 0 {
+ combined = append(combined, []byte("---\n")...)
+ }
+ combined = append(combined, data...)
+ if len(data) > 0 && data[len(data)-1] != '\n' {
+ combined = append(combined, '\n')
+ }
+ }
+
+ return combined, nil
+}
+
+// mixinSet returns the set of filenames that are environment-specific mixins.
+// A file is considered a mixin when it matches the pattern ".."
+// and the corresponding base file "." exists in the directory.
+//
+// When knownEnvs is non-empty, only suffixes present in the list are considered
+// environment names; otherwise any suffix is accepted (heuristic fallback for
+// when the environment list could not be fetched).
+func mixinSet(yamlFiles []string, knownEnvs []string) map[string]bool {
+ fileSet := make(map[string]bool, len(yamlFiles))
+ for _, f := range yamlFiles {
+ fileSet[f] = true
+ }
+
+ envSet := make(map[string]bool, len(knownEnvs))
+ for _, e := range knownEnvs {
+ envSet[e] = true
+ }
+
+ mixins := make(map[string]bool)
+
+ for _, name := range yamlFiles {
+ ext := filepath.Ext(name)
+ stem := name[:len(name)-len(ext)]
+
+ before, after, ok := strings.Cut(stem, ".")
+ if !ok {
+ continue
+ }
+
+ baseName := before + ext
+ if !fileSet[baseName] {
+ continue
+ }
+
+ suffix := after
+ if len(envSet) > 0 && !envSet[suffix] {
+ continue
+ }
+
+ mixins[name] = true
+ }
+
+ return mixins
+}
diff --git a/internal/apply/render_test.go b/internal/apply/render_test.go
index 44b0880b..8ab64448 100644
--- a/internal/apply/render_test.go
+++ b/internal/apply/render_test.go
@@ -136,3 +136,190 @@ func TestRender_UnsupportedExtensionFails(t *testing.T) {
t.Errorf("got %v, want error containing %q", err, "unsupported file extension")
}
}
+
+func TestRenderDir_CollectsBaseFilesAndExcludesMixins(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n")
+ writeFile(t, dir, "app.dev.yaml", "spec:\n image: dev-image\n")
+ writeFile(t, dir, "valkey.yaml", "kind: Valkey\nmetadata:\n name: myvalkey\n")
+
+ got, err := renderDir(dir, "dev", nil, discardWriter())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ output := string(got)
+ // app.yaml should be rendered with its mixin
+ if !strings.Contains(output, "dev-image") {
+ t.Errorf("output should contain mixin value 'dev-image', got:\n%s", output)
+ }
+ // valkey.yaml should be included
+ if !strings.Contains(output, "Valkey") {
+ t.Errorf("output should contain Valkey resource, got:\n%s", output)
+ }
+ // mixin file should NOT appear as its own resource
+ if strings.Count(output, "kind:") != 2 {
+ t.Errorf("expected 2 resources, got:\n%s", output)
+ }
+}
+
+func TestRenderDir_ExcludesMixinsForAllEnvironments(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "app.yaml", "apiVersion: nais.io/v1alpha1\nkind: Application\nmetadata:\n name: myapp\nspec:\n image: base\n")
+ writeFile(t, dir, "app.dev-gcp.yaml", "spec:\n image: dev\n")
+ writeFile(t, dir, "app.prod-gcp.yaml", "spec:\n image: prod\n")
+ writeFile(t, dir, "config.yaml", "version: v1\nkind: Config\nmetadata:\n name: myconfig\ndata:\n KEY: value\n")
+ writeFile(t, dir, "config.dev-gcp.yaml", "data:\n ENV: dev\n")
+ writeFile(t, dir, "config.prod-gcp.yaml", "data:\n ENV: prod\n")
+
+ got, err := renderDir(dir, "dev-gcp", nil, discardWriter())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ output := string(got)
+ // Only base files should produce resources
+ if strings.Count(output, "kind:") != 2 {
+ t.Errorf("expected 2 resources (app + config), got:\n%s", output)
+ }
+ // The active mixin for dev-gcp should be merged
+ if !strings.Contains(output, "dev") {
+ t.Errorf("expected dev-gcp mixin to be applied, got:\n%s", output)
+ }
+}
+
+func TestRenderDir_IgnoresNonYAMLFiles(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n")
+ writeFile(t, dir, "readme.md", "# Not a manifest")
+ writeFile(t, dir, "config.json", "{}")
+
+ got, err := renderDir(dir, "dev", nil, discardWriter())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(string(got), "Application") {
+ t.Errorf("expected Application in output, got:\n%s", got)
+ }
+}
+
+func TestRenderDir_EmptyDirectoryFails(t *testing.T) {
+ dir := t.TempDir()
+
+ _, err := renderDir(dir, "dev", nil, discardWriter())
+ if err == nil || !strings.Contains(err.Error(), "no YAML resource files found") {
+ t.Errorf("got %v, want error about no YAML files", err)
+ }
+}
+
+func TestRenderDir_OnlyMixinFilesFails(t *testing.T) {
+ dir := t.TempDir()
+ // Create a mixin without a corresponding base - but since there's no base,
+ // it won't be detected as a mixin and will be treated as a base file.
+ // To actually test "only mixins", we need a base that has a mixin.
+ writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n")
+ writeFile(t, dir, "app.dev.yaml", "spec:\n image: dev\n")
+
+ got, err := renderDir(dir, "dev", nil, discardWriter())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Should only render app.yaml (with mixin), not treat mixin as separate resource
+ if strings.Count(string(got), "kind:") != 1 {
+ t.Errorf("expected 1 resource (mixin excluded), got:\n%s", got)
+ }
+}
+
+func TestRenderDir_IgnoresSubdirectories(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "app.yaml", "kind: Application\nmetadata:\n name: myapp\n")
+ subdir := filepath.Join(dir, "subdir")
+ if err := os.Mkdir(subdir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, subdir, "other.yaml", "kind: Other\nmetadata:\n name: other\n")
+
+ got, err := renderDir(dir, "dev", nil, discardWriter())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if strings.Contains(string(got), "Other") {
+ t.Errorf("subdirectory files should not be included, got:\n%s", got)
+ }
+}
+
+func TestMixinSet(t *testing.T) {
+ t.Run("known environments", func(t *testing.T) {
+ files := []string{"app.yaml", "app.dev.yaml", "app.prod.yaml", "valkey.yaml", "valkey.dev.yaml", "standalone.yaml"}
+ envs := []string{"dev", "prod"}
+
+ mixins := mixinSet(files, envs)
+ if !mixins["app.dev.yaml"] {
+ t.Error("app.dev.yaml should be a mixin")
+ }
+ if !mixins["app.prod.yaml"] {
+ t.Error("app.prod.yaml should be a mixin")
+ }
+ if !mixins["valkey.dev.yaml"] {
+ t.Error("valkey.dev.yaml should be a mixin")
+ }
+ if mixins["app.yaml"] {
+ t.Error("app.yaml should NOT be a mixin")
+ }
+ if mixins["valkey.yaml"] {
+ t.Error("valkey.yaml should NOT be a mixin")
+ }
+ if mixins["standalone.yaml"] {
+ t.Error("standalone.yaml should NOT be a mixin")
+ }
+ })
+
+ t.Run("unknown environment suffix is not a mixin", func(t *testing.T) {
+ files := []string{"app.yaml", "app.unknown.yaml", "config.yaml", "config.unknown.yaml"}
+ envs := []string{"dev", "prod"}
+
+ mixins := mixinSet(files, envs)
+ if mixins["app.unknown.yaml"] {
+ t.Error("app.unknown.yaml should NOT be a mixin ('unknown' is not a known environment)")
+ }
+ })
+
+ t.Run("mixin without base is not filtered", func(t *testing.T) {
+ files := []string{"orphan.dev.yaml", "other.yaml"}
+ envs := []string{"dev"}
+
+ mixins := mixinSet(files, envs)
+ if mixins["orphan.dev.yaml"] {
+ t.Error("orphan.dev.yaml should NOT be a mixin (no orphan.yaml base)")
+ }
+ })
+
+ t.Run("compound environment names", func(t *testing.T) {
+ files := []string{"app.yaml", "app.dev-gcp.yaml", "app.prod-gcp.yaml", "config.yaml", "config.dev-gcp.yaml"}
+ envs := []string{"dev-gcp", "prod-gcp"}
+
+ mixins := mixinSet(files, envs)
+ if !mixins["app.dev-gcp.yaml"] {
+ t.Error("app.dev-gcp.yaml should be a mixin")
+ }
+ if !mixins["app.prod-gcp.yaml"] {
+ t.Error("app.prod-gcp.yaml should be a mixin")
+ }
+ if !mixins["config.dev-gcp.yaml"] {
+ t.Error("config.dev-gcp.yaml should be a mixin")
+ }
+ })
+
+ t.Run("fallback heuristic with empty env list", func(t *testing.T) {
+ files := []string{"app.yaml", "app.dev.yaml", "config.yaml", "config.prod.yaml"}
+ envs := []string{}
+
+ mixins := mixinSet(files, envs)
+ if !mixins["app.dev.yaml"] {
+ t.Error("app.dev.yaml should be a mixin (heuristic fallback)")
+ }
+ if !mixins["config.prod.yaml"] {
+ t.Error("config.prod.yaml should be a mixin (heuristic fallback)")
+ }
+ })
+}