package engine import ( "errors" "path/filepath" "runtime" "testing" ) func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) { t.Parallel() if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 { t.Fatalf("zero search diagnostics = %#v", diagnostics) } if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 { t.Fatalf("empty concatenation diagnostics = %#v", diagnostics) } if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 { t.Fatalf("empty path list diagnostics = %#v", diagnostics) } if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 { t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics) } generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}}) if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 { t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics) } if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid { t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics) } if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 { t.Fatalf("valid preset slug diagnostics = %#v", diagnostics) } } func TestSearchDescriptionSnapshotsInputs(t *testing.T) { t.Parallel() paths := []string{"first.yml", "second.yml"} pathSearch := Paths(paths...) paths[0] = "changed.yml" customNames := []string{"one.yml", "two.yml"} group := CustomFilenames(customNames...) customNames[0] = "changed.yml" groups := []FilenameGroup{group} options := DirectorySearchOptions{Filenames: groups} directorySearch := SearchDirectory("configs", options) groups[0] = GenericFilenames() options.Filenames[0] = NamedFilenames() searches := []Search{pathSearch, directorySearch} combined := Searches(searches...) searches[0] = Search{} if got := combined.entries[0].path; got != "first.yml" { t.Fatalf("snapshotted path = %q", got) } gotNames := combined.entries[2].filenames[0].names if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" { t.Fatalf("snapshotted custom names = %#v", gotNames) } if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 { t.Fatalf("snapshot description diagnostics = %#v", diagnostics) } } func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) { t.Parallel() omitted := SearchDirectory("configs") if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric { t.Fatalf("omitted groups = %#v", omitted.entries) } explicitNil := SearchDirectory("configs", DirectorySearchOptions{}) if len(explicitNil.entries[0].filenames) != 2 { t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames) } explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}}) if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 { t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames) } if len(validateSearch(explicitEmpty, "")) != 0 { t.Fatal("explicit empty filename selection should need no slug") } } func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) { t.Parallel() tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{}) if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 { t.Fatal("multiple option values should be invalid") } invalid := Paths("") combined := Searches(Paths("good.yml"), invalid) if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 { t.Fatal("concatenation lost a constructor error") } if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 { t.Fatal("later invalid path was hidden by an earlier component") } } func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) { t.Parallel() groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")} search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups}) got := search.entries[0].filenames if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" { t.Fatalf("filename group order = %#v", got) } if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 { t.Fatalf("empty custom group = %#v", got) } } func TestFilenameComponentValidation(t *testing.T) { t.Parallel() for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} { if validFilenameComponent(value) { t.Errorf("validFilenameComponent(%q) = true", value) } } if runtime.GOOS == "windows" { for _, value := range []string{"CON", "nul.txt", "name.", "bad:name"} { if validFilenameComponent(value) { t.Errorf("Windows component %q unexpectedly accepted", value) } } } else { for _, value := range []string{"name with spaces", "naïve"} { if !validFilenameComponent(value) { t.Errorf("host-valid component %q rejected", value) } } } if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 { t.Fatal("invalid custom filename was not retained for validation") } for _, value := range []string{"CON", "nul.txt", "COM¹.log", "LPT³.txt", "name.", "bad:name", "bad\x01name"} { if validWindowsFilenameComponent(value) { t.Errorf("Windows component %q unexpectedly accepted", value) } } for _, value := range []string{"name with spaces", "naïve"} { if !validWindowsFilenameComponent(value) { t.Errorf("valid Windows component %q rejected", value) } } } func TestSearchCandidatesPreserveDirectoryAndGroupOrder(t *testing.T) { t.Parallel() root := t.TempDir() helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }} search := Searches( Paths("first.yml"), SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{ GenericFilenames(), CustomFilenames("special.yml"), }}), Paths("last.yml"), ) candidates := collectSearchCandidates(t, search, "app", helpers) want := []string{ filepath.Join(root, "first.yml"), filepath.Join(root, "settings", "config.yml"), filepath.Join(root, "settings", "config.yaml"), filepath.Join(root, "settings", "main.yml"), filepath.Join(root, "settings", "main.yaml"), filepath.Join(root, "settings", "special.yml"), filepath.Join(root, "last.yml"), } assertCandidatePaths(t, candidates, want) reversed := SearchDirectory("settings", DirectorySearchOptions{Filenames: []FilenameGroup{NamedFilenames(), GenericFilenames()}}) reversedCandidates := collectSearchCandidates(t, reversed, "app", helpers) assertCandidatePaths(t, reversedCandidates[:2], []string{ filepath.Join(root, "settings", "app.yml"), filepath.Join(root, "settings", "app.yaml"), }) } func TestSearchPresetCandidateLists(t *testing.T) { t.Parallel() helpers := discoveryDirectoryHelpers{ userConfigDir: func() (string, error) { return "/controlled/config", nil }, userHomeDir: func() (string, error) { return "/controlled/home", nil }, workingDir: func() (string, error) { return "/controlled/work", nil }, unixSupported: func() bool { return true }, } unix := collectSearchCandidates(t, UnixSystemDirectories(), "app", helpers) if len(unix) != 14 { t.Fatalf("Unix preset candidate count = %d: %#v", len(unix), unix) } assertCandidatePaths(t, unix, []string{ "/usr/local/etc/app/app.yml", "/usr/local/etc/app/app.yaml", "/usr/local/etc/app/config.yml", "/usr/local/etc/app/config.yaml", "/usr/local/etc/app/main.yml", "/usr/local/etc/app/main.yaml", "/etc/app/app.yml", "/etc/app/app.yaml", "/etc/app/config.yml", "/etc/app/config.yaml", "/etc/app/main.yml", "/etc/app/main.yaml", "/etc/app.yml", "/etc/app.yaml", }) user := collectSearchCandidates(t, UserConfigDirectory(), "app", helpers) assertCandidatePaths(t, user, []string{ "/controlled/config/app/app.yml", "/controlled/config/app/app.yaml", "/controlled/config/app/config.yml", "/controlled/config/app/config.yaml", "/controlled/config/app/main.yml", "/controlled/config/app/main.yaml", "/controlled/config/app.yml", "/controlled/config/app.yaml", }) home := collectSearchCandidates(t, HomeDirectory(), "app", helpers) assertCandidatePaths(t, home, []string{ "/controlled/home/.app/app.yml", "/controlled/home/.app/app.yaml", "/controlled/home/.app/config.yml", "/controlled/home/.app/config.yaml", "/controlled/home/.app/main.yml", "/controlled/home/.app/main.yaml", "/controlled/home/app.yml", "/controlled/home/app.yaml", }) working := collectSearchCandidates(t, WorkingDirectory(), "app", helpers) assertCandidatePaths(t, working, []string{"/controlled/work/app.yml", "/controlled/work/app.yaml"}) } func TestSearchCandidateExpansionIsLazyAndSkipsEmptyDirectories(t *testing.T) { t.Parallel() configCalls := 0 helpers := discoveryDirectoryHelpers{ userConfigDir: func() (string, error) { configCalls++ return "/controlled/config", nil }, workingDir: func() (string, error) { return "/controlled/work", nil }, unixSupported: func() bool { return true }, } search := Searches(Paths("/selected.yml"), UserConfigDirectory()) iterator, diagnostics := newSearchCandidateIterator(search, "app", helpers) if len(diagnostics) != 0 { t.Fatalf("iterator diagnostics = %#v", diagnostics) } if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/selected.yml" { t.Fatalf("first candidate = %q, %v, %v", candidate, ok, err) } if configCalls != 0 { t.Fatalf("later user directory resolved before consumption: %d calls", configCalls) } emptyOnly := SearchDirectory("should-not-resolve", DirectorySearchOptions{Filenames: []FilenameGroup{}}) emptyCandidates := collectSearchCandidates(t, emptyOnly, "", helpers) if len(emptyCandidates) != 0 || configCalls != 0 { t.Fatalf("empty directory candidates = %#v, helper calls = %d", emptyCandidates, configCalls) } continued := collectSearchCandidates(t, Searches(emptyOnly, Paths("later.yml")), "", helpers) assertCandidatePaths(t, continued, []string{filepath.Join("/controlled/work", "later.yml")}) if configCalls != 0 { t.Fatalf("empty directory base was resolved: %d calls", configCalls) } } func TestSearchCandidateBaseFailuresAndPlatformApplicability(t *testing.T) { t.Parallel() baseFailure := errors.New("controlled base failure") helpers := discoveryDirectoryHelpers{ userConfigDir: func() (string, error) { return "", baseFailure }, workingDir: func() (string, error) { return "/controlled/work", nil }, unixSupported: func() bool { return false }, } iterator, diagnostics := newSearchCandidateIterator(UserConfigDirectory(), "app", helpers) if len(diagnostics) != 0 { t.Fatalf("user preset validation = %#v", diagnostics) } if _, ok, err := iterator.next(); ok || !errors.Is(err, baseFailure) { t.Fatalf("unavailable user base = ok %v, err %v", ok, err) } iterator, diagnostics = newSearchCandidateIterator(UnixSystemDirectories(), "app", helpers) if len(diagnostics) != 0 { t.Fatalf("Unix preset validation = %#v", diagnostics) } if _, ok, err := iterator.next(); ok || !errors.Is(err, errUnixSystemPresetUnavailable) { t.Fatalf("unsupported Unix preset = ok %v, err %v", ok, err) } iterator, diagnostics = newSearchCandidateIterator(Searches(Paths("/earlier.yml"), UnixSystemDirectories()), "app", helpers) if len(diagnostics) != 0 { t.Fatalf("composed Unix preset validation = %#v", diagnostics) } if candidate, ok, err := iterator.next(); err != nil || !ok || candidate != "/earlier.yml" { t.Fatalf("earlier candidate = %q, %v, %v", candidate, ok, err) } } func TestRelativeAndShellLikeSearchPathsRemainLiteral(t *testing.T) { t.Parallel() root := t.TempDir() helpers := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }} search := Searches(Paths("~/literal.yml", "$CONFIG/*.yaml"), SearchDirectory("./settings", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("name with spaces.yml")}})) candidates := collectSearchCandidates(t, search, "", helpers) assertCandidatePaths(t, candidates, []string{ filepath.Join(root, "~/literal.yml"), filepath.Join(root, "$CONFIG/*.yaml"), filepath.Join(root, "settings", "name with spaces.yml"), }) } func collectSearchCandidates(t *testing.T, search Search, application string, helpers discoveryDirectoryHelpers) []string { t.Helper() iterator, diagnostics := newSearchCandidateIterator(search, application, helpers) if len(diagnostics) != 0 { t.Fatalf("search validation = %#v", diagnostics) } var candidates []string for { candidate, ok, err := iterator.next() if err != nil { t.Fatalf("expand candidate: %v", err) } if !ok { return candidates } candidates = append(candidates, candidate) } } func assertCandidatePaths(t *testing.T, got, want []string) { t.Helper() if len(got) != len(want) { t.Fatalf("candidate count = %d, want %d: %#v", len(got), len(want), got) } for i := range want { if got[i] != want[i] { t.Fatalf("candidate %d = %q, want %q", i, got[i], want[i]) } } }