diff --git a/diagnostic.go b/diagnostic.go new file mode 100644 index 0000000..83afec5 --- /dev/null +++ b/diagnostic.go @@ -0,0 +1,239 @@ +package configkit + +import ( + "cmp" + "fmt" + "io" + "slices" + "strconv" + "strings" + "unicode" +) + +// LoadError reports safe structured diagnostics for a failed configuration load. +// It intentionally does not expose or unwrap parser and application errors. +type LoadError struct { + diagnostics []Diagnostic + sources []SourceInfo +} + +// Error implements error without rendering diagnostic messages or input values. +func (e *LoadError) Error() string { + if e == nil { + return "configkit: configuration load failed" + } + count := 0 + for _, diagnostic := range e.diagnostics { + if diagnostic.Severity == SeverityError { + count++ + } + } + if count == 1 { + return "configkit: configuration load failed with 1 error" + } + return fmt.Sprintf("configkit: configuration load failed with %d errors", count) +} + +// Diagnostics returns a deep copy of the error's canonical diagnostics. +func (e *LoadError) Diagnostics() []Diagnostic { + if e == nil { + return nil + } + return cloneDiagnostics(e.diagnostics) +} + +// Sources returns a deep copy of sources discovered before loading failed. +func (e *LoadError) Sources() []SourceInfo { + if e == nil { + return nil + } + return cloneSourceInfos(e.sources) +} + +// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal +// control characters and propagates writer errors, including short writes. +func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { + canonical := canonicalDiagnostics(diagnostics) + for _, diagnostic := range canonical { + if err := writeDiagnostic(output, diagnostic); err != nil { + return err + } + } + return nil +} + +type diagnosticAccumulator struct { + diagnostics []Diagnostic + sources []SourceInfo +} + +func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) { + for _, diagnostic := range diagnostics { + a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic)) + } +} + +func (a *diagnosticAccumulator) loadError() *LoadError { + return &LoadError{ + diagnostics: canonicalDiagnostics(a.diagnostics), + sources: cloneSourceInfos(a.sources), + } +} + +func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic { + canonical := cloneDiagnostics(diagnostics) + for i := range canonical { + slices.SortFunc(canonical[i].Related, compareLocation) + } + slices.SortFunc(canonical, compareDiagnostic) + return canonical +} + +func compareDiagnostic(left, right Diagnostic) int { + if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 { + return order + } + if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 { + return order + } + if order := cmp.Compare(left.Code, right.Code); order != 0 { + return order + } + if order := cmp.Compare(left.Severity, right.Severity); order != 0 { + return order + } + if order := cmp.Compare(left.Message, right.Message); order != 0 { + return order + } + return compareLocations(left.Related, right.Related) +} + +func compareOptionalLocation(left, right *Location) int { + if left == nil && right == nil { + return 0 + } + if left == nil { + return -1 + } + if right == nil { + return 1 + } + return compareLocation(*left, *right) +} + +func compareLocations(left, right []Location) int { + for i := 0; i < min(len(left), len(right)); i++ { + if order := compareLocation(left[i], right[i]); order != 0 { + return order + } + } + return cmp.Compare(len(left), len(right)) +} + +func compareLocation(left, right Location) int { + if order := compareSourceID(left.SourceID, right.SourceID); order != 0 { + return order + } + if order := cmp.Compare(left.Line, right.Line); order != 0 { + return order + } + return cmp.Compare(left.Column, right.Column) +} + +func compareSourceID(left, right string) int { + leftOrder, leftOK := sourceSequence(left) + rightOrder, rightOK := sourceSequence(right) + if leftOK && rightOK { + return cmp.Compare(leftOrder, rightOrder) + } + return cmp.Compare(left, right) +} + +func sourceSequence(sourceID string) (int, bool) { + const prefix = "source-" + if !strings.HasPrefix(sourceID, prefix) { + return 0, false + } + value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix)) + if err != nil || value < 1 { + return 0, false + } + return value, true +} + +func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error { + severity := diagnostic.Severity + if severity == "" { + severity = SeverityError + } + path := diagnostic.Path.String() + if path == "" { + path = "" + } + + var b strings.Builder + b.WriteString(escapeControls(string(severity))) + if diagnostic.Code != "" { + b.WriteString(" [") + b.WriteString(escapeControls(string(diagnostic.Code))) + b.WriteByte(']') + } + b.WriteByte(' ') + b.WriteString(escapeControls(path)) + if diagnostic.Primary != nil { + b.WriteString(" (") + writeLocationText(&b, *diagnostic.Primary) + b.WriteByte(')') + } + if diagnostic.Message != "" { + b.WriteString(": ") + b.WriteString(escapeControls(diagnostic.Message)) + } + b.WriteByte('\n') + for _, related := range diagnostic.Related { + b.WriteString(" related: ") + writeLocationText(&b, related) + b.WriteByte('\n') + } + return writeString(output, b.String()) +} + +func writeLocationText(output *strings.Builder, location Location) { + label := location.SourceID + if label == "" { + label = "" + } + output.WriteString(escapeControls(label)) + if location.Line > 0 { + output.WriteByte(':') + output.WriteString(strconv.Itoa(location.Line)) + if location.Column > 0 { + output.WriteByte(':') + output.WriteString(strconv.Itoa(location.Column)) + } + } +} + +func writeString(output io.Writer, value string) error { + written, err := io.WriteString(output, value) + if err != nil { + return err + } + if written != len(value) { + return io.ErrShortWrite + } + return nil +} + +func escapeControls(value string) string { + var b strings.Builder + for _, r := range value { + if unicode.IsControl(r) { + quoted := strconv.QuoteRune(r) + b.WriteString(quoted[1 : len(quoted)-1]) + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/diagnostic_test.go b/diagnostic_test.go new file mode 100644 index 0000000..16fe82a --- /dev/null +++ b/diagnostic_test.go @@ -0,0 +1,143 @@ +package configkit + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) { + t.Parallel() + + diagnostics := []Diagnostic{ + { + Code: "decode.type", + Severity: SeverityError, + Path: Keys("later"), + Message: "later", + Primary: &Location{SourceID: "source-10", Line: 1, Column: 1}, + }, + { + Code: "schema.invalid", + Severity: SeverityError, + Path: Keys("root"), + Message: "source-less", + }, + { + Code: "decode.type", + Severity: SeverityError, + Path: Keys("earlier"), + Message: "earlier", + Primary: &Location{SourceID: "source-2", Line: 3, Column: 4}, + Related: []Location{ + {SourceID: "source-10", Line: 1, Column: 1}, + {SourceID: "source-2", Line: 9, Column: 1}, + }, + }, + } + + var forward bytes.Buffer + if err := WriteDiagnostics(&forward, diagnostics); err != nil { + t.Fatal(err) + } + reversed := []Diagnostic{diagnostics[2], diagnostics[1], diagnostics[0]} + var backward bytes.Buffer + if err := WriteDiagnostics(&backward, reversed); err != nil { + t.Fatal(err) + } + if forward.String() != backward.String() { + t.Fatalf("rendering depends on submission order:\n%s\n---\n%s", forward.String(), backward.String()) + } + lines := strings.Split(forward.String(), "\n") + if !strings.Contains(lines[0], "source-less") || !strings.Contains(lines[1], "source-2:3:4") { + t.Fatalf("unexpected canonical order:\n%s", forward.String()) + } + if !strings.Contains(lines[2], "source-2:9:1") || !strings.Contains(lines[3], "source-10:1:1") { + t.Fatalf("related locations not canonical:\n%s", forward.String()) + } + + accumulator := diagnosticAccumulator{ + sources: []SourceInfo{{ + ID: "source-1", + Label: "config.yml", + ImportedFrom: &Location{SourceID: "source-0", Line: 1}, + }}, + } + accumulator.add(diagnostics...) + loadErr := accumulator.loadError() + var found *LoadError + if !errors.As(error(loadErr), &found) || found != loadErr { + t.Fatal("errors.As did not find LoadError") + } + + first := loadErr.Diagnostics() + first[1].Primary.SourceID = "mutated" + first[1].Path.segments[0].key = "mutated" + first[1].Related[0].SourceID = "mutated" + second := loadErr.Diagnostics() + if second[1].Primary.SourceID != "source-2" || second[1].Path.String() != "/earlier" || second[1].Related[0].SourceID != "source-2" { + t.Fatal("Diagnostics returned aliased records") + } + sources := loadErr.Sources() + sources[0].ImportedFrom.SourceID = "mutated" + if loadErr.Sources()[0].ImportedFrom.SourceID != "source-0" { + t.Fatal("Sources returned aliased records") + } + if got := loadErr.Error(); got != "configkit: configuration load failed with 3 errors" { + t.Fatalf("Error() = %q", got) + } +} + +func TestWriteDiagnosticsEscapesControls(t *testing.T) { + t.Parallel() + + diagnostic := Diagnostic{ + Code: "validate.error", + Severity: SeverityError, + Path: Keys("line\nbreak"), + Message: "message\r\nnext", + Primary: &Location{SourceID: "source\tlabel", Line: 1}, + } + var output bytes.Buffer + if err := WriteDiagnostics(&output, []Diagnostic{diagnostic}); err != nil { + t.Fatal(err) + } + got := output.String() + if strings.Count(got, "\n") != 1 { + t.Fatalf("control characters created output lines: %q", got) + } + for _, escaped := range []string{`line\nbreak`, `message\r\nnext`, `source\tlabel`} { + if !strings.Contains(got, escaped) { + t.Errorf("output %q does not contain %q", got, escaped) + } + } +} + +func TestWriteDiagnosticsPropagatesWriterFailures(t *testing.T) { + t.Parallel() + + diagnostics := []Diagnostic{{Severity: SeverityError, Message: "safe"}} + want := errors.New("write failed") + if err := WriteDiagnostics(errorWriter{err: want}, diagnostics); !errors.Is(err, want) { + t.Fatalf("error writer returned %v", err) + } + if err := WriteDiagnostics(shortWriter{}, diagnostics); !errors.Is(err, io.ErrShortWrite) { + t.Fatalf("short writer returned %v", err) + } +} + +type errorWriter struct { + err error +} + +func (w errorWriter) Write(p []byte) (int, error) { + return min(3, len(p)), w.err +} + +type shortWriter struct{} + +func (shortWriter) Write(p []byte) (int, error) { + return max(0, len(p)-1), nil +} diff --git a/doc.go b/doc.go index 61d146e..9811695 100644 --- a/doc.go +++ b/doc.go @@ -1,7 +1,8 @@ -// Package configkit will provide deterministic, application-owned configuration -// loading for Go programs. +// Package configkit provides foundational types for deterministic, +// application-owned configuration loading in Go programs. // -// The package currently establishes the ConfigKit module boundary only. Loading, -// decoding, composition, provenance, and inspection APIs will be introduced with -// their implemented contracts; no usable configuration loader is available yet. +// Semantic paths, metadata and provenance records, safe diagnostics, and structured +// load errors are available. Source, parsing, decoding, composition, and loading +// operations will be introduced with their implemented contracts; no usable +// configuration loader is available yet. package configkit diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 09c27a9..9679fe0 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -1,8 +1,14 @@ # Internal Component Overview ConfigKit currently contains the root `configkit` package, which establishes the -module and package documentation boundary. It intentionally exposes no loading API -or implemented configuration behavior yet. +module and package documentation boundary. Its implemented public primitives are: + +- immutable semantic `Path` construction and strict JSON Pointer parsing; +- detached provenance and diagnostic record types with empty `Metadata` queries; +- canonical, control-safe diagnostic rendering and structured `LoadError` access. + +It intentionally exposes no loading, source, parsing, or decoding API yet. Private +metadata population will be integrated with loading when that lifecycle exists. The source-extraction inventory records the application foundations assigned to future implementation work: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7646660..327bdcc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -2,7 +2,7 @@ ## Status and ownership -Status: implementation in progress; Stage 1 is complete and later stages are +Status: implementation in progress; Stages 1–2 are complete and later stages are pending. Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in @@ -333,7 +333,25 @@ Acceptance: ## Stage 2 — Implement semantic paths and safe diagnostic records -Status: pending. +Status: complete. + +Completion evidence (2026-09-06): + +- Implemented immutable key/index paths, strict JSON Pointer parsing, canonical + spelling, parsed numeric-token preservation, and private traversal intent in + [`path.go`](../../path.go). +- Implemented detached metadata/provenance records and empty/unknown query behavior + in [`records.go`](../../records.go) and [`metadata.go`](../../metadata.go). +- Implemented canonical diagnostic ordering, related-location normalization, + control-safe rendering, short/error writer propagation, and defensive + `LoadError` access in [`diagnostic.go`](../../diagnostic.go). No raw values, + parser errors, source parsing, or loading surface was introduced. +- Added focused tests for escaped slash/tilde/empty/dotted/numeric keys, literal + dot/percent tokens, malformed escapes, builder immutability, detached nested + records, `errors.As`, ordering independence, control escaping, and failing/short + writers. Verified `go test ./...`, `go vet ./...`, `go build ./...`, and + `git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required; + metadata population and configuration lifecycle behavior remain later work. Read: feature [paths](initial.md#paths-and-presence), [diagnostics](initial.md#provenance-diagnostics-and-redaction), public metadata API. diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index 7c49c13..c1e9451 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -451,16 +451,10 @@ large integers are not rounded through an intermediate floating-point value. ## Paths and presence -Use structured key/index segments internally. Public textual paths use -JSON Pointer escaping (`~0` for tilde and `~1` for slash), with the empty string -for the root. For example, `/transcription/concurrency` names a nested field; -`/outputs/a.b` addresses the literal key `a.b`. Numeric segments are interpreted -using the containing node's kind, distinguishing mapping keys from list indices. -Use YAML field names, not Go field names. - -Semantic paths are not filesystem paths or URLs. Empty keys, literal `.`/`..` -keys, and percent sequences retain their meaning; do not clean path components -or percent-decode them. Only JSON Pointer escape processing applies. +Semantic path construction, JSON Pointer parsing, and current metadata query +declarations are implemented. Their current contracts are owned by the Go +documentation in [`path.go`](../../path.go) and [`metadata.go`](../../metadata.go). +The remaining presence behavior below is target lifecycle work. Distinguish absent, explicitly null, explicitly zero/false/empty, and other explicit values. An explicit value equal to its default remains explicit. @@ -680,6 +674,11 @@ is outside this lifecycle. Do not run later hooks on an invalid typed value. ## Provenance, diagnostics, and redaction +Safe diagnostic/provenance value records, canonical diagnostic rendering, and +`LoadError` access are implemented. Their current contracts are owned by +[`records.go`](../../records.go) and [`diagnostic.go`](../../diagnostic.go). +Attaching those records to the loading lifecycle remains target work below. + Retain input sources, layer, line/column where available, import sites, effective origins, overridden definition records, and resolution operations. Container origins may be composite; never invent one file location for a mapping assembled @@ -866,32 +865,11 @@ func ReadWorkerDefaults() (*configkit.Result[Config], error) { ### Paths and metadata -```go -func Keys(keys ...string) Path -func ParsePath(pointer string) (Path, error) -func (p Path) Key(key string) Path -func (p Path) Index(index int) Path -func (p Path) String() string - -func (m Metadata) Lookup(path Path) (FieldInfo, bool) -func (m Metadata) IsExplicit(path Path) bool -func (m Metadata) History(path Path) []Change -``` - -`Path` is an immutable key/index representation with the textual form specified -in [paths and presence](#paths-and-presence). `Keys()` is the root. Key/index -builders escape dynamic names correctly; applications should not concatenate -pointer strings. Invalid indices and paths supplied to hooks become structured -errors. Path spelling uses YAML names throughout. - -`FieldInfo` reports explicitness, null state, and effective `Origin`. An origin -identifies defaults, input, or application resolution and associated source -locations; it may be composite. `Change` reports layer/operation, prior origins, -safe reasons, dependency paths, and related locations. Safe historical values -are available through `Explain`, rather than unrestricted raw-value references. These are library-owned metadata types, never parser nodes. Queries -return copies or immutable views. Unknown paths yield no lookup/history and -false explicitness. Callback mutation/validation paths are checked more strictly: -misspelled struct fields are errors, while valid missing map entries are allowed. +The path API, detached metadata records, and empty/unknown query behavior are now +implemented; [`path.go`](../../path.go), [`records.go`](../../records.go), and +[`metadata.go`](../../metadata.go) own their current Go contracts. Populating +effective presence, origins, and history belongs to the loading stages. Callback +path checking and safe historical values remain target behavior below. ### Resolution and validation callbacks diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..a1de205 --- /dev/null +++ b/metadata.go @@ -0,0 +1,45 @@ +package configkit + +// Metadata provides detached presence, origin, and history queries for a load. +// Its zero value contains no paths. +type Metadata struct { + fields map[string]FieldInfo + history map[string][]Change +} + +// Lookup returns information for an effective path. Unknown and invalid paths +// return false. +func (m Metadata) Lookup(path Path) (FieldInfo, bool) { + if !path.valid() { + return FieldInfo{}, false + } + info, ok := m.fields[path.String()] + if !ok { + return FieldInfo{}, false + } + return cloneFieldInfo(info), true +} + +// IsExplicit reports whether a surviving input contribution explicitly defined +// path. +func (m Metadata) IsExplicit(path Path) bool { + info, ok := m.Lookup(path) + return ok && info.Explicit +} + +// History returns detached definition and resolution records for path. Unknown and +// invalid paths return nil. +func (m Metadata) History(path Path) []Change { + if !path.valid() { + return nil + } + history, ok := m.history[path.String()] + if !ok { + return nil + } + cloned := make([]Change, len(history)) + for i, change := range history { + cloned[i] = cloneChange(change) + } + return cloned +} diff --git a/metadata_test.go b/metadata_test.go new file mode 100644 index 0000000..2b771b2 --- /dev/null +++ b/metadata_test.go @@ -0,0 +1,58 @@ +package configkit + +import "testing" + +func TestMetadataQueriesReturnDetachedRecords(t *testing.T) { + t.Parallel() + + path := Keys("service", "workers") + metadata := Metadata{ + fields: map[string]FieldInfo{ + path.String(): { + Explicit: true, + Origin: Origin{ + Layer: LayerBase, + Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}}, + }, + }, + }, + history: map[string][]Change{ + path.String(): {{ + Path: path, + Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}}, + Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}}, + Reasons: []string{"bounded worker count"}, + Dependencies: []Path{Keys("limits")}, + }}, + }, + } + + info, ok := metadata.Lookup(path) + if !ok || !info.Explicit || !metadata.IsExplicit(path) { + t.Fatalf("Lookup() = %#v, %v", info, ok) + } + info.Origin.Locations[0].SourceID = "mutated" + again, _ := metadata.Lookup(path) + if again.Origin.Locations[0].SourceID != "source-1" { + t.Fatal("Lookup returned aliased locations") + } + + history := metadata.History(path) + history[0].Reasons[0] = "mutated" + history[0].Previous[0].Locations[0].SourceID = "mutated" + history[0].Dependencies[0].segments[0].key = "mutated" + againHistory := metadata.History(path) + if againHistory[0].Reasons[0] != "bounded worker count" || + againHistory[0].Previous[0].Locations[0].SourceID != "default" || + againHistory[0].Dependencies[0].String() != "/limits" { + t.Fatal("History returned aliased nested records") + } + + missing := Keys("missing") + if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil { + t.Fatal("unknown path produced metadata") + } + if _, ok := metadata.Lookup(Path{}.Index(-1)); ok { + t.Fatal("invalid path produced metadata") + } +} diff --git a/path.go b/path.go new file mode 100644 index 0000000..fddd7da --- /dev/null +++ b/path.go @@ -0,0 +1,148 @@ +package configkit + +import ( + "errors" + "strconv" + "strings" +) + +// Path identifies a configuration value using YAML field names. Its textual form +// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1. +// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot, +// percent-containing, and numeric keys retain their literal meaning. Parsed +// numeric tokens are resolved only when traversal knows their containing type. +// +// Paths are immutable. The zero value is the root path. +type Path struct { + segments []pathSegment + invalid bool +} + +type pathSegmentKind uint8 + +const ( + pathSegmentKey pathSegmentKind = iota + pathSegmentIndex + pathSegmentParsed +) + +type pathSegment struct { + kind pathSegmentKind + key string + index int +} + +// Keys constructs a path of mapping keys. Keys with no arguments returns the +// root path. +func Keys(keys ...string) Path { + p := Path{} + for _, key := range keys { + p = p.Key(key) + } + return p +} + +// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema +// or document traversal determines whether the containing value is a mapping or +// sequence. +func ParsePath(pointer string) (Path, error) { + if pointer == "" { + return Path{}, nil + } + if pointer[0] != '/' { + return Path{}, errors.New("configkit: path must be empty or begin with slash") + } + + parts := strings.Split(pointer[1:], "/") + segments := make([]pathSegment, 0, len(parts)) + for _, part := range parts { + key, ok := unescapePathToken(part) + if !ok { + return Path{}, errors.New("configkit: path contains an invalid tilde escape") + } + segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key}) + } + return Path{segments: segments}, nil +} + +// Key returns a new path with key appended as a mapping-key segment. +func (p Path) Key(key string) Path { + segments := clonePathSegments(p.segments, 1) + segments = append(segments, pathSegment{kind: pathSegmentKey, key: key}) + return Path{segments: segments, invalid: p.invalid} +} + +// Index returns a new path with index appended as a sequence-index segment. +// A negative index produces an invalid path that configuration operations reject. +func (p Path) Index(index int) Path { + segments := clonePathSegments(p.segments, 1) + segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index}) + return Path{segments: segments, invalid: p.invalid || index < 0} +} + +// String returns the path's canonical JSON Pointer spelling. +func (p Path) String() string { + if len(p.segments) == 0 { + return "" + } + + var b strings.Builder + for _, segment := range p.segments { + b.WriteByte('/') + if segment.kind == pathSegmentIndex { + b.WriteString(strconv.Itoa(segment.index)) + continue + } + b.WriteString(escapePathToken(segment.key)) + } + return b.String() +} + +func (p Path) clone() Path { + return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid} +} + +func (p Path) valid() bool { + return !p.invalid +} + +func clonePathSegments(segments []pathSegment, extra int) []pathSegment { + if len(segments) == 0 && extra == 0 { + return nil + } + cloned := make([]pathSegment, len(segments), len(segments)+extra) + copy(cloned, segments) + return cloned +} + +func escapePathToken(token string) string { + token = strings.ReplaceAll(token, "~", "~0") + return strings.ReplaceAll(token, "/", "~1") +} + +func unescapePathToken(token string) (string, bool) { + if !strings.Contains(token, "~") { + return token, true + } + + var b strings.Builder + for i := 0; i < len(token); i++ { + if token[i] != '~' { + b.WriteByte(token[i]) + continue + } + if i+1 >= len(token) { + return "", false + } + i++ + switch token[i] { + case '0': + b.WriteByte('~') + case '1': + b.WriteByte('/') + default: + return "", false + } + } + return b.String(), true +} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..c0efb6e --- /dev/null +++ b/path_test.go @@ -0,0 +1,88 @@ +package configkit + +import "testing" + +func TestPathRoundTripsSemanticKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pointer string + }{ + {name: "root", pointer: ""}, + {name: "slash and tilde", pointer: "/a~1b/c~0d"}, + {name: "empty key", pointer: "/"}, + {name: "dotted key", pointer: "/outputs/a.b"}, + {name: "numeric key", pointer: "/outputs/0"}, + {name: "dot keys", pointer: "/./.."}, + {name: "percent text", pointer: "/%2F/%7E"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, err := ParsePath(test.pointer) + if err != nil { + t.Fatalf("ParsePath() error = %v", err) + } + if got := parsed.String(); got != test.pointer { + t.Fatalf("String() = %q, want %q", got, test.pointer) + } + }) + } + + if got := Keys("a/b", "c~d", "", "..").String(); got != "/a~1b/c~0d//.." { + t.Fatalf("Keys().String() = %q", got) + } + if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" { + t.Fatalf("indexed path = %q", got) + } +} + +func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) { + t.Parallel() + + key := Keys("items", "0") + index := Keys("items").Index(0) + parsed, err := ParsePath("/items/0") + if err != nil { + t.Fatal(err) + } + if key.String() != index.String() || index.String() != parsed.String() { + t.Fatal("equivalent textual paths should share a JSON Pointer spelling") + } + if key.segments[1].kind != pathSegmentKey { + t.Fatal("Keys numeric segment lost mapping-key intent") + } + if index.segments[1].kind != pathSegmentIndex { + t.Fatal("Index segment lost sequence intent") + } + if parsed.segments[1].kind != pathSegmentParsed { + t.Fatal("parsed numeric segment was resolved before traversal") + } + + base := Keys("parent") + child := base.Key("child") + child.segments[0].key = "changed" + if got := base.String(); got != "/parent" { + t.Fatalf("builder mutated parent: %q", got) + } + + negative := Path{}.Index(-1) + if negative.valid() { + t.Fatal("negative index must produce an invalid path") + } +} + +func TestParsePathRejectsMalformedPointers(t *testing.T) { + t.Parallel() + + for _, pointer := range []string{"relative", "~0", "/bad~", "/bad~2", "/bad~01~"} { + if _, err := ParsePath(pointer); err == nil { + t.Errorf("ParsePath(%q) succeeded", pointer) + } + } + + if parsed, err := ParsePath("/missing"); err != nil || !parsed.valid() { + t.Fatalf("valid missing path rejected: path=%v error=%v", parsed, err) + } +} diff --git a/records.go b/records.go new file mode 100644 index 0000000..26fc16a --- /dev/null +++ b/records.go @@ -0,0 +1,167 @@ +package configkit + +// Layer identifies the precedence layer responsible for a configuration value. +type Layer string + +const ( + LayerDefault Layer = "default" + LayerBase Layer = "base" + LayerProfile Layer = "profile" + LayerCLI Layer = "cli" + LayerResolution Layer = "resolution" +) + +// ChangeKind identifies an application resolution operation. The zero value is +// used for input and default origins. +type ChangeKind string + +const ( + ChangeDerivedDefault ChangeKind = "derived_default" + ChangeNormalization ChangeKind = "normalization" +) + +// Severity classifies a diagnostic. +type Severity string + +const ( + SeverityError Severity = "error" + SeverityWarning Severity = "warning" +) + +// Code is a stable, machine-readable diagnostic classification. +type Code string + +// Location identifies a position in a configuration source. Zero line and column +// values mean that coordinates are unavailable or synthetic. +type Location struct { + SourceID string + Line int + Column int +} + +// SourceInfo describes a source discovered during a load. Path is a canonical +// local path when one exists. ImportedFrom identifies the declaration that +// included this source. +type SourceInfo struct { + ID string + Label string + Path string + Layer Layer + ImportedFrom *Location +} + +// Diagnostic is a safe structured problem report. Message and source labels must +// never contain configuration values or other secrets. +type Diagnostic struct { + Code Code + Severity Severity + Path Path + Message string + Primary *Location + Related []Location +} + +// Origin describes where an effective definition came from. Multiple locations +// represent a composite origin. +type Origin struct { + Layer Layer + Locations []Location + Operation ChangeKind +} + +// FieldInfo describes an effective configuration path. +type FieldInfo struct { + Explicit bool + Null bool + Origin Origin +} + +// Change describes a definition or application-resolution operation. Historical +// values are intentionally not exposed by this record. +type Change struct { + Path Path + Origin Origin + Previous []Origin + Reasons []string + Dependencies []Path +} + +func cloneLocation(location *Location) *Location { + if location == nil { + return nil + } + cloned := *location + return &cloned +} + +func cloneLocations(locations []Location) []Location { + if locations == nil { + return nil + } + return append([]Location(nil), locations...) +} + +func cloneSourceInfo(source SourceInfo) SourceInfo { + source.ImportedFrom = cloneLocation(source.ImportedFrom) + return source +} + +func cloneSourceInfos(sources []SourceInfo) []SourceInfo { + if sources == nil { + return nil + } + cloned := make([]SourceInfo, len(sources)) + for i, source := range sources { + cloned[i] = cloneSourceInfo(source) + } + return cloned +} + +func cloneOrigin(origin Origin) Origin { + origin.Locations = cloneLocations(origin.Locations) + return origin +} + +func cloneFieldInfo(info FieldInfo) FieldInfo { + info.Origin = cloneOrigin(info.Origin) + return info +} + +func cloneChange(change Change) Change { + change.Path = change.Path.clone() + change.Origin = cloneOrigin(change.Origin) + if change.Previous != nil { + previous := make([]Origin, len(change.Previous)) + for i, origin := range change.Previous { + previous[i] = cloneOrigin(origin) + } + change.Previous = previous + } + change.Reasons = append([]string(nil), change.Reasons...) + if change.Dependencies != nil { + dependencies := make([]Path, len(change.Dependencies)) + for i, path := range change.Dependencies { + dependencies[i] = path.clone() + } + change.Dependencies = dependencies + } + return change +} + +func cloneDiagnostic(diagnostic Diagnostic) Diagnostic { + diagnostic.Path = diagnostic.Path.clone() + diagnostic.Primary = cloneLocation(diagnostic.Primary) + diagnostic.Related = cloneLocations(diagnostic.Related) + return diagnostic +} + +func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic { + if diagnostics == nil { + return nil + } + cloned := make([]Diagnostic, len(diagnostics)) + for i, diagnostic := range diagnostics { + cloned[i] = cloneDiagnostic(diagnostic) + } + return cloned +}