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:
@@ -2,13 +2,10 @@
|
||||
package datafiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larvit/fejkdata/internal/grammar"
|
||||
@@ -59,180 +56,6 @@ func (s Source) dirPath(dir []string) string {
|
||||
return "."
|
||||
}
|
||||
|
||||
// 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, the paths
|
||||
// List advertises below it, and the categories its templates reference.
|
||||
type IndexEntry struct {
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
}
|
||||
|
||||
// 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 in the order of the entries naming them.
|
||||
func decodeManifest(b []byte) (Manifest, error) {
|
||||
var raw any
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
top, isObject := raw.(map[string]any)
|
||||
if !isObject {
|
||||
return Manifest{}, fmt.Errorf("a manifest is a JSON object")
|
||||
}
|
||||
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
|
||||
}
|
||||
index, isObject := top[k].(map[string]any)
|
||||
if !isObject {
|
||||
errs = append(errs, fmt.Errorf("index is a JSON object of category paths"))
|
||||
continue
|
||||
}
|
||||
m.Index = map[string]IndexEntry{}
|
||||
for _, p := range sortedKeys(index) {
|
||||
e, err := decodeEntry(p, index[p])
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
m.Index[p] = e
|
||||
}
|
||||
}
|
||||
for _, p := range sortedKeys(m.Index) {
|
||||
if err := checkParent(m.Index, p); err != nil {
|
||||
errs = append(errs, fmt.Errorf("index entry %q: %w", p, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return Manifest{}, errors.Join(errs...)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func decodeEntry(p string, v any) (IndexEntry, error) {
|
||||
var e IndexEntry
|
||||
errs := []error{checkCategoryPath(p)}
|
||||
fields, isObject := v.(map[string]any)
|
||||
if !isObject {
|
||||
return e, fmt.Errorf("an entry is a JSON object of parent, paths and reads")
|
||||
}
|
||||
if _, has := fields["paths"]; !has {
|
||||
errs = append(errs, fmt.Errorf("paths is missing"))
|
||||
}
|
||||
var err error
|
||||
for _, k := range sortedKeys(fields) {
|
||||
switch k {
|
||||
case "parent":
|
||||
var isString bool
|
||||
if e.Parent, isString = fields[k].(string); !isString {
|
||||
err = fmt.Errorf("parent is a string")
|
||||
} else if err = grammar.CheckIdentifier(e.Parent); err != nil {
|
||||
err = fmt.Errorf("parent %w", err)
|
||||
}
|
||||
case "paths":
|
||||
e.Paths, err = stringList(k, fields[k], func(q string) error {
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
return checkCategoryPath(q)
|
||||
})
|
||||
case "reads":
|
||||
e.Reads, err = stringList(k, fields[k], checkCategoryPath)
|
||||
default:
|
||||
err = fmt.Errorf("unknown key %q; an entry holds parent, paths and reads", k)
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return e, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// stringList is the JSON array of strings v, each passing check.
|
||||
func stringList(key string, v any, check func(string) error) ([]string, error) {
|
||||
items, isArray := v.([]any)
|
||||
if !isArray {
|
||||
return nil, fmt.Errorf("%s is an array of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
s, isString := item.(string)
|
||||
if !isString {
|
||||
return nil, fmt.Errorf("%s is an array of strings", key)
|
||||
}
|
||||
if err := check(s); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Walk hands compile every category of the tree, folders and files in name order. A
|
||||
// folder holding no category anywhere below it is skipped; a hidden file or folder is no
|
||||
// category, though a category may name a hidden rows file beside it.
|
||||
|
||||
Reference in New Issue
Block a user