Expose sequence placement controls in the public API
This commit is contained in:
@@ -43,10 +43,11 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
||||||
|
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
||||||
}
|
}
|
||||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
|
|||||||
@@ -67,13 +67,18 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
|
|||||||
contributes no source, and continues through defaults, decode, and projection. A
|
contributes no source, and continues through defaults, decode, and projection. A
|
||||||
missing file remains an error; it is never interpreted as no input.
|
missing file remains an error; it is never interpreted as no input.
|
||||||
|
|
||||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
`PlacementMode`, explicit path and glob imports, one selected overlay, filesystem
|
||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
may register strict sequence composition paths through
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
||||||
containing a reserved `configkit` mapping key is rejected while imports are
|
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||||
disabled.
|
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||||
|
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||||
|
CLI coordinates remain rooted at the application root. `NoSource` rejects
|
||||||
|
imports, active overlays, and filesystem context as invalid combinations but
|
||||||
|
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
|
||||||
|
while imports are disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||||
@@ -176,12 +181,21 @@ follow the same strict composition within a tier and recursive precedence
|
|||||||
behavior described above. A selected overlay and CLI overrides remain rooted at
|
behavior described above. A selected overlay and CLI overrides remain rooted at
|
||||||
the application root and are not moved beneath the main input's destination.
|
the application root and are not moved beneath the main input's destination.
|
||||||
|
|
||||||
`SourceInfo.Destination` reports the placement for each acquired input source,
|
`SourceInfo.Destination` reports each acquired source's effective mapping or
|
||||||
including imports. Placement does not change source IDs, acquisition identity,
|
sequence-container path, including imports. `PlacementMode` reports how that
|
||||||
physical source locations, or passive resource contexts. Introduced ancestor
|
source was placed; mapping is the zero value, and CLI/overlay sources report
|
||||||
mappings use the contributing document's root location as a source fallback;
|
mapping unless item placement was applied. `DestinationUnresolved` is true only
|
||||||
authored content retains its original source coordinates. Returned source
|
when an enclosing item index needed to name the source's destination has not yet
|
||||||
records are detached copies.
|
been assigned. In that case `Destination` reports the nearest resolved enclosing
|
||||||
|
collection container and omits the unresolved local suffix. A source's own item
|
||||||
|
placement still has an exact destination at its sequence container. Completed
|
||||||
|
loads project nested source destinations through their owning items. Callback and
|
||||||
|
failed-load snapshots preserve the evidence available at that point; a fallback
|
||||||
|
snapshot remains unresolved, while a later exact snapshot is false. Returned
|
||||||
|
source records and paths are detached. Placement does not change source IDs,
|
||||||
|
acquisition identity, physical source locations, or passive resource contexts.
|
||||||
|
Introduced ancestor mappings use the contributing document's root location as a
|
||||||
|
source fallback; authored content retains its original source coordinates.
|
||||||
|
|
||||||
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||||
@@ -744,12 +758,39 @@ Application defaults do not participate in this operation.
|
|||||||
| Mapping | Mapping | Recurse by mapping key |
|
| Mapping | Mapping | Recurse by mapping key |
|
||||||
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
||||||
|
|
||||||
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
|
Every non-mapping overlap is a conflict, even when values are equal, except for
|
||||||
sequences are present atomic values, so they also conflict when repeated; sequences
|
sequence/sequence contributions at a registered sequence-composition path. Nulls
|
||||||
are never joined by index. Disjoint entries under shared mappings compose, making
|
remain atomic conflicts. Unregistered sequences conflict when repeated and are
|
||||||
|
never joined by index. Disjoint entries under shared mappings compose, making
|
||||||
stable keyed mappings the natural representation for independently contributed
|
stable keyed mappings the natural representation for independently contributed
|
||||||
entries.
|
entries.
|
||||||
|
|
||||||
|
`Definition.SequenceCompositions` registers literal mapping paths and one literal
|
||||||
|
`IdentityField` for sequences that should compose independently authored records.
|
||||||
|
Registration paths follow input-destination key rules: parsed numeric tokens are
|
||||||
|
literal mapping keys, explicit index segments and reserved `configkit` or
|
||||||
|
mapping-valued `extends` segments are invalid, and the root registration is
|
||||||
|
harmless and unused. The identity field is one literal string key; neither the
|
||||||
|
field name nor identity strings are normalized.
|
||||||
|
At each registered path, every actual sequence contribution in a strict tier is
|
||||||
|
validated, including a singleton contribution: each record must be a mapping with
|
||||||
|
a present string identity field. Distinct exact identity strings append in source
|
||||||
|
and list order; duplicate identities conflict even if the records are otherwise
|
||||||
|
equal or disjoint. Empty identity strings are valid. Malformed records produce
|
||||||
|
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
|
||||||
|
sequence container and source locations identifying a duplicate without exposing
|
||||||
|
identity values. Missing paths and non-sequence values do not fail by themselves.
|
||||||
|
Identical registrations coalesce; conflicting registrations at one path and
|
||||||
|
invalid registration paths produce `options.invalid`. Unused and root registrations
|
||||||
|
are allowed and have no effect.
|
||||||
|
|
||||||
|
Registration changes strict composition only. Each underlay group and the main
|
||||||
|
tier has independent duplicate-identity state. Precedence assembly is unchanged:
|
||||||
|
a present higher-priority sequence replaces the lower sequence as one value,
|
||||||
|
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
|
||||||
|
and application resolution do not run the registered identity check. The accepted
|
||||||
|
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
||||||
|
|
||||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||||
Shared mapping containers retain all contributing value locations as their composite
|
Shared mapping containers retain all contributing value locations as their composite
|
||||||
@@ -889,8 +930,9 @@ Each path or glob declaration may include `placement: mapping` or
|
|||||||
including every match of a glob; placement is not inherited. Sequence-item
|
including every match of a glob; placement is not inherited. Sequence-item
|
||||||
placement requires a nonempty effective destination and wraps the imported
|
placement requires a nonempty effective destination and wraps the imported
|
||||||
mapping as one element of a sequence there. It does not split source sequences
|
mapping as one element of a sequence there. It does not split source sequences
|
||||||
or enable sequence accumulation: overlapping sequences still follow the strict
|
or infer identity fields. Repeated sequences still conflict unless their path is
|
||||||
composition rules above.
|
registered through `Definition.SequenceCompositions`; registration does not
|
||||||
|
change placement or precedence behavior.
|
||||||
|
|
||||||
An item import first opens a local mapping scope. Its body and ordinary helper
|
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||||
imports compose within that record. An omitted helper destination or
|
imports compose within that record. An omitted helper destination or
|
||||||
|
|||||||
@@ -170,6 +170,13 @@ Tier precedence is explicit; traversal order within a tier establishes stable
|
|||||||
reporting, not permission to override. The distinction and its rationale are
|
reporting, not permission to override. The distinction and its rationale are
|
||||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||||
|
|
||||||
|
At definition-registered sequence paths, strict composition appends mapping
|
||||||
|
records with distinct present string identities and rejects duplicates.
|
||||||
|
Placement and registration are explicit source and definition controls; they do
|
||||||
|
not change precedence replacement. The [configuration contract](../config.md#strict-document-composition)
|
||||||
|
defines the behavior, and [ADR 0014](../adr/0014-sequence-placement-and-composition.md)
|
||||||
|
records the decision.
|
||||||
|
|
||||||
The main document and its imports form a strictly composed tier. Explicit
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
underlay groups compose strictly within each group, then combine recursively
|
underlay groups compose strictly within each group, then combine recursively
|
||||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Implementation Plan: Sequence Placement and Composition
|
# Implementation Plan: Sequence Placement and Composition
|
||||||
|
|
||||||
Status: implementation in progress; stages 1–10 completed.
|
Status: implementation in progress; stages 1–11 completed.
|
||||||
|
|
||||||
## Purpose and authority
|
## Purpose and authority
|
||||||
|
|
||||||
@@ -658,6 +658,25 @@ Validation: focused facade tests,
|
|||||||
Completion: both public entry points expose the complete feature with the same
|
Completion: both public entry points expose the complete feature with the same
|
||||||
composition rules and independently owned evidence.
|
composition rules and independently owned evidence.
|
||||||
|
|
||||||
|
Completion evidence: the root facade now exposes sequence registrations,
|
||||||
|
ordinary and layered placement controls, and detached placement-mode and
|
||||||
|
destination-resolution source evidence. Public integration coverage exercises
|
||||||
|
ordinary/layered placement, strict registration with authored and glob-imported
|
||||||
|
items, shifted origins and resource contexts, option preflight, early and
|
||||||
|
post-placement failure snapshots, nested fallback containers, registration and
|
||||||
|
snapshot ownership, and zero-value mapping behavior. The existing resolver
|
||||||
|
callback runs after item projection, so its source snapshots are exact; the
|
||||||
|
unresolved helper fallback is observable on early failure. No acquisition-time
|
||||||
|
callback exposes `SourceInfo`, and no additional callback lifecycle was added.
|
||||||
|
These checks passed with Go `go1.26.9 linux/amd64`:
|
||||||
|
|
||||||
|
- `GOTOOLCHAIN=local GOPROXY=off go test . -run 'TestPublicFacadeBoundary|TestDependencyImportBoundary' -count=1`
|
||||||
|
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...`
|
||||||
|
- `GOTOOLCHAIN=local GOPROXY=off ./scripts/check.sh`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
|
No environmental limitation affected validation; checks ran on Linux/amd64.
|
||||||
|
|
||||||
## Stage 12 — Close cross-cutting regression and limit gaps
|
## Stage 12 — Close cross-cutting regression and limit gaps
|
||||||
|
|
||||||
Scope: risks that require the assembled feature, not duplicate mechanism tests.
|
Scope: risks that require the assembled feature, not duplicate mechanism tests.
|
||||||
|
|||||||
323
facade_sequence_test.go
Normal file
323
facade_sequence_test.go
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
||||||
|
}
|
||||||
|
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ordinary item load: %v", err)
|
||||||
|
}
|
||||||
|
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
||||||
|
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
||||||
|
}
|
||||||
|
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
|
||||||
|
t.Fatalf("ordinary item source = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||||
|
{Source: configkit.Mapping("first", map[string]any{"id": "first"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||||
|
{Source: configkit.Mapping("second", map[string]any{"id": "second"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("layered item load: %v", err)
|
||||||
|
}
|
||||||
|
items := layered.Config["entries"].([]any)
|
||||||
|
if len(items) != 2 || items[0].(map[string]any)["id"] != "first" || items[1].(map[string]any)["id"] != "second" {
|
||||||
|
t.Fatalf("layered item placement = %#v", layered.Config)
|
||||||
|
}
|
||||||
|
for _, source := range layered.Sources() {
|
||||||
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||||
|
t.Errorf("layered item source = %#v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
precedence, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||||
|
"entries": []any{map[string]any{"id": "main"}},
|
||||||
|
})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Mapping("lower", map[string]any{"id": "lower"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registered sequence precedence load: %v", err)
|
||||||
|
}
|
||||||
|
if got := precedence.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "main" {
|
||||||
|
t.Fatalf("higher-priority list did not replace the lower sequence: %#v", precedence.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
load func(configkit.Source) error
|
||||||
|
}{
|
||||||
|
{name: "ordinary", load: func(source configkit.Source) error {
|
||||||
|
_, loadErr := configkit.Load(source, configkit.Options[map[string]any]{PlacementMode: configkit.PlacementMode(9)})
|
||||||
|
return loadErr
|
||||||
|
}},
|
||||||
|
{name: "layered", load: func(source configkit.Source) error {
|
||||||
|
_, loadErr := configkit.LoadLayered[map[string]any](configkit.Input{Source: source, PlacementMode: configkit.PlacementMode(9)})
|
||||||
|
return loadErr
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
reader := &destinationCountingReader{}
|
||||||
|
err := test.load(configkit.Reader("unread", reader))
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || reader.reads != 0 {
|
||||||
|
t.Fatalf("invalid placement result = %v, reads = %d", err, reader.reads)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- glob: records/*.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
items:
|
||||||
|
- id: authored
|
||||||
|
name: authored item
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, id := range map[string]string{"b.yml": "imported-b", "a.yml": "imported-a"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(directory, "records"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "records", name), []byte("id: "+id+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var callbackSources []configkit.SourceInfo
|
||||||
|
var callbackContexts []configkit.ResourceContext
|
||||||
|
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("items"), IdentityField: "id"}},
|
||||||
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
for _, id := range []string{"source-2", "source-3"} {
|
||||||
|
source, ok := resolution.Source(id)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("resolver could not query %s", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
context, found := resolution.SourceContext(id)
|
||||||
|
if !found || context.FS == nil || context.BaseDir != "records" {
|
||||||
|
t.Errorf("resolver context for %s = %#v, %v", id, context, found)
|
||||||
|
}
|
||||||
|
callbackSources = append(callbackSources, source)
|
||||||
|
callbackContexts = append(callbackContexts, context)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load item glob: %v", err)
|
||||||
|
}
|
||||||
|
items := result.Config["items"].([]any)
|
||||||
|
wantIDs := []string{"authored", "imported-a", "imported-b"}
|
||||||
|
for index, item := range items {
|
||||||
|
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||||
|
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(callbackSources) != 2 || len(callbackContexts) != 2 {
|
||||||
|
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||||
|
}
|
||||||
|
for index, source := range callbackSources {
|
||||||
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
||||||
|
t.Errorf("callback source = %#v", source)
|
||||||
|
}
|
||||||
|
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||||
|
t.Errorf("callback context = %#v", callbackContexts[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
for index := 1; index < len(wantIDs); index++ {
|
||||||
|
path := configkit.Keys("items").Index(index).Key("id")
|
||||||
|
info, ok := metadata.Lookup(path)
|
||||||
|
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] {
|
||||||
|
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
||||||
|
t.Fatalf("final item sources = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
|
helperPath := filepath.Join(directory, "helper.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("early failure = %v", err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||||
|
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||||
|
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
||||||
|
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||||
|
t.Fatalf("early source snapshots = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Metadata struct {
|
||||||
|
Value int `yaml:"value"`
|
||||||
|
} `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{
|
||||||
|
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
})
|
||||||
|
loadError, ok = err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("post-placement failure = %v", err)
|
||||||
|
}
|
||||||
|
sources = loadError.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||||
|
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
||||||
|
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||||
|
}
|
||||||
|
firstSnapshot := sources[1]
|
||||||
|
sources[1].Destination = configkit.Keys("mutated")
|
||||||
|
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
||||||
|
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||||
|
}
|
||||||
|
|
||||||
|
var callback configkit.SourceInfo
|
||||||
|
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
callback, _ = resolution.Source("source-2")
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("successful item load: %v", err)
|
||||||
|
}
|
||||||
|
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||||
|
t.Fatalf("callback source snapshot = %#v", callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
|
childPath := filepath.Join(directory, "child.yml")
|
||||||
|
helperPath := filepath.Join(directory, "helper.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("nested acquisition failure = %v", err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("nested source snapshots = %#v", sources)
|
||||||
|
}
|
||||||
|
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||||
|
t.Errorf("outer item snapshot = %#v", got)
|
||||||
|
}
|
||||||
|
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||||
|
t.Errorf("nested item fallback = %#v", got)
|
||||||
|
}
|
||||||
|
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
||||||
|
t.Errorf("nested helper fallback = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
|
||||||
|
registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceCompositions: registrations,
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(configkit.Version, *configkit.Validation) error {
|
||||||
|
registrations[0].Path = configkit.Keys("other")
|
||||||
|
registrations[0].IdentityField = "changed"
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.Mapping("registration", map[string]any{
|
||||||
|
"version": "1",
|
||||||
|
"records": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||||
|
}), configkit.Options[map[string]any]{
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("duplicate registered identity = %v", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadError.Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("registration mutation changed the in-flight policy: %#v", loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||||
|
zero := configkit.SourceInfo{}
|
||||||
|
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
|
||||||
|
t.Fatalf("zero source info = %#v", zero)
|
||||||
|
}
|
||||||
|
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
|
||||||
|
t.Fatalf("mapping source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
first := sources[0]
|
||||||
|
first.Destination = configkit.Keys("caller")
|
||||||
|
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
|
||||||
|
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,26 @@ const (
|
|||||||
placementModeSequenceItem
|
placementModeSequenceItem
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
||||||
|
// can reject unsupported modes.
|
||||||
|
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
||||||
|
|
||||||
|
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
||||||
|
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
||||||
|
|
||||||
|
// InputWithPlacement copies the root-owned mode into a private engine input.
|
||||||
|
func InputWithPlacement(input Input, value uint8) Input {
|
||||||
|
input.placementMode = placementMode(value)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionsWithPlacement copies root-owned controls into private engine options.
|
||||||
|
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
||||||
|
options.destination = destination.clone()
|
||||||
|
options.placementMode = placementMode(value)
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeDestination(path Path) (Path, error) {
|
func normalizeDestination(path Path) (Path, error) {
|
||||||
if !path.valid() {
|
if !path.valid() {
|
||||||
return Path{}, errInvalidDestination
|
return Path{}, errInvalidDestination
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type importTraversal struct {
|
|||||||
scope acquisitionScope
|
scope acquisitionScope
|
||||||
itemScope bool
|
itemScope bool
|
||||||
itemPrefix Path
|
itemPrefix Path
|
||||||
|
itemFallback Path
|
||||||
sources []SourceInfo
|
sources []SourceInfo
|
||||||
active map[resourceIdentity]int
|
active map[resourceIdentity]int
|
||||||
visited map[resourceIdentity]importRoute
|
visited map[resourceIdentity]importRoute
|
||||||
@@ -135,9 +136,6 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
mainInfo := cloneSourceInfo(*main.source)
|
mainInfo := cloneSourceInfo(*main.source)
|
||||||
mainInfo.Destination = destination.clone()
|
mainInfo.Destination = destination.clone()
|
||||||
mainIsItem := main.source.placementMode == placementModeSequenceItem
|
mainIsItem := main.source.placementMode == placementModeSequenceItem
|
||||||
if mainIsItem {
|
|
||||||
mainInfo.destinationUnresolved = true
|
|
||||||
}
|
|
||||||
if mainPath != "" {
|
if mainPath != "" {
|
||||||
mainInfo.Path = mainPath
|
mainInfo.Path = mainPath
|
||||||
}
|
}
|
||||||
@@ -161,6 +159,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess,
|
|||||||
if mainIsItem {
|
if mainIsItem {
|
||||||
state.itemScope = true
|
state.itemScope = true
|
||||||
state.itemPrefix = destination.clone()
|
state.itemPrefix = destination.clone()
|
||||||
|
state.itemFallback = destination.clone()
|
||||||
state.scope.destination = Path{}
|
state.scope.destination = Path{}
|
||||||
state.sources[0] = cloneSourceInfo(mainInfo)
|
state.sources[0] = cloneSourceInfo(mainInfo)
|
||||||
}
|
}
|
||||||
@@ -394,7 +393,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
if declaration.placement == placementModeSequenceItem {
|
if declaration.placement == placementModeSequenceItem {
|
||||||
info.placementMode = placementModeSequenceItem
|
info.placementMode = placementModeSequenceItem
|
||||||
}
|
}
|
||||||
info.destinationUnresolved = s.itemScope || declaration.placement == placementModeSequenceItem
|
// A source's own item placement still has an exact sequence-container
|
||||||
|
// destination. Only an unresolved enclosing item index makes the path a
|
||||||
|
// fallback container for this snapshot.
|
||||||
|
info.destinationUnresolved = s.itemScope
|
||||||
if kind == SourceKindFilesystem {
|
if kind == SourceKindFilesystem {
|
||||||
info.namespace = s.access.token
|
info.namespace = s.access.token
|
||||||
}
|
}
|
||||||
@@ -484,18 +486,21 @@ func (s *importTraversal) sourceDestination(destination Path) Path {
|
|||||||
if !s.itemScope {
|
if !s.itemScope {
|
||||||
return destination.clone()
|
return destination.clone()
|
||||||
}
|
}
|
||||||
return appendDestination(s.itemPrefix, destination)
|
// Until the enclosing item receives its assigned index, only the collection
|
||||||
|
// container is exact. Keep the local suffix in the acquisition projection.
|
||||||
|
return s.itemFallback.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic {
|
func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic {
|
||||||
parentScope := s.scope
|
parentScope := s.scope
|
||||||
parentItemScope, parentPrefix := s.itemScope, s.itemPrefix
|
parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback
|
||||||
s.scope = acquisitionScope{}
|
s.scope = acquisitionScope{}
|
||||||
s.itemScope = true
|
s.itemScope = true
|
||||||
if parentItemScope {
|
if parentItemScope {
|
||||||
s.itemPrefix = appendDestination(parentPrefix, destination)
|
s.itemPrefix = appendDestination(parentPrefix, destination)
|
||||||
} else {
|
} else {
|
||||||
s.itemPrefix = destination.clone()
|
s.itemPrefix = destination.clone()
|
||||||
|
s.itemFallback = destination.clone()
|
||||||
}
|
}
|
||||||
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
s.scope.contributions = append(s.scope.contributions, acquiredContribution{
|
||||||
source: cloneSourceInfo(info), payload: body, document: body,
|
source: cloneSourceInfo(info), payload: body, document: body,
|
||||||
@@ -503,7 +508,7 @@ func (s *importTraversal) visitItemSource(canonical string, declaration importDe
|
|||||||
})
|
})
|
||||||
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{})
|
||||||
itemScope := s.scope
|
itemScope := s.scope
|
||||||
s.scope, s.itemScope, s.itemPrefix = parentScope, parentItemScope, parentPrefix
|
s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,24 @@ type SourceInfo struct {
|
|||||||
destinationUnresolved bool
|
destinationUnresolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SourceInfoPlacementModeForFacade converts private placement evidence to its
|
||||||
|
// root-owned numeric representation.
|
||||||
|
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {
|
||||||
|
return uint8(source.placementMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoDestinationUnresolvedForFacade returns the private fallback marker.
|
||||||
|
func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool {
|
||||||
|
return source.destinationUnresolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceInfoWithPlacement copies facade-owned source evidence into private fields.
|
||||||
|
func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo {
|
||||||
|
source.placementMode = placementMode(mode)
|
||||||
|
source.destinationUnresolved = unresolved
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
// SourceVersionCheck is a detached, public-safe record of a source-local
|
// SourceVersionCheck is a detached, public-safe record of a source-local
|
||||||
// version declaration observed during an enabled load.
|
// version declaration observed during an enabled load.
|
||||||
type SourceVersionCheck struct {
|
type SourceVersionCheck struct {
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ type sequencePolicy struct {
|
|||||||
identityFields map[string]string
|
identityFields map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a
|
||||||
|
// detached engine definition without exposing the registration representation.
|
||||||
|
func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] {
|
||||||
|
if paths == nil {
|
||||||
|
definition.sequenceRegistrations = nil
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
definition.sequenceRegistrations = make([]sequenceRegistration, len(paths))
|
||||||
|
for index := range paths {
|
||||||
|
definition.sequenceRegistrations[index] = sequenceRegistration{
|
||||||
|
path: paths[index].clone(), identityField: identityFields[index],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
|
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
|
||||||
policy := sequencePolicy{identityFields: make(map[string]string, len(registrations))}
|
policy := sequencePolicy{identityFields: make(map[string]string, len(registrations))}
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ type Input struct {
|
|||||||
// path or append a relative destination declared in YAML. Its zero value
|
// path or append a relative destination declared in YAML. Its zero value
|
||||||
// keeps root placement.
|
// keeps root placement.
|
||||||
Destination Path
|
Destination Path
|
||||||
|
// PlacementMode defaults to mapping placement. Sequence-item placement wraps
|
||||||
|
// the source mapping as one record; unknown values and an empty destination
|
||||||
|
// fail option validation before acquisition.
|
||||||
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
Imports bool
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
}
|
}
|
||||||
@@ -55,10 +59,11 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T],
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toEngineInput(input Input) engine.Input {
|
func toEngineInput(input Input) engine.Input {
|
||||||
return engine.Input{
|
result := engine.Input{
|
||||||
Source: input.Source.toEngine(), Imports: input.Imports,
|
Source: input.Source.toEngine(), Imports: input.Imports,
|
||||||
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination),
|
||||||
}
|
}
|
||||||
|
return engine.InputWithPlacement(result, uint8(input.PlacementMode))
|
||||||
}
|
}
|
||||||
|
|
||||||
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
|
func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] {
|
||||||
|
|||||||
52
lifecycle.go
52
lifecycle.go
@@ -15,6 +15,37 @@ type Definition[T any] struct {
|
|||||||
Validate func(*T, *Validation)
|
Validate func(*T, *Validation)
|
||||||
Version *VersionRule
|
Version *VersionRule
|
||||||
Secret func(Path) bool
|
Secret func(Path) bool
|
||||||
|
// SequenceCompositions registers mapping paths whose sequence contributions
|
||||||
|
// strictly append distinct records by the selected literal identity field.
|
||||||
|
// Repeated identities and conflicting registrations fail option validation.
|
||||||
|
// Registration paths are copied when a load begins; definitions are read-only
|
||||||
|
// after being shared.
|
||||||
|
SequenceCompositions []SequenceComposition
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlacementMode selects how an input mapping is placed at its Destination.
|
||||||
|
// The zero value, PlacementModeMapping, preserves ordinary mapping placement.
|
||||||
|
// Unknown values and sequence-item placement at the root fail option validation.
|
||||||
|
type PlacementMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PlacementModeMapping places a source mapping at Destination.
|
||||||
|
PlacementModeMapping PlacementMode = iota
|
||||||
|
// PlacementModeSequenceItem wraps a source mapping as one item at Destination.
|
||||||
|
PlacementModeSequenceItem
|
||||||
|
)
|
||||||
|
|
||||||
|
// SequenceComposition registers a sequence path for strict identity-based
|
||||||
|
// composition. Path is copied at load start; IdentityField is one literal key,
|
||||||
|
// including the empty string. Invalid or conflicting registrations fail option
|
||||||
|
// validation.
|
||||||
|
type SequenceComposition struct {
|
||||||
|
// Path selects a literal mapping path; registration paths are copied at load
|
||||||
|
// start. Invalid paths fail option validation; the root path is harmless.
|
||||||
|
Path Path
|
||||||
|
// IdentityField is one literal mapping key and may be empty. Records must
|
||||||
|
// contain a string at this key; malformed records fail strict composition.
|
||||||
|
IdentityField string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceVersionMode controls schema-version declarations across one complete
|
// SourceVersionMode controls schema-version declarations across one complete
|
||||||
@@ -44,10 +75,17 @@ const (
|
|||||||
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
// Imports enables explicit path and glob imports; Overlay selects one fixed overlay;
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// Overrides contains only values explicitly supplied by an application CLI.
|
||||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||||
// document's local coordinates. Main protection remains at that path prefixed
|
// document's local coordinates. Placement projects checks and main protection
|
||||||
// by the main input's Destination.
|
// through the input destination; item placement adds the source's item
|
||||||
|
// coordinate to that projection. See docs/config.md for the full contract.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
|
// Destination places the ordinary source beneath literal mapping keys. Its
|
||||||
|
// zero value means the application root. Invalid paths fail before acquisition.
|
||||||
|
Destination Path
|
||||||
|
// PlacementMode defaults to mapping placement; unknown values and root item
|
||||||
|
// placement fail before acquisition.
|
||||||
|
PlacementMode PlacementMode
|
||||||
Imports bool
|
Imports bool
|
||||||
Overlay string
|
Overlay string
|
||||||
Filesystem *FilesystemContext
|
Filesystem *FilesystemContext
|
||||||
@@ -261,6 +299,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
|
|||||||
Overlay: options.Overlay,
|
Overlay: options.Overlay,
|
||||||
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
|
||||||
}
|
}
|
||||||
|
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
|
||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
result.Filesystem = toEngineFilesystemContext(options.Filesystem)
|
||||||
}
|
}
|
||||||
@@ -289,6 +328,15 @@ func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemCon
|
|||||||
|
|
||||||
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
|
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
|
||||||
result := engine.Definition[T]{Defaults: definition.Defaults}
|
result := engine.Definition[T]{Defaults: definition.Defaults}
|
||||||
|
if definition.SequenceCompositions != nil {
|
||||||
|
paths := make([]engine.Path, len(definition.SequenceCompositions))
|
||||||
|
identityFields := make([]string, len(definition.SequenceCompositions))
|
||||||
|
for index, registration := range definition.SequenceCompositions {
|
||||||
|
paths[index] = toEnginePath(registration.Path.clone())
|
||||||
|
identityFields[index] = registration.IdentityField
|
||||||
|
}
|
||||||
|
result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields)
|
||||||
|
}
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
|
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
|
||||||
return definition.Resolve(configuration, &Resolution{value: resolution})
|
return definition.Resolve(configuration, &Resolution{value: resolution})
|
||||||
|
|||||||
13
records.go
13
records.go
@@ -189,10 +189,17 @@ type SourceInfo struct {
|
|||||||
ID string
|
ID string
|
||||||
Label string
|
Label string
|
||||||
Path string
|
Path string
|
||||||
// Destination is the effective mapping path. Imports inherit their declaring
|
// Destination is the effective mapping or sequence-container path. It is a
|
||||||
// document's path unless they append a YAML destination; overlay and CLI
|
// detached snapshot. A fallback container is marked by DestinationUnresolved.
|
||||||
// sources use the root path.
|
|
||||||
Destination Path
|
Destination Path
|
||||||
|
// PlacementMode describes how this source was placed. Mapping is the zero
|
||||||
|
// value; CLI and overlay sources report mapping placement unless item placement
|
||||||
|
// was applied to that source.
|
||||||
|
PlacementMode PlacementMode
|
||||||
|
// DestinationUnresolved marks Destination as the nearest resolved enclosing
|
||||||
|
// container when an enclosing item index is not yet assigned. It is false for
|
||||||
|
// exact destinations, including an item source's own sequence container.
|
||||||
|
DestinationUnresolved bool
|
||||||
Layer Layer
|
Layer Layer
|
||||||
Kind SourceKind
|
Kind SourceKind
|
||||||
UnderlayIndex int
|
UnderlayIndex int
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
func TestOverlayPublicValues(t *testing.T) {
|
func TestOverlayPublicValues(t *testing.T) {
|
||||||
if LayerOverlay != "overlay" {
|
if LayerOverlay != "overlay" {
|
||||||
@@ -23,14 +27,17 @@ func TestOverlayPublicValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) {
|
||||||
destination := Keys("collections", "a/b", "0")
|
destination := Keys("collections", "a/b", "0")
|
||||||
public := SourceInfo{ID: "source-1", Destination: destination}
|
public := SourceInfo{
|
||||||
|
ID: "source-1", Destination: destination,
|
||||||
|
PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true,
|
||||||
|
}
|
||||||
internal := toEngineSourceInfo(public)
|
internal := toEngineSourceInfo(public)
|
||||||
if internal.Destination.String() != destination.String() {
|
if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) {
|
||||||
t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String())
|
t.Fatalf("engine source evidence = %#v", internal)
|
||||||
}
|
}
|
||||||
converted := fromEngineSourceInfo(internal)
|
converted := fromEngineSourceInfo(internal)
|
||||||
if converted.Destination.String() != destination.String() {
|
if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved {
|
||||||
t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String())
|
t.Fatalf("public source evidence = %#v", converted)
|
||||||
}
|
}
|
||||||
copy := cloneSourceInfo(converted)
|
copy := cloneSourceInfo(converted)
|
||||||
copy.Destination = Path{}
|
copy.Destination = Path{}
|
||||||
|
|||||||
Reference in New Issue
Block a user