Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5959537d1a | |||
| 0369111d30 | |||
| 2fedb0307f | |||
| 087050fa25 | |||
| 1ac1f8bc9e | |||
| 4dc3e70877 | |||
| f64497cb13 | |||
| 68cf5c489b | |||
| e50fee2ca5 | |||
| eec214944c | |||
| 4f2c113237 | |||
| 2d93a5c703 | |||
| 2f6c1eca00 | |||
| 8642337966 | |||
| 53ce6c63d8 | |||
| 2f0900535d | |||
| 4748313105 | |||
| e2634c8815 | |||
| 424c89780e | |||
| 92309ddcc3 |
@@ -45,6 +45,11 @@ ConfigKit does not search conventional application paths implicitly. See the
|
|||||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||||
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
||||||
|
|
||||||
|
Main-file discovery is explicit too: applications choose and order `Search`
|
||||||
|
descriptions and pass `SearchConfig` to the existing load APIs. See the
|
||||||
|
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
|
||||||
|
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
|
|||||||
90
discovery.go
Normal file
90
discovery.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
|
||||||
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
|
// It is consumed only when passed to SearchConfig and used by a load.
|
||||||
|
type Search struct{ value engine.Search }
|
||||||
|
|
||||||
|
// FilenameGroup selects filenames in order for a directory search.
|
||||||
|
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
|
||||||
|
type FilenameGroup struct{ value engine.FilenameGroup }
|
||||||
|
|
||||||
|
// SearchOptions controls whether a search must select a file.
|
||||||
|
type SearchOptions struct {
|
||||||
|
// Required reports a missing-source error if the complete search is exhausted.
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
|
||||||
|
type DirectorySearchOptions struct {
|
||||||
|
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
|
||||||
|
// a non-nil empty slice selects no filenames.
|
||||||
|
Filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||||
|
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
|
||||||
|
|
||||||
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||||
|
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
|
||||||
|
|
||||||
|
// CustomFilenames selects literal filename components in the supplied order.
|
||||||
|
// Names cannot be empty or contain a directory component.
|
||||||
|
func CustomFilenames(names ...string) FilenameGroup {
|
||||||
|
return FilenameGroup{value: engine.CustomFilenames(names...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDirectory describes a search within directory. Omitted options or a nil
|
||||||
|
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
|
||||||
|
// selects no names and does not resolve the directory base.
|
||||||
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||||
|
options := make([]engine.DirectorySearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
if option.Filenames == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
|
||||||
|
for groupIndex, group := range option.Filenames {
|
||||||
|
options[index].Filenames[groupIndex] = group.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths describes literal host-file candidates in the supplied order.
|
||||||
|
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||||
|
|
||||||
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
|
func Searches(searches ...Search) Search {
|
||||||
|
values := make([]engine.Search, len(searches))
|
||||||
|
for index, search := range searches {
|
||||||
|
values[index] = search.value
|
||||||
|
}
|
||||||
|
return Search{value: engine.Searches(values...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||||
|
// Loading on an unsupported platform fails if this preset is reached.
|
||||||
|
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
|
||||||
|
|
||||||
|
// UserConfigDirectory describes searches rooted at the Go user-config directory.
|
||||||
|
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
|
||||||
|
|
||||||
|
// HomeDirectory describes searches rooted at the Go home directory.
|
||||||
|
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
|
||||||
|
|
||||||
|
// WorkingDirectory describes application-named files in the current directory.
|
||||||
|
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
|
||||||
|
// the absent-input lifecycle. Description and option errors are reported while
|
||||||
|
// loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
options := make([]engine.SearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
options[index] = engine.SearchOptions{Required: option.Required}
|
||||||
|
}
|
||||||
|
return Source{value: engine.SearchConfig(application, search.value, options...)}
|
||||||
|
}
|
||||||
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-24
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications often need a small ordered convention for choosing a main
|
||||||
|
configuration file. Keeping that choice entirely in each application repeats
|
||||||
|
path selection and file-loading plumbing. Moving the convention into ConfigKit
|
||||||
|
without an explicit declaration would make ordinary loads search paths that a
|
||||||
|
consumer did not choose, obscuring both precedence and filesystem access.
|
||||||
|
|
||||||
|
ConfigKit already owns synchronous source acquisition, parsing, composition,
|
||||||
|
decoding, validation, and inspection. A selected host file can therefore enter
|
||||||
|
that lifecycle as the existing file source, preserving its source identity,
|
||||||
|
resource context, and diagnostics. Optional exhaustion can use the same absent
|
||||||
|
main lifecycle as an explicitly absent source.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Consumers choose and order the main-file conventions they want to use. They may
|
||||||
|
describe literal paths, directory filename groups, and supported directory
|
||||||
|
presets, and may mark the complete search as required. Search descriptions are
|
||||||
|
explicit inputs to loading; no preset or convention is activated implicitly.
|
||||||
|
|
||||||
|
During synchronous loading, ConfigKit resolves the description to one real host
|
||||||
|
file or to absence before parsing. The selected file follows the existing source
|
||||||
|
acquisition and processing rules. A selected-file access, parse, version,
|
||||||
|
composition, decode, or validation failure is terminal and does not resume the
|
||||||
|
search. Optional exhaustion shares the absent-main lifecycle; required
|
||||||
|
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
|
||||||
|
added to public metadata.
|
||||||
|
|
||||||
|
This keeps convention choice with applications while moving repeated selection
|
||||||
|
and loading plumbing into the library's established lifecycle. It preserves
|
||||||
|
explicit control, ordinary source behavior, and complete-on-return ownership.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep all search and fallback loading in each application.** This preserves
|
||||||
|
consumer ownership but repeats the file-selection and load coordination that
|
||||||
|
ConfigKit already owns, and risks divergent handling of selected-file errors.
|
||||||
|
- **Search conventional locations implicitly during every load.** This reduces
|
||||||
|
setup, but activates filesystem access and precedence the consumer did not
|
||||||
|
select and makes ordinary source behavior less predictable.
|
||||||
|
- **Return candidate paths for the application to load one by one.** This keeps
|
||||||
|
selection outside the load lifecycle and encourages repeated complete loads,
|
||||||
|
potentially rerunning defaults, callbacks, and validation during fallback.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The public search description must be immutable and root-owned, with private
|
||||||
|
engine conversion and selection. The contract documents candidate ordering,
|
||||||
|
selection, exhaustion, context, and provenance; the API declaration documents
|
||||||
|
the public symbols. Dynamic bases and file availability are observed when each
|
||||||
|
load consumes the description. Applications remain responsible for selecting
|
||||||
|
conventions and for their schema and domain requirements.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md) owns implemented behavior, and the
|
||||||
|
[component overview](../internal/overview.md) describes implementation ownership.
|
||||||
|
The completed implementation plan and remediation record are preserved in Git
|
||||||
|
history at `0369111` under `docs/roadmap/`.
|
||||||
@@ -12,6 +12,7 @@ ConfigKit exposes opaque sources through these constructors:
|
|||||||
| Constructor | Current contract |
|
| Constructor | Current contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
||||||
|
| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. |
|
||||||
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
||||||
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
||||||
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
||||||
@@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and
|
|||||||
cycles or unsupported types are rejected without exposing values. Mapping input
|
cycles or unsupported types are rejected without exposing values. Mapping input
|
||||||
uses the same directive and version rules as YAML, not CLI override restrictions.
|
uses the same directive and version rules as YAML, not CLI override restrictions.
|
||||||
|
|
||||||
|
### Configuration discovery
|
||||||
|
|
||||||
|
Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches,
|
||||||
|
or the supported directory presets, then pass it to `SearchConfig`. Search
|
||||||
|
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||||
|
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||||
|
It preserves the supplied description order and does not deduplicate candidates.
|
||||||
|
|
||||||
|
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||||
|
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
||||||
|
`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are
|
||||||
|
omitted or `Filenames` is nil. A non-nil empty filename list contributes no
|
||||||
|
candidates and does not resolve its directory. `CustomFilenames` supplies
|
||||||
|
literal single filename components; it does not expand application-name
|
||||||
|
placeholders. An application slug is required only when a selected directory or
|
||||||
|
filename group uses it. Literal custom paths are not expanded for `~`, variables,
|
||||||
|
or wildcards; relative paths are resolved from the working directory at load time.
|
||||||
|
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
|
||||||
|
contribute no candidates. An explicitly supplied empty path or filename is
|
||||||
|
invalid. Slugs and custom filenames must be usable single host filename
|
||||||
|
components; ConfigKit preserves their case and spelling.
|
||||||
|
|
||||||
|
Search expansion is ordered concatenation. For each directory search, ConfigKit
|
||||||
|
tries its filename groups and names before advancing to the next directory.
|
||||||
|
When multiple directories use the same filename list, candidates follow the
|
||||||
|
directory-first Cartesian product: every filename in one directory is tried
|
||||||
|
before the next directory. Explicit file candidates stay at their declared
|
||||||
|
positions among directory searches.
|
||||||
|
|
||||||
|
The presets expand to these ordered directories and filename groups:
|
||||||
|
|
||||||
|
| Preset | Directory, in order | Filename groups |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `UnixSystemDirectories` | `/usr/local/etc/<application>`, `/etc/<application>` | Named, then Generic |
|
||||||
|
| `UnixSystemDirectories` | `/etc` | Named only |
|
||||||
|
| `UserConfigDirectory` | Go user-config directory / `<application>` | Named, then Generic |
|
||||||
|
| `UserConfigDirectory` | Go user-config directory | Named only |
|
||||||
|
| `HomeDirectory` | Go home directory / `.<application>` | Named, then Generic |
|
||||||
|
| `HomeDirectory` | Go home directory | Named only |
|
||||||
|
| `WorkingDirectory` | Current working directory | Named only |
|
||||||
|
|
||||||
|
The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd,
|
||||||
|
hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another
|
||||||
|
platform is an option error. Other presets use Go's user-config, home, and
|
||||||
|
working-directory helpers. A reached
|
||||||
|
directory-helper error terminates selection. Bases for later searches are not
|
||||||
|
resolved after an earlier candidate is selected. Missing candidates continue;
|
||||||
|
the first existing candidate is selected and then follows ordinary file loading
|
||||||
|
rules. Errors while acquiring or processing a selected file are terminal and do
|
||||||
|
not resume the search.
|
||||||
|
|
||||||
|
Searches are optional by default. Exhaustion uses the same absent-main lifecycle
|
||||||
|
as `NoSource()`: there is no main document, source record, source ID, or explicit
|
||||||
|
presence contribution. Imports may be enabled but there are no main declarations
|
||||||
|
to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and
|
||||||
|
validation retain their normal lifecycle. `SearchOptions{Required: true}` makes
|
||||||
|
exhaustion an error; it does not change candidate or selected-file error handling.
|
||||||
|
|
||||||
|
For an ordinary active overlay after optional exhaustion, provide an explicit
|
||||||
|
filesystem context. A host context with only `Root` is sufficient; ConfigKit
|
||||||
|
loads the overlay at its normal root-relative location and does not infer a root
|
||||||
|
from attempted search directories. The same root-only context can accompany a
|
||||||
|
search that selects a file: the selected file determines its base and must be
|
||||||
|
inside `Root`. A selected host file rejects an explicit `BaseDir` or native
|
||||||
|
filesystem context. Invalid context fails without resuming selection. Layered
|
||||||
|
overlays keep their independent `OverlayFilesystem` requirement.
|
||||||
|
|
||||||
|
When a search selects a file, inspection reports that actual file with ordinary
|
||||||
|
source IDs, paths, placement, metadata origins, and resource context. Missing
|
||||||
|
candidates create no records. `SearchConfig` can be used as the main source of
|
||||||
|
ordinary or layered loading, including an underlay input, and does not add a new
|
||||||
|
precedence tier.
|
||||||
|
|
||||||
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||||
@@ -78,9 +152,14 @@ non-root destination. Unknown modes, invalid destinations, and invalid or
|
|||||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||||
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||||
source-root collection declarations whether or not imports are enabled; an
|
source-root collection declarations whether or not imports are enabled; an
|
||||||
`imports` declaration still requires imports to be enabled. `NoSource` rejects
|
`imports` declaration still requires imports to be enabled. `NoSource` may enable
|
||||||
imports, active overlays, and filesystem context as invalid combinations but
|
imports, but it has no main declarations to traverse. It accepts an explicit
|
||||||
accepts overrides and remains an absent source rather than an empty document.
|
filesystem context: a host context requires an absolute existing `Root` and may
|
||||||
|
omit `BaseDir` to use that root as its internal import base; a native context
|
||||||
|
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
|
||||||
|
when imports are disabled. An active ordinary overlay requires this explicit
|
||||||
|
context. `NoSource` accepts overrides and remains absent rather than contributing
|
||||||
|
an empty document.
|
||||||
|
|
||||||
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
|
||||||
@@ -149,7 +228,8 @@ An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly
|
|||||||
independent of the main source's namespace. Its name and root-relative lookup
|
independent of the main source's namespace. Its name and root-relative lookup
|
||||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
follow the selected-overlay rules below; inactive overlays ignore the context.
|
||||||
The same path in a different filesystem namespace is not a repeated file.
|
The same path in a different filesystem namespace is not a repeated file.
|
||||||
Ordinary `Load` continues to infer its selected-overlay context from its source.
|
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||||
|
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
||||||
|
|
||||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||||
@@ -969,6 +1049,12 @@ Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, wi
|
|||||||
no extension fallback, case normalization, inheritance between overlay files,
|
no extension fallback, case normalization, inheritance between overlay files,
|
||||||
or overlay stack. A selected missing overlay is an error.
|
or overlay stack. A selected missing overlay is an error.
|
||||||
|
|
||||||
|
With `NoSource`, the main input stays absent and the explicit filesystem context
|
||||||
|
supplies the overlay root. The overlay is recorded as the first acquired source
|
||||||
|
when no other source exists. Host contexts may use the root-only form described
|
||||||
|
under [single-source loading](#single-source-loading); native contexts retain
|
||||||
|
their complete namespace, base, and root requirements.
|
||||||
|
|
||||||
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||||
base import graph is composed and its schema version is checked, but before defaults
|
base import graph is composed and its schema version is checked, but before defaults
|
||||||
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
|||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||||
execution profile concepts.
|
execution profile concepts.
|
||||||
|
|
||||||
|
## Explicit main-file discovery
|
||||||
|
|
||||||
|
Applications choose the search conventions and their precedence, then pass the
|
||||||
|
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
|
||||||
|
For example, the maintained sequence catalog puts a caller-selected file before
|
||||||
|
a custom directory fallback while keeping its underlay and overlay choices
|
||||||
|
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
|
||||||
|
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
|
||||||
|
the [working-directory executable example](../../examples/consumers/discovery_test.go).
|
||||||
|
Together, the root-owned [discovery API](../../discovery.go) and [configuration
|
||||||
|
discovery contract](../config.md#configuration-discovery) define ordered
|
||||||
|
concatenation, directory-first Cartesian expansion, optional exhaustion, and
|
||||||
|
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
|
records why discovery is explicitly selected.
|
||||||
|
|
||||||
## Import defaults and selection inspection
|
## Import defaults and selection inspection
|
||||||
|
|
||||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ Accepted decisions:
|
|||||||
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||||
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -182,6 +183,12 @@ Current behavior belongs to the configuration contract's
|
|||||||
The completed remediation record, including the algorithm-audit findings, is
|
The completed remediation record, including the algorithm-audit findings, is
|
||||||
preserved at `ea46001:docs/roadmap/remediation.md`.
|
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||||
|
|
||||||
|
The completed configuration-discovery roadmap, implementation plan, and
|
||||||
|
remediation record are preserved in Git history at `0369111`, for example
|
||||||
|
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
|
||||||
|
[configuration discovery](config.md#configuration-discovery); the public
|
||||||
|
[discovery declarations](../discovery.go) own the Go API.
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
|
||||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
@@ -231,6 +238,7 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||||
|
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
|
||||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ private state; public signatures and fields do not expose engine types.
|
|||||||
|
|
||||||
Implemented component owners:
|
Implemented component owners:
|
||||||
|
|
||||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
- `internal/engine` owns host and native-filesystem source acquisition and
|
||||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
and sequence-item placement, YAML collection declarations and operations for
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
identified sequences, and application identity registrations for strict
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
|
It also owns grouped strict composition, recursive precedence assembly,
|
||||||
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
composition, inheritance, and final validation. It applies the selected
|
composition, inheritance, and final validation. It applies the selected
|
||||||
collection operation at precedence boundaries and owns dependency-ready,
|
collection operation at precedence boundaries and owns dependency-ready,
|
||||||
destination-local inheritance before typed decoding. CLI assignment follows
|
destination-local inheritance before typed decoding. CLI assignment follows
|
||||||
@@ -31,9 +34,9 @@ Implemented component owners:
|
|||||||
or records. Callback wrappers pass only live engine context during a callback;
|
or records. Callback wrappers pass only live engine context during a callback;
|
||||||
`Result.Config` is detached and its private inspection remains frozen
|
`Result.Config` is detached and its private inspection remains frozen
|
||||||
independently.
|
independently.
|
||||||
- Root conversion files explicitly cross the path, record, callback, secret, and
|
- Root conversion files explicitly cross the path, record, callback, secret,
|
||||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
search-description, and error boundaries. There are no public aliases,
|
||||||
public engine bridge methods.
|
anonymous engine embeddings, or public engine bridge methods.
|
||||||
- Root `Diff` and `Difference` provide the public comparison boundary and
|
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||||
convert detached engine records to root-owned paths and snapshots; consumer
|
convert detached engine records to root-owned paths and snapshots; consumer
|
||||||
examples own presentation of the safe changed regions.
|
examples own presentation of the safe changed regions.
|
||||||
@@ -53,7 +56,9 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
|||||||
internal types.
|
internal types.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
semantics, including [input destinations](../config.md#input-destinations),
|
semantics, including
|
||||||
|
[configuration discovery](../config.md#configuration-discovery),
|
||||||
|
[input destinations](../config.md#input-destinations),
|
||||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||||
The [strict composition contract](../config.md#strict-document-composition)
|
The [strict composition contract](../config.md#strict-document-composition)
|
||||||
@@ -78,5 +83,7 @@ records the shared dependency-ready ordering decision; [ADR
|
|||||||
0016](../adr/0016-independent-import-controls.md) records the independent
|
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||||
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||||
operations and CLI ordering. The [import internals](imports.md) document candidate and
|
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
namespace identities, discovery and scheduling, and the bounded algorithms.
|
records explicit main-file discovery. The [import internals](imports.md)
|
||||||
|
document candidate and namespace identities, discovery and scheduling, and the
|
||||||
|
bounded algorithms.
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ remains in the roadmap until implemented.
|
|||||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||||
resolution, validation, and inspection. Applications own their configuration
|
resolution, validation, and inspection. Applications own their configuration
|
||||||
types, field meanings, defaults, normalization, semantic validation, and schema
|
types, field meanings, defaults, normalization, semantic validation, and schema
|
||||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
version policy. Applications choose and order their main-file search conventions;
|
||||||
remote acquisition, runtime initialization, and resource checks.
|
ConfigKit provides explicitly selected search descriptions that resolve during
|
||||||
|
loading. Applications also own CLI wiring, remote acquisition, runtime
|
||||||
|
initialization, and resource checks. See the [configuration discovery
|
||||||
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
|
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
@@ -157,11 +161,13 @@ placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
|||||||
the import extension.
|
the import extension.
|
||||||
|
|
||||||
Library code must not depend on consuming applications or initialize their
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, implicitly
|
||||||
application-specific paths, or change process-global state during ordinary `Load`.
|
discover application-specific paths, or change process-global state during
|
||||||
|
ordinary `Load`.
|
||||||
Import discovery is available only through declarations in selected sources:
|
Import discovery is available only through declarations in selected sources:
|
||||||
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||||
ConfigKit does not search conventional application paths or discover a main
|
ConfigKit does not search conventional application paths unless the application
|
||||||
|
explicitly supplies a discovery description, and it never discovers a main
|
||||||
configuration file implicitly. Imports and sibling inheritance share
|
configuration file implicitly. Imports and sibling inheritance share
|
||||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
|||||||
77
docs/releases/v0.10.0.md
Normal file
77
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ConfigKit v0.10.0
|
||||||
|
|
||||||
|
This minor release adds explicit, composable discovery of a configuration file.
|
||||||
|
Applications choose the search conventions and their order; ConfigKit selects one
|
||||||
|
file during loading and returns the final configuration with its normal metadata.
|
||||||
|
Optional searches that find no file share the expanded `NoSource()` lifecycle.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
|
||||||
|
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
|
||||||
|
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
|
||||||
|
home, or working-directory presets with `Searches`. See the
|
||||||
|
[public declarations](../../discovery.go) and
|
||||||
|
[discovery contract](../config.md#configuration-discovery).
|
||||||
|
- Expand searches in supplied order, trying a directory's filename groups before
|
||||||
|
moving to the next directory. Named and generic groups provide conventional
|
||||||
|
YAML filenames; custom groups provide literal names. Directory searches
|
||||||
|
distinguish omitted filename selection from an explicitly empty selection.
|
||||||
|
Constructors perform no filesystem access, and each load observes current
|
||||||
|
directory bases and file availability.
|
||||||
|
- Select the first existing candidate. Access errors and failures while loading
|
||||||
|
the selected file terminate the load rather than trying another configuration.
|
||||||
|
Selected files retain physical source identity, placement, provenance, imports,
|
||||||
|
and relative-resource context. Discovery works with ordinary loading and
|
||||||
|
layered inputs, including underlays, without adding a precedence tier.
|
||||||
|
- Permit enabled imports with an absent main input: there are no main import
|
||||||
|
declarations to traverse, while actual underlay sources retain normal imports.
|
||||||
|
Support ordinary selected overlays with an absent main and explicit filesystem
|
||||||
|
context, including a root-only host context. Layered overlays retain their
|
||||||
|
independent `OverlayFilesystem` requirement. See
|
||||||
|
[single-source loading](../config.md#single-source-loading) and
|
||||||
|
[layered loading](../config.md#layered-loading).
|
||||||
|
- Preserve ordinary file-context behavior after discovery and include the
|
||||||
|
failing candidate's file identity in access diagnostics. Use Go's
|
||||||
|
platform-aware Windows component rules and native drive-root-relative path
|
||||||
|
resolution. Isolate user-config tests according to the host Go conventions.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Existing explicit source constructors and loading APIs remain available.
|
||||||
|
`File(path)` remains an exclusive selection that fails for a missing file;
|
||||||
|
discovery is activated only by explicitly supplying `SearchConfig`.
|
||||||
|
|
||||||
|
Searches are optional by default. Set `SearchOptions{Required: true}` when the
|
||||||
|
application must find a file. An exhausted optional search contributes no main
|
||||||
|
document or source record; application version checks, defaults, resolution,
|
||||||
|
and validation still run under their existing rules and may reject incomplete
|
||||||
|
configuration. A selected overlay is still required to exist.
|
||||||
|
|
||||||
|
The former `NoSource()` rejections of enabled imports and explicit filesystem
|
||||||
|
context are relaxed. An active ordinary overlay with no main file now loads
|
||||||
|
when explicit context is supplied. Applications that relied on those combinations
|
||||||
|
being rejected should review their option handling. Selected host files derive
|
||||||
|
their own base directory; do not supply an explicit `BaseDir` or native filesystem
|
||||||
|
context for a search that selects a host file.
|
||||||
|
|
||||||
|
Applications may replace their own fallback-loading loops with the new search
|
||||||
|
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
|
||||||
|
show simple preset selection and custom-first discovery alongside existing
|
||||||
|
configuration tiers. Composition, collection operations, inheritance, CLI
|
||||||
|
precedence, and the complete-on-return ownership model retain their existing
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
|
||||||
|
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
|
||||||
|
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
|
||||||
|
targets; macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)
|
||||||
@@ -173,9 +173,9 @@ overlapping work and select its starting revision rather than duplicating the
|
|||||||
remediation plan here. These structural changes do not close a finding merely
|
remediation plan here. These structural changes do not close a finding merely
|
||||||
because its function moved or became smaller.
|
because its function moved or became smaller.
|
||||||
|
|
||||||
The pending [discovery roadmap](discovery.md) remains separate feature work.
|
[Configuration discovery](../config.md#configuration-discovery) is implemented.
|
||||||
Import cleanup should leave clear private boundaries for future use without
|
Import cleanup should preserve its source-acquisition boundary and behavior as
|
||||||
implementing discovery behavior as part of this refactor.
|
part of this refactor.
|
||||||
|
|
||||||
## Work Needed And Validation
|
## Work Needed And Validation
|
||||||
|
|
||||||
|
|||||||
@@ -1,381 +0,0 @@
|
|||||||
# Composable Configuration Discovery Roadmap
|
|
||||||
|
|
||||||
Status: proposal with OD-1 through OD-5 and the final review clarifications
|
|
||||||
settled and integrated below.
|
|
||||||
This document describes future work, not an implemented contract. A staged
|
|
||||||
implementation plan will follow review of the complete roadmap.
|
|
||||||
|
|
||||||
## Purpose And Ownership
|
|
||||||
|
|
||||||
Make ordinary file-based loading concise for applications that want conventional
|
|
||||||
configuration locations. ConfigKit supplies useful built-in candidate lists and
|
|
||||||
an ordered selection operation. Consumers continue to own main-file search
|
|
||||||
conventions: they choose which lists apply, their order, custom candidates, and
|
|
||||||
whether a configuration file is required.
|
|
||||||
|
|
||||||
A consumer can select one preset for a simple application or compose several
|
|
||||||
presets and custom path lists. The application supplies an explicit slug where
|
|
||||||
needed to construct conventional paths. ConfigKit does not infer application
|
|
||||||
identity from the executable name or enable discovery for existing explicit
|
|
||||||
sources.
|
|
||||||
|
|
||||||
Discovery selects one source for the existing loading lifecycle. It does not
|
|
||||||
create additional configuration tiers or merge every matching file. Existing
|
|
||||||
layered loading remains available when consumers intentionally combine sources.
|
|
||||||
|
|
||||||
## Current Baseline
|
|
||||||
|
|
||||||
The [source contract](../config.md#sources) provides explicit host files, native
|
|
||||||
filesystem files, bytes, readers, mappings, and `NoSource()`. A missing explicit
|
|
||||||
file fails. The zero source is invalid. There is no conventional main-file search.
|
|
||||||
|
|
||||||
[Ordinary loading](../config.md#single-source-loading) infers a selected overlay's
|
|
||||||
filesystem context from the main source. `NoSource()` currently rejects enabled
|
|
||||||
imports, active overlays, and filesystem context. In
|
|
||||||
[layered loading](../config.md#layered-loading), an absent main input can coexist
|
|
||||||
with underlays; a selected overlay requires an explicit `OverlayFilesystem`.
|
|
||||||
These distinctions matter when an optional search finds nothing.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md#responsibility-boundaries)
|
|
||||||
assigns search conventions to consumers and currently excludes implicit main-file
|
|
||||||
discovery. The addition should clarify that consumers may explicitly select
|
|
||||||
library-provided search conventions. Consumer ownership remains unchanged.
|
|
||||||
|
|
||||||
## Agreed Direction
|
|
||||||
|
|
||||||
### Filename Groups, Directory Searches, And Search Presets
|
|
||||||
|
|
||||||
Organize discovery around three composable descriptions. These are ordered lists:
|
|
||||||
their order determines precedence.
|
|
||||||
|
|
||||||
**Filename groups** provide reusable, ordered filename templates. The built-in
|
|
||||||
groups are:
|
|
||||||
|
|
||||||
| Group | Filenames, in order |
|
|
||||||
| --- | --- |
|
|
||||||
| Named | `<appname>.yml`, `<appname>.yaml` |
|
|
||||||
| Generic | `config.yml`, `config.yaml`, `main.yml`, `main.yaml` |
|
|
||||||
|
|
||||||
`<appname>` is the application slug supplied by the consumer.
|
|
||||||
|
|
||||||
**Directory searches** pair a directory with an ordered list of filename groups.
|
|
||||||
The default selection is `[Named, Generic]`, producing the six filenames above
|
|
||||||
in that order. A directory search may select different groups or reverse their
|
|
||||||
precedence, such as `[Generic, Named]`, while retaining each group's internal
|
|
||||||
order. Consumers can also supply custom filenames, subject to the literal
|
|
||||||
single-component rules under OD-5 below.
|
|
||||||
|
|
||||||
**Search presets** bundle ordered directory searches. Consumers can select a
|
|
||||||
complete preset for the simple case or compose presets, custom directory searches,
|
|
||||||
and explicit file candidates in their desired order. Explicit file candidates
|
|
||||||
are literal paths and receive no filename expansion.
|
|
||||||
|
|
||||||
### Ordered Expansion And Concatenation
|
|
||||||
|
|
||||||
The selection model is **ordered expansion and concatenation**, followed by
|
|
||||||
first-match selection:
|
|
||||||
|
|
||||||
1. Expand the selected presets into directory searches in their supplied order.
|
|
||||||
2. For each directory search, concatenate its selected filename groups in order,
|
|
||||||
substitute the application slug where needed, and join each filename to that
|
|
||||||
directory.
|
|
||||||
3. Concatenate the resulting candidate lists, preserving the positions of custom
|
|
||||||
directory searches and explicit file candidates.
|
|
||||||
4. Try candidates in order, stopping at the first selected file or an error as
|
|
||||||
described below.
|
|
||||||
|
|
||||||
Directory priority comes before filename priority: exhaust one directory's
|
|
||||||
filenames before trying the next directory. When several directories share a
|
|
||||||
filename list, their expansion is a Cartesian product traversed directory first.
|
|
||||||
Different directories may select different lists, so ordered expansion and
|
|
||||||
concatenation describes the complete model. Set union does not express its
|
|
||||||
precedence semantics.
|
|
||||||
|
|
||||||
The eventual documentation should explain this model using the applicable
|
|
||||||
mathematical nomenclature: ordered lists, ordered concatenation, and Cartesian
|
|
||||||
product where a shared filename list applies. Retain these names in relevant
|
|
||||||
implementation comments where they help readers understand the operations. The
|
|
||||||
model describes ordering without requiring eager construction of every candidate.
|
|
||||||
|
|
||||||
### Unix System Preset
|
|
||||||
|
|
||||||
The agreed Unix system preset is:
|
|
||||||
|
|
||||||
| Directory, in search order | Filename groups, in order |
|
|
||||||
| --- | --- |
|
|
||||||
| `/usr/local/etc/<appname>` | `[Named, Generic]` |
|
|
||||||
| `/etc/<appname>` | `[Named, Generic]` |
|
|
||||||
| `/etc` | `[Named]` |
|
|
||||||
|
|
||||||
This produces 14 candidates: six in each application-specific directory, then
|
|
||||||
`/etc/<appname>.yml` and `/etc/<appname>.yaml`. For example,
|
|
||||||
`/usr/local/etc/<appname>/main.yaml` precedes
|
|
||||||
`/etc/<appname>/<appname>.yml`. Selecting only `Named` for `/etc` is an ordinary
|
|
||||||
directory-search description; the selection algorithm needs no special exception.
|
|
||||||
|
|
||||||
### Additional Presets (OD-1 — Settled)
|
|
||||||
|
|
||||||
Provide the following independently selectable presets alongside Unix system.
|
|
||||||
Within each preset, search directories in the order shown:
|
|
||||||
|
|
||||||
| Preset | Directory | Filename groups, in order |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| User configuration | `<user-config-dir>/<appname>` | `[Named, Generic]` |
|
|
||||||
| User configuration | `<user-config-dir>` | `[Named]` |
|
|
||||||
| Home directory | `<home>/.<appname>` | `[Named, Generic]` |
|
|
||||||
| Home directory | `<home>` | `[Named]` |
|
|
||||||
| Working directory | Current working directory | `[Named]` |
|
|
||||||
|
|
||||||
Resolve the user-configuration base with
|
|
||||||
[`os.UserConfigDir`](https://pkg.go.dev/os#UserConfigDir), the home base with
|
|
||||||
[`os.UserHomeDir`](https://pkg.go.dev/os#UserHomeDir), and the working-directory
|
|
||||||
base with [`os.Getwd`](https://pkg.go.dev/os#Getwd). Adopt the Go helpers'
|
|
||||||
platform-specific directory and environment behavior, including their errors;
|
|
||||||
the settled OD-2 rules below govern how discovery handles resolution errors.
|
|
||||||
|
|
||||||
The goal is to support familiar conventions through idiomatic Go. This feature
|
|
||||||
does not add a separate XDG compatibility layer or XDG system preset. A selected
|
|
||||||
user-configuration base has no additional hard-coded `<home>/.config` fallback.
|
|
||||||
The Home directory preset is a separate, explicit choice.
|
|
||||||
|
|
||||||
The working-directory preset searches only that directory and uses application-named
|
|
||||||
files; it does not walk ancestor directories. The consumer chooses precedence
|
|
||||||
between all presets. Selecting one does not automatically select any others.
|
|
||||||
|
|
||||||
### Illustrative Consumer API
|
|
||||||
|
|
||||||
Names below are illustrative and are not implemented Go API:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// A simple system-wide application.
|
|
||||||
source := configkit.SearchConfig("myapp", configkit.UnixSystemDirectories())
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// A consumer-selected working-directory, user, then system search.
|
|
||||||
source := configkit.SearchConfig(
|
|
||||||
"myapp",
|
|
||||||
configkit.WorkingDirectory(),
|
|
||||||
configkit.UserConfigDirectory(),
|
|
||||||
configkit.UnixSystemDirectories(),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Explicit custom candidates followed by a selected conventional fallback.
|
|
||||||
source := configkit.SearchConfig(
|
|
||||||
"myapp",
|
|
||||||
configkit.Paths("/run/myapp/config.yml", "/opt/myapp/config.yaml"),
|
|
||||||
configkit.UnixSystemDirectories(),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the resulting source with ordinary loading or as a layered main input. Custom
|
|
||||||
searches and file lists participate at their specified position; they do not
|
|
||||||
silently append unselected presets. Reordering presets or their directory
|
|
||||||
searches changes search priority.
|
|
||||||
|
|
||||||
### Selection And Failure Behavior
|
|
||||||
|
|
||||||
| Situation | Selected behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| Candidate is absent | Continue to the next candidate. |
|
|
||||||
| A candidate file is selected | Stop searching and load that file. |
|
|
||||||
| Candidate is inaccessible, unreadable, or not an accepted file type | Fail using normal source diagnostics. |
|
|
||||||
| Selected file fails parsing, version checks, composition, decoding, or validation | Fail without trying another candidate. |
|
|
||||||
| Entire search has no match | Absence is permitted by default; follow the shared absent-main lifecycle settled under OD-3 below. |
|
|
||||||
| Entire search has no match and is required | Fail with a diagnostic explaining the unsuccessful search. |
|
|
||||||
| Caller explicitly uses `File(path)` | Retain existing exclusive-file behavior, including errors for a missing file. |
|
|
||||||
|
|
||||||
Requiredness applies to the complete search, not to each preset or directory.
|
|
||||||
Permitting an absent file does not waive application version, schema, or domain
|
|
||||||
requirements; the remaining lifecycle may still reject an incomplete configuration.
|
|
||||||
|
|
||||||
Perform discovery before parsing the chosen main file. Do not implement fallback
|
|
||||||
by repeatedly running complete loads. Defaults, application resolution, secret
|
|
||||||
lookup, and final validation retain their existing lifecycle execution rules.
|
|
||||||
Once a file has been selected, later failure does not restart discovery.
|
|
||||||
|
|
||||||
### Construction And Filesystem Behavior
|
|
||||||
|
|
||||||
Constructors describe the search without filesystem access. Resolve dynamic base
|
|
||||||
directories and access candidate files during loading, so reusable descriptions
|
|
||||||
can observe changes on subsequent loads. Do not change the process working
|
|
||||||
directory or environment.
|
|
||||||
|
|
||||||
Reuse the host-file acquisition rules for selected files, including regular-file
|
|
||||||
checks, symlink handling, resource ownership, and source limits. The feature does
|
|
||||||
not require a new filesystem traversal engine, recursive directory search, or
|
|
||||||
new file-size limits. Candidate filenames and groups are bounded by their
|
|
||||||
explicit descriptions, rather than by scanning unrelated directories.
|
|
||||||
|
|
||||||
The exact Go names, constructors, and representation of search-wide options
|
|
||||||
belong in the implementation plan. Keep the public surface root-owned and keep
|
|
||||||
the single-preset case concise.
|
|
||||||
|
|
||||||
### Unavailable Bases And Platform Applicability (OD-2 — Settled)
|
|
||||||
|
|
||||||
Resolve directory bases only as the ordered search reaches them. Once an earlier
|
|
||||||
candidate is selected, later searches do not resolve their bases. Static invalid
|
|
||||||
search arguments still fail ordinary option validation before source acquisition.
|
|
||||||
An explicitly empty filename selection skips directory resolution as specified
|
|
||||||
under OD-4; it has no candidates to locate.
|
|
||||||
|
|
||||||
If a selected directory's base cannot be resolved, stop and report the error.
|
|
||||||
A missing directory or candidate file beneath a resolved base allows the search
|
|
||||||
to continue; a directory-resolution error does not. This distinction also applies
|
|
||||||
to optional searches: permitting no match does not suppress errors encountered
|
|
||||||
while searching. Preserve the agreed errors for inaccessible or unreadable
|
|
||||||
candidates.
|
|
||||||
|
|
||||||
Treat using the Unix system preset on an unsupported platform as an explicit
|
|
||||||
configuration error when that preset is reached. Specify its supported platforms
|
|
||||||
in the implementation plan. Do not translate the Unix preset into a different
|
|
||||||
platform's convention. User, home, and working-directory presets use their native
|
|
||||||
Go directory helpers.
|
|
||||||
|
|
||||||
This keeps search priority predictable when directory resolution fails. Consumers
|
|
||||||
select the presets appropriate to their application and environment; this proposal
|
|
||||||
does not add an option to skip unavailable bases or unsupported presets.
|
|
||||||
|
|
||||||
### No Match, Imports, And Selected Overlays (OD-3 — Settled)
|
|
||||||
|
|
||||||
Use a shared absent-main lifecycle for optional no-match and explicit `NoSource()`.
|
|
||||||
Allow imports to be enabled when the main file is absent: there are no main-file
|
|
||||||
import declarations to process, and enablement does not introduce automatic
|
|
||||||
directory scanning. Other actual sources retain their normal import behavior.
|
|
||||||
In layered loads, process underlays normally and retain the existing explicit
|
|
||||||
`OverlayFilesystem` requirement for an active overlay.
|
|
||||||
|
|
||||||
For ordinary loads with an active overlay and no main file, require an explicit
|
|
||||||
filesystem context to locate the overlay. Do not infer an overlay root from the
|
|
||||||
first or last searched directory. A missing selected overlay remains an error;
|
|
||||||
absence of a main file does not silently disable a requested overlay.
|
|
||||||
|
|
||||||
For an absent main input, a host filesystem context specifying only `Root` is
|
|
||||||
sufficient to locate the overlay; no main-file `BaseDir` is needed. If discovery
|
|
||||||
selects a host file, retain the normal file-context rules: the selected file
|
|
||||||
determines its base directory, an explicit `BaseDir` is rejected, and the file
|
|
||||||
must be contained within any supplied root. This allows one root-only context
|
|
||||||
to work with both selected and absent main inputs, subject to those existing
|
|
||||||
root and containment checks. An invalid context is an error, not a reason to
|
|
||||||
resume discovery.
|
|
||||||
|
|
||||||
Defaults, CLI overrides, application resolution, and validation continue through
|
|
||||||
the normal lifecycle. Main-version checking receives an absent main input and
|
|
||||||
may reject it under the application's existing policy. Create no synthetic empty
|
|
||||||
document or source record for the absent main file.
|
|
||||||
|
|
||||||
This deliberately relaxes the current `NoSource()` restrictions on imports and
|
|
||||||
on overlays with explicit context. Both ways of obtaining an absent main input
|
|
||||||
have the same behavior. Implementation must cover this lifecycle change as well
|
|
||||||
as candidate selection.
|
|
||||||
|
|
||||||
### Empty Composition And Implicit Preset Selection (OD-4 — Settled)
|
|
||||||
|
|
||||||
Require explicit preset or search selection to activate a convention. Supplying
|
|
||||||
no presets or custom searches produces no candidates and activates no implicit
|
|
||||||
fallback. Empty custom path lists and explicitly empty filename-group selections
|
|
||||||
also contribute no candidates. An empty component does not prevent later
|
|
||||||
components from contributing candidates.
|
|
||||||
|
|
||||||
A directory search with an explicitly empty filename selection does not resolve
|
|
||||||
its directory base. Static argument validation still applies. This gives empty
|
|
||||||
selections their no-candidate meaning without introducing directory-resolution
|
|
||||||
errors for a search that cannot select a file.
|
|
||||||
|
|
||||||
If the complete search is empty, an optional search follows the absent-main
|
|
||||||
lifecycle settled under OD-3; a required search fails as exhausted. Empty lists
|
|
||||||
are valid, supporting consumers that construct their searches programmatically.
|
|
||||||
|
|
||||||
Distinguish omission from an explicitly empty filename selection for a directory
|
|
||||||
search. Omitting the selection uses the agreed `[Named, Generic]` default;
|
|
||||||
explicitly selecting an empty list means no filenames. The eventual API must
|
|
||||||
represent both intentions clearly. Presets retain their specified filename groups.
|
|
||||||
|
|
||||||
Selecting a single preset keeps the simple case concise. Search-wide requiredness
|
|
||||||
provides the check for consumers that must find a configuration file.
|
|
||||||
|
|
||||||
### Slugs And Custom Path Interpretation (OD-5 — Settled)
|
|
||||||
|
|
||||||
Require a nonempty single-component application slug whenever a selected search
|
|
||||||
uses it to construct a directory or filename. Reject path separators, `.` and
|
|
||||||
`..`, and forms unusable as that component on the target platform. Preserve
|
|
||||||
spelling and case; do not trim, lowercase, or impose a kebab-case naming scheme.
|
|
||||||
Validation keeps the slug from changing the intended directory structure without
|
|
||||||
imposing a separate naming style.
|
|
||||||
|
|
||||||
Searches that do not use the slug do not require one. Examples include explicit
|
|
||||||
file candidates and a custom directory searched with only Generic filenames.
|
|
||||||
|
|
||||||
Treat custom paths as literal host paths. Resolve relative paths against the
|
|
||||||
working directory at load time. Do not expand shell variables, `~`, or wildcard
|
|
||||||
syntax. Consumers can construct expanded paths themselves; User and Home presets
|
|
||||||
provide the common directory-resolution conveniences. These rules keep custom
|
|
||||||
paths consistent with explicit file loading.
|
|
||||||
|
|
||||||
Custom filenames within directory searches are literal single filename
|
|
||||||
components. They cannot be absolute paths or contain subdirectory components;
|
|
||||||
use custom directory searches or explicit file candidates for those layouts.
|
|
||||||
They receive no application-name placeholder expansion. The Named group supplies
|
|
||||||
application-name expansion, while consumers may construct custom filenames
|
|
||||||
themselves before supplying them.
|
|
||||||
|
|
||||||
### Placement, Provenance, And Existing Features
|
|
||||||
|
|
||||||
The selected file participates as the actual file source. Preserve its physical
|
|
||||||
identity, effective placement, source origin, and passive resource context.
|
|
||||||
Relative imports and relative application resources use the selected file's
|
|
||||||
normal context. Discovery does not relocate overlay or CLI coordinates.
|
|
||||||
|
|
||||||
Missing candidates are not acquired sources and do not create source IDs,
|
|
||||||
configuration history, or explicit input presence. An entirely absent search
|
|
||||||
contributes no synthetic empty configuration file. Diagnostics should distinguish
|
|
||||||
an exhausted required search from a failed candidate. Existing selected-source
|
|
||||||
inspection remains the foundation; a new public per-attempt trace is not needed
|
|
||||||
for this proposal.
|
|
||||||
|
|
||||||
Preserve collection policies, inheritance, source-version rules, CLI ordering,
|
|
||||||
final validation, and frozen inspection after selection. Search-list order does
|
|
||||||
not change precedence among underlays, the main input, an overlay, and CLI values.
|
|
||||||
An explicitly selected overlay is not another main-file fallback candidate.
|
|
||||||
|
|
||||||
## Work Needed From The Baseline
|
|
||||||
|
|
||||||
The subsequent implementation plan should cover:
|
|
||||||
|
|
||||||
- Define filename-group, directory-search, preset, and search-source representations,
|
|
||||||
slug validation, and a search-wide requiredness option. Keep construction
|
|
||||||
separate from acquisition and the simple preset selection concise. Distinguish
|
|
||||||
omitted and explicitly empty directory-search filename selections.
|
|
||||||
- Implement ordered expansion and concatenation for presets, filename groups,
|
|
||||||
custom searches, and explicit file candidates, followed by first-match selection
|
|
||||||
through existing file access. Resolve each source once per consumption before
|
|
||||||
entering its parse and composition lifecycle.
|
|
||||||
- Integrate selected and absent outcomes with ordinary and layered main inputs,
|
|
||||||
placement, imports, application version checking, and overlay contexts according
|
|
||||||
to OD-3, including root-only host contexts for absent main inputs and normal
|
|
||||||
file-context checks when a file is selected. Preserve ownership and close
|
|
||||||
acquired handles on success and failure.
|
|
||||||
- Preserve real selected-source metadata and safe diagnostics without treating
|
|
||||||
absent candidates as configuration contributions.
|
|
||||||
- Exercise preset, directory, filename-group, and filename ordering, reversed
|
|
||||||
group precedence, the Unix system preset's 14 candidates and named-only `/etc`
|
|
||||||
search, custom-list composition, both filename extensions, no-match behavior,
|
|
||||||
empty components and searches, omitted versus empty filename selections,
|
|
||||||
skipped directory resolution for empty filename selections, slug-dependent and
|
|
||||||
slug-independent searches, literal custom paths and filename components,
|
|
||||||
required searches, unavailable preset bases, errors that must stop fallback,
|
|
||||||
repeated loads, and untouched explicit `File`
|
|
||||||
behavior. Cover imports, overlays, underlays, defaults, CLI, source versions,
|
|
||||||
source contexts, and source-list/inspection evidence at their boundaries.
|
|
||||||
- Clarify the architecture policy's consumer-selected discovery boundary. Record
|
|
||||||
any warranted durable ownership decision in an ADR; keep preset filenames and
|
|
||||||
API mechanics in their canonical contract when implemented. Update consumer
|
|
||||||
examples and the configuration contract when the feature lands. Explain the
|
|
||||||
three-part discovery model and its mathematical terminology in the eventual
|
|
||||||
documentation, with simple preset selection and custom composition examples.
|
|
||||||
|
|
||||||
This roadmap does not change implementation, policy, or ADRs by itself. Coordinate
|
|
||||||
implementation with the ongoing sequence integration audit rather than assuming
|
|
||||||
its in-progress changes are part of a fixed implementation baseline.
|
|
||||||
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"),
|
||||||
|
|||||||
316
facade_discovery_test.go
Normal file
316
facade_discovery_test.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
540
internal/engine/discovery.go
Normal file
540
internal/engine/discovery.go
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
|
// It does not access the filesystem until a later loading integration consumes it.
|
||||||
|
type Search struct {
|
||||||
|
entries []searchEntry
|
||||||
|
issues []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilenameGroup is an immutable ordered group used by a directory search.
|
||||||
|
type FilenameGroup struct {
|
||||||
|
kind filenameGroupKind
|
||||||
|
names []string
|
||||||
|
issues []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectorySearchOptions controls the filename groups used by SearchDirectory.
|
||||||
|
// A nil Filenames slice selects the default groups; a non-nil empty slice selects none.
|
||||||
|
type DirectorySearchOptions struct {
|
||||||
|
Filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions controls whether a later source consumption requires a match.
|
||||||
|
type SearchOptions struct {
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed. Description and option errors are reported during loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
captured := Search{entries: cloneSearchEntries(search.entries), issues: append([]string(nil), search.issues...)}
|
||||||
|
source := Source{kind: sourceSearch, search: &captured, searchApp: application}
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
source.searchIssues = []string{"SearchConfig accepts at most one options value"}
|
||||||
|
} else if len(supplied) == 1 {
|
||||||
|
source.searchRequired = supplied[0].Required
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
type filenameGroupKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
filenameGroupEmpty filenameGroupKind = iota
|
||||||
|
filenameGroupNamed
|
||||||
|
filenameGroupGeneric
|
||||||
|
filenameGroupCustom
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchEntryKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
searchEntryPath searchEntryKind = iota + 1
|
||||||
|
searchEntryDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
type directoryBaseKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
directoryBaseLiteral directoryBaseKind = iota
|
||||||
|
directoryBaseUnixLocal
|
||||||
|
directoryBaseUnixEtc
|
||||||
|
directoryBaseUserConfig
|
||||||
|
directoryBaseHome
|
||||||
|
directoryBaseWorking
|
||||||
|
)
|
||||||
|
|
||||||
|
type searchEntry struct {
|
||||||
|
kind searchEntryKind
|
||||||
|
path string
|
||||||
|
baseKind directoryBaseKind
|
||||||
|
directory string
|
||||||
|
appendSlug bool
|
||||||
|
slugPrefix string
|
||||||
|
filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||||
|
func NamedFilenames() FilenameGroup {
|
||||||
|
return FilenameGroup{kind: filenameGroupNamed}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||||
|
func GenericFilenames() FilenameGroup {
|
||||||
|
return FilenameGroup{kind: filenameGroupGeneric}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomFilenames selects literal filename components in the supplied order.
|
||||||
|
func CustomFilenames(names ...string) FilenameGroup {
|
||||||
|
group := FilenameGroup{kind: filenameGroupCustom, names: append([]string(nil), names...)}
|
||||||
|
for _, name := range group.names {
|
||||||
|
if !validFilenameComponent(name) {
|
||||||
|
group.issues = append(group.issues, "custom filename must be a usable host filename component")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDirectory searches the supplied directory using the selected filename groups.
|
||||||
|
// With omitted options or nil Filenames, it selects Named then Generic filenames.
|
||||||
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||||
|
search := Search{}
|
||||||
|
if len(supplied) > 1 {
|
||||||
|
search.issues = append(search.issues, "directory search accepts at most one options value")
|
||||||
|
}
|
||||||
|
var groups []FilenameGroup
|
||||||
|
if len(supplied) == 0 || supplied[0].Filenames == nil {
|
||||||
|
groups = []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
} else {
|
||||||
|
groups = cloneFilenameGroups(supplied[0].Filenames)
|
||||||
|
}
|
||||||
|
search.entries = append(search.entries, searchEntry{
|
||||||
|
kind: searchEntryDirectory, baseKind: directoryBaseLiteral,
|
||||||
|
directory: directory, filenames: groups,
|
||||||
|
})
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths describes literal host-file candidates in the supplied order.
|
||||||
|
func Paths(paths ...string) Search {
|
||||||
|
search := Search{entries: make([]searchEntry, 0, len(paths))}
|
||||||
|
for _, path := range append([]string(nil), paths...) {
|
||||||
|
search.entries = append(search.entries, searchEntry{kind: searchEntryPath, path: path})
|
||||||
|
}
|
||||||
|
return search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
|
func Searches(searches ...Search) Search {
|
||||||
|
result := Search{}
|
||||||
|
for _, search := range append([]Search(nil), searches...) {
|
||||||
|
result.entries = append(result.entries, cloneSearchEntries(search.entries)...)
|
||||||
|
result.issues = append(result.issues, search.issues...)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||||
|
func UnixSystemDirectories() Search {
|
||||||
|
namedGeneric := []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixLocal, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, appendSlug: true, filenames: cloneFilenameGroups(namedGeneric)},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUnixEtc, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserConfigDirectory describes the Go user-configuration directory searches.
|
||||||
|
func UserConfigDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, appendSlug: true, filenames: defaultFilenameGroups()},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseUserConfig, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeDirectory describes the Go home-directory searches.
|
||||||
|
func HomeDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, appendSlug: true, slugPrefix: ".", filenames: defaultFilenameGroups()},
|
||||||
|
{kind: searchEntryDirectory, baseKind: directoryBaseHome, filenames: []FilenameGroup{NamedFilenames()}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkingDirectory describes application-named files in the current directory.
|
||||||
|
func WorkingDirectory() Search {
|
||||||
|
return Search{entries: []searchEntry{{
|
||||||
|
kind: searchEntryDirectory, baseKind: directoryBaseWorking,
|
||||||
|
filenames: []FilenameGroup{NamedFilenames()},
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFilenameGroups() []FilenameGroup {
|
||||||
|
return []FilenameGroup{NamedFilenames(), GenericFilenames()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFilenameGroups(groups []FilenameGroup) []FilenameGroup {
|
||||||
|
if groups == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]FilenameGroup, len(groups))
|
||||||
|
for i, group := range groups {
|
||||||
|
cloned[i] = FilenameGroup{
|
||||||
|
kind: group.kind, names: append([]string(nil), group.names...),
|
||||||
|
issues: append([]string(nil), group.issues...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSearchEntries(entries []searchEntry) []searchEntry {
|
||||||
|
if entries == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make([]searchEntry, len(entries))
|
||||||
|
for i, entry := range entries {
|
||||||
|
cloned[i] = entry
|
||||||
|
cloned[i].filenames = cloneFilenameGroups(entry.filenames)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSearch(search Search, application string) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, issue := range search.issues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
slugNeeded := false
|
||||||
|
for _, entry := range search.entries {
|
||||||
|
switch entry.kind {
|
||||||
|
case searchEntryPath:
|
||||||
|
if entry.path == "" {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search path must not be empty"))
|
||||||
|
}
|
||||||
|
case searchEntryDirectory:
|
||||||
|
if entry.baseKind == directoryBaseLiteral && entry.directory == "" {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "directory search path must not be empty"))
|
||||||
|
}
|
||||||
|
if entry.appendSlug {
|
||||||
|
slugNeeded = true
|
||||||
|
}
|
||||||
|
for _, group := range entry.filenames {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupEmpty, filenameGroupGeneric:
|
||||||
|
case filenameGroupNamed:
|
||||||
|
slugNeeded = true
|
||||||
|
case filenameGroupCustom:
|
||||||
|
default:
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "filename group is invalid"))
|
||||||
|
}
|
||||||
|
for _, issue := range group.issues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "search description is invalid"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slugNeeded && !validFilenameComponent(application) {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "application name must be a usable host filename component"))
|
||||||
|
}
|
||||||
|
return canonicalDiagnostics(diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validFilenameComponent(value string) bool {
|
||||||
|
if value == "" || value == "." || value == ".." || strings.IndexByte(value, 0) >= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(value) || filepath.VolumeName(value) != "" || filepath.Base(value) != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if !filepath.IsLocal(value) || strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character < 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var errUnixSystemPresetUnavailable = errors.New("Unix system directories are unavailable on this platform")
|
||||||
|
|
||||||
|
type discoveryDirectoryHelpers struct {
|
||||||
|
userConfigDir func() (string, error)
|
||||||
|
userHomeDir func() (string, error)
|
||||||
|
workingDir func() (string, error)
|
||||||
|
unixSupported func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchCandidateIterator struct {
|
||||||
|
search Search
|
||||||
|
application string
|
||||||
|
helpers discoveryDirectoryHelpers
|
||||||
|
entryIndex int
|
||||||
|
groupIndex int
|
||||||
|
nameIndex int
|
||||||
|
directory string
|
||||||
|
dirReady bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSearchCandidateIterator(search Search, application string, helpers discoveryDirectoryHelpers) (*searchCandidateIterator, []Diagnostic) {
|
||||||
|
if diagnostics := validateSearch(search, application); len(diagnostics) != 0 {
|
||||||
|
return nil, diagnostics
|
||||||
|
}
|
||||||
|
defaults := defaultDiscoveryDirectoryHelpers()
|
||||||
|
if helpers.userConfigDir == nil {
|
||||||
|
helpers.userConfigDir = defaults.userConfigDir
|
||||||
|
}
|
||||||
|
if helpers.userHomeDir == nil {
|
||||||
|
helpers.userHomeDir = defaults.userHomeDir
|
||||||
|
}
|
||||||
|
if helpers.workingDir == nil {
|
||||||
|
helpers.workingDir = defaults.workingDir
|
||||||
|
}
|
||||||
|
if helpers.unixSupported == nil {
|
||||||
|
helpers.unixSupported = defaults.unixSupported
|
||||||
|
}
|
||||||
|
return &searchCandidateIterator{search: search, application: application, helpers: helpers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDiscoveryDirectoryHelpers() discoveryDirectoryHelpers {
|
||||||
|
return discoveryDirectoryHelpers{
|
||||||
|
userConfigDir: os.UserConfigDir,
|
||||||
|
userHomeDir: os.UserHomeDir,
|
||||||
|
workingDir: os.Getwd,
|
||||||
|
unixSupported: unixSystemPresetSupported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next expands one candidate at a time, resolving only the directory currently
|
||||||
|
// reached. Directory filenames are visited before moving to the next directory.
|
||||||
|
func (iterator *searchCandidateIterator) next() (string, bool, error) {
|
||||||
|
for {
|
||||||
|
if iterator.dirReady {
|
||||||
|
entry := iterator.search.entries[iterator.entryIndex]
|
||||||
|
for iterator.groupIndex < len(entry.filenames) {
|
||||||
|
group := entry.filenames[iterator.groupIndex]
|
||||||
|
count := filenameGroupLength(group)
|
||||||
|
if iterator.nameIndex < count {
|
||||||
|
name := filenameGroupAt(group, iterator.nameIndex, iterator.application)
|
||||||
|
iterator.nameIndex++
|
||||||
|
return filepath.Join(iterator.directory, name), true, nil
|
||||||
|
}
|
||||||
|
iterator.groupIndex++
|
||||||
|
iterator.nameIndex = 0
|
||||||
|
}
|
||||||
|
iterator.dirReady = false
|
||||||
|
iterator.entryIndex++
|
||||||
|
}
|
||||||
|
if iterator.entryIndex >= len(iterator.search.entries) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
entry := iterator.search.entries[iterator.entryIndex]
|
||||||
|
if entry.kind == searchEntryPath {
|
||||||
|
iterator.entryIndex++
|
||||||
|
path, err := iterator.absoluteCandidate(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
if !directoryHasCandidates(entry.filenames) {
|
||||||
|
iterator.entryIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
directory, err := iterator.resolveDirectory(entry)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
iterator.directory = directory
|
||||||
|
iterator.groupIndex = 0
|
||||||
|
iterator.nameIndex = 0
|
||||||
|
iterator.dirReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iterator *searchCandidateIterator) absoluteCandidate(path string) (string, error) {
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
|
// Drive-relative and drive-root-relative Windows paths need native
|
||||||
|
// resolution; joining a rooted path to the whole working directory changes it.
|
||||||
|
if filepath.VolumeName(path) != "" || runtime.GOOS == "windows" && len(path) > 0 && os.IsPathSeparator(path[0]) {
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
working, err := iterator.helpers.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(working, path)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iterator *searchCandidateIterator) resolveDirectory(entry searchEntry) (string, error) {
|
||||||
|
var base string
|
||||||
|
switch entry.baseKind {
|
||||||
|
case directoryBaseLiteral:
|
||||||
|
base = entry.directory
|
||||||
|
case directoryBaseUnixLocal:
|
||||||
|
if !iterator.helpers.unixSupported() {
|
||||||
|
return "", errUnixSystemPresetUnavailable
|
||||||
|
}
|
||||||
|
base = "/usr/local/etc"
|
||||||
|
case directoryBaseUnixEtc:
|
||||||
|
if !iterator.helpers.unixSupported() {
|
||||||
|
return "", errUnixSystemPresetUnavailable
|
||||||
|
}
|
||||||
|
base = "/etc"
|
||||||
|
case directoryBaseUserConfig:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.userConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case directoryBaseHome:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.userHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case directoryBaseWorking:
|
||||||
|
var err error
|
||||||
|
base, err = iterator.helpers.workingDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", errors.New("directory search base is invalid")
|
||||||
|
}
|
||||||
|
if entry.appendSlug {
|
||||||
|
base = filepath.Join(base, entry.slugPrefix+iterator.application)
|
||||||
|
}
|
||||||
|
if entry.baseKind == directoryBaseLiteral && !filepath.IsAbs(base) {
|
||||||
|
return iterator.absoluteCandidate(base)
|
||||||
|
}
|
||||||
|
return filepath.Clean(base), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func directoryHasCandidates(groups []FilenameGroup) bool {
|
||||||
|
for _, group := range groups {
|
||||||
|
if filenameGroupLength(group) != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameGroupLength(group FilenameGroup) int {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupNamed:
|
||||||
|
return 2
|
||||||
|
case filenameGroupGeneric:
|
||||||
|
return 4
|
||||||
|
case filenameGroupCustom:
|
||||||
|
return len(group.names)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filenameGroupAt(group FilenameGroup, index int, application string) string {
|
||||||
|
switch group.kind {
|
||||||
|
case filenameGroupNamed:
|
||||||
|
if index == 0 {
|
||||||
|
return application + ".yml"
|
||||||
|
}
|
||||||
|
return application + ".yaml"
|
||||||
|
case filenameGroupGeneric:
|
||||||
|
return [...]string{"config.yml", "config.yaml", "main.yml", "main.yaml"}[index]
|
||||||
|
case filenameGroupCustom:
|
||||||
|
return group.names[index]
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type discoveryHostProbe struct {
|
||||||
|
evalSymlinks func(string) (string, error)
|
||||||
|
stat func(string) (fs.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDiscoveryHostProbe() discoveryHostProbe {
|
||||||
|
return discoveryHostProbe{evalSymlinks: filepath.EvalSymlinks, stat: os.Stat}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSearchInput associates a terminal candidate failure with the same file
|
||||||
|
// evidence as ordinary acquisition. Exhaustion and base failures have no file.
|
||||||
|
func resolveSearchInput(source Source, sourceID string, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, *SourceInfo, []Diagnostic) {
|
||||||
|
selected, diagnostics := resolveSearchSource(source, source.search, source.searchApp, source.searchRequired, helpers, probe)
|
||||||
|
if len(diagnostics) == 0 || selected.kind != sourceFile {
|
||||||
|
return selected, nil, diagnostics
|
||||||
|
}
|
||||||
|
info := &SourceInfo{ID: sourceID, Label: selected.label, Path: selected.path, Layer: LayerBase, Kind: SourceKindFile}
|
||||||
|
for index := range diagnostics {
|
||||||
|
diagnostics[index].Primary = &Location{SourceID: sourceID}
|
||||||
|
}
|
||||||
|
return selected, info, diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSearchSource selects one ordinary file source. A nil description leaves
|
||||||
|
// an existing source untouched; the selected file is read later by readSource.
|
||||||
|
// A terminal candidate failure returns its file descriptor for error evidence.
|
||||||
|
func resolveSearchSource(source Source, description *Search, application string, required bool, helpers discoveryDirectoryHelpers, probe discoveryHostProbe) (Source, []Diagnostic) {
|
||||||
|
if description == nil {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(*description, application, helpers)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return Source{}, diagnostics
|
||||||
|
}
|
||||||
|
defaults := defaultDiscoveryHostProbe()
|
||||||
|
if probe.evalSymlinks == nil {
|
||||||
|
probe.evalSymlinks = defaults.evalSymlinks
|
||||||
|
}
|
||||||
|
if probe.stat == nil {
|
||||||
|
probe.stat = defaults.stat
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
candidate, ok, err := iterator.next()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errUnixSystemPresetUnavailable) {
|
||||||
|
return Source{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Unix system directory search is unavailable on this platform")}
|
||||||
|
}
|
||||||
|
return Source{}, []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if required {
|
||||||
|
return Source{}, []Diagnostic{sourceDiagnostic(CodeSourceMissing, "", "required configuration search found no file")}
|
||||||
|
}
|
||||||
|
return NoSource(), nil
|
||||||
|
}
|
||||||
|
canonical, err := probe.evalSymlinks(candidate)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return File(candidate), []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
if _, err := probe.stat(canonical); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failed := File(candidate)
|
||||||
|
failed.path = canonical
|
||||||
|
return failed, []Diagnostic{classifySourceError(err, "")}
|
||||||
|
}
|
||||||
|
return File(candidate), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
430
internal/engine/discovery_load_test.go
Normal file
430
internal/engine/discovery_load_test.go
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type discoveryLoadConfig struct {
|
||||||
|
Service struct {
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
} `yaml:"service"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchLoadMatchesExplicitFileThroughImportsAndContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, root, "app.yml", `configkit:
|
||||||
|
imports: [{path: fragments/extra.yml}]
|
||||||
|
settings: {main: selected}
|
||||||
|
`)
|
||||||
|
importPath := writeImportFile(t, root, "fragments/extra.yml", "settings: {imported: value}\n")
|
||||||
|
search := SearchConfig("app", SearchDirectory(root))
|
||||||
|
options := Options[discoveryLoadConfig]{
|
||||||
|
Imports: true, Filesystem: &FilesystemContext{Root: root}, destination: Keys("service"),
|
||||||
|
}
|
||||||
|
searched, err := testLoad(search, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad(File(mainPath), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||||
|
Keys("service", "settings", "main"), Keys("service", "settings", "imported"))
|
||||||
|
|
||||||
|
sources := searched.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Kind != SourceKindFile || sources[0].Path != canonicalTestPath(t, mainPath) || sources[1].Path != canonicalTestPath(t, importPath) {
|
||||||
|
t.Fatalf("searched source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchLayeredMainAndUnderlayMatchExplicitFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||||
|
underlayPath := writeImportFile(t, root, "underlay/base.yml", `configkit:
|
||||||
|
imports: [{path: extra.yml}]
|
||||||
|
settings: {underlay: value}
|
||||||
|
`)
|
||||||
|
importPath := writeImportFile(t, root, "underlay/extra.yml", "settings: {imported: value}\n")
|
||||||
|
mainSearch := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("main.yml")}}))
|
||||||
|
underlaySearch := SearchConfig("", SearchDirectory(filepath.Join(root, "underlay"), DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("base.yml")}}))
|
||||||
|
load := func(main, underlay Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(
|
||||||
|
Input{Source: main, Imports: true}, testSecretBinding(),
|
||||||
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: underlay, Imports: true}}}}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
searched, err := load(mainSearch, underlaySearch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := load(File(mainPath), File(underlayPath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection,
|
||||||
|
Keys("settings", "main"), Keys("settings", "underlay"), Keys("settings", "imported"))
|
||||||
|
|
||||||
|
sources := searched.Inspection.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].ID != "source-1" || sources[0].Layer != LayerBase || sources[1].ID != "source-2" || sources[1].Layer != LayerUnderlay || sources[2].Path != canonicalTestPath(t, importPath) || sources[2].UnderlayIndex != 1 {
|
||||||
|
t.Fatalf("layered search source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionalSearchExhaustionMatchesNoSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
defaults := func() discoveryLoadConfig {
|
||||||
|
return discoveryLoadConfig{Value: "default"}
|
||||||
|
}
|
||||||
|
options := Options[discoveryLoadConfig]{
|
||||||
|
Definition: Definition[discoveryLoadConfig]{Defaults: defaults},
|
||||||
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||||
|
}
|
||||||
|
searched, err := testLoad(SearchConfig("", Paths(missing)), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad(NoSource(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||||
|
if len(searched.Sources()) != 0 {
|
||||||
|
t.Fatalf("optional exhausted search created sources: %#v", searched.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSelectedFileFailuresDoNotFallBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("malformed YAML", func(t *testing.T) {
|
||||||
|
first := writeImportFile(t, t.TempDir(), "first.yml", ": malformed\n")
|
||||||
|
writeImportFile(t, filepath.Dir(first), "later.yml", "value: valid\n")
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(filepath.Dir(first), "later.yml"))), Options[discoveryLoadConfig]{})
|
||||||
|
if loaded != nil || err == nil {
|
||||||
|
t.Fatalf("malformed selected source = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version check", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "version: rejected\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "version: accepted\n")
|
||||||
|
var checked []string
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[discoveryLoadConfig]{
|
||||||
|
SourceVersionMode: sourceVersionModeIfPresent,
|
||||||
|
Definition: Definition[discoveryLoadConfig]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = append(checked, version.Text)
|
||||||
|
if version.Text == "rejected" {
|
||||||
|
return errors.New("version rejected")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if loaded != nil || err == nil || !reflect.DeepEqual(checked, []string{"rejected"}) {
|
||||||
|
t.Fatalf("version failure search = %#v, %v; checked %#v", loaded, err, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decode", func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: not-an-integer\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "value: 3\n")
|
||||||
|
type config struct {
|
||||||
|
Value int `yaml:"value"`
|
||||||
|
}
|
||||||
|
loaded, err := testLoad[config](SearchConfig("", Paths(first, filepath.Join(root, "later.yml"))), Options[config]{})
|
||||||
|
if loaded != nil || err == nil {
|
||||||
|
t.Fatalf("decode failure search = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchProbeFailureRetainsFileEvidence(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
main := writeImportFile(t, root, "main.yml", "value: main\n")
|
||||||
|
underlay := writeImportFile(t, root, "underlay.yml", "settings: {first: underlay}\n")
|
||||||
|
bad := filepath.Join(main, "child.yml")
|
||||||
|
later := writeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||||
|
}{
|
||||||
|
{"ordinary", "source-1", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{destination: Keys("settings")})
|
||||||
|
}},
|
||||||
|
{"underlay after acquired sources", "source-3", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: File(main)}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{
|
||||||
|
{Source: File(underlay)}, {Source: source, Destination: Keys("service", "settings")},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
_, err := entry.load(File(bad))
|
||||||
|
var explicit *LoadError
|
||||||
|
if !errors.As(err, &explicit) || len(explicit.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("explicit failed file = %v", err)
|
||||||
|
}
|
||||||
|
if explicit.Diagnostics()[0].Code == CodeSourceMissing {
|
||||||
|
t.Skip("host classifies a non-directory ancestor as absence; controlled probe tests cover access failures")
|
||||||
|
}
|
||||||
|
result, err := entry.load(SearchConfig("", Paths(missing, bad, later)))
|
||||||
|
var searched *LoadError
|
||||||
|
if result != nil || !errors.As(err, &searched) {
|
||||||
|
t.Fatalf("failed search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(searched.Sources(), explicit.Sources()) || !reflect.DeepEqual(searched.Diagnostics(), explicit.Diagnostics()) {
|
||||||
|
t.Fatalf("search failure differs from explicit file:\nsources %#v / %#v\ndiagnostics %#v / %#v", searched.Sources(), explicit.Sources(), searched.Diagnostics(), explicit.Diagnostics())
|
||||||
|
}
|
||||||
|
primary := searched.Diagnostics()[0].Primary
|
||||||
|
if primary == nil || primary.SourceID != entry.id {
|
||||||
|
t.Fatalf("failed candidate location = %#v, want %s", primary, entry.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchValidationPrecedesCandidateProbing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: selected\n")
|
||||||
|
_, err := testLoad[discoveryLoadConfig](SearchConfig("", Searches(Paths(first), Paths(""))), Options[discoveryLoadConfig]{})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid later search error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("invalid later search component diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
malformed := writeImportFile(t, t.TempDir(), "main.yml", ": malformed\n")
|
||||||
|
loaded, err := LoadLayered(
|
||||||
|
Input{Source: SearchConfig("", Paths(malformed))}, testSecretBinding(),
|
||||||
|
LayeredOptions[discoveryLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{Source: SearchConfig("", Paths(""))}}}}},
|
||||||
|
)
|
||||||
|
if loaded != nil || !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("invalid later underlay search = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
if diagnostic.Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("layered validation acquired main before rejecting underlay: %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||||
|
writeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
search := SearchConfig("", Paths(first, filepath.Join(root, "later.yml")))
|
||||||
|
options := Options[discoveryLoadConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}}
|
||||||
|
_, err := testLoad[discoveryLoadConfig](search, options)
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("selected file with explicit base error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("selected file context diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selected := writeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
context := &FilesystemContext{}
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
load func(Source) (*Loaded[discoveryLoadConfig], error)
|
||||||
|
}{
|
||||||
|
{"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context})
|
||||||
|
}},
|
||||||
|
{"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{})
|
||||||
|
}},
|
||||||
|
{"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) {
|
||||||
|
return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}},
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
explicit, err := entry.load(File(selected))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
searched, err := entry.load(SearchConfig("", Paths(missing, selected)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value"))
|
||||||
|
if searched.Config.Value != "selected" {
|
||||||
|
t.Fatalf("selected value = %q", searched.Config.Value)
|
||||||
|
}
|
||||||
|
for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} {
|
||||||
|
result, err := entry.load(source)
|
||||||
|
var failure *LoadError
|
||||||
|
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("absent input with empty context = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
file := writeImportFile(t, root, "config.yml", "value: first\n")
|
||||||
|
source := SearchConfig("", SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("config.yml")}}))
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first search value = %q", value)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte("value: second\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated search value = %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesUserConfigEnvironmentChanges(t *testing.T) {
|
||||||
|
setBase := func(directory string) string {
|
||||||
|
t.Helper()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
t.Setenv("AppData", directory)
|
||||||
|
case "darwin", "ios":
|
||||||
|
t.Setenv("HOME", directory)
|
||||||
|
case "plan9":
|
||||||
|
t.Setenv("home", directory)
|
||||||
|
default:
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", directory)
|
||||||
|
}
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
firstBase := setBase(filepath.Join(t.TempDir(), "first"))
|
||||||
|
first := writeImportFile(t, firstBase, "app/app.yml", "value: first\n")
|
||||||
|
source := SearchConfig("app", UserConfigDirectory())
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[config](source, Options[config]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first user-config search value = %q (%s)", value, first)
|
||||||
|
}
|
||||||
|
secondBase := setBase(filepath.Join(t.TempDir(), "second"))
|
||||||
|
second := writeImportFile(t, secondBase, "app/app.yml", "value: second\n")
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated user-config search value = %q (%s)", value, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchSourceObservesWorkingDirectoryChanges(t *testing.T) {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(original); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
firstDir := t.TempDir()
|
||||||
|
secondDir := t.TempDir()
|
||||||
|
writeImportFile(t, firstDir, "app.yml", "value: first\n")
|
||||||
|
writeImportFile(t, secondDir, "app.yml", "value: second\n")
|
||||||
|
source := SearchConfig("app", WorkingDirectory())
|
||||||
|
load := func() string {
|
||||||
|
loaded, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return loaded.Config.Value
|
||||||
|
}
|
||||||
|
if err := os.Chdir(firstDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "first" {
|
||||||
|
t.Fatalf("first working-directory search value = %q", value)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(secondDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value := load(); value != "second" {
|
||||||
|
t.Fatalf("updated working-directory search value = %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEquivalentDiscoveryLoads[T any](t *testing.T, leftConfig T, left *Inspection, rightConfig T, right *Inspection, presencePaths ...Path) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(leftConfig, rightConfig) {
|
||||||
|
t.Fatalf("search config %#v differs from explicit file config %#v", leftConfig, rightConfig)
|
||||||
|
}
|
||||||
|
leftSources, rightSources := left.Sources(), right.Sources()
|
||||||
|
if !reflect.DeepEqual(leftSources, rightSources) {
|
||||||
|
t.Fatalf("search sources %#v differ from explicit file sources %#v", leftSources, rightSources)
|
||||||
|
}
|
||||||
|
leftMetadata, rightMetadata := left.Metadata(), right.Metadata()
|
||||||
|
for _, path := range presencePaths {
|
||||||
|
leftInfo, leftPresent := leftMetadata.Lookup(path)
|
||||||
|
rightInfo, rightPresent := rightMetadata.Lookup(path)
|
||||||
|
if leftPresent != rightPresent || !reflect.DeepEqual(leftInfo, rightInfo) {
|
||||||
|
t.Fatalf("search metadata at %s = %#v, %v; explicit metadata = %#v, %v", path.String(), leftInfo, leftPresent, rightInfo, rightPresent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range leftSources {
|
||||||
|
leftContext, leftOK := left.SourceContext(source.ID)
|
||||||
|
rightContext, rightOK := right.SourceContext(source.ID)
|
||||||
|
if leftOK != rightOK || leftContext.BaseDir != rightContext.BaseDir || (leftContext.FS != nil) != (rightContext.FS != nil) {
|
||||||
|
t.Fatalf("search context for %s = %#v, %v; explicit context = %#v, %v", source.ID, leftContext, leftOK, rightContext, rightOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/engine/discovery_platform_other.go
Normal file
5
internal/engine/discovery_platform_other.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !hurd && !illumos && !ios && !linux && !netbsd && !openbsd && !solaris
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
func unixSystemPresetSupported() bool { return false }
|
||||||
5
internal/engine/discovery_platform_unix.go
Normal file
5
internal/engine/discovery_platform_unix.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build aix || android || darwin || dragonfly || freebsd || hurd || illumos || ios || linux || netbsd || openbsd || solaris
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
func unixSystemPresetSupported() bool { return true }
|
||||||
519
internal/engine/discovery_test.go
Normal file
519
internal/engine/discovery_test.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"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", "CONIN$", "CONOUT$", "name.", "bad:name", "bad\x01name"} {
|
||||||
|
if validFilenameComponent(value) {
|
||||||
|
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Device names with extensions depend on the Windows version. Match
|
||||||
|
// Go's platform-aware decision instead of imposing a blanket ban.
|
||||||
|
for _, value := range []string{"nul.txt", "COM¹.log", "LPT³.txt", "CON.extra.yml"} {
|
||||||
|
if got, want := validFilenameComponent(value), filepath.IsLocal(value); got != want {
|
||||||
|
t.Errorf("Windows component %q validity = %v, want Go's %v", value, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, value := range []string{"CONIN$", "CONOUT$", "nul.txt"} {
|
||||||
|
if !validFilenameComponent(value) {
|
||||||
|
t.Errorf("Windows component restriction applied to this host: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchSelectsFirstExistingCandidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "first.yml")
|
||||||
|
second := filepath.Join(root, "second.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: first\n")
|
||||||
|
writeDiscoveryFile(t, second, "value: second\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(filepath.Join(root, "missing.yml"), first, second)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.kind != sourceFile || selected.path != first {
|
||||||
|
t.Fatalf("selection = %#v, diagnostics %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 0 || string(read.data) != "value: first\n" || read.info.Path != canonicalTestPath(t, first) {
|
||||||
|
t.Fatalf("selected source read = %#v, diagnostics %#v", read, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchNamedExtensionsAndNonregularSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "app.yml"), "value: yml\n")
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "app.yaml"), "value: yaml\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(SearchDirectory(root)), "app", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != filepath.Join(root, "app.yml") {
|
||||||
|
t.Fatalf("named extension selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
directory := filepath.Join(root, "subdirectory")
|
||||||
|
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
good := filepath.Join(root, "good.yml")
|
||||||
|
writeDiscoveryFile(t, good, "value: later\n")
|
||||||
|
selected, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(directory, good)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != directory {
|
||||||
|
t.Fatalf("directory candidate selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
|
||||||
|
t.Fatalf("selected directory read diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchContainedSymlinkAndNoFallbackAfterReadFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "actual.yml")
|
||||||
|
link := filepath.Join(root, "linked.yml")
|
||||||
|
writeDiscoveryFile(t, target, "value: linked\n")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, "later.yml"), "value: later\n")
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(link, filepath.Join(root, "later.yml"))), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != link {
|
||||||
|
t.Fatalf("symlink selection = %#v, %#v", selected, diagnostics)
|
||||||
|
}
|
||||||
|
read, diagnostics := readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 0 || string(read.data) != "value: linked\n" || read.info.Path != canonicalTestPath(t, target) {
|
||||||
|
t.Fatalf("symlink read = %#v, %#v", read, diagnostics)
|
||||||
|
}
|
||||||
|
if err := os.Remove(target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, diagnostics = readSource(selected, "source-1", defaultSourceLimits())
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||||
|
t.Fatalf("selected source read failure = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchRequiredAndOptionalExhaustion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
optional, diagnostics := resolveSearchSource(Source{}, ptrSearch(Search{}), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||||
|
t.Fatalf("optional empty search = %#v, %#v", optional, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Search{}), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || diagnostics[0].Primary != nil || !strings.Contains(diagnostics[0].Message, "search") {
|
||||||
|
t.Fatalf("required empty search = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
missing := filepath.Join(root, "missing.yml")
|
||||||
|
optional, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || optional.kind != sourceNone {
|
||||||
|
t.Fatalf("optional missing search = %#v, %#v", optional, diagnostics)
|
||||||
|
}
|
||||||
|
_, diagnostics = resolveSearchSource(Source{}, ptrSearch(Paths(missing)), "", true, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing {
|
||||||
|
t.Fatalf("required missing search = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchLeavesNonSearchSourcesUntouched(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
source := Bytes("memory", []byte("value: unchanged\n"))
|
||||||
|
resolved, diagnostics := resolveSearchSource(source, nil, "", false, discoveryDirectoryHelpers{}, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || resolved.kind != sourceBytes || string(resolved.data) != "value: unchanged\n" {
|
||||||
|
t.Fatalf("non-search source = %#v, diagnostics = %#v", resolved, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchStopsOnReachedProbeError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "first.yml")
|
||||||
|
second := filepath.Join(root, "second.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: first\n")
|
||||||
|
writeDiscoveryFile(t, second, "value: second\n")
|
||||||
|
statCalls := 0
|
||||||
|
probe := discoveryHostProbe{stat: func(path string) (fs.FileInfo, error) {
|
||||||
|
statCalls++
|
||||||
|
return nil, fs.ErrPermission
|
||||||
|
}}
|
||||||
|
selected, info, diagnostics := resolveSearchInput(SearchConfig("", Paths(first, second)), "source-3", discoveryDirectoryHelpers{}, probe)
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission || statCalls != 1 {
|
||||||
|
t.Fatalf("probe failure = %#v, stat calls = %d", diagnostics, statCalls)
|
||||||
|
}
|
||||||
|
if selected.kind != sourceFile || info == nil || info.Path != canonicalTestPath(t, first) || info.Label != first || info.Kind != SourceKindFile || info.ID != "source-3" {
|
||||||
|
t.Fatalf("failed candidate evidence = %#v, %#v", selected, info)
|
||||||
|
}
|
||||||
|
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != info.ID {
|
||||||
|
t.Fatalf("failed candidate diagnostic does not identify its file: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchLeavesLaterDirectoryUnresolvedAfterSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := filepath.Join(root, "selected.yml")
|
||||||
|
writeDiscoveryFile(t, first, "value: selected\n")
|
||||||
|
baseCalls := 0
|
||||||
|
helpers := discoveryDirectoryHelpers{userConfigDir: func() (string, error) {
|
||||||
|
baseCalls++
|
||||||
|
return "", errors.New("later base should not be reached")
|
||||||
|
}}
|
||||||
|
search := ptrSearch(Searches(Paths(first), UserConfigDirectory()))
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, search, "app", false, helpers, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != first || baseCalls != 0 {
|
||||||
|
t.Fatalf("selected = %#v, diagnostics = %#v, later base calls = %d", selected, diagnostics, baseCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSearchTreatsShellLikeNamesLiterally(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
paths := []string{"~/literal.yml", "$CONFIG/literal.yml", "*.yaml"}
|
||||||
|
for _, name := range paths {
|
||||||
|
writeDiscoveryFile(t, filepath.Join(root, name), "value: literal\n")
|
||||||
|
}
|
||||||
|
helper := discoveryDirectoryHelpers{workingDir: func() (string, error) { return root, nil }}
|
||||||
|
for _, name := range paths {
|
||||||
|
selected, diagnostics := resolveSearchSource(Source{}, ptrSearch(Paths(name)), "", false, helper, discoveryHostProbe{})
|
||||||
|
if len(diagnostics) != 0 || selected.path != filepath.Join(root, name) {
|
||||||
|
t.Fatalf("literal %q selection = %#v, %#v", name, selected, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrSearch(search Search) *Search { return &search }
|
||||||
|
|
||||||
|
func writeDiscoveryFile(t *testing.T, path, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
internal/engine/discovery_windows_test.go
Normal file
91
internal/engine/discovery_windows_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowsSearchRejectsReservedComponentsBeforeAcquisition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
first := writeImportFile(t, root, "first.yml", "value: first\n")
|
||||||
|
for _, name := range []string{"CONIN$", "CONOUT$"} {
|
||||||
|
for _, source := range []Source{
|
||||||
|
SearchConfig(name, Searches(Paths(first), WorkingDirectory())),
|
||||||
|
SearchConfig("", Searches(Paths(first), SearchDirectory(root, DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames(name)},
|
||||||
|
}))),
|
||||||
|
} {
|
||||||
|
result, err := testLoad[discoveryLoadConfig](source, Options[discoveryLoadConfig]{})
|
||||||
|
var failure *LoadError
|
||||||
|
if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
t.Fatalf("reserved component %q accepted: %#v, %v", name, result, err)
|
||||||
|
}
|
||||||
|
if len(failure.Sources()) != 0 {
|
||||||
|
t.Fatalf("reserved component %q was checked after acquisition: %#v", name, failure.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsSearchPathsMatchHostAbsoluteResolution(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
file := writeImportFile(t, root, "settings/config.yml", "value: selected\n")
|
||||||
|
working := t.TempDir()
|
||||||
|
t.Chdir(working)
|
||||||
|
relative, err := filepath.Rel(working, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
volume := filepath.VolumeName(file)
|
||||||
|
rooted := strings.TrimPrefix(file, volume)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"relative", relative},
|
||||||
|
{"drive root relative", rooted},
|
||||||
|
{"slash root relative", filepath.ToSlash(rooted)},
|
||||||
|
{"drive relative", volume + relative},
|
||||||
|
{"absolute", file},
|
||||||
|
} {
|
||||||
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
|
want, err := filepath.Abs(entry.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explicit, err := testLoad[discoveryLoadConfig](File(entry.path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, search := range []Search{
|
||||||
|
Paths(entry.path),
|
||||||
|
SearchDirectory(filepath.Dir(entry.path), DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames(filepath.Base(entry.path))},
|
||||||
|
}),
|
||||||
|
} {
|
||||||
|
iterator, diagnostics := newSearchCandidateIterator(search, "", discoveryDirectoryHelpers{})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
candidate, ok, err := iterator.next()
|
||||||
|
if err != nil || !ok || candidate != want {
|
||||||
|
t.Fatalf("candidate = %q, %v, %v; want %q", candidate, ok, err, want)
|
||||||
|
}
|
||||||
|
result, err := testLoad[discoveryLoadConfig](SearchConfig("", search))
|
||||||
|
if err != nil || result.Config.Value != explicit.Config.Value {
|
||||||
|
t.Fatalf("searched path = %#v, %v; explicit value %q", result, err, explicit.Config.Value)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Path != explicit.Sources()[0].Path {
|
||||||
|
t.Fatalf("searched source = %#v; explicit source %#v", sources, explicit.Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||||
|
if source.kind == sourceNone && context.FS == nil {
|
||||||
|
return validateAbsentHostFilesystemContext(context)
|
||||||
|
}
|
||||||
if nativeContext(source, context) {
|
if nativeContext(source, context) {
|
||||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
@@ -33,10 +36,57 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic {
|
||||||
|
rootPath, err := canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
basePath := rootPath
|
||||||
|
if context.BaseDir != "" {
|
||||||
|
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containedPath(rootPath, basePath) {
|
||||||
|
value := importDiagnostic(CodeImportOutsideRoot, "import base must stay inside the configuration root", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{rootPath, basePath} {
|
||||||
|
info, err := os.Stat(candidate)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root, err := os.OpenRoot(rootPath)
|
||||||
|
if err != nil {
|
||||||
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||||
|
value.Primary = nil
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceNone:
|
case sourceNone:
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
|
case sourceSearch:
|
||||||
|
if context.FS != nil {
|
||||||
|
return absentFilesystemOptionDiagnostics(context)
|
||||||
|
}
|
||||||
|
// A selected host file can derive its root; absence requires an
|
||||||
|
// explicit root. Check that compatibility only after selection.
|
||||||
|
if context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base directory must be an absolute host path")}
|
||||||
|
}
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
if context.FS != nil {
|
if context.FS != nil {
|
||||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
||||||
@@ -69,6 +119,17 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func absentFilesystemOptionDiagnostics(context *FilesystemContext) []Diagnostic {
|
||||||
|
if context.FS != nil {
|
||||||
|
if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
|
||||||
|
}
|
||||||
|
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||||
|
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func nativeContext(source Source, context *FilesystemContext) bool {
|
func nativeContext(source Source, context *FilesystemContext) bool {
|
||||||
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
return source.kind == sourceFilesystem || context != nil && context.FS != nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,48 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoSourceImportsAndFilesystemContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
for _, imports := range []bool{false, true} {
|
||||||
|
name := "imports disabled"
|
||||||
|
if imports {
|
||||||
|
name = "imports enabled"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
result, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{
|
||||||
|
Imports: imports, Filesystem: &FilesystemContext{Root: directory},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 0 {
|
||||||
|
t.Fatalf("NoSource sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(Keys("items", "root")); ok {
|
||||||
|
t.Fatal("NoSource contributed explicit input")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := t.TempDir()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
context FilesystemContext
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{name: "missing root", context: FilesystemContext{BaseDir: directory}, code: CodeOptionsInvalid},
|
||||||
|
{name: "relative root", context: FilesystemContext{Root: "."}, code: CodeOptionsInvalid},
|
||||||
|
{name: "missing root directory", context: FilesystemContext{Root: filepath.Join(directory, "missing")}, code: CodeImportInvalid},
|
||||||
|
{name: "base outside root", context: FilesystemContext{BaseDir: outside, Root: directory}, code: CodeImportOutsideRoot},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{Filesystem: &test.context}, test.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
requiredLine := ""
|
requiredLine := ""
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ label: helper
|
|||||||
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
||||||
|
|
||||||
loaded, err := LoadLayered(
|
loaded, err := LoadLayered(
|
||||||
Input{Source: NoSource()}, testSecretBinding(),
|
Input{Source: NoSource(), Imports: true}, testSecretBinding(),
|
||||||
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
||||||
}}}}},
|
}}}}},
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ type Definition[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables traversal of declarations in a selected main document; an
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||||
|
// overlay and requires filesystem context for NoSource. Overrides contain only
|
||||||
|
// values explicitly supplied by an application CLI.
|
||||||
type Options[T any] struct {
|
type Options[T any] struct {
|
||||||
Definition Definition[T]
|
Definition Definition[T]
|
||||||
Imports bool
|
Imports bool
|
||||||
@@ -39,7 +41,9 @@ type Options[T any] struct {
|
|||||||
importDefaultsValidated bool
|
importDefaultsValidated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
// FilesystemContext supplies filesystem roots for imports and overlays. For an
|
||||||
|
// absent host input, Root is required and BaseDir may be omitted to use Root as
|
||||||
|
// the import base. Native contexts still require both BaseDir and Root.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
@@ -198,6 +202,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
|
|||||||
}
|
}
|
||||||
accumulator.versionState = versionState
|
accumulator.versionState = versionState
|
||||||
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
input, diagnostics := assembleBaseInputTrackedWithVersion(request.main.source, mainOptions, request.main.destination, accounting, nil, nil, versionState)
|
||||||
|
request.main.source = input.source
|
||||||
accumulator.sources = input.sources
|
accumulator.sources = input.sources
|
||||||
accumulator.add(versionState.diagnostics...)
|
accumulator.add(versionState.diagnostics...)
|
||||||
reportedVersionDiagnostics := len(versionState.diagnostics)
|
reportedVersionDiagnostics := len(versionState.diagnostics)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
// assembledInput is the source/document handoff across schema and version checks.
|
// assembledInput is the source/document handoff across schema and version checks.
|
||||||
type assembledInput struct {
|
type assembledInput struct {
|
||||||
|
source Source
|
||||||
parsed parsedSource
|
parsed parsedSource
|
||||||
document *document.Node
|
document *document.Node
|
||||||
composition *composedDocument
|
composition *composedDocument
|
||||||
@@ -101,20 +102,41 @@ func assembleBaseInputTrackedWithVersion[T any](source Source, options Options[T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], destination Path, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken, versionState *sourceVersionState, role sourceVersionRole) (assembledInput, []Diagnostic) {
|
||||||
|
sourceID := "source-" + strconv.Itoa(accounting.sources+1)
|
||||||
|
selectedSource := source
|
||||||
|
if source.kind == sourceSearch {
|
||||||
|
resolved, failedSource, diagnostics := resolveSearchInput(source, sourceID, defaultDiscoveryDirectoryHelpers(), defaultDiscoveryHostProbe())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
if failedSource != nil {
|
||||||
|
failedSource.Destination = destination.clone()
|
||||||
|
failedSource.placementMode = options.placementMode
|
||||||
|
}
|
||||||
|
return assembledInput{source: resolved, sources: sourceInfos(failedSource), versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
selectedSource = resolved
|
||||||
|
if options.Filesystem != nil {
|
||||||
|
if diagnostics := filesystemOptionDiagnostics(selectedSource, options.Filesystem); len(diagnostics) != 0 {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedSource.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||||
|
return assembledInput{source: selectedSource, versionState: versionState, accounting: accounting}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")}
|
||||||
|
}
|
||||||
|
}
|
||||||
limits := defaultImportLimits()
|
limits := defaultImportLimits()
|
||||||
parseLimits := defaultParseLimits()
|
parseLimits := defaultParseLimits()
|
||||||
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
parseLimits.source.remaining = limits.bytes - accounting.bytes
|
||||||
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
|
parsed, diagnostics := parseSource(selectedSource, sourceID, parseLimits)
|
||||||
if parsed.source != nil {
|
if parsed.source != nil {
|
||||||
parsed.source.Destination = destination.clone()
|
parsed.source.Destination = destination.clone()
|
||||||
parsed.source.placementMode = options.placementMode
|
parsed.source.placementMode = options.placementMode
|
||||||
}
|
}
|
||||||
input := assembledInput{parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
input := assembledInput{source: selectedSource, parsed: parsed, document: parsed.document, sources: sourceInfos(parsed.source), versionState: versionState, accounting: accounting}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
input.sources = sourceInfos(parsed.source)
|
input.sources = sourceInfos(parsed.source)
|
||||||
// Mapping value issues already identify application content. Other early
|
// Mapping value issues already identify application content. Other early
|
||||||
// failures describe source syntax or access, not a placed value.
|
// failures describe source syntax or access, not a placed value.
|
||||||
if source.kind == sourceMapping && source.mapIssue != nil && len(destination.segments) != 0 {
|
if selectedSource.kind == sourceMapping && selectedSource.mapIssue != nil && len(destination.segments) != 0 {
|
||||||
for index := range diagnostics {
|
for index := range diagnostics {
|
||||||
relative := diagnostics[index].Path
|
relative := diagnostics[index].Path
|
||||||
segments := clonePathSegments(destination.segments, len(relative.segments))
|
segments := clonePathSegments(destination.segments, len(relative.segments))
|
||||||
@@ -200,11 +222,11 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
versionState.mainProcessing = &processed
|
versionState.mainProcessing = &processed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if options.Imports {
|
if options.Imports && selectedSource.kind != sourceNone {
|
||||||
if options.deferAssembly {
|
if options.deferAssembly {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
||||||
} else {
|
} else {
|
||||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessing(selectedSource, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy)
|
||||||
}
|
}
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return input, diagnostics
|
return input, diagnostics
|
||||||
@@ -218,8 +240,8 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
if versionProcessing != nil {
|
if versionProcessing != nil {
|
||||||
input.document = versionProcessing.payload
|
input.document = versionProcessing.payload
|
||||||
}
|
}
|
||||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
if options.Filesystem != nil && (selectedSource.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
if diagnostic := validateFilesystemContext(selectedSource, options.Filesystem, parsed); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +273,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes {
|
||||||
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
|
||||||
}
|
}
|
||||||
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
if diagnostic := registerAcquiredSource(selectedSource, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
accounting.sources++
|
accounting.sources++
|
||||||
@@ -259,7 +281,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
|||||||
accounting.nodes += rawNodes + placementNodes
|
accounting.nodes += rawNodes + placementNodes
|
||||||
}
|
}
|
||||||
if !activeOverlay(options.Overlay) || options.Imports {
|
if !activeOverlay(options.Overlay) || options.Imports {
|
||||||
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
|
contexts, diagnostic := contextsForAcquiredSources(selectedSource, options.Filesystem, parsed.source, input.sources)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return input, []Diagnostic{*diagnostic}
|
return input, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
|||||||
if options.Filesystem != nil {
|
if options.Filesystem != nil {
|
||||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||||
}
|
}
|
||||||
if source.kind == sourceNone && options.Imports {
|
if source.kind == sourceSearch {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
for _, issue := range source.searchIssues {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, issue))
|
||||||
}
|
}
|
||||||
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
if source.search == nil {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "configuration search is invalid"))
|
||||||
|
} else {
|
||||||
|
diagnostics = append(diagnostics, validateSearch(*source.search, source.searchApp)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil {
|
||||||
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context"))
|
||||||
}
|
}
|
||||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
type config struct {
|
type config struct {
|
||||||
Count int `yaml:"count"`
|
Count int `yaml:"count"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
Derived int `yaml:"derived"`
|
Derived int `yaml:"derived"`
|
||||||
}
|
}
|
||||||
calls := 0
|
calls := 0
|
||||||
@@ -137,27 +138,33 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
|||||||
Definition: Definition[config]{
|
Definition: Definition[config]{
|
||||||
Defaults: func() config {
|
Defaults: func() config {
|
||||||
calls++
|
calls++
|
||||||
return config{Count: 2}
|
return config{Count: 2, Enabled: true}
|
||||||
},
|
},
|
||||||
Resolve: func(value *config, resolution *Resolution) error {
|
Resolve: func(value *config, resolution *Resolution) error {
|
||||||
info, ok := resolution.InputInfo(Keys("count"))
|
for _, key := range []string{"count", "enabled"} {
|
||||||
|
info, ok := resolution.InputInfo(Keys(key))
|
||||||
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
||||||
t.Errorf("resolution count info = %#v, %v", info, ok)
|
t.Errorf("resolution %s info = %#v, %v", key, info, ok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
|
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
|
||||||
|
Default(resolution, Keys("enabled"), &value.Enabled, true, "preserve CLI value")
|
||||||
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
|
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}},
|
Overrides: []Override{
|
||||||
|
{Path: Keys("count"), Value: 0, Flag: "--count"},
|
||||||
|
{Path: Keys("enabled"), Value: false, Flag: "--enabled"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if calls != 1 || result.Config.Count != 4 || result.Config.Derived != 12 {
|
if calls != 1 || result.Config.Count != 0 || result.Config.Enabled || result.Config.Derived != 0 {
|
||||||
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
|
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
|
||||||
}
|
}
|
||||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Layer != LayerCLI {
|
if sources := result.Sources(); len(sources) != 2 || sources[0].Layer != LayerCLI || sources[1].Layer != LayerCLI {
|
||||||
t.Fatalf("defaults-only CLI sources = %#v", sources)
|
t.Fatalf("defaults-only CLI sources = %#v", sources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,25 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex
|
|||||||
}
|
}
|
||||||
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
|
return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy)
|
||||||
}
|
}
|
||||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
var rootPath string
|
||||||
|
if source.kind == sourceNone {
|
||||||
|
if context == nil {
|
||||||
|
value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")
|
||||||
|
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
rootPath, err = canonicalExistingPath(context.Root)
|
||||||
|
if err != nil {
|
||||||
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
|
return cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var diagnostic *Diagnostic
|
||||||
|
rootPath, _, _, diagnostic = importFilesystem(source, context, main)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
return cloneSourceInfos(sources), []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
access, err := openHostAccess(rootPath)
|
access, err := openHostAccess(rootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected overlay", Location{})
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type overlayTestConfig struct {
|
type overlayTestConfig struct {
|
||||||
@@ -21,6 +24,81 @@ type overlayTestConfig struct {
|
|||||||
Schema map[string]string `yaml:"schema"`
|
Schema map[string]string `yaml:"schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayUsesRootedHostContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
overlayPath := writeImportFile(t, root, "overlays/testing.yml", "resource: asset.txt\n")
|
||||||
|
writeImportFile(t, root, "overlays/asset.txt", "host asset\n")
|
||||||
|
loaded, err := testLoad[config](NoSource(), Options[config]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != canonicalTestPath(t, overlayPath) || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFile {
|
||||||
|
t.Fatalf("NoSource overlay sources = %#v", sources)
|
||||||
|
}
|
||||||
|
context, ok := loaded.Inspection.SourceContext("source-1")
|
||||||
|
if !ok || context.BaseDir != "overlays" {
|
||||||
|
t.Fatalf("overlay resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource))
|
||||||
|
if err != nil || string(data) != "host asset\n" {
|
||||||
|
t.Fatalf("overlay resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayRequiresContextAndSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{Overlay: "testing"}, CodeOptionsInvalid)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root},
|
||||||
|
}, CodeOverlayMissing)
|
||||||
|
|
||||||
|
outside := t.TempDir()
|
||||||
|
requireLoadCode(t, NoSource(), Options[importTestConfig]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{Root: root, BaseDir: outside},
|
||||||
|
}, CodeImportOutsideRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoSourceSelectedOverlayUsesCompleteNativeContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
filesystem := NewFilesystem("embedded", fstest.MapFS{
|
||||||
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("resource: asset.txt\n")},
|
||||||
|
"overlays/asset.txt": &fstest.MapFile{Data: []byte("native asset\n")},
|
||||||
|
})
|
||||||
|
loaded, err := testLoad[config](NoSource(), Options[config]{
|
||||||
|
Overlay: "testing", Filesystem: &FilesystemContext{FS: &filesystem, BaseDir: ".", Root: "."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != "overlays/testing.yml" || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFilesystem {
|
||||||
|
t.Fatalf("native NoSource overlay sources = %#v", sources)
|
||||||
|
}
|
||||||
|
context, ok := loaded.Inspection.SourceContext("source-1")
|
||||||
|
if !ok || context.BaseDir != "overlays" {
|
||||||
|
t.Fatalf("native overlay resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource))
|
||||||
|
if err != nil || string(data) != "native asset\n" {
|
||||||
|
t.Fatalf("native overlay resource = %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type Source struct {
|
|||||||
mapBytes int64
|
mapBytes int64
|
||||||
mapNodes int
|
mapNodes int
|
||||||
createErr sourceCreationError
|
createErr sourceCreationError
|
||||||
|
search *Search
|
||||||
|
searchApp string
|
||||||
|
searchRequired bool
|
||||||
|
searchIssues []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceKind uint8
|
type sourceKind uint8
|
||||||
@@ -44,6 +48,7 @@ const (
|
|||||||
sourceReader
|
sourceReader
|
||||||
sourceMapping
|
sourceMapping
|
||||||
sourceNone
|
sourceNone
|
||||||
|
sourceSearch
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceCreationError uint8
|
type sourceCreationError uint8
|
||||||
@@ -174,7 +179,9 @@ func Mapping(label string, values map[string]any) Source {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||||
|
// source record and has no main declarations to traverse when imports are enabled.
|
||||||
|
// An active overlay requires explicit filesystem context.
|
||||||
func NoSource() Source {
|
func NoSource() Source {
|
||||||
return Source{kind: sourceNone}
|
return Source{kind: sourceNone}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,15 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS
|
|||||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
|
if source.kind == sourceNone && context.BaseDir == "" {
|
||||||
|
basePath = rootPath
|
||||||
|
} else {
|
||||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,41 @@ func TestSourceVersionNoSourceMainRetainsAbsentCallback(t *testing.T) {
|
|||||||
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
|
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
|
||||||
t.Fatalf("NoSource result = %#v, callback called = %v", result, called)
|
t.Fatalf("NoSource result = %#v, callback called = %v", result, called)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type config struct{}
|
||||||
|
for _, reject := range []bool{false, true} {
|
||||||
|
called = false
|
||||||
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
called = true
|
||||||
|
if version != (Version{}) {
|
||||||
|
t.Errorf("NoSource load version = %#v", version)
|
||||||
|
}
|
||||||
|
if reject {
|
||||||
|
return errors.New("rejected absent main")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
loaded, loadErr := testLoad(NoSource(), Options[config]{
|
||||||
|
Definition: definition, Imports: true, SourceVersionMode: test.mode,
|
||||||
|
})
|
||||||
|
if !called {
|
||||||
|
t.Fatal("NoSource load did not call the source-version checker")
|
||||||
|
}
|
||||||
|
if reject {
|
||||||
|
var failure *LoadError
|
||||||
|
if !errors.As(loadErr, &failure) {
|
||||||
|
t.Fatalf("rejected NoSource load = %v, want *LoadError", loadErr)
|
||||||
|
}
|
||||||
|
requireSourceVersionDiagnostic(t, failure.Diagnostics(), CodeVersionHookFailed, "/version")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if loadErr != nil || loaded == nil || len(loaded.Sources()) != 0 {
|
||||||
|
t.Fatalf("accepted NoSource load = %#v, %v", loaded, loadErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
lifecycle.go
14
lifecycle.go
@@ -83,8 +83,10 @@ type ImportDefaults struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// Options contains the application definition and per-load input selections.
|
||||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
// Imports enables traversal of declarations in a selected main document; an
|
||||||
// Overrides contains only values explicitly supplied by an application CLI.
|
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||||
|
// overlay and requires filesystem context for NoSource. Overrides contain 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. Placement projects checks and main protection
|
// document's local coordinates. Placement projects checks and main protection
|
||||||
// through the input destination; item placement adds the source's item
|
// through the input destination; item placement adds the source's item
|
||||||
@@ -109,9 +111,11 @@ type Options[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
||||||
// BaseDir and Root use absolute host paths. With FS set, they use slash-relative
|
// BaseDir and Root use absolute host paths. For NoSource, Root is required and
|
||||||
// paths in that namespace ("." denotes its root). File sources derive BaseDir
|
// BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
|
||||||
// from the selected file; a native file's explicit Root limits its scope.
|
// and Root use slash-relative paths in that namespace ("." denotes its root).
|
||||||
|
// File sources derive BaseDir from the selected file; a native file's explicit
|
||||||
|
// Root limits its scope.
|
||||||
type FilesystemContext struct {
|
type FilesystemContext struct {
|
||||||
BaseDir string
|
BaseDir string
|
||||||
Root string
|
Root string
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Source is an opaque configuration input. Construct sources with File,
|
// Source is an opaque configuration input. Construct sources with File,
|
||||||
// Filesystem.File, Bytes, Reader, Mapping, or NoSource; the zero value is invalid.
|
// Filesystem.File, SearchConfig, Bytes, Reader, Mapping, or NoSource; the zero
|
||||||
|
// value is invalid. SearchConfig selects a host file when a load consumes it.
|
||||||
type Source struct{ value engine.Source }
|
type Source struct{ value engine.Source }
|
||||||
|
|
||||||
// Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies
|
// Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies
|
||||||
@@ -47,7 +48,9 @@ func Mapping(label string, values map[string]any) Source {
|
|||||||
return Source{value: engine.Mapping(label, values)}
|
return Source{value: engine.Mapping(label, values)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||||
|
// source record and has no main declarations to traverse when imports are enabled.
|
||||||
|
// An active overlay requires explicit filesystem context.
|
||||||
func NoSource() Source { return Source{value: engine.NoSource()} }
|
func NoSource() Source { return Source{value: engine.NoSource()} }
|
||||||
|
|
||||||
func (s Source) toEngine() engine.Source { return s.value }
|
func (s Source) toEngine() engine.Source { return s.value }
|
||||||
|
|||||||
Reference in New Issue
Block a user