Key changes:
- Added internal/project/effective.go with normalized effective config, defaults, provenance, and
JSON output.
- Added config subcommands:
- config validate
- config effective [--json|--yaml]
- config inspect [<key>]
- config explain <key>
- config sources
- Added YAML schema fields for configurable outputs, cache roots, script cache, inventory extensions,
topdata output/wiki paths, and autogen cache root.
- Routed existing hardcoded paths through effective config wrappers for module archives, HAK
manifests/archives, script cache, music cache/credits, topdata outputs, and autogen caches.
- Added validation for unsafe generated output/cache paths.
- Updated command descriptions to avoid fixed build/, .cache, sow_top, etc.
- Added regression tests for defaults, provenance, deterministic effective config, YAML-controlled
derivation, config commands, and invalid paths.
319 lines
12 KiB
Go
319 lines
12 KiB
Go
package project
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultBuildPath = "build"
|
|
DefaultCachePath = ".cache"
|
|
DefaultToolsPath = "tools"
|
|
DefaultModuleArchiveTemplate = "{module.resref}.mod"
|
|
DefaultHAKManifest = "haks.json"
|
|
DefaultHAKArchiveTemplate = "{hak.name}.hak"
|
|
DefaultSourceJSONPattern = "{resref}.{extension}.json"
|
|
DefaultScriptsSourceDir = "scripts"
|
|
DefaultScriptsCache = "{paths.cache}/ncs"
|
|
DefaultMusicStageRoot = "{paths.cache}/music"
|
|
DefaultCreditsRoot = "{paths.cache}/credits"
|
|
DefaultCreditsOverlayFile = "CREDITS.md"
|
|
DefaultTopDataBuild = "{paths.build}/topdata"
|
|
DefaultTopDataCompiled2DADir = "2da"
|
|
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
|
|
DefaultTopDataWikiOutputRoot = "wiki"
|
|
DefaultTopDataWikiPagesDir = "pages"
|
|
DefaultTopDataWikiStateFile = "state.json"
|
|
DefaultTopDataPackageHAK = "sow_top.hak"
|
|
DefaultTopDataPackageTLK = "sow_tlk.tlk"
|
|
DefaultAutogenCacheRoot = "{paths.cache}"
|
|
DefaultAutogenCacheMaxAge = time.Hour
|
|
DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH"
|
|
)
|
|
|
|
type ConfigProvenance map[string]ConfigValueProvenance
|
|
|
|
type ConfigValueProvenance struct {
|
|
Source string `json:"source" yaml:"source"`
|
|
Detail string `json:"detail,omitempty" yaml:"detail,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
|
|
}
|
|
|
|
type EffectiveConfig struct {
|
|
ConfigSource ConfigSource `json:"config_source" yaml:"config_source"`
|
|
Module ModuleConfig `json:"module" yaml:"module"`
|
|
Paths EffectivePathConfig `json:"paths" yaml:"paths"`
|
|
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
|
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
|
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
|
|
Music EffectiveMusicConfig `json:"music" yaml:"music"`
|
|
TopData EffectiveTopDataConfig `json:"topdata" yaml:"topdata"`
|
|
Extract ExtractConfig `json:"extract" yaml:"extract"`
|
|
Autogen EffectiveAutogenConfig `json:"autogen" yaml:"autogen"`
|
|
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
|
Provenance ConfigProvenance `json:"provenance" yaml:"provenance"`
|
|
}
|
|
|
|
type EffectivePathConfig struct {
|
|
Source string `json:"source" yaml:"source"`
|
|
Assets string `json:"assets" yaml:"assets"`
|
|
Build string `json:"build" yaml:"build"`
|
|
Cache string `json:"cache" yaml:"cache"`
|
|
Tools string `json:"tools" yaml:"tools"`
|
|
}
|
|
|
|
type EffectiveOutputConfig struct {
|
|
ModuleArchive string `json:"module_archive" yaml:"module_archive"`
|
|
HAKManifest string `json:"hak_manifest" yaml:"hak_manifest"`
|
|
HAKArchive string `json:"hak_archive" yaml:"hak_archive"`
|
|
}
|
|
|
|
type EffectiveScriptsConfig struct {
|
|
SourceDir string `json:"source_dir" yaml:"source_dir"`
|
|
Cache string `json:"cache" yaml:"cache"`
|
|
}
|
|
|
|
type EffectiveMusicConfig struct {
|
|
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
|
|
StageRoot string `json:"stage_root" yaml:"stage_root"`
|
|
CreditsRoot string `json:"credits_root" yaml:"credits_root"`
|
|
CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"`
|
|
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"`
|
|
}
|
|
|
|
type EffectiveTopDataConfig struct {
|
|
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"`
|
|
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
|
|
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
|
|
PackageHAK string `json:"package_hak" yaml:"package_hak"`
|
|
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
|
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
|
}
|
|
|
|
type EffectiveAutogenConfig struct {
|
|
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
|
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
|
Cache EffectiveAutogenCacheConfig `json:"cache" yaml:"cache"`
|
|
}
|
|
|
|
type EffectiveAutogenCacheConfig struct {
|
|
Root string `json:"root" yaml:"root"`
|
|
MaxAge string `json:"max_age" yaml:"max_age"`
|
|
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
|
}
|
|
|
|
func (p *Project) EffectiveConfig() EffectiveConfig {
|
|
provenance := cloneProvenance(p.Provenance)
|
|
markMissingDefaults(provenance)
|
|
|
|
paths := EffectivePathConfig{
|
|
Source: strings.TrimSpace(p.Config.Paths.Source),
|
|
Assets: strings.TrimSpace(p.Config.Paths.Assets),
|
|
Build: defaultString(p.Config.Paths.Build, DefaultBuildPath),
|
|
Cache: defaultString(p.Config.Paths.Cache, DefaultCachePath),
|
|
Tools: defaultString(p.Config.Paths.Tools, DefaultToolsPath),
|
|
}
|
|
outputs := EffectiveOutputConfig{
|
|
ModuleArchive: defaultString(p.Config.Outputs.ModuleArchive, DefaultModuleArchiveTemplate),
|
|
HAKManifest: defaultString(p.Config.Outputs.HAKManifest, DefaultHAKManifest),
|
|
HAKArchive: defaultString(p.Config.Outputs.HAKArchive, DefaultHAKArchiveTemplate),
|
|
}
|
|
inventory := InventoryConfig{
|
|
SourceExtensions: defaultStringSlice(p.Config.Inventory.SourceExtensions, SourceExtensions),
|
|
AssetExtensions: defaultStringSlice(p.Config.Inventory.AssetExtensions, AssetExtensions),
|
|
SourceJSONPattern: defaultString(p.Config.Inventory.SourceJSONPattern, DefaultSourceJSONPattern),
|
|
}
|
|
scripts := EffectiveScriptsConfig{
|
|
SourceDir: defaultString(p.Config.Scripts.SourceDir, DefaultScriptsSourceDir),
|
|
Cache: expandPathTemplate(defaultString(p.Config.Scripts.Cache, DefaultScriptsCache), paths),
|
|
}
|
|
topBuild := expandPathTemplate(defaultString(p.Config.TopData.Build, DefaultTopDataBuild), paths)
|
|
top := EffectiveTopDataConfig{
|
|
Source: strings.TrimSpace(p.Config.TopData.Source),
|
|
Build: topBuild,
|
|
ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder),
|
|
Assets: strings.TrimSpace(p.Config.TopData.Assets),
|
|
Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir),
|
|
CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK),
|
|
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
|
|
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
|
|
Wiki: TopDataWikiConfig{
|
|
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
|
|
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
|
|
StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile),
|
|
},
|
|
}
|
|
|
|
return EffectiveConfig{
|
|
ConfigSource: p.ConfigSource,
|
|
Module: p.Config.Module,
|
|
Paths: paths,
|
|
Outputs: outputs,
|
|
Inventory: inventory,
|
|
Scripts: scripts,
|
|
Music: EffectiveMusicConfig{
|
|
Prefixes: cloneStringMap(p.Config.Music.Prefixes),
|
|
StageRoot: expandPathTemplate(DefaultMusicStageRoot, paths),
|
|
CreditsRoot: expandPathTemplate(DefaultCreditsRoot, paths),
|
|
CreditsOverlay: DefaultCreditsOverlayFile,
|
|
MaxStemLength: 16,
|
|
},
|
|
TopData: top,
|
|
Extract: p.Config.Extract,
|
|
Autogen: EffectiveAutogenConfig{
|
|
Producers: slices.Clone(p.Config.Autogen.Producers),
|
|
Consumers: slices.Clone(p.Config.Autogen.Consumers),
|
|
Cache: EffectiveAutogenCacheConfig{
|
|
Root: expandPathTemplate(defaultString(p.Config.Autogen.Cache.Root, DefaultAutogenCacheRoot), paths),
|
|
MaxAge: DefaultAutogenCacheMaxAge.String(),
|
|
RefreshEnv: DefaultAutogenRefreshEnv,
|
|
},
|
|
},
|
|
HAKs: slices.Clone(p.Config.HAKs),
|
|
Provenance: provenance,
|
|
}
|
|
}
|
|
|
|
func (p *Project) EffectiveConfigJSON() ([]byte, error) {
|
|
return json.MarshalIndent(p.EffectiveConfig(), "", " ")
|
|
}
|
|
|
|
func (p *Project) ExplainConfig(key string) (any, ConfigValueProvenance, bool) {
|
|
effective := p.EffectiveConfig()
|
|
value, ok := lookupEffectiveValue(effective, key)
|
|
if !ok {
|
|
return nil, ConfigValueProvenance{}, false
|
|
}
|
|
prov := effective.Provenance[key]
|
|
if prov.Source == "" {
|
|
prov = ConfigValueProvenance{Source: "yaml", Detail: p.ConfigSource.Name}
|
|
}
|
|
return value, prov, true
|
|
}
|
|
|
|
func lookupEffectiveValue(effective EffectiveConfig, key string) (any, bool) {
|
|
raw, err := json.Marshal(effective)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var data any
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, false
|
|
}
|
|
current := data
|
|
for _, part := range strings.Split(key, ".") {
|
|
object, ok := current.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
current, ok = object[part]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func markMissingDefaults(provenance ConfigProvenance) {
|
|
defaults := map[string]string{
|
|
"paths.build": "build",
|
|
"paths.cache": ".cache",
|
|
"paths.tools": "tools",
|
|
"outputs.module_archive": DefaultModuleArchiveTemplate,
|
|
"outputs.hak_manifest": DefaultHAKManifest,
|
|
"outputs.hak_archive": DefaultHAKArchiveTemplate,
|
|
"inventory.source_extensions": "NWN source resource extensions",
|
|
"inventory.asset_extensions": "NWN asset resource extensions",
|
|
"inventory.source_json_pattern": DefaultSourceJSONPattern,
|
|
"scripts.source_dir": DefaultScriptsSourceDir,
|
|
"scripts.cache": DefaultScriptsCache,
|
|
"music.stage_root": DefaultMusicStageRoot,
|
|
"music.credits_root": DefaultCreditsRoot,
|
|
"music.credits_overlay": DefaultCreditsOverlayFile,
|
|
"music.max_stem_length": "16",
|
|
"topdata.build": DefaultTopDataBuild,
|
|
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
|
|
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
|
|
"topdata.package_hak": DefaultTopDataPackageHAK,
|
|
"topdata.package_tlk": DefaultTopDataPackageTLK,
|
|
"topdata.wiki.output_root": DefaultTopDataWikiOutputRoot,
|
|
"topdata.wiki.pages_dir": DefaultTopDataWikiPagesDir,
|
|
"topdata.wiki.state_file": DefaultTopDataWikiStateFile,
|
|
"autogen.cache.root": DefaultAutogenCacheRoot,
|
|
"autogen.cache.max_age": DefaultAutogenCacheMaxAge.String(),
|
|
"autogen.cache.refresh_env": DefaultAutogenRefreshEnv,
|
|
}
|
|
for key, detail := range defaults {
|
|
if _, ok := provenance[key]; !ok {
|
|
provenance[key] = ConfigValueProvenance{Source: "toolkit default", Detail: detail}
|
|
}
|
|
}
|
|
}
|
|
|
|
func cloneProvenance(input ConfigProvenance) ConfigProvenance {
|
|
out := ConfigProvenance{}
|
|
for key, value := range input {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return fallback
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func defaultStringSlice(value, fallback []string) []string {
|
|
if len(value) == 0 {
|
|
return slices.Clone(fallback)
|
|
}
|
|
return slices.Clone(value)
|
|
}
|
|
|
|
func cloneStringMap(input map[string]string) map[string]string {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(input))
|
|
for key, value := range input {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func expandPathTemplate(template string, paths EffectivePathConfig) string {
|
|
replacer := strings.NewReplacer(
|
|
"{paths.source}", paths.Source,
|
|
"{paths.assets}", paths.Assets,
|
|
"{paths.build}", paths.Build,
|
|
"{paths.cache}", paths.Cache,
|
|
"{paths.tools}", paths.Tools,
|
|
)
|
|
return filepath.ToSlash(replacer.Replace(template))
|
|
}
|
|
|
|
func (o EffectiveOutputConfig) ModuleArchiveName(module ModuleConfig) string {
|
|
return strings.NewReplacer("{module.resref}", strings.TrimSpace(module.ResRef)).Replace(o.ModuleArchive)
|
|
}
|
|
|
|
func (o EffectiveOutputConfig) HAKArchiveName(name string) string {
|
|
return strings.NewReplacer("{hak.name}", strings.TrimSpace(name)).Replace(o.HAKArchive)
|
|
}
|
|
|
|
func (p ConfigValueProvenance) String() string {
|
|
if p.Detail == "" {
|
|
return p.Source
|
|
}
|
|
return fmt.Sprintf("%s (%s)", p.Source, p.Detail)
|
|
}
|