Answer architecture round 3: New loads only sources without an index and what they read, an entry drops reads, ErrLoad sits beside New, the manifest decoder moves to manifest.go and names each mistake once as jsonvalue.Kind words it
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package datafiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larvit/fejkdata/internal/grammar"
|
||||
"github.com/larvit/fejkdata/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// ManifestFile is the file at a source's root that describes the source.
|
||||
const ManifestFile = ".fejkdata.json"
|
||||
|
||||
// Manifest is what a source's manifest says of it.
|
||||
type Manifest struct {
|
||||
// Index is every category of the source by dot path; nil where the manifest has none.
|
||||
Index map[string]IndexEntry `json:"index"`
|
||||
}
|
||||
|
||||
// IndexEntry is what an index says of a category: its parent table, "" for none, and the
|
||||
// paths List advertises below it.
|
||||
type IndexEntry struct {
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
// Manifest reads the manifest at the source's root, the zero Manifest where there is none.
|
||||
func (s Source) Manifest() (Manifest, error) {
|
||||
if err := s.check(); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
b, err := fs.ReadFile(s.fsys, path.Join(s.base, ManifestFile))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Manifest{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
m, err := decodeManifest(b)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("%s: %w", s.ManifestPath(), err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ManifestPath is the manifest's path as errors name it.
|
||||
func (s Source) ManifestPath() string { return s.labelled(path.Join(s.base, ManifestFile)) }
|
||||
|
||||
// decodeManifest decodes a manifest, refusing any key or shape it does not define, and
|
||||
// reports every mistake: first each entry's own, in path order, then each parent naming
|
||||
// no entry.
|
||||
func decodeManifest(b []byte) (Manifest, error) {
|
||||
raw, err := jsonvalue.Decode(b)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
top, isObject := raw.Any().(map[string]any)
|
||||
if !isObject {
|
||||
return Manifest{}, fmt.Errorf("a manifest must be an object, not %s", jsonvalue.Kind(raw.Any()))
|
||||
}
|
||||
var m Manifest
|
||||
var errs []error
|
||||
for _, k := range sortedKeys(top) {
|
||||
if k != "index" {
|
||||
errs = append(errs, fmt.Errorf("unknown key %q; a manifest holds index", k))
|
||||
continue
|
||||
}
|
||||
var indexErrs []error
|
||||
m.Index, indexErrs = decodeIndex(top[k])
|
||||
errs = append(errs, indexErrs...)
|
||||
}
|
||||
return m, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func decodeIndex(v any) (map[string]IndexEntry, []error) {
|
||||
entries, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
return nil, []error{fmt.Errorf("index must be an object, not %s", jsonvalue.Kind(v))}
|
||||
}
|
||||
index := map[string]IndexEntry{}
|
||||
var errs []error
|
||||
for _, p := range sortedKeys(entries) {
|
||||
e, err := decodeEntry(p, entries[p])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
index[p] = e
|
||||
}
|
||||
for _, p := range sortedKeys(index) {
|
||||
if err := checkParent(index, p); err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
}
|
||||
return index, errs
|
||||
}
|
||||
|
||||
// decodeEntry decodes one entry, leaving out a parent it refuses, so checkParent names no
|
||||
// mistake twice.
|
||||
func decodeEntry(p string, v any) (IndexEntry, error) {
|
||||
var e IndexEntry
|
||||
errs := []error{checkCategoryPath(p)}
|
||||
fields, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
errs = append(errs, fmt.Errorf("an entry must be an object, not %s", jsonvalue.Kind(v)))
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
if _, has := fields["paths"]; !has {
|
||||
errs = append(errs, fmt.Errorf("paths is missing"))
|
||||
}
|
||||
for _, k := range sortedKeys(fields) {
|
||||
switch k {
|
||||
case "parent":
|
||||
parent, err := decodeParent(fields[k])
|
||||
e.Parent = parent
|
||||
errs = append(errs, err)
|
||||
case "paths":
|
||||
var err error
|
||||
e.Paths, err = decodePaths(fields[k])
|
||||
errs = append(errs, err)
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("unknown key %q; an entry holds parent and paths", k))
|
||||
}
|
||||
}
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func decodeParent(v any) (string, error) {
|
||||
parent, isString := v.(string)
|
||||
if !isString {
|
||||
return "", fmt.Errorf("parent must be a string, not %s", jsonvalue.Kind(v))
|
||||
}
|
||||
if err := grammar.CheckIdentifier(parent); err != nil {
|
||||
return "", fmt.Errorf("parent %w", err)
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// decodePaths decodes a list of paths below a category, "" being the category itself.
|
||||
func decodePaths(v any) ([]string, error) {
|
||||
items, isList := v.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("paths must be a list, not %s", jsonvalue.Kind(v))
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
var errs []error
|
||||
for i, item := range items {
|
||||
q, isString := item.(string)
|
||||
if !isString {
|
||||
errs = append(errs, fmt.Errorf("paths item %d must be a string, not %s", i+1, jsonvalue.Kind(item)))
|
||||
continue
|
||||
}
|
||||
if q != "" {
|
||||
if err := checkCategoryPath(q); err != nil {
|
||||
errs = append(errs, fmt.Errorf("paths item %d: %w", i+1, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// checkParent refuses a parent naming no entry beside p: a table loads with its parent,
|
||||
// from one source.
|
||||
func checkParent(index map[string]IndexEntry, p string) error {
|
||||
parent := index[p].Parent
|
||||
if parent == "" {
|
||||
return nil
|
||||
}
|
||||
sibling := parent
|
||||
if dot := strings.LastIndexByte(p, '.'); dot >= 0 {
|
||||
sibling = p[:dot+1] + parent
|
||||
}
|
||||
if _, ok := index[sibling]; !ok {
|
||||
return fmt.Errorf("parent %q names no entry beside it in this index", parent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCategoryPath(p string) error {
|
||||
for _, seg := range strings.Split(p, ".") {
|
||||
if err := grammar.CheckIdentifier(seg); err != nil {
|
||||
return fmt.Errorf("%q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user