Define immutable configuration searches
This commit is contained in:
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) will own implemented behavior, the
|
||||
[component overview](../internal/overview.md) will describe implementation
|
||||
ownership, and the [implementation plan](../roadmap/implementation.md) tracks
|
||||
delivery progress.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration Discovery Implementation Plan
|
||||
|
||||
Status: ready for implementation. No stages are complete.
|
||||
Status: in progress. Stage 1 is complete.
|
||||
|
||||
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
||||
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
||||
@@ -190,7 +190,7 @@ from attempted directories. Layered overlays still require their independent
|
||||
or context descriptors are acceptable; they must not become an acquired main
|
||||
document, source ID, or explicit-presence contribution.
|
||||
|
||||
## Stage 1 — Define Immutable Search Descriptions And Validation
|
||||
## Stage 1 — Define Immutable Search Descriptions And Validation (Complete)
|
||||
|
||||
Read [the facade ADR](../adr/0006-public-facade.md), the roadmap, and the source,
|
||||
options, filesystem, and lifecycle owners named in the component overview.
|
||||
|
||||
266
internal/engine/discovery.go
Normal file
266
internal/engine/discovery.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
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" {
|
||||
return validWindowsFilenameComponent(value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validWindowsFilenameComponent(value string) bool {
|
||||
if strings.ContainsAny(value, `<>:"|?*`) || strings.HasSuffix(value, ".") || strings.HasSuffix(value, " ") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 32 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
base := strings.ToUpper(strings.TrimSuffix(value, filepath.Ext(value)))
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
152
internal/engine/discovery_test.go
Normal file
152
internal/engine/discovery_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchDescriptionEmptyAndSlugRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if diagnostics := validateSearch(Search{}, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("zero search diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Searches(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty concatenation diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths(), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("empty path list diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("relative/config.yml"), ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("literal path unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
generic := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{GenericFilenames()}})
|
||||
if diagnostics := validateSearch(generic, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("generic-only directory unexpectedly requires a slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs"), ""); len(diagnostics) == 0 || diagnostics[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("default named directory accepted an empty slug: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics := validateSearch(UnixSystemDirectories(), "myapp"); len(diagnostics) != 0 {
|
||||
t.Fatalf("valid preset slug diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionSnapshotsInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := []string{"first.yml", "second.yml"}
|
||||
pathSearch := Paths(paths...)
|
||||
paths[0] = "changed.yml"
|
||||
|
||||
customNames := []string{"one.yml", "two.yml"}
|
||||
group := CustomFilenames(customNames...)
|
||||
customNames[0] = "changed.yml"
|
||||
groups := []FilenameGroup{group}
|
||||
options := DirectorySearchOptions{Filenames: groups}
|
||||
directorySearch := SearchDirectory("configs", options)
|
||||
groups[0] = GenericFilenames()
|
||||
options.Filenames[0] = NamedFilenames()
|
||||
|
||||
searches := []Search{pathSearch, directorySearch}
|
||||
combined := Searches(searches...)
|
||||
searches[0] = Search{}
|
||||
|
||||
if got := combined.entries[0].path; got != "first.yml" {
|
||||
t.Fatalf("snapshotted path = %q", got)
|
||||
}
|
||||
gotNames := combined.entries[2].filenames[0].names
|
||||
if len(gotNames) != 2 || gotNames[0] != "one.yml" || gotNames[1] != "two.yml" {
|
||||
t.Fatalf("snapshotted custom names = %#v", gotNames)
|
||||
}
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("snapshot description diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryFilenameDefaultsAndExplicitEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
omitted := SearchDirectory("configs")
|
||||
if len(omitted.entries) != 1 || len(omitted.entries[0].filenames) != 2 || omitted.entries[0].filenames[0].kind != filenameGroupNamed || omitted.entries[0].filenames[1].kind != filenameGroupGeneric {
|
||||
t.Fatalf("omitted groups = %#v", omitted.entries)
|
||||
}
|
||||
explicitNil := SearchDirectory("configs", DirectorySearchOptions{})
|
||||
if len(explicitNil.entries[0].filenames) != 2 {
|
||||
t.Fatalf("nil filename selection did not use defaults: %#v", explicitNil.entries[0].filenames)
|
||||
}
|
||||
explicitEmpty := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
if explicitEmpty.entries[0].filenames == nil || len(explicitEmpty.entries[0].filenames) != 0 {
|
||||
t.Fatalf("explicit empty selection lost its distinction: %#v", explicitEmpty.entries[0].filenames)
|
||||
}
|
||||
if len(validateSearch(explicitEmpty, "")) != 0 {
|
||||
t.Fatal("explicit empty filename selection should need no slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionArityAndErrorConcatenation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tooMany := SearchDirectory("configs", DirectorySearchOptions{}, DirectorySearchOptions{})
|
||||
if diagnostics := validateSearch(tooMany, "app"); len(diagnostics) == 0 {
|
||||
t.Fatal("multiple option values should be invalid")
|
||||
}
|
||||
invalid := Paths("")
|
||||
combined := Searches(Paths("good.yml"), invalid)
|
||||
if diagnostics := validateSearch(combined, ""); len(diagnostics) == 0 {
|
||||
t.Fatal("concatenation lost a constructor error")
|
||||
}
|
||||
if diagnostics := validateSearch(Paths("good.yml", ""), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("later invalid path was hidden by an earlier component")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionPreservesFilenameGroupOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
groups := []FilenameGroup{GenericFilenames(), NamedFilenames(), CustomFilenames("override.yml")}
|
||||
search := SearchDirectory("configs", DirectorySearchOptions{Filenames: groups})
|
||||
got := search.entries[0].filenames
|
||||
if len(got) != 3 || got[0].kind != filenameGroupGeneric || got[1].kind != filenameGroupNamed || got[2].names[0] != "override.yml" {
|
||||
t.Fatalf("filename group order = %#v", got)
|
||||
}
|
||||
if got := SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames()}}).entries[0].filenames[0].names; len(got) != 0 {
|
||||
t.Fatalf("empty custom group = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilenameComponentValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"", ".", "..", "nested/name", "bad\x00name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("validFilenameComponent(%q) = true", value)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, value := range []string{"CON", "nul.txt", "name.", "bad:name"} {
|
||||
if validFilenameComponent(value) {
|
||||
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, value := range []string{"name with spaces", "naïve"} {
|
||||
if !validFilenameComponent(value) {
|
||||
t.Errorf("host-valid component %q rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if diagnostics := validateSearch(SearchDirectory("configs", DirectorySearchOptions{Filenames: []FilenameGroup{CustomFilenames("bad/name")}}), ""); len(diagnostics) == 0 {
|
||||
t.Fatal("invalid custom filename was not retained for validation")
|
||||
}
|
||||
for _, value := range []string{"CON", "nul.txt", "COM¹.log", "LPT³.txt", "name.", "bad:name", "bad\x01name"} {
|
||||
if validWindowsFilenameComponent(value) {
|
||||
t.Errorf("Windows component %q unexpectedly accepted", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"name with spaces", "naïve"} {
|
||||
if !validWindowsFilenameComponent(value) {
|
||||
t.Errorf("valid Windows component %q rejected", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user