internal/config/loader/yaml.go

// Package loader reads YAML into a config.Config.
//
// See mercemay.top/src/portr/ for context.
package loader

import (
	"fmt"
	"io"
	"os"

	"gopkg.in/yaml.v3"

	"github.com/mercemay/portr/internal/config"
)

// LoadFile reads path and decodes it on top of config.Defaults().
func LoadFile(path string) (config.Config, error) {
	f, err := os.Open(path)
	if err != nil {
		return config.Config{}, fmt.Errorf("loader: open %s: %w", path, err)
	}
	defer f.Close()
	return Load(f)
}

// Load decodes from any reader.
func Load(r io.Reader) (config.Config, error) {
	b, err := io.ReadAll(r)
	if err != nil {
		return config.Config{}, fmt.Errorf("loader: read: %w", err)
	}
	c := config.Defaults()
	dec := yaml.NewDecoder(bytesReader(b))
	dec.KnownFields(true)
	if err := dec.Decode(&c); err != nil && err != io.EOF {
		return config.Config{}, fmt.Errorf("loader: decode: %w", err)
	}
	if err := c.Validate(); err != nil {
		return config.Config{}, err
	}
	return c, nil
}

// bytesReader is a tiny helper so tests can feed strings without
// pulling in bytes.NewReader here (keeps imports minimal).
func bytesReader(b []byte) io.Reader {
	return &sliceReader{b: b}
}

type sliceReader struct {
	b []byte
	i int
}

func (s *sliceReader) Read(p []byte) (int, error) {
	if s.i >= len(s.b) {
		return 0, io.EOF
	}
	n := copy(p, s.b[s.i:])
	s.i += n
	return n, nil
}