Extend discovery coverage across consumer workflows
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Configuration Discovery Implementation Plan
|
# Configuration Discovery Implementation Plan
|
||||||
|
|
||||||
Status: in progress. Stages 1, 2, 3, 4, 5, 6, and 7 are complete.
|
Status: in progress. Stages 1, 2, 3, 4, 5, 6, 7, and 8 are complete.
|
||||||
|
|
||||||
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
||||||
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
||||||
@@ -380,7 +380,7 @@ Acceptance: consumers can use the feature entirely through the root package.
|
|||||||
Run focused facade discovery/context tests, public/dependency boundary tests,
|
Run focused facade discovery/context tests, public/dependency boundary tests,
|
||||||
and the everyday check. Mark and commit.
|
and the everyday check. Mark and commit.
|
||||||
|
|
||||||
## Stage 8 — Complete Cross-Feature Coverage And Consumer Examples
|
## Stage 8 — Complete Cross-Feature Coverage And Consumer Examples (Complete)
|
||||||
|
|
||||||
1. Add a small maintained consumer example/test for a simple explicitly selected
|
1. Add a small maintained consumer example/test for a simple explicitly selected
|
||||||
preset with test-controlled directories, and another for custom-first search
|
preset with test-controlled directories, and another for custom-first search
|
||||||
|
|||||||
45
examples/consumers/discovery_test.go
Normal file
45
examples/consumers/discovery_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workingDirectoryPresetConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_workingDirectoryPreset() {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(original) }()
|
||||||
|
root, err := os.MkdirTemp("", "configkit-working-search-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(root)
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[workingDirectoryPresetConfig](
|
||||||
|
configkit.SearchConfig("service", configkit.WorkingDirectory()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
||||||
|
// Output: example service.yml
|
||||||
|
}
|
||||||
@@ -31,13 +31,23 @@ type SequenceCatalogEntry struct {
|
|||||||
Credential configkit.Secret `yaml:"credential"`
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSequenceCatalog assembles imported main fragments over a file-backed
|
// LoadSequenceCatalog searches its caller-selected main path before a custom
|
||||||
// underlay, applies one selected collection overlay, then resolves and validates
|
// fallback directory, assembles imported fragments over a file-backed underlay,
|
||||||
// the resulting application configuration.
|
// applies one selected collection overlay, then resolves and validates once.
|
||||||
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||||
root := filepath.Dir(mainPath)
|
absoluteMain, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root := filepath.Dir(absoluteMain)
|
||||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||||
|
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||||
|
configkit.Paths(absoluteMain),
|
||||||
|
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||||
|
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||||
|
}),
|
||||||
|
), configkit.SearchOptions{Required: true})
|
||||||
definition := configkit.Definition[SequenceCatalogConfig]{
|
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||||
SequenceIdentities: []configkit.SequenceIdentity{
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
@@ -51,8 +61,9 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
|||||||
Validate: validateSequenceCatalog,
|
Validate: validateSequenceCatalog,
|
||||||
}
|
}
|
||||||
return configkit.LoadLayered(configkit.Input{
|
return configkit.LoadLayered(configkit.Input{
|
||||||
Source: filesystem.File(filepath.Base(mainPath)),
|
Source: mainSearch,
|
||||||
Imports: true,
|
Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: root},
|
||||||
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
Source: filesystem.File("underlay.yml"),
|
Source: filesystem.File("underlay.yml"),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,19 @@ type facadeDiscoveryConfig struct {
|
|||||||
Settings map[string]string `yaml:"settings"`
|
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) {
|
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
||||||
original, err := os.Getwd()
|
original, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,6 +221,78 @@ func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
||||||
for _, diagnostic := range err.Diagnostics() {
|
for _, diagnostic := range err.Diagnostics() {
|
||||||
if diagnostic.Code == code {
|
if diagnostic.Code == code {
|
||||||
|
|||||||
Reference in New Issue
Block a user