package configkit import ( "reflect" "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) // Definition contains reusable application rules for a configuration type. // Definitions should be treated as read-only after being shared. type Definition[T any] struct { Defaults func() T Resolve func(*T, *Resolution) error Validate func(*T, *Validation) Version *VersionRule Secret func(Path) bool } // Options contains the application definition and per-load input selections. // The current single-source boundary rejects features scheduled for later work. type Options[T any] struct { Definition Definition[T] Imports bool Profile string Filesystem *FilesystemContext Overrides []Override } // FilesystemContext supplies filesystem roots for imports and profiles. type FilesystemContext struct { BaseDir string Root string } // Override describes one explicitly supplied application override. type Override struct { Path Path Value any Flag string } // Resolution exposes surviving-input presence and records audited application // resolution changes. A resolver must not retain or concurrently use the context. type Resolution struct { state *resolutionState } // VersionRule declares the input path and application checker for a schema version. type VersionRule struct { Path Path Check func(Version, *Validation) error } // Version preserves the absent, null, and string states of a schema-version input. type Version struct { Present bool Null bool Text string } // Result is a successful independently owned configuration load. type Result[T any] struct { Config T metadata Metadata sources []SourceInfo diagnostics []Diagnostic final *document.Node resolution []resolutionSnapshot } // Metadata returns detached effective presence, origin, and history records. func (r *Result[T]) Metadata() Metadata { if r == nil { return Metadata{} } return cloneMetadata(r.metadata) } // Sources returns a detached list of sources used by the load. func (r *Result[T]) Sources() []SourceInfo { if r == nil { return nil } return cloneSourceInfos(r.sources) } // Diagnostics returns detached nonfatal diagnostics from the load. func (r *Result[T]) Diagnostics() []Diagnostic { if r == nil { return nil } return cloneDiagnostics(r.diagnostics) } // Load parses and strictly decodes zero or one source. It accepts at most one // Options value and returns no result when any error is reported. func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) { accumulator := diagnosticAccumulator{} options, diagnostics := selectOptions(source, supplied) if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) if parsed.source != nil { accumulator.sources = append(accumulator.sources, *parsed.source) } if len(diagnostics) != 0 { accumulator.add(diagnostics...) return nil, accumulator.loadError() } if parsed.document != nil { directiveDiagnostics := disabledDirectiveDiagnostics(parsed.document, Path{}) if len(directiveDiagnostics) != 0 { accumulator.add(directiveDiagnostics...) return nil, accumulator.loadError() } } schema, schemaIssue := buildSchema(reflect.TypeFor[T]()) if schemaIssue != nil { accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) return nil, accumulator.loadError() } versionDiagnostics := checkVersion(options.Definition.Version, parsed.document) accumulator.add(versionDiagnostics...) if hasErrors(versionDiagnostics) { return nil, accumulator.loadError() } defaults := reflect.Zero(reflect.TypeFor[T]()) if options.Definition.Defaults != nil { defaults = reflect.ValueOf(options.Definition.Defaults()) } decoded, decodeIssues := decodeConfiguration(schema, defaults, parsed.document) if len(decodeIssues) != 0 { for _, issue := range decodeIssues { accumulator.add(diagnosticFromDecodeIssue(issue)) } return nil, accumulator.loadError() } configuration := decoded.value.Interface().(T) metadata := metadataFromDecoded(decoded) final := decoded.final var resolutionSnapshots []resolutionSnapshot if options.Definition.Resolve != nil { resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, parsed.document) accumulator.add(resolved.diagnostics...) if hasErrors(resolved.diagnostics) { return nil, accumulator.loadError() } final = resolved.final resolutionSnapshots = resolved.snapshots applyResolutionMetadata(&metadata, resolved) } return &Result[T]{ Config: configuration, metadata: metadata, sources: cloneSourceInfos(accumulator.sources), diagnostics: canonicalDiagnostics(accumulator.diagnostics), final: document.Clone(final), resolution: cloneResolutionSnapshots(resolutionSnapshots), }, nil } func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) { if len(supplied) > 1 { return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")} } var options Options[T] if len(supplied) == 1 { options = supplied[0] } var diagnostics []Diagnostic definition := options.Definition if definition.Validate != nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application validation is not available in this release boundary")) } if definition.Secret != nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary")) } if options.Imports { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration imports are not available in this release boundary")) } if options.Profile != "" && options.Profile != "default" { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration profiles are not available in this release boundary")) } if options.Filesystem != nil { code := CodeOptionsUnavailable message := "filesystem context is not available in this release boundary" if source.kind == sourceNone { code = CodeOptionsInvalid message = "NoSource cannot use filesystem context" } diagnostics = append(diagnostics, optionDiagnostic(code, message)) } if len(options.Overrides) != 0 { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "configuration overrides are not available in this release boundary")) } if source.kind == sourceNone && options.Imports { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports")) } if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile")) } if definition.Version != nil { if !definition.Version.Path.valid() { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid")) } if definition.Version.Check == nil { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker")) } } return options, canonicalDiagnostics(diagnostics) } func optionDiagnostic(code Code, message string) Diagnostic { return Diagnostic{Code: code, Severity: SeverityError, Message: message} } func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic { var diagnostics []Diagnostic if node.Kind == document.Mapping { for _, entry := range node.Mapping { childPath := path.Key(entry.Key) if entry.Key == "_configkit" { location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column} diagnostics = append(diagnostics, Diagnostic{ Code: CodeImportDisabled, Severity: SeverityError, Path: childPath, Message: "configuration directives require imports to be enabled", Primary: &location, }) } diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...) } } else if node.Kind == document.Sequence { for index, child := range node.Sequence { diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...) } } return diagnostics } func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic { if rule == nil { return nil } version := Version{} node, present := documentAtPath(root, rule.Path) if present { version.Present = true switch node.Kind { case document.Null: version.Null = true case document.String: version.Text = node.Text default: location, _ := nearestDocumentLocation(root, rule.Path) primary := documentLocationPointer(location) return []Diagnostic{{ Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(), Message: "schema version must be a string or null", Primary: primary, }} } } state := &validationState{ active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, document: root, } validation := &Validation{state: state} err := rule.Check(version, validation) state.active = false if err != nil && !hasErrors(state.diagnostics) { location, _ := nearestDocumentLocation(root, rule.Path) state.diagnostics = append(state.diagnostics, Diagnostic{ Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(), Message: "schema-version check failed", Primary: documentLocationPointer(location), }) } return canonicalDiagnostics(state.diagnostics) } func documentAtPath(root *document.Node, path Path) (*document.Node, bool) { if root == nil || !path.valid() { return nil, false } current := root for _, segment := range path.segments { switch current.Kind { case document.Mapping: if segment.kind == pathSegmentIndex { return nil, false } found := false for _, entry := range current.Mapping { if entry.Key == segment.key { current = entry.Value found = true break } } if !found { return nil, false } case document.Sequence: index, ok := schemaIndex(segment) if !ok || index >= len(current.Sequence) { return nil, false } current = current.Sequence[index] default: return nil, false } } return current, true } func documentLocationPointer(location document.Location) *Location { if !locationKnown(location) { return nil } return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column} } func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic { return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message} } func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic { return Diagnostic{ Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message, Primary: documentLocationPointer(issue.location), } } func hasErrors(diagnostics []Diagnostic) bool { for _, diagnostic := range diagnostics { if diagnostic.Severity == SeverityError { return true } } return false }