YAML Configuration Refactor
This commit is contained in:
+241
-70
@@ -1,17 +1,27 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ConfigFile = "nwn-tool.json"
|
||||
const (
|
||||
ConfigFile = "nwn-tool.yaml"
|
||||
ConfigFileYML = "nwn-tool.yml"
|
||||
LegacyConfigFile = "nwn-tool.json"
|
||||
)
|
||||
|
||||
var ConfigFileCandidates = []string{ConfigFile, ConfigFileYML, LegacyConfigFile}
|
||||
|
||||
var SourceExtensions = []string{
|
||||
".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
|
||||
@@ -23,96 +33,104 @@ var AssetExtensions = []string{
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
Root string
|
||||
Config Config
|
||||
Inventory Inventory
|
||||
Root string
|
||||
Config Config
|
||||
ConfigSource ConfigSource
|
||||
Inventory Inventory
|
||||
}
|
||||
|
||||
type ConfigSource struct {
|
||||
Path string
|
||||
Name string
|
||||
Format string
|
||||
Legacy bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Module ModuleConfig `json:"module"`
|
||||
Paths PathConfig `json:"paths"`
|
||||
HAKs []HAKConfig `json:"haks"`
|
||||
Music MusicConfig `json:"music"`
|
||||
TopData TopDataConfig `json:"topdata"`
|
||||
Extract ExtractConfig `json:"extract"`
|
||||
Autogen AutogenConfig `json:"autogen"`
|
||||
Module ModuleConfig `json:"module" yaml:"module"`
|
||||
Paths PathConfig `json:"paths" yaml:"paths"`
|
||||
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
||||
Music MusicConfig `json:"music" yaml:"music"`
|
||||
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
|
||||
Extract ExtractConfig `json:"extract" yaml:"extract"`
|
||||
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
|
||||
}
|
||||
|
||||
type ModuleConfig struct {
|
||||
Name string `json:"name"`
|
||||
ResRef string `json:"resref"`
|
||||
Description string `json:"description"`
|
||||
HAKOrder []string `json:"hak_order"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
ResRef string `json:"resref" yaml:"resref"`
|
||||
Description string `json:"description" yaml:"description"`
|
||||
HAKOrder []string `json:"hak_order" yaml:"hak_order"`
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
Source string `json:"source"`
|
||||
Assets string `json:"assets"`
|
||||
Build string `json:"build"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Assets string `json:"assets" yaml:"assets"`
|
||||
Build string `json:"build" yaml:"build"`
|
||||
}
|
||||
|
||||
type HAKConfig struct {
|
||||
Name string `json:"name"`
|
||||
Priority int `json:"priority"`
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
Split bool `json:"split"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
Include []string `json:"include"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Priority int `json:"priority" yaml:"priority"`
|
||||
MaxBytes int64 `json:"max_bytes" yaml:"max_bytes"`
|
||||
Split bool `json:"split" yaml:"split"`
|
||||
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"`
|
||||
Include []string `json:"include" yaml:"include"`
|
||||
}
|
||||
|
||||
type MusicConfig struct {
|
||||
Prefixes map[string]string `json:"prefixes"`
|
||||
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
||||
}
|
||||
|
||||
type TopDataConfig struct {
|
||||
Source string `json:"source"`
|
||||
Build string `json:"build"`
|
||||
ReferenceBuilder string `json:"reference_builder"`
|
||||
Assets string `json:"assets"`
|
||||
PackageHAK string `json:"package_hak"`
|
||||
PackageTLK string `json:"package_tlk"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Build string `json:"build" yaml:"build"`
|
||||
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
|
||||
Assets string `json:"assets" yaml:"assets"`
|
||||
PackageHAK string `json:"package_hak" yaml:"package_hak"`
|
||||
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
||||
}
|
||||
|
||||
type ExtractConfig struct {
|
||||
IgnoreExtensions []string `json:"ignore_extensions"`
|
||||
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty"`
|
||||
IgnoreExtensions []string `json:"ignore_extensions" yaml:"ignore_extensions"`
|
||||
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
|
||||
}
|
||||
|
||||
type AutogenConfig struct {
|
||||
Producers []AutogenProducerConfig `json:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers"`
|
||||
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
||||
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
||||
}
|
||||
|
||||
type AutogenProducerConfig struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"root"`
|
||||
Include []string `json:"include"`
|
||||
Derive AutogenDeriveConfig `json:"derive"`
|
||||
Manifest AutogenManifestConfig `json:"manifest"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Root string `json:"root" yaml:"root"`
|
||||
Include []string `json:"include" yaml:"include"`
|
||||
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
||||
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
||||
}
|
||||
|
||||
type AutogenConsumerConfig struct {
|
||||
ID string `json:"id"`
|
||||
Producer string `json:"producer"`
|
||||
Dataset string `json:"dataset"`
|
||||
Mode string `json:"mode"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
Root string `json:"root"`
|
||||
Include []string `json:"include"`
|
||||
Derive AutogenDeriveConfig `json:"derive"`
|
||||
Manifest AutogenManifestConfig `json:"manifest"`
|
||||
LocalOverrideRoot string `json:"local_override_root,omitempty"`
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Producer string `json:"producer" yaml:"producer"`
|
||||
Dataset string `json:"dataset" yaml:"dataset"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"`
|
||||
Root string `json:"root" yaml:"root"`
|
||||
Include []string `json:"include" yaml:"include"`
|
||||
Derive AutogenDeriveConfig `json:"derive" yaml:"derive"`
|
||||
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
||||
LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
|
||||
}
|
||||
|
||||
type AutogenDeriveConfig struct {
|
||||
Kind string `json:"kind"`
|
||||
GroupFrom string `json:"group_from,omitempty"`
|
||||
Kind string `json:"kind" yaml:"kind"`
|
||||
GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
|
||||
}
|
||||
|
||||
type AutogenManifestConfig struct {
|
||||
ReleaseTag string `json:"release_tag"`
|
||||
AssetName string `json:"asset_name"`
|
||||
CacheName string `json:"cache_name"`
|
||||
ReleaseTag string `json:"release_tag" yaml:"release_tag"`
|
||||
AssetName string `json:"asset_name" yaml:"asset_name"`
|
||||
CacheName string `json:"cache_name" yaml:"cache_name"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
@@ -132,35 +150,82 @@ type InventoryReport struct {
|
||||
func FindRoot(start string) (string, error) {
|
||||
current := start
|
||||
for {
|
||||
candidate := filepath.Join(current, ConfigFile)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return current, nil
|
||||
for _, name := range ConfigFileCandidates {
|
||||
candidate := filepath.Join(current, name)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", fmt.Errorf("could not find %s from %s", ConfigFile, start)
|
||||
return "", fmt.Errorf("could not find %s, %s, or legacy %s from %s", ConfigFile, ConfigFileYML, LegacyConfigFile, start)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func Load(root string) (*Project, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(root, ConfigFile))
|
||||
source, err := findConfigSource(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", ConfigFile, err)
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(source.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", source.Name, err)
|
||||
}
|
||||
|
||||
cfg := defaultConfig()
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", ConfigFile, err)
|
||||
if source.Format == "yaml" {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", source.Name, err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return nil, fmt.Errorf("parse %s: multiple YAML documents are not supported", source.Name)
|
||||
}
|
||||
} else {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse legacy %s: %w", source.Name, err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return nil, fmt.Errorf("parse legacy %s: multiple JSON documents are not supported", source.Name)
|
||||
}
|
||||
}
|
||||
normalizeConfig(&cfg)
|
||||
|
||||
return &Project{
|
||||
Root: root,
|
||||
Config: cfg,
|
||||
Root: root,
|
||||
Config: cfg,
|
||||
ConfigSource: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func findConfigSource(root string) (ConfigSource, error) {
|
||||
for _, name := range ConfigFileCandidates {
|
||||
path := filepath.Join(root, name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
format := "yaml"
|
||||
legacy := false
|
||||
if name == LegacyConfigFile {
|
||||
format = "json"
|
||||
legacy = true
|
||||
}
|
||||
return ConfigSource{
|
||||
Path: path,
|
||||
Name: name,
|
||||
Format: format,
|
||||
Legacy: legacy,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return ConfigSource{}, fmt.Errorf("could not find %s, %s, or legacy %s in %s", ConfigFile, ConfigFileYML, LegacyConfigFile, root)
|
||||
}
|
||||
|
||||
func (p *Project) ValidateLayout() error {
|
||||
var failures []error
|
||||
|
||||
@@ -173,6 +238,22 @@ func (p *Project) ValidateLayout() error {
|
||||
if len(p.Config.Module.ResRef) > 16 {
|
||||
failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
|
||||
}
|
||||
if strings.TrimSpace(p.Config.Paths.Source) == "" && strings.TrimSpace(p.Config.Paths.Assets) == "" && !p.HasTopData() {
|
||||
failures = append(failures, errors.New("at least one of paths.source, paths.assets, or topdata.source is required"))
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"paths.source": p.Config.Paths.Source,
|
||||
"paths.assets": p.Config.Paths.Assets,
|
||||
"paths.build": p.Config.Paths.Build,
|
||||
"topdata.source": p.Config.TopData.Source,
|
||||
"topdata.build": p.Config.TopData.Build,
|
||||
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
|
||||
"topdata.assets": p.Config.TopData.Assets,
|
||||
} {
|
||||
if strings.Contains(value, "\x00") {
|
||||
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
||||
}
|
||||
}
|
||||
if p.HasTopData() {
|
||||
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
|
||||
failures = append(failures, err)
|
||||
@@ -185,10 +266,22 @@ func (p *Project) ValidateLayout() error {
|
||||
}
|
||||
}
|
||||
failures = append(failures, validateAutogenConfig(p.Config.Autogen)...)
|
||||
failures = append(failures, validateMusicConfig(p.Config.Music)...)
|
||||
|
||||
hakNames := map[string]struct{}{}
|
||||
for index, hak := range p.Config.HAKs {
|
||||
name := strings.TrimSpace(hak.Name)
|
||||
if strings.TrimSpace(hak.Name) == "" {
|
||||
failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
|
||||
} else {
|
||||
normalizedName := strings.ToLower(name)
|
||||
if _, exists := hakNames[normalizedName]; exists {
|
||||
failures = append(failures, fmt.Errorf("haks[%d].name %q is duplicated", index, name))
|
||||
}
|
||||
hakNames[normalizedName] = struct{}{}
|
||||
if err := validateOutputFileName(fmt.Sprintf("haks[%d].name", index), name+".hak", ".hak"); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
}
|
||||
if hak.Priority <= 0 {
|
||||
failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
|
||||
@@ -199,6 +292,7 @@ func (p *Project) ValidateLayout() error {
|
||||
if len(hak.Include) == 0 {
|
||||
failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
|
||||
}
|
||||
failures = append(failures, validateGlobList(fmt.Sprintf("haks[%d].include", index), hak.Include)...)
|
||||
}
|
||||
|
||||
for label, pathFn := range map[string]func() string{
|
||||
@@ -483,18 +577,38 @@ func defaultConfig() Config {
|
||||
Paths: PathConfig{
|
||||
Build: "build",
|
||||
},
|
||||
TopData: TopDataConfig{
|
||||
Build: ".cache",
|
||||
PackageHAK: "sow_top.hak",
|
||||
PackageTLK: "sow_tlk.tlk",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg *Config) {
|
||||
for i := range cfg.HAKs {
|
||||
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
|
||||
}
|
||||
cfg.Extract.IgnoreExtensions = normalizeStringSlice(cfg.Extract.IgnoreExtensions)
|
||||
for i := range cfg.Autogen.Producers {
|
||||
cfg.Autogen.Producers[i].Include = normalizeStringSlice(cfg.Autogen.Producers[i].Include)
|
||||
}
|
||||
for i := range cfg.Autogen.Consumers {
|
||||
cfg.Autogen.Consumers[i].Include = normalizeStringSlice(cfg.Autogen.Consumers[i].Include)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStringSlice(input []string) []string {
|
||||
if len(input) == 0 {
|
||||
return input
|
||||
}
|
||||
out := slices.Clone(input)
|
||||
slices.SortFunc(out, func(a, b string) int {
|
||||
return strings.Compare(strings.TrimSpace(a), strings.TrimSpace(b))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||
var failures []error
|
||||
producerIDs := map[string]struct{}{}
|
||||
consumerIDs := map[string]struct{}{}
|
||||
datasetIDs := map[string]struct{}{}
|
||||
|
||||
for index, producer := range cfg.Producers {
|
||||
fieldPrefix := fmt.Sprintf("autogen.producers[%d]", index)
|
||||
@@ -513,6 +627,7 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||
if len(producer.Include) == 0 {
|
||||
failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
|
||||
}
|
||||
failures = append(failures, validateGlobList(fieldPrefix+".include", producer.Include)...)
|
||||
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", producer.Derive)...)
|
||||
failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", producer.Manifest)...)
|
||||
}
|
||||
@@ -533,6 +648,17 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||
}
|
||||
if strings.TrimSpace(consumer.Dataset) == "" {
|
||||
failures = append(failures, fmt.Errorf("%s.dataset is required", fieldPrefix))
|
||||
} else {
|
||||
dataset := strings.TrimSpace(consumer.Dataset)
|
||||
if _, exists := datasetIDs[dataset]; exists {
|
||||
failures = append(failures, fmt.Errorf("%s.dataset %q is duplicated", fieldPrefix, dataset))
|
||||
}
|
||||
datasetIDs[dataset] = struct{}{}
|
||||
}
|
||||
if strings.TrimSpace(consumer.Producer) != "" {
|
||||
if _, exists := producerIDs[strings.TrimSpace(consumer.Producer)]; len(producerIDs) > 0 && !exists {
|
||||
failures = append(failures, fmt.Errorf("%s.producer %q does not match a configured producer", fieldPrefix, consumer.Producer))
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(consumer.Mode) {
|
||||
case "parts_rows", "head_visualeffects":
|
||||
@@ -547,6 +673,7 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||
if len(consumer.Include) == 0 {
|
||||
failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
|
||||
}
|
||||
failures = append(failures, validateGlobList(fieldPrefix+".include", consumer.Include)...)
|
||||
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", consumer.Derive)...)
|
||||
failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", consumer.Manifest)...)
|
||||
}
|
||||
@@ -554,6 +681,50 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateMusicConfig(cfg MusicConfig) []error {
|
||||
var failures []error
|
||||
normalizedRoots := map[string]string{}
|
||||
for root, prefix := range cfg.Prefixes {
|
||||
normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root)))
|
||||
if normalizedRoot == "" {
|
||||
failures = append(failures, errors.New("music.prefixes contains an empty root"))
|
||||
continue
|
||||
}
|
||||
if prior, exists := normalizedRoots[normalizedRoot]; exists {
|
||||
failures = append(failures, fmt.Errorf("music.prefixes roots %q and %q normalize to the same path", prior, root))
|
||||
}
|
||||
normalizedRoots[normalizedRoot] = root
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
failures = append(failures, fmt.Errorf("music.prefixes[%q] must not be empty", root))
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateGlobList(field string, patterns []string) []error {
|
||||
var failures []error
|
||||
seen := map[string]struct{}{}
|
||||
for index, pattern := range patterns {
|
||||
trimmed := strings.TrimSpace(pattern)
|
||||
if trimmed == "" {
|
||||
failures = append(failures, fmt.Errorf("%s[%d] must not be empty", field, index))
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "\x00") {
|
||||
failures = append(failures, fmt.Errorf("%s[%d] must not contain NUL bytes", field, index))
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
failures = append(failures, fmt.Errorf("%s[%d] duplicates %q", field, index, trimmed))
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func trimConfigSlashes(path string) string {
|
||||
return strings.Trim(path, "/")
|
||||
}
|
||||
|
||||
func validateAutogenDeriveConfig(fieldPrefix string, cfg AutogenDeriveConfig) []error {
|
||||
var failures []error
|
||||
switch strings.TrimSpace(cfg.Kind) {
|
||||
|
||||
Reference in New Issue
Block a user