Add ConfigKit validation and diagnostics

This commit is contained in:
2026-10-07 13:50:48 +00:00
parent cc9b0eddab
commit 3945f1c33a
9 changed files with 490 additions and 14 deletions

View File

@@ -5,8 +5,9 @@ and shared command infrastructure across related applications.
The current Go library provides an instance-owned command registry, typed flag
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
and application or CommandKit version reporting. Standard configuration and
PromptKit command families remain planned.
application or CommandKit version reporting, configuration scope discovery, and
ConfigKit-backed validation. Effective display, source and explanation reports,
and PromptKit commands remain planned.
## Documentation

View File

@@ -2,9 +2,11 @@ package commandkit
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
@@ -129,11 +131,22 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
return fmt.Errorf("command path %q is already registered", "config")
}
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil)
commands := make([]Command, 0, 2)
installed := make([]ConfigOperation, 0, 1)
if configOperationDeclared(normalized.Scopes, ConfigValidate) {
validate, err := newConfigValidateCommand(normalized)
if err != nil {
return err
}
commands = append(commands, validate)
installed = append(installed, ConfigValidate)
}
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed)
if err != nil {
return err
}
root, err := Group("config", "Inspect configuration.", command)
commands = append(commands, command)
root, err := Group("config", "Inspect configuration.", commands...)
if err != nil {
return err
}
@@ -144,6 +157,80 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
return nil
}
// NewConfigKitScope adapts a ConfigKit loader into a configuration scope.
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")
}
return ConfigScope[C]{
Name: name,
Summary: summary,
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) {
result, err := load(ctx, request.Selection, contextValue)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
}
return Acquired[ConfigView]{}, err
}
if result == nil {
return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
return Acquired[ConfigView]{
Value: ConfigView{Effective: result, Sources: result, Explanation: result},
Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()),
}, nil
},
}, nil
}
// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend.
func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic {
if len(records) == 0 {
return nil
}
severity := configkit.SeverityWarning
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = configkit.SeverityError
break
}
}
return []Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}
func writeDiagnosticText(writer io.Writer, value string) error {
written, err := io.WriteString(writer, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
normalized := suite
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)

View File

@@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome)
}
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
wantText := "app: validate\npromptkit:\nmetadata:\nzeta:\n"
if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
}

242
configkit_test.go Normal file
View File

@@ -0,0 +1,242 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"testing/fstest"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) {
type configuration struct {
Mode string `yaml:"mode"`
Defaulted string `yaml:"defaulted"`
}
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")},
"config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")},
})
definition := configkit.Definition[configuration]{
Defaults: func() configuration { return configuration{Defaulted: "from default"} },
Resolve: func(input *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use")
input.Mode = strings.ToUpper(input.Mode)
return nil
},
}
var received commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
received = selection
return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{
Definition: definition,
Overlay: selection.Overlay.Value,
OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
})
})
if err != nil {
t.Fatal(err)
}
selection := commandkit.Selection{
Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "dev", Set: true},
}
acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, selection) {
t.Fatalf("loader selection = %#v, want %#v", received, selection)
}
if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil {
t.Fatalf("ConfigKit result facets = %#v", acquired.Value)
}
if got := acquired.Value.Sources.Sources(); len(got) < 2 {
t.Fatalf("sources = %#v, want base and overlay", got)
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") {
t.Fatalf("effective configuration = %q", effective.String())
}
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 {
t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok)
}
}
func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
}
var seen []commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
seen = append(seen, selection)
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}})
})
if err != nil {
t.Fatal(err)
}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
if err != nil {
t.Fatal(err)
}
empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{})
if err != nil {
t.Fatal(err)
}
want := []commandkit.Selection{{}, empty}
if !reflect.DeepEqual(seen, want) {
t.Fatalf("loader selections = %#v, want %#v", seen, want)
}
}
func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) {
if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil {
t.Fatal("accepted a nil ConfigKit loader")
}
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil result error = %v", err)
}
}
func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
var loadError error
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
_, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}})
return nil, loadError
})
if err != nil {
t.Fatal(err)
}
acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
var publicLoadError *configkit.LoadError
if acquireError != loadError || !errors.As(acquireError, &publicLoadError) {
t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError {
t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics)
}
var rendered bytes.Buffer
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
t.Fatalf("grouped diagnostics = %q", rendered.String())
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 {
t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
}
}
func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) {
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var calls []string
newContextCalls := 0
failure := errors.New("promptkit could not load")
suite := commandkit.ConfigSuite[string]{
NewContext: func() string { newContextCalls++; return "runtime" },
Scopes: []commandkit.ConfigScope[string]{
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "zeta")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "promptkit")
return commandkit.Acquired[commandkit.ConfigView]{}, failure
}},
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "app")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
},
}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete {
t.Fatalf("validation outcome = %#v", outcome)
}
if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 {
t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls)
}
if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
}
func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Validate: func(_ *configuration, validation *configkit.Validation) {
validation.Warning(configkit.Keys("name"), "uses a demonstration name")
},
}})
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("warning validation outcome = %#v", outcome)
}
if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) {
t.Fatalf("report short write outcome = %#v", outcome)
}
var delivered bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}})
if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
t.Fatalf("diagnostic short write outcome = %#v", outcome)
}
}
type configKitPartialWriter struct{}
func (configKitPartialWriter) Write(data []byte) (int, error) {
if len(data) == 0 {
return 0, nil
}
return len(data) - 1, nil
}

View File

@@ -102,6 +102,29 @@ runtime context or acquire configuration. It reports the intersection between
declared scope capabilities and installed handlers, so an operation is listed
only when the corresponding command is available.
### Validate configuration
`config validate` loads and validates the selected scope. The default scope is
`app`; `--scope all` validates every registered scope and is available when each
scope declares validation. The command checks all selected capabilities before
calling scope providers.
Its default format is text; `--format yaml` emits a `scopes` sequence with
`name` and `valid: true` for each scope that completed successfully.
```text
example config validate
example config validate --scope promptkit --format yaml
example config validate --scope all
```
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
at the first load or output failure; records from earlier successful scopes may
already have been written. Provider warnings go to the diagnostic stream and do
not alone make validation fail. `--config` and `--overlay` preserve whether an
empty value was explicitly supplied, so a scope callback can map those inputs to
its own ConfigKit load policy. Help and invalid syntax do not call scope
providers.
## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`.

View File

@@ -4,8 +4,9 @@ CommandKit currently contains adopted repository policies, accepted architectura
decisions, implementation plans, identity and outcome primitives, typed command
declarations, an instance-owned registry, a Cobra-backed routing and parsing
adapter, public `App.Execute` coordination, and generated help/version commands.
Configuration suite registration and scope discovery are implemented; the
configuration operations remain pending. The
Configuration suite registration, scope discovery, ConfigKit integration, and
validation are implemented; other configuration and PromptKit operations remain
pending. The
[README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan
Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending.
Status: implementation underway; Stages 1-8 complete and Stages 9-14 pending.
Date: 2026-10-07.
@@ -551,7 +551,7 @@ sequenced in Stages 8-11.
## Stage 8: integrate ConfigKit and implement validation
**Status:** pending. **Prerequisite:** Stage 7.
**Status:** complete. **Prerequisite:** Stage 7.
```go
func NewConfigKitScope[C, T any](name, summary string,
@@ -594,6 +594,16 @@ empty overlay callback input; warnings permit validation success; structured loa
errors retain public identity and render once; all validates in order and stops
on failure. Report/diagnostic short writes retain their writer errors.
**Completion evidence:** added the ConfigKit scope adapter and grouped diagnostic
writer. The adapter exposes native effective/source/explanation facets, captures
success and `LoadError` diagnostics, and preserves loader errors. `config validate`
now installs for declared scopes, preflights all requested capabilities, acquires
in stable order, and emits success records incrementally in text or YAML. Checks
cover ConfigKit defaults, overlay, recorded resolution, absent/empty selections,
warning success, load-error identity and single presentation, aggregate stop
behavior, and report/diagnostic short writes. `go test ./...` passes; README,
development orientation, and CLI reference describe the implemented boundary.
## Stage 9: implement native effective YAML display
**Status:** pending. **Prerequisite:** Stage 8.

View File

@@ -64,6 +64,19 @@ func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
func parseString(value string) (string, error) { return value, nil }
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
scopes, err := selectConfigScopes(suite, input, operation)
if err != nil {
return nil, err
}
if suite.CheckInput != nil {
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
return nil, err
}
}
return scopes, nil
}
func selectConfigScopes[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
if err := checkConfigFormat(input.Format); err != nil {
return nil, err
}
@@ -114,11 +127,6 @@ func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operatio
return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope)
}
}
if suite.CheckInput != nil {
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
return nil, err
}
}
return scopes, nil
}

104
validate.go Normal file
View File

@@ -0,0 +1,104 @@
package commandkit
import (
"bytes"
"fmt"
"strings"
"go.yaml.in/yaml/v4"
)
func configOperationDeclared[C any](scopes []ConfigScope[C], operation ConfigOperation) bool {
for _, scope := range scopes {
if containsOperation(scope.Operations, operation) {
return true
}
}
return false
}
func newConfigValidateCommand[C any](suite ConfigSuite[C]) (Command, error) {
spec := CommandSpec[configInput[C]]{
Name: string(ConfigValidate),
Summary: "Validate selected configuration scopes.",
Flags: configInputFlags(suite, "text"),
CheckInput: func(input configInput[C]) error {
_, err := preflightConfig(suite, input, ConfigValidate)
return err
},
Run: func(invocation Invocation, input configInput[C]) Outcome {
return runConfigValidation(invocation, suite, input)
},
}
return NewCommand(spec)
}
func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome {
scopeNames, err := selectConfigScopes(suite, input, ConfigValidate)
if err != nil {
return Failure(err)
}
contexts := contextsForScopes(suite, input, scopeNames)
diagnostics := make([]Diagnostic, 0)
writtenRecord := false
for _, name := range scopeNames {
scope, ok := findConfigScope(suite.Scopes, name)
if !ok || scope.Acquire == nil {
return validationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, writtenRecord)
}
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigValidate, Selection: input.Selection}, contexts[name])
diagnostics = append(diagnostics, acquired.Diagnostics...)
if acquireErr != nil {
return validationFailure(acquireErr, diagnostics, writtenRecord)
}
record, err := validationSuccessRecord(name, input.Format)
if err != nil {
return validationFailure(err, diagnostics, writtenRecord)
}
if input.Format == "yaml" && !writtenRecord {
if err := writeReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
return validationFailure(err, diagnostics, true)
}
}
if err := writeReport(invocation.Streams.Out, record); err != nil {
return validationFailure(err, diagnostics, true)
}
writtenRecord = true
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func validationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
outcome := Failure(cause)
outcome.Diagnostics = diagnostics
outcome.ReportIncomplete = reportStarted
return outcome
}
func validationSuccessRecord(scope, format string) ([]byte, error) {
if format == "text" {
return []byte(scope + ": valid\n"), nil
}
encoded, err := yaml.Marshal(struct {
Name string `yaml:"name"`
Valid bool `yaml:"valid"`
}{Name: scope, Valid: true})
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSuffix(string(encoded), "\n"), "\n")
var record bytes.Buffer
for index, line := range lines {
if index == 0 {
record.WriteString(" - ")
} else {
record.WriteString(" ")
}
record.WriteString(line)
record.WriteByte('\n')
}
return record.Bytes(), nil
}