ci / ci (pull_request) Successful in 3m21s
Follow-up to the mdb removal in the previous commit. Three of the six extensions #64 listed as deliberate local additions are NWN2 formats, not NWN:EE ones, and belong in an NWN:EE tool no more than mdb did. Checked against xoreos (src/aurora/types.h), which covers every Aurora game and so distinguishes the games' tables: gr2 4003, sits in the NWN2 block (MDB2 4000, MDA2 4001, SPT2 4002, GR2 4003, PWC 4008). Granny is NWN2; NWN:EE does not use it. wlk xoreos numbers it 20004 and xml 20003, both above xml kFileTypeMAXArchive in the section headed "Entries for files not found in archives with numerical type IDs / Found in NWN2's ZIP files". Neither has an archive restype in any Aurora game, so Crucible's 0x0BCC and 0x0BCD were invented outright, in a band Aurora does use elsewhere (3022 FSM, 3023 ART). NWN:EE covers those jobs with formats already in the table: wok, pwk and dwk for walkmeshes, and EE's own UI XML loads from ui/ovr rather than from a HAK by restype, which is consistent with upstream neverwinter.nim registering no xml number. The remaining three local additions stay. lyt 3000, vis 3001 and mdx 3008 are real Aurora archive types that NWN1 ships in its own data/*.bif; xoreos gives the same numbers. Upstream simply does not list them. No asset in the corpus uses gr2, wlk or xml, so nothing stops building. Also drops them from AssetExtensions, and records the rule in a comment on the table plus a test, so the next NWN2 format gets caught. Refs ShadowsOverWestgate/sow-tools#64 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2044 lines
79 KiB
Go
2044 lines
79 KiB
Go
package project
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
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",
|
|
".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
|
|
}
|
|
|
|
var AssetExtensions = []string{
|
|
".2da", ".bik", ".bmp", ".bmu", ".dds", ".dwk", ".itp", ".jpg", ".lod", ".lyt", ".mdl", ".mdx", ".mtr", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wok",
|
|
}
|
|
|
|
var BuiltinScriptPrefixes = []string{
|
|
"nw_",
|
|
"x0_",
|
|
"x1_",
|
|
"x2_",
|
|
"x3_",
|
|
"ga_",
|
|
"gc_",
|
|
"gen_",
|
|
"gui_",
|
|
"nwg_",
|
|
"ta_",
|
|
}
|
|
|
|
var RequiredFields = map[string][]string{
|
|
"ifo": {"Mod_Name"},
|
|
}
|
|
|
|
func DefaultBuiltinScriptPrefixes() []string {
|
|
return slices.Clone(BuiltinScriptPrefixes)
|
|
}
|
|
|
|
func DefaultRequiredFields() map[string][]string {
|
|
out := make(map[string][]string, len(RequiredFields))
|
|
for extension, fields := range RequiredFields {
|
|
out[extension] = slices.Clone(fields)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type Project struct {
|
|
Root string
|
|
Config Config
|
|
ConfigSource ConfigSource
|
|
Provenance ConfigProvenance
|
|
Inventory Inventory
|
|
}
|
|
|
|
type ConfigSource struct {
|
|
Path string
|
|
Name string
|
|
Format string
|
|
Legacy bool
|
|
}
|
|
|
|
type Config struct {
|
|
Module ModuleConfig `json:"module" yaml:"module"`
|
|
Paths PathConfig `json:"paths" yaml:"paths"`
|
|
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
|
|
Build BuildConfig `json:"build" yaml:"build"`
|
|
Generated GeneratedConfig `json:"generated_assets" yaml:"generated_assets"`
|
|
HAKs []HAKConfig `json:"haks" yaml:"haks"`
|
|
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
|
|
Validation ValidationConfig `json:"validation" yaml:"validation"`
|
|
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" 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" 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 OutputConfig 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 BuildConfig struct {
|
|
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
|
|
}
|
|
|
|
type GeneratedConfig struct {
|
|
TopData2DA []GeneratedTopData2DAConfig `json:"topdata_2da" yaml:"topdata_2da"`
|
|
}
|
|
|
|
type GeneratedTopData2DAConfig struct {
|
|
ID string `json:"id" yaml:"id"`
|
|
Source string `json:"source" yaml:"source"`
|
|
Output string `json:"output" yaml:"output"`
|
|
IncludeDatasets []string `json:"include_datasets" yaml:"include_datasets"`
|
|
PackageRoot string `json:"package_root" yaml:"package_root"`
|
|
Autogen AutogenConsumerConfig `json:"autogen" yaml:"autogen"`
|
|
}
|
|
|
|
type InventoryConfig struct {
|
|
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
|
|
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
|
|
SourceJSONPattern string `json:"source_json_pattern" yaml:"source_json_pattern"`
|
|
}
|
|
|
|
type ValidationConfig struct {
|
|
Profile string `json:"profile" yaml:"profile"`
|
|
BuiltinScriptPrefixes []string `json:"builtin_script_prefixes" yaml:"builtin_script_prefixes"`
|
|
RequiredFields map[string][]string `json:"required_fields" yaml:"required_fields"`
|
|
}
|
|
|
|
type HAKConfig struct {
|
|
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 TopDataConfig 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"`
|
|
ValueEncodings []TopDataValueEncodingConfig `json:"value_encodings" yaml:"value_encodings"`
|
|
ValueDefaults []TopDataValueDefaultConfig `json:"value_defaults" yaml:"value_defaults"`
|
|
RowGeneration []TopDataRowGenerationConfig `json:"row_generation" yaml:"row_generation"`
|
|
RowExtensions []TopDataRowExtensionConfig `json:"row_extensions" yaml:"row_extensions"`
|
|
ClassFeatInjections TopDataClassFeatInjectionConfig `json:"class_feat_injections" yaml:"class_feat_injections"`
|
|
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
|
|
}
|
|
|
|
type TopDataRowGenerationConfig struct {
|
|
Dataset string `json:"dataset,omitempty" yaml:"dataset,omitempty"`
|
|
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
MinimumRow int `json:"minimum_row,omitempty" yaml:"minimum_row,omitempty"`
|
|
}
|
|
|
|
type TopDataRowExtensionConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
LevelColumn string `json:"level_column" yaml:"level_column"`
|
|
TargetLevel int `json:"target_level" yaml:"target_level"`
|
|
}
|
|
|
|
type TopDataClassFeatInjectionConfig struct {
|
|
GlobalFeats []TopDataClassFeatGlobalRule `json:"global_feats" yaml:"global_feats"`
|
|
ClassSkillMasterfeats []TopDataClassFeatMasterfeatRule `json:"class_skill_masterfeats" yaml:"class_skill_masterfeats"`
|
|
}
|
|
|
|
type TopDataClassFeatGlobalRule struct {
|
|
Feat string `json:"feat" yaml:"feat"`
|
|
List string `json:"list" yaml:"list"`
|
|
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
|
|
OnMenu string `json:"on_menu" yaml:"on_menu"`
|
|
RequirePresent []string `json:"require_present,omitempty" yaml:"require_present,omitempty"`
|
|
UnlessPresent []string `json:"unless_present,omitempty" yaml:"unless_present,omitempty"`
|
|
}
|
|
|
|
type TopDataClassFeatMasterfeatRule struct {
|
|
Masterfeat string `json:"masterfeat" yaml:"masterfeat"`
|
|
List string `json:"list" yaml:"list"`
|
|
GrantedOnLevel string `json:"granted_on_level" yaml:"granted_on_level"`
|
|
OnMenu string `json:"on_menu" yaml:"on_menu"`
|
|
}
|
|
|
|
type TopDataValueEncodingConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Column string `json:"column" yaml:"column"`
|
|
Mode string `json:"mode" yaml:"mode"`
|
|
Min int `json:"min" yaml:"min"`
|
|
Max int `json:"max" yaml:"max"`
|
|
HexWidth int `json:"hex_width" yaml:"hex_width"`
|
|
}
|
|
|
|
type TopDataValueDefaultConfig struct {
|
|
Dataset string `json:"dataset" yaml:"dataset"`
|
|
Column string `json:"column" yaml:"column"`
|
|
Value any `json:"value" yaml:"value"`
|
|
}
|
|
|
|
type TopDataWikiConfig struct {
|
|
OutputRoot string `json:"output_root" yaml:"output_root"`
|
|
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
|
|
StateFile string `json:"state_file" yaml:"state_file"`
|
|
ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"`
|
|
DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"`
|
|
DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
|
|
Source string `json:"source" yaml:"source"`
|
|
Renderer string `json:"renderer" yaml:"renderer"`
|
|
LinkStrategy string `json:"link_strategy" yaml:"link_strategy"`
|
|
NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"`
|
|
TablesFile string `json:"tables_file" yaml:"tables_file"`
|
|
VisibilityFile string `json:"visibility_file" yaml:"visibility_file"`
|
|
DataFile string `json:"data_file" yaml:"data_file"`
|
|
PagePathsFile string `json:"page_paths_file" yaml:"page_paths_file"`
|
|
TemplatesDir string `json:"templates_dir" yaml:"templates_dir"`
|
|
PageTemplatesDir string `json:"page_templates_dir" yaml:"page_templates_dir"`
|
|
ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"`
|
|
ManualSectionsFile string `json:"manual_sections_file" yaml:"manual_sections_file"`
|
|
PageIndexFile string `json:"page_index_file" yaml:"page_index_file"`
|
|
AlignmentLinks TopDataWikiAlignmentLinks `json:"alignment_links" yaml:"alignment_links"`
|
|
TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"`
|
|
StatusPages *bool `json:"status_pages,omitempty" yaml:"status_pages,omitempty"`
|
|
StatusListingScope string `json:"status_listing_scope" yaml:"status_listing_scope"`
|
|
StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"`
|
|
ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"`
|
|
}
|
|
|
|
type TopDataWikiAlignmentLinks struct {
|
|
TargetPattern string `json:"target_pattern" yaml:"target_pattern"`
|
|
LinkFormat string `json:"link_format" yaml:"link_format"`
|
|
}
|
|
|
|
type TopDataWikiStalePagesConfig struct {
|
|
Default string `json:"default" yaml:"default"`
|
|
LiveCleanup string `json:"live_cleanup" yaml:"live_cleanup"`
|
|
}
|
|
|
|
type TopDataWikiManagedRegionConfig struct {
|
|
MarkerFormat string `json:"marker_format" yaml:"marker_format"`
|
|
PageMarkerPrefix string `json:"page_marker_prefix" yaml:"page_marker_prefix"`
|
|
}
|
|
|
|
type ExtractConfig struct {
|
|
IgnoreExtensions []string `json:"ignore_extensions" yaml:"ignore_extensions"`
|
|
Archives []string `json:"archives,omitempty" yaml:"archives,omitempty"`
|
|
Layout string `json:"layout" yaml:"layout"`
|
|
HAKDiscovery string `json:"hak_discovery" yaml:"hak_discovery"`
|
|
CleanupStale *bool `json:"cleanup_stale,omitempty" yaml:"cleanup_stale,omitempty"`
|
|
ConsumeArchives *bool `json:"consume_archives,omitempty" yaml:"consume_archives,omitempty"`
|
|
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
|
|
Merge ExtractMergeConfig `json:"merge" yaml:"merge"`
|
|
}
|
|
|
|
type ExtractMergeConfig struct {
|
|
GFFJSON []ExtractGFFJSONMergeRule `json:"gff_json" yaml:"gff_json"`
|
|
}
|
|
|
|
type ExtractGFFJSONMergeRule struct {
|
|
Target string `json:"target" yaml:"target"`
|
|
PreserveFields []string `json:"preserve_fields" yaml:"preserve_fields"`
|
|
MergeLists []ExtractListMergeRule `json:"merge_lists" yaml:"merge_lists"`
|
|
SetFields []ExtractSetFieldRule `json:"set_fields" yaml:"set_fields"`
|
|
}
|
|
|
|
// ExtractSetFieldRule forces an Int field to a fixed value on every extract,
|
|
// overriding whatever the toolset saved. Fields absent from the extracted
|
|
// document are left absent.
|
|
type ExtractSetFieldRule struct {
|
|
Field string `json:"field" yaml:"field"`
|
|
Value int32 `json:"value" yaml:"value"`
|
|
}
|
|
|
|
type ExtractListMergeRule struct {
|
|
Field string `json:"field" yaml:"field"`
|
|
KeyField string `json:"key_field" yaml:"key_field"`
|
|
Strategy string `json:"strategy" yaml:"strategy"`
|
|
}
|
|
|
|
type AutogenConfig struct {
|
|
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
|
|
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
|
|
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
|
|
ReleaseSource AutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
|
|
}
|
|
|
|
type AutogenCacheConfig struct {
|
|
Root string `json:"root" yaml:"root"`
|
|
MaxAge string `json:"max_age" yaml:"max_age"`
|
|
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
|
|
}
|
|
|
|
type AutogenReleaseSourceConfig struct {
|
|
Provider string `json:"provider" yaml:"provider"`
|
|
ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
|
|
RepoEnv string `json:"repo_env" yaml:"repo_env"`
|
|
}
|
|
|
|
type AutogenProducerConfig struct {
|
|
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"`
|
|
AccessoryVisualeffects AccessoryVisualeffectsConfig `json:"accessory_visualeffects" yaml:"accessory_visualeffects"`
|
|
}
|
|
|
|
type AutogenConsumerConfig struct {
|
|
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"`
|
|
PartsRows PartsRowsConfig `json:"parts_rows" yaml:"parts_rows"`
|
|
AccessoryVisualeffects AccessoryVisualeffectsConfig `json:"accessory_visualeffects" yaml:"accessory_visualeffects"`
|
|
Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
|
|
LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
|
|
ManifestFile string `json:"manifest_file,omitempty" yaml:"manifest_file,omitempty"`
|
|
Source AutogenSourceConfig `json:"source,omitempty" yaml:"source,omitempty"`
|
|
}
|
|
|
|
type AccessoryVisualeffectsConfig struct {
|
|
Groups map[string]AccessoryVisualeffectGroupConfig `json:"groups,omitempty" yaml:"groups"`
|
|
GroupTokenSource string `json:"group_token_source,omitempty" yaml:"group_token_source"`
|
|
CategoryFrom string `json:"category_from,omitempty" yaml:"category_from"`
|
|
Delimiter string `json:"delimiter,omitempty" yaml:"delimiter"`
|
|
Case string `json:"case,omitempty" yaml:"case"`
|
|
StripModelPrefixes []string `json:"strip_model_prefixes,omitempty" yaml:"strip_model_prefixes"`
|
|
KeyFormat string `json:"key_format,omitempty" yaml:"key_format"`
|
|
LabelFormat string `json:"label_format,omitempty" yaml:"label_format"`
|
|
ModelColumn string `json:"model_column,omitempty" yaml:"model_column"`
|
|
RowDefaults map[string]string `json:"row_defaults,omitempty" yaml:"row_defaults"`
|
|
}
|
|
|
|
type AccessoryVisualeffectGroupConfig struct {
|
|
Prefix string `json:"prefix" yaml:"prefix"`
|
|
ModelColumn string `json:"model_column,omitempty" yaml:"model_column"`
|
|
ModelColumns []string `json:"model_columns,omitempty" yaml:"model_columns"`
|
|
RowDefaults map[string]string `json:"row_defaults,omitempty" yaml:"row_defaults"`
|
|
}
|
|
|
|
type PartsRowsConfig struct {
|
|
RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"`
|
|
ACBonus PartsRowsACBonusConfig `json:"acbonus" yaml:"acbonus"`
|
|
Datasets map[string]PartsRowsDatasetConfig `json:"datasets" yaml:"datasets"`
|
|
}
|
|
|
|
type PartsRowsDatasetConfig struct {
|
|
ACBonus PartsRowsACBonusPolicy `json:"acbonus" yaml:"acbonus"`
|
|
}
|
|
|
|
type PartsRowsACBonusConfig struct {
|
|
Default PartsRowsACBonusPolicy `json:"default" yaml:"default"`
|
|
Datasets map[string]PartsRowsACBonusPolicy `json:"datasets" yaml:"datasets"`
|
|
}
|
|
|
|
type PartsRowsACBonusPolicy struct {
|
|
Strategy string `json:"strategy" yaml:"strategy"`
|
|
MaxRowID int `json:"max_row_id" yaml:"max_row_id"`
|
|
Divisor int `json:"divisor" yaml:"divisor"`
|
|
Format string `json:"format" yaml:"format"`
|
|
Value string `json:"value" yaml:"value"`
|
|
}
|
|
|
|
type AutogenDeriveConfig struct {
|
|
Kind string `json:"kind" yaml:"kind"`
|
|
GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
|
|
}
|
|
|
|
type AutogenSourceConfig struct {
|
|
Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
|
|
CDNBase string `json:"cdn_base,omitempty" yaml:"cdn_base,omitempty"`
|
|
CDNBaseEnv string `json:"cdn_base_env,omitempty" yaml:"cdn_base_env,omitempty"`
|
|
ChannelsPath string `json:"channels_path,omitempty" yaml:"channels_path,omitempty"`
|
|
ManifestPath string `json:"manifest_path,omitempty" yaml:"manifest_path,omitempty"`
|
|
ReleaseMarkerPath string `json:"release_marker_path,omitempty" yaml:"release_marker_path,omitempty"`
|
|
ChannelEnv string `json:"channel_env,omitempty" yaml:"channel_env,omitempty"`
|
|
OfflineOverrideEnv string `json:"offline_override_env,omitempty" yaml:"offline_override_env,omitempty"`
|
|
}
|
|
|
|
type AutogenManifestConfig struct {
|
|
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 {
|
|
SourceFiles []string
|
|
ScriptFiles []string
|
|
AssetFiles []string
|
|
Extensions []string
|
|
}
|
|
|
|
type InventoryReport struct {
|
|
SourceFiles int
|
|
ScriptFiles int
|
|
AssetFiles int
|
|
Extensions []string
|
|
}
|
|
|
|
func FindRoot(start string) (string, error) {
|
|
current := start
|
|
for {
|
|
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, %s, or legacy %s from %s", ConfigFile, ConfigFileYML, LegacyConfigFile, start)
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func Load(root string) (*Project, error) {
|
|
source, err := findConfigSource(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := os.ReadFile(source.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", source.Name, err)
|
|
}
|
|
|
|
cfg := defaultConfig()
|
|
provenance, err := configProvenance(raw, source)
|
|
if err != nil {
|
|
return nil, 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,
|
|
ConfigSource: source,
|
|
Provenance: provenance,
|
|
}, 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 configProvenance(raw []byte, source ConfigSource) (ConfigProvenance, error) {
|
|
var data any
|
|
if source.Format == "yaml" {
|
|
if err := yaml.Unmarshal(raw, &data); err != nil {
|
|
return nil, fmt.Errorf("parse %s for provenance: %w", source.Name, err)
|
|
}
|
|
} else {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, fmt.Errorf("parse legacy %s for provenance: %w", source.Name, err)
|
|
}
|
|
}
|
|
provenance := ConfigProvenance{}
|
|
sourceName := "yaml"
|
|
if source.Legacy {
|
|
sourceName = "legacy json"
|
|
}
|
|
recordProvenance(provenance, "", data, ConfigValueProvenance{
|
|
Source: sourceName,
|
|
Detail: source.Name,
|
|
Deprecated: source.Legacy,
|
|
})
|
|
return provenance, nil
|
|
}
|
|
|
|
func recordProvenance(provenance ConfigProvenance, prefix string, value any, source ConfigValueProvenance) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, nested := range typed {
|
|
next := key
|
|
if prefix != "" {
|
|
next = prefix + "." + key
|
|
}
|
|
recordProvenance(provenance, next, nested, source)
|
|
}
|
|
case []any:
|
|
if prefix != "" {
|
|
provenance[prefix] = source
|
|
}
|
|
default:
|
|
if prefix != "" {
|
|
provenance[prefix] = source
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Project) ValidateLayout() error {
|
|
var failures []error
|
|
|
|
if strings.TrimSpace(p.Config.Module.Name) == "" {
|
|
failures = append(failures, errors.New("module.name is required"))
|
|
}
|
|
if strings.TrimSpace(p.Config.Module.ResRef) == "" {
|
|
failures = append(failures, errors.New("module.resref is required"))
|
|
}
|
|
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,
|
|
"paths.cache": p.Config.Paths.Cache,
|
|
"paths.tools": p.Config.Paths.Tools,
|
|
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
|
|
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
|
|
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
|
|
"generated_assets.topdata_2da": "",
|
|
"validation.profile": p.Config.Validation.Profile,
|
|
"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,
|
|
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
|
|
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
|
|
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
|
|
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
|
|
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
|
|
"topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest,
|
|
"topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary,
|
|
"topdata.wiki.source": p.Config.TopData.Wiki.Source,
|
|
"topdata.wiki.namespaces_file": p.Config.TopData.Wiki.NamespacesFile,
|
|
"topdata.wiki.tables_file": p.Config.TopData.Wiki.TablesFile,
|
|
"topdata.wiki.visibility_file": p.Config.TopData.Wiki.VisibilityFile,
|
|
"topdata.wiki.data_file": p.Config.TopData.Wiki.DataFile,
|
|
"topdata.wiki.page_paths_file": p.Config.TopData.Wiki.PagePathsFile,
|
|
"topdata.wiki.templates_dir": p.Config.TopData.Wiki.TemplatesDir,
|
|
"topdata.wiki.page_templates_dir": p.Config.TopData.Wiki.PageTemplatesDir,
|
|
"topdata.wiki.manual_sections_dir": p.Config.TopData.Wiki.ManualSectionsDir,
|
|
"topdata.wiki.manual_sections_file": p.Config.TopData.Wiki.ManualSectionsFile,
|
|
"topdata.wiki.page_index_file": p.Config.TopData.Wiki.PageIndexFile,
|
|
"topdata.wiki.alignment_links.link_format": p.Config.TopData.Wiki.AlignmentLinks.LinkFormat,
|
|
"extract.layout": p.Config.Extract.Layout,
|
|
"extract.hak_discovery": p.Config.Extract.HAKDiscovery,
|
|
"autogen.cache.root": p.Config.Autogen.Cache.Root,
|
|
"autogen.cache.max_age": p.Config.Autogen.Cache.MaxAge,
|
|
"autogen.cache.refresh_env": p.Config.Autogen.Cache.RefreshEnv,
|
|
"autogen.release_source.provider": p.Config.Autogen.ReleaseSource.Provider,
|
|
"autogen.release_source.server_url_env": p.Config.Autogen.ReleaseSource.ServerURLEnv,
|
|
"autogen.release_source.repo_env": p.Config.Autogen.ReleaseSource.RepoEnv,
|
|
} {
|
|
if strings.Contains(value, "\x00") {
|
|
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
|
|
}
|
|
}
|
|
failures = append(failures, validateValidationConfig(p.Config.Validation)...)
|
|
effective := p.EffectiveConfig()
|
|
failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
|
|
failures = append(failures, validateTreeRootPath("paths.source", effective.Paths.Source)...)
|
|
failures = append(failures, validateTreeRootPath("paths.assets", effective.Paths.Assets)...)
|
|
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
|
|
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
|
|
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
|
|
failures = append(failures, validateGeneratedConfig(effective.Generated)...)
|
|
failures = append(failures, validateTopDataValueEncodings(effective.TopData.ValueEncodings)...)
|
|
failures = append(failures, validateTopDataValueDefaults(effective.TopData.ValueDefaults)...)
|
|
failures = append(failures, validateTopDataRowGeneration(effective.TopData.RowGeneration)...)
|
|
failures = append(failures, validateTopDataRowExtensions(effective.TopData.RowExtensions)...)
|
|
failures = append(failures, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...)
|
|
failures = append(failures, validateAutogenConsumerSources(effective.Autogen.Consumers)...)
|
|
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
|
|
failures = append(failures, validateRelativePath("topdata.compiled_tlk", effective.TopData.CompiledTLK)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.output_root", effective.TopData.Wiki.OutputRoot)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.pages_dir", effective.TopData.Wiki.PagesDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.state_file", effective.TopData.Wiki.StateFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.source", effective.TopData.Wiki.Source)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.namespaces_file", effective.TopData.Wiki.NamespacesFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.tables_file", effective.TopData.Wiki.TablesFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.visibility_file", effective.TopData.Wiki.VisibilityFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.data_file", effective.TopData.Wiki.DataFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_paths_file", effective.TopData.Wiki.PagePathsFile)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_templates_dir", effective.TopData.Wiki.PageTemplatesDir)...)
|
|
failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.manual_sections_file", effective.TopData.Wiki.ManualSectionsFile)...)
|
|
failures = append(failures, validateRelativePath("topdata.wiki.page_index_file", effective.TopData.Wiki.PageIndexFile)...)
|
|
failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
|
|
if err := validateOutputFileName("outputs.module_archive", filepath.Base(effective.Outputs.ModuleArchive), ".mod"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("outputs.hak_manifest", filepath.Base(effective.Outputs.HAKManifest), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.wiki.deploy_manifest", filepath.Base(effective.TopData.Wiki.DeployManifest), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.wiki.page_index_file", filepath.Base(effective.TopData.Wiki.PageIndexFile), ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
switch effective.TopData.Wiki.Renderer {
|
|
case "nodebb_tiptap_html":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.renderer %q is not supported", effective.TopData.Wiki.Renderer))
|
|
}
|
|
switch effective.TopData.Wiki.LinkStrategy {
|
|
case "preserve_westgate_wiki_links":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.link_strategy %q is not supported", effective.TopData.Wiki.LinkStrategy))
|
|
}
|
|
switch effective.TopData.Wiki.AlignmentLinks.LinkFormat {
|
|
case "wiki_markup", "html_anchor":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.alignment_links.link_format %q is not supported", effective.TopData.Wiki.AlignmentLinks.LinkFormat))
|
|
}
|
|
if effective.TopData.Wiki.TitlePrefixMinLength < 1 {
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.title_prefix_min_length must be at least 1"))
|
|
}
|
|
switch effective.TopData.Wiki.StatusListingScope {
|
|
case "all", "namespace":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("topdata.wiki.status_listing_scope %q is not supported", effective.TopData.Wiki.StatusListingScope))
|
|
}
|
|
for key, policy := range map[string]string{
|
|
"topdata.wiki.stale_pages.default": effective.TopData.Wiki.StalePages.Default,
|
|
"topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup,
|
|
} {
|
|
switch policy {
|
|
case "report", "archive", "purge":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
|
|
}
|
|
}
|
|
switch effective.Extract.Layout {
|
|
case "nwn_canonical_json":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("extract.layout %q is not supported", effective.Extract.Layout))
|
|
}
|
|
switch effective.Extract.HAKDiscovery {
|
|
case "build_glob", "configured_haks":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("extract.hak_discovery %q is not supported", effective.Extract.HAKDiscovery))
|
|
}
|
|
failures = append(failures, validateGlobList("extract.archives", effective.Extract.Archives)...)
|
|
failures = append(failures, validateExtractMergeConfig(effective.Extract.Merge)...)
|
|
if _, err := time.ParseDuration(effective.Autogen.Cache.MaxAge); err != nil {
|
|
failures = append(failures, fmt.Errorf("autogen.cache.max_age must be a duration: %w", err))
|
|
}
|
|
if effective.Autogen.ReleaseSource.Provider != "gitea" {
|
|
failures = append(failures, fmt.Errorf("autogen.release_source.provider %q is not supported", effective.Autogen.ReleaseSource.Provider))
|
|
}
|
|
if p.HasTopData() {
|
|
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.package_tlk", p.TopDataPackageTLKName(), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName("topdata.compiled_tlk", filepath.Base(p.TopDataCompiledTLKPath()), ".tlk"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
}
|
|
failures = append(failures, validateAutogenConfig(p.Config.Autogen)...)
|
|
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))
|
|
}
|
|
if hak.MaxBytes < 0 {
|
|
failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
|
|
}
|
|
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{
|
|
"paths.build": p.BuildDir,
|
|
} {
|
|
path := pathFn()
|
|
if path == "" || filepath.Clean(path) == p.Root {
|
|
continue
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
// An output dir that does not exist yet is fine: the builder creates
|
|
// it (MkdirAll) before writing. A bare clone must validate/build from
|
|
// a clean tree with no pre-step (R2/parity) — only a path that exists
|
|
// but is NOT a directory is a real error.
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
failures = append(failures, fmt.Errorf("%s: %w", label, err))
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
|
|
}
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateTopDataValueEncodings(encodings []TopDataValueEncodingConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, encoding := range encodings {
|
|
prefix := fmt.Sprintf("topdata.value_encodings[%d]", index)
|
|
dataset := strings.TrimSpace(encoding.Dataset)
|
|
column := strings.TrimSpace(encoding.Column)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.column is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(encoding.Mode) {
|
|
case "packed_hex_list", "external_hex_list", "alignment_hex_list", "alignment_set_mask", "id_hex_list":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, encoding.Mode))
|
|
}
|
|
if encoding.Min < 0 {
|
|
failures = append(failures, fmt.Errorf("%s.min must be zero or greater", prefix))
|
|
}
|
|
if encoding.Max < encoding.Min {
|
|
failures = append(failures, fmt.Errorf("%s.max must be greater than or equal to min", prefix))
|
|
}
|
|
if encoding.HexWidth <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.hex_width must be greater than zero", prefix))
|
|
} else if encoding.Max >= intPow(16, encoding.HexWidth) {
|
|
failures = append(failures, fmt.Errorf("%s.max does not fit in hex_width %d", prefix, encoding.HexWidth))
|
|
}
|
|
key := filepath.ToSlash(dataset) + "\x00" + strings.ToLower(column)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset/column encoding", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataValueDefaults(defaults []TopDataValueDefaultConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, valueDefault := range defaults {
|
|
prefix := fmt.Sprintf("topdata.value_defaults[%d]", index)
|
|
dataset := strings.TrimSpace(valueDefault.Dataset)
|
|
column := strings.TrimSpace(valueDefault.Column)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.column is required", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset) + "\x00" + strings.ToLower(column)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset/column default", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataRowGeneration(rules []TopDataRowGenerationConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, rule := range rules {
|
|
prefix := fmt.Sprintf("topdata.row_generation[%d]", index)
|
|
dataset := strings.TrimSpace(rule.Dataset)
|
|
namespace := strings.TrimSpace(rule.Namespace)
|
|
if dataset == "" {
|
|
dataset = namespace
|
|
}
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(rule.Mode) {
|
|
case "", "after_base", "first_null_row":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode))
|
|
}
|
|
if rule.MinimumRow < 0 {
|
|
failures = append(failures, fmt.Errorf("%s.minimum_row must be zero or greater", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row generation rule", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataRowExtensions(rules []TopDataRowExtensionConfig) []error {
|
|
failures := []error{}
|
|
seen := map[string]struct{}{}
|
|
for index, rule := range rules {
|
|
prefix := fmt.Sprintf("topdata.row_extensions[%d]", index)
|
|
dataset := strings.TrimSpace(rule.Dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.dataset is required", prefix))
|
|
}
|
|
switch strings.TrimSpace(rule.Mode) {
|
|
case "repeat_last":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", prefix, rule.Mode))
|
|
}
|
|
if strings.TrimSpace(rule.LevelColumn) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.level_column is required", prefix))
|
|
}
|
|
if rule.TargetLevel <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.target_level must be greater than zero", prefix))
|
|
}
|
|
key := filepath.ToSlash(dataset)
|
|
if _, ok := seen[key]; ok {
|
|
failures = append(failures, fmt.Errorf("%s duplicates an earlier dataset row extension rule", prefix))
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTopDataClassFeatInjections(config TopDataClassFeatInjectionConfig) []error {
|
|
failures := []error{}
|
|
for index, rule := range config.GlobalFeats {
|
|
prefix := fmt.Sprintf("topdata.class_feat_injections.global_feats[%d]", index)
|
|
if feat := strings.TrimSpace(rule.Feat); feat == "" {
|
|
failures = append(failures, fmt.Errorf("%s.feat is required", prefix))
|
|
} else if !strings.HasPrefix(feat, "feat:") {
|
|
failures = append(failures, fmt.Errorf("%s.feat must use a feat: reference", prefix))
|
|
}
|
|
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
|
|
failures = append(failures, validateFeatReferenceList(prefix+".require_present", rule.RequirePresent)...)
|
|
failures = append(failures, validateFeatReferenceList(prefix+".unless_present", rule.UnlessPresent)...)
|
|
}
|
|
for index, rule := range config.ClassSkillMasterfeats {
|
|
prefix := fmt.Sprintf("topdata.class_feat_injections.class_skill_masterfeats[%d]", index)
|
|
if masterfeat := strings.TrimSpace(rule.Masterfeat); masterfeat == "" {
|
|
failures = append(failures, fmt.Errorf("%s.masterfeat is required", prefix))
|
|
} else if !strings.HasPrefix(masterfeat, "masterfeats:") {
|
|
failures = append(failures, fmt.Errorf("%s.masterfeat must use a masterfeats: reference", prefix))
|
|
}
|
|
failures = append(failures, validateClassFeatInjectionFields(prefix, rule.List, rule.GrantedOnLevel, rule.OnMenu)...)
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateClassFeatInjectionFields(prefix, list, grantedOnLevel, onMenu string) []error {
|
|
failures := []error{}
|
|
for field, value := range map[string]string{
|
|
"list": list,
|
|
"granted_on_level": grantedOnLevel,
|
|
"on_menu": onMenu,
|
|
} {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
failures = append(failures, fmt.Errorf("%s.%s is required", prefix, field))
|
|
continue
|
|
}
|
|
if _, err := strconv.Atoi(trimmed); err != nil {
|
|
failures = append(failures, fmt.Errorf("%s.%s must be an integer", prefix, field))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateFeatReferenceList(prefix string, refs []string) []error {
|
|
failures := []error{}
|
|
for index, ref := range refs {
|
|
trimmed := strings.TrimSpace(ref)
|
|
if trimmed == "" {
|
|
failures = append(failures, fmt.Errorf("%s[%d] is required", prefix, index))
|
|
} else if !strings.HasPrefix(trimmed, "feat:") {
|
|
failures = append(failures, fmt.Errorf("%s[%d] must use a feat: reference", prefix, index))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func intPow(base, exponent int) int {
|
|
result := 1
|
|
for i := 0; i < exponent; i++ {
|
|
result *= base
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validateExtractMergeConfig(config ExtractMergeConfig) []error {
|
|
var failures []error
|
|
seenTargets := map[string]struct{}{}
|
|
for ruleIndex, rule := range config.GFFJSON {
|
|
prefix := fmt.Sprintf("extract.merge.gff_json[%d]", ruleIndex)
|
|
target := strings.TrimSpace(rule.Target)
|
|
switch {
|
|
case target == "":
|
|
failures = append(failures, fmt.Errorf("%s.target must not be empty", prefix))
|
|
case filepath.IsAbs(filepath.FromSlash(target)):
|
|
failures = append(failures, fmt.Errorf("%s.target %q must be relative to paths.source", prefix, rule.Target))
|
|
default:
|
|
clean := filepath.Clean(filepath.FromSlash(target))
|
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
failures = append(failures, fmt.Errorf("%s.target %q must not escape paths.source", prefix, rule.Target))
|
|
}
|
|
normalized := filepath.ToSlash(clean)
|
|
if _, exists := seenTargets[normalized]; exists {
|
|
failures = append(failures, fmt.Errorf("extract.merge.gff_json target %q is configured more than once", normalized))
|
|
}
|
|
seenTargets[normalized] = struct{}{}
|
|
}
|
|
|
|
for setIndex, setRule := range rule.SetFields {
|
|
if strings.TrimSpace(setRule.Field) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.set_fields[%d].field must not be empty", prefix, setIndex))
|
|
}
|
|
}
|
|
|
|
for listIndex, listRule := range rule.MergeLists {
|
|
listPrefix := fmt.Sprintf("%s.merge_lists[%d]", prefix, listIndex)
|
|
if strings.TrimSpace(listRule.Field) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.field must not be empty", listPrefix))
|
|
}
|
|
if strings.TrimSpace(listRule.KeyField) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.key_field must not be empty", listPrefix))
|
|
}
|
|
switch listRule.Strategy {
|
|
case "preserve_existing_order_append_new":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.strategy must not be empty", listPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.strategy %q is not supported", listPrefix, listRule.Strategy))
|
|
}
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func (p *Project) Scan() error {
|
|
var sourceFiles []string
|
|
var sourceExts []string
|
|
effective := p.EffectiveConfig()
|
|
|
|
sourceDir := p.SourceDir()
|
|
if sourceDir != "" && filepath.Clean(sourceDir) != filepath.Clean(p.Root) {
|
|
var err error
|
|
sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
return ext == ".json" || ext == ".nss"
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("scan source tree: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
assetDir := p.AssetsDir()
|
|
var assetFiles []string
|
|
if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) {
|
|
var err error
|
|
assetFiles, _, err = scanDir(assetDir, func(path string) bool {
|
|
return slices.Contains(effective.Inventory.AssetExtensions, strings.ToLower(filepath.Ext(path)))
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
assetFiles = nil
|
|
} else {
|
|
return fmt.Errorf("scan assets tree: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var scripts []string
|
|
var sources []string
|
|
extensionSet := map[string]struct{}{}
|
|
for _, ext := range sourceExts {
|
|
extensionSet[ext] = struct{}{}
|
|
}
|
|
|
|
for _, path := range sourceFiles {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".nss":
|
|
scripts = append(scripts, path)
|
|
case ".json":
|
|
sources = append(sources, path)
|
|
}
|
|
}
|
|
|
|
extensions := make([]string, 0, len(extensionSet))
|
|
for ext := range extensionSet {
|
|
extensions = append(extensions, ext)
|
|
}
|
|
slices.Sort(extensions)
|
|
|
|
p.Inventory = Inventory{
|
|
SourceFiles: sources,
|
|
ScriptFiles: scripts,
|
|
AssetFiles: assetFiles,
|
|
Extensions: extensions,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Project) SourceDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Source)
|
|
}
|
|
|
|
func (p *Project) AssetsDir() string {
|
|
assets := p.EffectiveConfig().Paths.Assets
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
return p.rootPath(assets)
|
|
}
|
|
|
|
func (p *Project) BuildDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Build)
|
|
}
|
|
|
|
func (p *Project) CacheDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Paths.Cache)
|
|
}
|
|
|
|
func (p *Project) ModuleArchivePath() string {
|
|
effective := p.EffectiveConfig()
|
|
return filepath.Join(p.BuildDir(), effective.Outputs.ModuleArchiveName(effective.Module))
|
|
}
|
|
|
|
func (p *Project) HAKManifestPath() string {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKManifest))
|
|
}
|
|
|
|
func (p *Project) HAKArchivePath(name string) string {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKArchiveName(name)))
|
|
}
|
|
|
|
func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
|
|
if len(names) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
wanted := make(map[string]struct{}, len(names))
|
|
for _, name := range names {
|
|
trimmed := strings.TrimSpace(strings.ToLower(name))
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
wanted[trimmed] = struct{}{}
|
|
}
|
|
if len(wanted) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
|
|
found := make(map[string]struct{}, len(wanted))
|
|
for _, hak := range p.Config.HAKs {
|
|
key := strings.ToLower(strings.TrimSpace(hak.Name))
|
|
if _, ok := wanted[key]; !ok {
|
|
continue
|
|
}
|
|
filtered = append(filtered, hak)
|
|
found[key] = struct{}{}
|
|
}
|
|
|
|
var missing []string
|
|
for _, name := range names {
|
|
key := strings.TrimSpace(strings.ToLower(name))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, ok := found[key]; !ok {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
clone := *p
|
|
clone.Config = p.Config
|
|
clone.Config.HAKs = filtered
|
|
clone.Inventory = p.Inventory
|
|
return &clone, nil
|
|
}
|
|
|
|
func (p *Project) HasTopData() bool {
|
|
return strings.TrimSpace(p.Config.TopData.Source) != ""
|
|
}
|
|
|
|
func (p *Project) TopDataSourceDir() string {
|
|
if !p.HasTopData() {
|
|
return ""
|
|
}
|
|
return filepath.Join(p.Root, p.Config.TopData.Source)
|
|
}
|
|
|
|
func (p *Project) TopDataBuildDir() string {
|
|
return p.rootPath(p.EffectiveConfig().TopData.Build)
|
|
}
|
|
|
|
func (p *Project) TopDataReferenceBuilderDir() string {
|
|
ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
|
|
if ref == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(ref) {
|
|
return ref
|
|
}
|
|
return filepath.Join(p.Root, ref)
|
|
}
|
|
|
|
func (p *Project) TopDataAssetsDir() string {
|
|
assets := strings.TrimSpace(p.Config.TopData.Assets)
|
|
if assets == "" {
|
|
return ""
|
|
}
|
|
if filepath.IsAbs(assets) {
|
|
return assets
|
|
}
|
|
absPath := filepath.Join(p.Root, assets)
|
|
if _, err := os.Stat(absPath); err == nil {
|
|
return absPath
|
|
}
|
|
return absPath
|
|
}
|
|
|
|
func (p *Project) TopDataUsesRepoCache() bool {
|
|
return sameCleanPath(p.TopDataBuildDir(), p.CacheDir())
|
|
}
|
|
|
|
func (p *Project) TopDataCompiled2DADir() string {
|
|
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Compiled2DADir))
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKPath() string {
|
|
tlkName := p.EffectiveConfig().TopData.CompiledTLK
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.BuildDir(), filepath.FromSlash(tlkName))
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(tlkName))
|
|
}
|
|
|
|
func (p *Project) TopDataCompiledTLKDir() string {
|
|
return filepath.Dir(p.TopDataCompiledTLKPath())
|
|
}
|
|
|
|
func (p *Project) TopDataWikiRootDir() string {
|
|
wikiRoot := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.OutputRoot)
|
|
if p.TopDataUsesRepoCache() {
|
|
return filepath.Join(p.CacheDir(), wikiRoot)
|
|
}
|
|
return filepath.Join(p.TopDataBuildDir(), wikiRoot)
|
|
}
|
|
|
|
func (p *Project) TopDataWikiSourceDir() string {
|
|
source := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.Source)
|
|
if filepath.IsAbs(source) {
|
|
return source
|
|
}
|
|
return filepath.Join(p.Root, source)
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPagesDir() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagesDir))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiStatePath() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.StateFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPageIndexPath() string {
|
|
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PageIndexFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiDataPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.DataFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPagePathsPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagePathsFile))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiPageTemplatesDir() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PageTemplatesDir))
|
|
}
|
|
|
|
func (p *Project) TopDataWikiManualSectionsPath() string {
|
|
return filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.ManualSectionsFile))
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKName() string {
|
|
return p.EffectiveConfig().TopData.PackageHAK
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKName() string {
|
|
return p.EffectiveConfig().TopData.PackageTLK
|
|
}
|
|
|
|
func (p *Project) TopDataPackageHAKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageHAKName())
|
|
}
|
|
|
|
func (p *Project) TopDataPackageTLKPath() string {
|
|
return filepath.Join(p.BuildDir(), p.TopDataPackageTLKName())
|
|
}
|
|
|
|
func (p *Project) AutogenCacheDir() string {
|
|
return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
|
|
}
|
|
|
|
func (p *Project) AutogenCachePath(name string) string {
|
|
return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
|
|
}
|
|
|
|
func (p *Project) AutogenCacheMaxAge() time.Duration {
|
|
duration, err := time.ParseDuration(p.EffectiveConfig().Autogen.Cache.MaxAge)
|
|
if err != nil {
|
|
return DefaultAutogenCacheMaxAge
|
|
}
|
|
return duration
|
|
}
|
|
|
|
func (p *Project) AutogenRefreshEnv() string {
|
|
return p.EffectiveConfig().Autogen.Cache.RefreshEnv
|
|
}
|
|
|
|
func (p *Project) PartsManifestRefreshEnv() string {
|
|
return p.EffectiveConfig().Autogen.Cache.PartsManifestRefreshEnv
|
|
}
|
|
|
|
func (p *Project) AssetsServerURLEnv() string {
|
|
return p.EffectiveConfig().Autogen.ReleaseSource.ServerURLEnv
|
|
}
|
|
|
|
func (p *Project) AssetsRepoEnv() string {
|
|
return p.EffectiveConfig().Autogen.ReleaseSource.RepoEnv
|
|
}
|
|
|
|
func (p *Project) rootPath(path string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return p.Root
|
|
}
|
|
if filepath.IsAbs(path) {
|
|
return path
|
|
}
|
|
return filepath.Join(p.Root, filepath.FromSlash(path))
|
|
}
|
|
|
|
func (i Inventory) Report() InventoryReport {
|
|
return InventoryReport{
|
|
SourceFiles: len(i.SourceFiles),
|
|
ScriptFiles: len(i.ScriptFiles),
|
|
AssetFiles: len(i.AssetFiles),
|
|
Extensions: i.Extensions,
|
|
}
|
|
}
|
|
|
|
func defaultConfig() Config {
|
|
return Config{
|
|
Paths: PathConfig{
|
|
Build: DefaultBuildPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
func normalizeConfig(cfg *Config) {
|
|
cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
|
|
cfg.Inventory.AssetExtensions = normalizeExtensionSlice(cfg.Inventory.AssetExtensions)
|
|
cfg.Validation.BuiltinScriptPrefixes = normalizeLowerStringSlice(cfg.Validation.BuiltinScriptPrefixes)
|
|
cfg.Validation.Profile = strings.ToLower(strings.TrimSpace(cfg.Validation.Profile))
|
|
cfg.Validation.RequiredFields = normalizeRequiredFields(cfg.Validation.RequiredFields)
|
|
for i := range cfg.HAKs {
|
|
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
|
|
}
|
|
cfg.Extract.IgnoreExtensions = normalizeStringSlice(cfg.Extract.IgnoreExtensions)
|
|
cfg.Extract.Archives = normalizeStringSlice(cfg.Extract.Archives)
|
|
for i := range cfg.Extract.Merge.GFFJSON {
|
|
target := strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].Target)
|
|
if target != "" {
|
|
target = filepath.ToSlash(filepath.Clean(filepath.FromSlash(target)))
|
|
}
|
|
cfg.Extract.Merge.GFFJSON[i].Target = target
|
|
cfg.Extract.Merge.GFFJSON[i].PreserveFields = normalizeStringSlice(cfg.Extract.Merge.GFFJSON[i].PreserveFields)
|
|
for j := range cfg.Extract.Merge.GFFJSON[i].SetFields {
|
|
cfg.Extract.Merge.GFFJSON[i].SetFields[j].Field = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].SetFields[j].Field)
|
|
}
|
|
for j := range cfg.Extract.Merge.GFFJSON[i].MergeLists {
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Field)
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].KeyField)
|
|
cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Strategy = strings.TrimSpace(cfg.Extract.Merge.GFFJSON[i].MergeLists[j].Strategy)
|
|
}
|
|
}
|
|
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 normalizeExtensionSlice(input []string) []string {
|
|
if len(input) == 0 {
|
|
return input
|
|
}
|
|
out := make([]string, 0, len(input))
|
|
for _, value := range input {
|
|
trimmed := strings.ToLower(strings.TrimSpace(value))
|
|
if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
|
|
trimmed = "." + trimmed
|
|
}
|
|
out = append(out, trimmed)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func normalizeExtension(input string) string {
|
|
trimmed := strings.ToLower(strings.TrimSpace(input))
|
|
if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
|
|
trimmed = "." + trimmed
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func normalizeLowerStringSlice(input []string) []string {
|
|
if len(input) == 0 {
|
|
return input
|
|
}
|
|
out := make([]string, 0, len(input))
|
|
for _, value := range input {
|
|
trimmed := strings.ToLower(strings.TrimSpace(value))
|
|
if trimmed != "" {
|
|
out = append(out, trimmed)
|
|
}
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func normalizeRequiredFields(input map[string][]string) map[string][]string {
|
|
if len(input) == 0 {
|
|
return input
|
|
}
|
|
out := make(map[string][]string, len(input))
|
|
for extension, labels := range input {
|
|
key := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
normalizedLabels := make([]string, 0, len(labels))
|
|
for _, label := range labels {
|
|
label = strings.TrimSpace(label)
|
|
if label != "" {
|
|
normalizedLabels = append(normalizedLabels, label)
|
|
}
|
|
}
|
|
slices.Sort(normalizedLabels)
|
|
out[key] = normalizedLabels
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validateValidationConfig(cfg ValidationConfig) []error {
|
|
var failures []error
|
|
switch strings.TrimSpace(cfg.Profile) {
|
|
case "", DefaultValidationProfile:
|
|
default:
|
|
failures = append(failures, fmt.Errorf("validation.profile %q is not supported", cfg.Profile))
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for index, prefix := range cfg.BuiltinScriptPrefixes {
|
|
prefix = strings.TrimSpace(prefix)
|
|
if prefix == "" {
|
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not be empty", index))
|
|
continue
|
|
}
|
|
if strings.Contains(prefix, "\x00") {
|
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not contain NUL bytes", index))
|
|
}
|
|
normalized := strings.ToLower(prefix)
|
|
if _, exists := seen[normalized]; exists {
|
|
failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] %q is duplicated", index, prefix))
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
}
|
|
seenExtensions := map[string]struct{}{}
|
|
for extension, labels := range cfg.RequiredFields {
|
|
normalizedExtension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
|
if normalizedExtension == "" {
|
|
failures = append(failures, errors.New("validation.required_fields contains an empty extension"))
|
|
continue
|
|
}
|
|
if strings.Contains(normalizedExtension, "\x00") {
|
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q] must not contain NUL bytes", extension))
|
|
}
|
|
if _, exists := seenExtensions[normalizedExtension]; exists {
|
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q] is duplicated after normalization", extension))
|
|
}
|
|
seenExtensions[normalizedExtension] = struct{}{}
|
|
seenLabels := map[string]struct{}{}
|
|
for index, label := range labels {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not be empty", extension, index))
|
|
continue
|
|
}
|
|
if strings.Contains(label, "\x00") {
|
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not contain NUL bytes", extension, index))
|
|
}
|
|
if _, exists := seenLabels[label]; exists {
|
|
failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] %q is duplicated", extension, index, label))
|
|
}
|
|
seenLabels[label] = struct{}{}
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
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)
|
|
id := strings.TrimSpace(producer.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := producerIDs[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
producerIDs[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(producer.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
|
|
}
|
|
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)...)
|
|
if accessoryVisualeffectsConfigConfigured(producer.AccessoryVisualeffects) {
|
|
failures = append(failures, validateAccessoryVisualeffectsConfig(fieldPrefix+".accessory_visualeffects", producer.AccessoryVisualeffects)...)
|
|
}
|
|
}
|
|
|
|
for index, consumer := range cfg.Consumers {
|
|
fieldPrefix := fmt.Sprintf("autogen.consumers[%d]", index)
|
|
id := strings.TrimSpace(consumer.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := consumerIDs[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
consumerIDs[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(consumer.Producer) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.producer is required", fieldPrefix))
|
|
}
|
|
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", "accessory_visualeffects":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.mode is required", fieldPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.mode %q is not supported", fieldPrefix, consumer.Mode))
|
|
}
|
|
if strings.TrimSpace(consumer.Mode) == "accessory_visualeffects" && accessoryVisualeffectsConfigConfigured(consumer.AccessoryVisualeffects) {
|
|
failures = append(failures, validateAccessoryVisualeffectsConfig(fieldPrefix+".accessory_visualeffects", consumer.AccessoryVisualeffects)...)
|
|
}
|
|
// A manifest_file consumer reads a pre-resolved local manifest, and a
|
|
// cdn_channel consumer resolves entirely from Source.* at runtime, so
|
|
// neither reads the released-source fields (root/include/derive/manifest)
|
|
// at build time — don't require them. See resolveAutogenConsumerManifest.
|
|
if strings.TrimSpace(consumer.ManifestFile) == "" &&
|
|
strings.TrimSpace(consumer.Source.Kind) != "cdn_channel" {
|
|
if strings.TrimSpace(consumer.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
|
|
}
|
|
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)...)
|
|
}
|
|
}
|
|
|
|
return failures
|
|
}
|
|
|
|
func validateAutogenConsumerSources(consumers []AutogenConsumerConfig) []error {
|
|
var failures []error
|
|
partsRows := 0
|
|
for _, c := range consumers {
|
|
if strings.TrimSpace(c.Mode) == "parts_rows" {
|
|
partsRows++
|
|
}
|
|
}
|
|
if partsRows > 1 {
|
|
failures = append(failures, fmt.Errorf("at most one autogen consumer may use mode parts_rows; found %d (override precedence would be ambiguous)", partsRows))
|
|
}
|
|
for _, c := range consumers {
|
|
if strings.TrimSpace(c.Source.Kind) != "cdn_channel" {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(c.ID)
|
|
if label == "" {
|
|
label = "<unnamed>"
|
|
}
|
|
if strings.TrimSpace(c.Source.ChannelsPath) == "" {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.channels_path is required for kind cdn_channel", label))
|
|
}
|
|
manifestPath := strings.TrimSpace(c.Source.ManifestPath)
|
|
if manifestPath == "" {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.manifest_path is required for kind cdn_channel", label))
|
|
} else if !strings.Contains(manifestPath, "{tag}") {
|
|
failures = append(failures, fmt.Errorf("autogen consumer %s: source.manifest_path must contain {tag}", label))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateGeneratedConfig(cfg GeneratedConfig) []error {
|
|
var failures []error
|
|
seen := map[string]struct{}{}
|
|
for index, top2da := range cfg.TopData2DA {
|
|
fieldPrefix := fmt.Sprintf("generated_assets.topdata_2da[%d]", index)
|
|
id := strings.TrimSpace(top2da.ID)
|
|
if id == "" {
|
|
failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
|
|
} else {
|
|
if _, exists := seen[id]; exists {
|
|
failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(top2da.Source) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.source is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".source", top2da.Source)...)
|
|
if strings.TrimSpace(top2da.Output) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.output is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateRelativePath(fieldPrefix+".output", top2da.Output)...)
|
|
if strings.TrimSpace(top2da.PackageRoot) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.package_root is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".package_root", top2da.PackageRoot)...)
|
|
if len(top2da.IncludeDatasets) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.include_datasets must contain at least one pattern", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".include_datasets", top2da.IncludeDatasets)...)
|
|
autogenMode := strings.TrimSpace(top2da.Autogen.Mode)
|
|
switch autogenMode {
|
|
case "parts_rows", "cachedmodels_rows":
|
|
case "":
|
|
continue
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.autogen.mode %q is not supported", fieldPrefix, top2da.Autogen.Mode))
|
|
}
|
|
if strings.TrimSpace(top2da.Autogen.Root) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.autogen.root is required", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".autogen.root", top2da.Autogen.Root)...)
|
|
if len(top2da.Autogen.Include) == 0 {
|
|
failures = append(failures, fmt.Errorf("%s.autogen.include must contain at least one glob", fieldPrefix))
|
|
}
|
|
failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
|
|
failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
|
|
if autogenMode == "parts_rows" && partsRowsConfigConfigured(top2da.Autogen.PartsRows) {
|
|
failures = append(failures, validatePartsRowsConfig(fieldPrefix+".autogen.parts_rows", top2da.Autogen.PartsRows)...)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func accessoryVisualeffectsConfigConfigured(cfg AccessoryVisualeffectsConfig) bool {
|
|
return len(cfg.Groups) > 0 ||
|
|
strings.TrimSpace(cfg.GroupTokenSource) != "" ||
|
|
strings.TrimSpace(cfg.CategoryFrom) != "" ||
|
|
strings.TrimSpace(cfg.Delimiter) != "" ||
|
|
strings.TrimSpace(cfg.Case) != "" ||
|
|
len(cfg.StripModelPrefixes) > 0 ||
|
|
strings.TrimSpace(cfg.KeyFormat) != "" ||
|
|
strings.TrimSpace(cfg.LabelFormat) != "" ||
|
|
strings.TrimSpace(cfg.ModelColumn) != "" ||
|
|
len(cfg.RowDefaults) > 0
|
|
}
|
|
|
|
func validateAccessoryVisualeffectsConfig(fieldPrefix string, cfg AccessoryVisualeffectsConfig) []error {
|
|
var failures []error
|
|
groupTokenSource := strings.TrimSpace(cfg.GroupTokenSource)
|
|
switch groupTokenSource {
|
|
case "", "prefix", "folder_name":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.group_token_source %q is not supported", fieldPrefix, cfg.GroupTokenSource))
|
|
}
|
|
categoryFrom := strings.TrimSpace(cfg.CategoryFrom)
|
|
switch categoryFrom {
|
|
case "", "none", "immediate_parent", "full_subgroup":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.category_from %q is not supported", fieldPrefix, cfg.CategoryFrom))
|
|
}
|
|
if strings.TrimSpace(cfg.Delimiter) == "" && cfg.Delimiter != "" {
|
|
failures = append(failures, fmt.Errorf("%s.delimiter must not be only whitespace", fieldPrefix))
|
|
}
|
|
if strings.ContainsAny(cfg.Delimiter, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.delimiter must not contain whitespace or NUL bytes", fieldPrefix))
|
|
}
|
|
caseMode := strings.TrimSpace(cfg.Case)
|
|
switch caseMode {
|
|
case "", "preserve", "lower", "upper":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.case %q is not supported", fieldPrefix, cfg.Case))
|
|
}
|
|
for group, groupCfg := range cfg.Groups {
|
|
group = strings.TrimSpace(group)
|
|
if group == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups contains an empty group key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validateTreeRootPath(fieldPrefix+".groups["+group+"]", group)...)
|
|
prefix := strings.TrimSpace(groupCfg.Prefix)
|
|
if prefix == "" && groupTokenSource != "folder_name" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].prefix is required", fieldPrefix, group))
|
|
}
|
|
if strings.ContainsAny(prefix, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].prefix must not contain whitespace or NUL bytes", fieldPrefix, group))
|
|
}
|
|
if column := strings.TrimSpace(groupCfg.ModelColumn); column != "" && strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_column must not contain whitespace or NUL bytes", fieldPrefix, group))
|
|
}
|
|
for index, column := range groupCfg.ModelColumns {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_columns[%d] must not be empty", fieldPrefix, group, index))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].model_columns[%d] must not contain whitespace or NUL bytes", fieldPrefix, group, index))
|
|
}
|
|
}
|
|
for column := range groupCfg.RowDefaults {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].row_defaults contains an empty column name", fieldPrefix, group))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.groups[%s].row_defaults[%s] column name must not contain whitespace or NUL bytes", fieldPrefix, group, column))
|
|
}
|
|
}
|
|
}
|
|
for index, prefix := range cfg.StripModelPrefixes {
|
|
if strings.TrimSpace(prefix) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.strip_model_prefixes[%d] must not be empty", fieldPrefix, index))
|
|
}
|
|
if strings.Contains(prefix, "\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.strip_model_prefixes[%d] must not contain NUL bytes", fieldPrefix, index))
|
|
}
|
|
}
|
|
if column := strings.TrimSpace(cfg.ModelColumn); column != "" && strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.model_column must not contain whitespace or NUL bytes", fieldPrefix))
|
|
}
|
|
for column := range cfg.RowDefaults {
|
|
column = strings.TrimSpace(column)
|
|
if column == "" {
|
|
failures = append(failures, fmt.Errorf("%s.row_defaults contains an empty column name", fieldPrefix))
|
|
}
|
|
if strings.ContainsAny(column, " \t\r\n\x00") {
|
|
failures = append(failures, fmt.Errorf("%s.row_defaults[%s] column name must not contain whitespace or NUL bytes", fieldPrefix, column))
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func partsRowsConfigConfigured(cfg PartsRowsConfig) bool {
|
|
if len(cfg.RowDefaults) > 0 {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
|
return true
|
|
}
|
|
if len(cfg.ACBonus.Datasets) > 0 {
|
|
return true
|
|
}
|
|
if len(cfg.Datasets) > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validatePartsRowsConfig(fieldPrefix string, cfg PartsRowsConfig) []error {
|
|
var failures []error
|
|
if partsRowsProjectACBonusConfigured(cfg) {
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.default", cfg.ACBonus.Default, false)...)
|
|
for dataset, policy := range cfg.ACBonus.Datasets {
|
|
dataset = strings.TrimSpace(dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.acbonus.datasets contains an empty dataset key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.datasets."+dataset, policy, true)...)
|
|
}
|
|
for dataset, datasetCfg := range cfg.Datasets {
|
|
dataset = strings.TrimSpace(dataset)
|
|
if dataset == "" {
|
|
failures = append(failures, fmt.Errorf("%s.datasets contains an empty dataset key", fieldPrefix))
|
|
continue
|
|
}
|
|
failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".datasets."+dataset+".acbonus", datasetCfg.ACBonus, true)...)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func partsRowsProjectACBonusConfigured(cfg PartsRowsConfig) bool {
|
|
if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
|
|
return true
|
|
}
|
|
if len(cfg.ACBonus.Datasets) > 0 {
|
|
return true
|
|
}
|
|
for _, dataset := range cfg.Datasets {
|
|
if strings.TrimSpace(dataset.ACBonus.Strategy) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validatePartsRowsACBonusPolicy(fieldPrefix string, policy PartsRowsACBonusPolicy, allowEmpty bool) []error {
|
|
strategy := strings.TrimSpace(policy.Strategy)
|
|
if strategy == "" {
|
|
if allowEmpty {
|
|
return nil
|
|
}
|
|
return []error{fmt.Errorf("%s.strategy is required", fieldPrefix)}
|
|
}
|
|
switch strategy {
|
|
case "ascending_row_id_sort_key", "descending_row_id_sort_key":
|
|
var failures []error
|
|
if strategy == "descending_row_id_sort_key" && policy.MaxRowID <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.max_row_id must be greater than 0", fieldPrefix))
|
|
}
|
|
if policy.Divisor <= 0 {
|
|
failures = append(failures, fmt.Errorf("%s.divisor must be greater than 0", fieldPrefix))
|
|
}
|
|
if err := validatePartsRowsFormat(policy.Format); err != nil {
|
|
failures = append(failures, fmt.Errorf("%s.format %q is invalid: %w", fieldPrefix, policy.Format, err))
|
|
}
|
|
return failures
|
|
case "fixed":
|
|
if strings.TrimSpace(policy.Value) == "" {
|
|
return []error{fmt.Errorf("%s.value is required for fixed strategy", fieldPrefix)}
|
|
}
|
|
return nil
|
|
default:
|
|
return []error{fmt.Errorf("%s.strategy %q is not supported", fieldPrefix, policy.Strategy)}
|
|
}
|
|
}
|
|
|
|
func validatePartsRowsFormat(format string) error {
|
|
if strings.TrimSpace(format) == "" {
|
|
return fmt.Errorf("must not be empty")
|
|
}
|
|
formatted := fmt.Sprintf(format, 1.25)
|
|
if strings.Contains(formatted, "%!") {
|
|
return fmt.Errorf("must format a numeric value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathEscapesRoot(path string) bool {
|
|
clean := filepath.Clean(filepath.FromSlash(path))
|
|
return clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
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) {
|
|
case "trailing_numeric_suffix", "model_stem":
|
|
case "":
|
|
failures = append(failures, fmt.Errorf("%s.kind is required", fieldPrefix))
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.kind %q is not supported", fieldPrefix, cfg.Kind))
|
|
}
|
|
switch strings.TrimSpace(cfg.GroupFrom) {
|
|
case "", "first_path_segment":
|
|
default:
|
|
failures = append(failures, fmt.Errorf("%s.group_from %q is not supported", fieldPrefix, cfg.GroupFrom))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateAutogenManifestConfig(fieldPrefix string, cfg AutogenManifestConfig) []error {
|
|
var failures []error
|
|
if strings.TrimSpace(cfg.ReleaseTag) == "" {
|
|
failures = append(failures, fmt.Errorf("%s.release_tag is required", fieldPrefix))
|
|
}
|
|
if err := validateOutputFileName(fieldPrefix+".asset_name", cfg.AssetName, ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
if err := validateOutputFileName(fieldPrefix+".cache_name", cfg.CacheName, ".json"); err != nil {
|
|
failures = append(failures, err)
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
|
|
var files []string
|
|
extSet := map[string]struct{}{}
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !include(path) {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
files = append(files, rel)
|
|
|
|
if strings.HasSuffix(rel, ".json") {
|
|
base := strings.TrimSuffix(filepath.Base(rel), ".json")
|
|
ext := strings.ToLower(filepath.Ext(base))
|
|
if ext != "" {
|
|
extSet[ext] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
slices.Sort(files)
|
|
|
|
exts := make([]string, 0, len(extSet))
|
|
for ext := range extSet {
|
|
exts = append(exts, ext)
|
|
}
|
|
slices.Sort(exts)
|
|
return files, exts, nil
|
|
}
|
|
|
|
func validateOutputFileName(field, name, expectedExt string) error {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("%s is required", field)
|
|
}
|
|
if filepath.Base(trimmed) != trimmed {
|
|
return fmt.Errorf("%s must be a file name, not a path: %q", field, name)
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(trimmed), expectedExt) {
|
|
return fmt.Errorf("%s must use %s extension: %q", field, expectedExt, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathIsUnder(root, rel string) bool {
|
|
root = strings.Trim(strings.TrimSpace(filepath.ToSlash(root)), "/")
|
|
rel = strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/")
|
|
if root == "" || rel == "" {
|
|
return false
|
|
}
|
|
return rel == root || strings.HasPrefix(rel, root+"/")
|
|
}
|
|
|
|
func validateRelativePath(field, path string) []error {
|
|
var failures []error
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return failures
|
|
}
|
|
if filepath.IsAbs(trimmed) {
|
|
failures = append(failures, fmt.Errorf("%s must be relative to the repository root: %q", field, path))
|
|
}
|
|
clean := filepath.Clean(filepath.FromSlash(trimmed))
|
|
if clean == "." {
|
|
return failures
|
|
}
|
|
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
|
failures = append(failures, fmt.Errorf("%s must not escape the repository root: %q", field, path))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func validateTreeRootPath(field, path string) []error {
|
|
failures := validateRelativePath(field, path)
|
|
trimmed := strings.TrimSpace(path)
|
|
if trimmed == "" {
|
|
return failures
|
|
}
|
|
if filepath.Clean(filepath.FromSlash(trimmed)) == "." {
|
|
failures = append(failures, fmt.Errorf("%s must not resolve to the repository root: %q", field, path))
|
|
}
|
|
return failures
|
|
}
|
|
|
|
func sameCleanPath(a, b string) bool {
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|