317 lines
13 KiB
Go
317 lines
13 KiB
Go
package configkit
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
type facadeDiscoveryConfig struct {
|
|
Value string `yaml:"value"`
|
|
Settings map[string]string `yaml:"settings"`
|
|
}
|
|
|
|
type facadeDiscoveryLifecycleConfig struct {
|
|
Value string `yaml:"value"`
|
|
Resolved string `yaml:"resolved"`
|
|
}
|
|
|
|
type facadeDiscoveryPlacementConfig struct {
|
|
Items []facadeDiscoveryPlacementItem `yaml:"items"`
|
|
}
|
|
|
|
type facadeDiscoveryPlacementItem struct {
|
|
Name string `yaml:"name"`
|
|
}
|
|
|
|
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
|
original, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root := t.TempDir()
|
|
if err := os.Chdir(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Chdir(original); err != nil {
|
|
t.Errorf("restore working directory: %v", err)
|
|
}
|
|
})
|
|
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
|
|
|
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
|
|
if err != nil || result.Config.Value != "working" {
|
|
t.Fatalf("working-directory search = %#v, %v", result, err)
|
|
}
|
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
|
|
t.Fatalf("working-directory sources = %#v", sources)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
|
|
custom := SearchDirectory(root, DirectorySearchOptions{
|
|
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
|
|
})
|
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
|
|
if err != nil || result.Config.Value != "found" {
|
|
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
|
|
}
|
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
|
|
t.Fatalf("custom generic-only sources = %#v", sources)
|
|
}
|
|
|
|
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
|
|
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
|
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
|
|
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
|
|
})
|
|
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
|
|
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
|
|
}
|
|
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
|
|
if err != nil || omitted.Config.Value != "found" {
|
|
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
|
|
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
|
paths := []string{filepath.Join(root, "missing.yml")}
|
|
names := []string{"config.yml"}
|
|
groups := []FilenameGroup{CustomFilenames(names...)}
|
|
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
|
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
|
source := SearchConfig("", search, SearchOptions{Required: true})
|
|
|
|
paths[0] = laterPath
|
|
names[0] = "later.yml"
|
|
groups[0] = CustomFilenames("later.yml")
|
|
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
|
search = Paths(laterPath)
|
|
|
|
result, err := Load[facadeDiscoveryConfig](source)
|
|
if err != nil || result.Config.Value != "selected" {
|
|
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
|
|
}
|
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
|
|
t.Fatalf("ordered search sources = %#v", sources)
|
|
}
|
|
|
|
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
|
var loadErr *LoadError
|
|
if !errors.As(err, &loadErr) {
|
|
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
|
}
|
|
foundMissing := false
|
|
for _, diagnostic := range loadErr.Diagnostics() {
|
|
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
|
|
}
|
|
if !foundMissing {
|
|
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
|
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
|
result, err := LoadLayered(
|
|
Input{Source: SearchConfig("", Paths(mainPath))},
|
|
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
|
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
|
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
|
})),
|
|
}}}}},
|
|
)
|
|
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
|
|
t.Fatalf("layered search = %#v, %v", result, err)
|
|
}
|
|
sources := result.Sources()
|
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
|
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
|
t.Fatalf("layered search sources = %#v", sources)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
|
|
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
|
|
filesystem := &FilesystemContext{Root: root}
|
|
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
|
|
|
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
|
if err != nil || selected.Config.Value != "overlay" {
|
|
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
|
}
|
|
sources := selected.Sources()
|
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
|
|
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
|
|
t.Fatalf("selected search source evidence = %#v", sources)
|
|
}
|
|
mainContext, ok := selected.SourceContext("source-1")
|
|
if !ok || mainContext.BaseDir != "." {
|
|
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
|
}
|
|
|
|
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
|
searchedAbsent, err := Load(missing, options)
|
|
if err != nil {
|
|
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
|
}
|
|
explicitAbsent, err := Load(NoSource(), options)
|
|
if err != nil {
|
|
t.Fatalf("NoSource with root-only overlay context: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
|
|
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
|
|
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
|
|
}
|
|
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
|
|
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
|
|
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
|
|
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
|
|
}
|
|
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
|
|
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
|
outsideRoot := t.TempDir()
|
|
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
|
search := SearchConfig("", Paths(outside, inside))
|
|
|
|
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
|
var loadErr *LoadError
|
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
|
|
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
|
|
}
|
|
|
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
|
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
|
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
|
|
}
|
|
|
|
native := NewFilesystem("native", fstest.MapFS{})
|
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
|
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
|
t.Fatalf("selected file with native context error = %v, want option failure", err)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
|
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
|
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
|
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
|
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
|
Defaults: func() facadeDiscoveryLifecycleConfig {
|
|
defaultsCalls++
|
|
return facadeDiscoveryLifecycleConfig{}
|
|
},
|
|
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
|
resolveCalls++
|
|
configuration.Resolved = "from:" + configuration.Value
|
|
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
|
return nil
|
|
},
|
|
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
|
},
|
|
})
|
|
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
|
|
t.Fatalf("search lifecycle result = %#v, %v", result, err)
|
|
}
|
|
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
|
|
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
|
|
}
|
|
sources := result.Sources()
|
|
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
|
|
t.Fatalf("selected source evidence = %#v", sources)
|
|
}
|
|
valueInfo, found := result.Metadata().Lookup(Keys("value"))
|
|
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
|
|
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
|
|
}
|
|
var rendered strings.Builder
|
|
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
|
|
t.Fatalf("effective output = %q, %v", rendered.String(), err)
|
|
}
|
|
}
|
|
|
|
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
|
|
options := Options[facadeDiscoveryPlacementConfig]{
|
|
Destination: Keys("items"),
|
|
PlacementMode: PlacementModeSequenceItem,
|
|
}
|
|
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
|
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
|
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
|
}
|
|
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
|
|
t.Fatalf("selected sequence-item source = %#v", sources)
|
|
}
|
|
|
|
defaults := func() facadeDiscoveryPlacementConfig {
|
|
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
|
}
|
|
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
|
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
|
Destination: options.Destination,
|
|
PlacementMode: options.PlacementMode,
|
|
})
|
|
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
|
|
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
|
|
}
|
|
}
|
|
|
|
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
|
for _, diagnostic := range err.Diagnostics() {
|
|
if diagnostic.Code == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func canonicalFacadePath(t *testing.T, path string) string {
|
|
t.Helper()
|
|
absolute, err := filepath.Abs(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical, err := filepath.EvalSymlinks(absolute)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return canonical
|
|
}
|