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", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mp3", ".mtr", ".ogg", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml", } 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"` Scripts ScriptsConfig `json:"scripts" yaml:"scripts"` Music MusicConfig `json:"music" yaml:"music"` TopData TopDataConfig `json:"topdata" yaml:"topdata"` Extract ExtractConfig `json:"extract" yaml:"extract"` Autogen AutogenConfig `json:"autogen" yaml:"autogen"` } type ModuleConfig struct { Name string `json:"name" 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 ScriptsConfig struct { SourceDir string `json:"source_dir" yaml:"source_dir"` Cache string `json:"cache" yaml:"cache"` Compiler ScriptCompilerConfig `json:"compiler" yaml:"compiler"` } type ScriptCompilerConfig struct { Path string `json:"path" yaml:"path"` Search []string `json:"search" yaml:"search"` Env ScriptCompilerEnvConfig `json:"env" yaml:"env"` } type ScriptCompilerEnvConfig struct { Path string `json:"path" yaml:"path"` NWNRoot []string `json:"nwn_root" yaml:"nwn_root"` NWNUserDirectory []string `json:"nwn_user_directory" yaml:"nwn_user_directory"` } 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 MusicConfig struct { Prefixes map[string]string `json:"prefixes,omitempty" yaml:"prefixes,omitempty"` Tools MusicToolsConfig `json:"tools,omitempty" yaml:"tools,omitempty"` Defaults *MusicDefaultsConfig `json:"defaults,omitempty" yaml:"defaults,omitempty"` Datasets map[string]MusicDatasetConfig `json:"datasets,omitempty" yaml:"datasets,omitempty"` } type MusicToolsConfig struct { FFmpeg string `json:"ffmpeg,omitempty" yaml:"ffmpeg,omitempty"` FFprobe string `json:"ffprobe,omitempty" yaml:"ffprobe,omitempty"` } type MusicDefaultsConfig struct { StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` CreditsOverlay string `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"` MaxStemLength *int `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"` NamingScheme string `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"` OutputExtension string `json:"output_extension,omitempty" yaml:"output_extension,omitempty"` ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"` } type MusicDatasetConfig struct { Source string `json:"source" yaml:"source"` Output string `json:"output,omitempty" yaml:"output,omitempty"` Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` StageRoot string `json:"stage_root,omitempty" yaml:"stage_root,omitempty"` CreditsRoot string `json:"credits_root,omitempty" yaml:"credits_root,omitempty"` NamingScheme string `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"` OutputExtension string `json:"output_extension,omitempty" yaml:"output_extension,omitempty"` PackageMode string `json:"package_mode,omitempty" yaml:"package_mode,omitempty"` ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"` } 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"` 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"` } 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"` TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` 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"` } 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"` } 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"` } 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"` Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"` LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"` } 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 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, "scripts.source_dir": p.Config.Scripts.SourceDir, "scripts.cache": p.Config.Scripts.Cache, "scripts.compiler.path": p.Config.Scripts.Compiler.Path, "scripts.compiler.env.path": p.Config.Scripts.Compiler.Env.Path, "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.tables_file": p.Config.TopData.Wiki.TablesFile, "topdata.wiki.visibility_file": p.Config.TopData.Wiki.VisibilityFile, "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, validateTopDataClassFeatInjections(effective.TopData.ClassFeatInjections)...) failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...) failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...) 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, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...) failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...) 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) } 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)) } 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)...) failures = append(failures, validateMusicConfig(p.Config.Music)...) hakNames := map[string]struct{}{} for index, hak := range p.Config.HAKs { name := strings.TrimSpace(hak.Name) if strings.TrimSpace(hak.Name) == "" { failures = append(failures, fmt.Errorf("haks[%d].name is required", index)) } else { normalizedName := strings.ToLower(name) if _, exists := hakNames[normalizedName]; exists { failures = append(failures, fmt.Errorf("haks[%d].name %q is duplicated", index, name)) } hakNames[normalizedName] = struct{}{} if err := validateOutputFileName(fmt.Sprintf("haks[%d].name", index), name+".hak", ".hak"); err != nil { failures = append(failures, err) } } if hak.Priority <= 0 { failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index)) } 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 { 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", "alignment_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)) } 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 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 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) { musicSourceExts := make(map[string]struct{}) for _, ext := range effective.Music.ConvertExtensions { musicSourceExts[ext] = struct{}{} } musicSourceRoots := make(map[string]map[string]struct{}) for _, ds := range effective.Music.Datasets { if strings.TrimSpace(ds.Source) == "" { continue } exts := musicSourceRoots[ds.Source] if exts == nil { exts = make(map[string]struct{}) musicSourceRoots[ds.Source] = exts } for _, ext := range ds.ConvertExtensions { exts[ext] = struct{}{} } for ext := range musicSourceExts { exts[ext] = struct{}{} } } var err error assetFiles, _, err = scanDir(assetDir, func(path string) bool { rel, err := filepath.Rel(assetDir, path) if err != nil { return false } rel = filepath.ToSlash(rel) ext := strings.ToLower(filepath.Ext(rel)) if slices.Contains(effective.Inventory.AssetExtensions, ext) { return true } for root, exts := range musicSourceRoots { if !pathIsUnder(root, rel) { continue } _, ok := exts[ext] return ok } return false }) 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) 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) ScriptSourceDir() string { return filepath.Join(p.SourceDir(), filepath.FromSlash(p.EffectiveConfig().Scripts.SourceDir)) } func (p *Project) ScriptCacheDir() string { return p.rootPath(p.EffectiveConfig().Scripts.Cache) } func (p *Project) ScriptCompilerPath() string { path := strings.TrimSpace(p.EffectiveConfig().Scripts.Compiler.Path) if path == "" { return "" } if filepath.IsAbs(path) { return path } return filepath.Join(p.Root, filepath.FromSlash(path)) } func (p *Project) ScriptCompilerSearchPaths() []string { search := p.EffectiveConfig().Scripts.Compiler.Search out := make([]string, 0, len(search)) for _, candidate := range search { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } if strings.HasPrefix(candidate, "PATH:") || filepath.IsAbs(candidate) { out = append(out, candidate) continue } out = append(out, filepath.Join(p.Root, filepath.FromSlash(candidate))) } return out } func (p *Project) ScriptCompilerPathEnv() string { return p.EffectiveConfig().Scripts.Compiler.Env.Path } func (p *Project) ScriptCompilerNWNRootEnvKeys() []string { return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNRoot) } func (p *Project) ScriptCompilerNWNUserDirectoryEnvKeys() []string { return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNUserDirectory) } func (p *Project) MusicStageDir() string { return p.rootPath(p.EffectiveConfig().Music.StageRoot) } func (p *Project) CreditsDir() string { return p.rootPath(p.EffectiveConfig().Music.CreditsRoot) } func (p *Project) MusicToolPath(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if filepath.IsAbs(path) { return path } return filepath.Join(p.Root, filepath.FromSlash(path)) } func (p *Project) MusicFFmpegPath() string { return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFmpeg) } func (p *Project) MusicFFprobePath() string { return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFprobe) } 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].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) } normalizeMusicConfig(&cfg.Music) } func normalizeMusicConfig(music *MusicConfig) { if music.Defaults != nil { music.Defaults.ConvertExtensions = normalizeExtensionSlice(music.Defaults.ConvertExtensions) music.Defaults.OutputExtension = normalizeExtension(music.Defaults.OutputExtension) } if len(music.Prefixes) > 0 && len(music.Datasets) == 0 { datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes)) for dir, prefix := range music.Prefixes { source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(dir))) if source == "" { continue } key := strings.ReplaceAll(source, "/", "_") datasets[key] = MusicDatasetConfig{ Source: source, Prefix: strings.TrimSpace(prefix), } } music.Datasets = datasets } for id, ds := range music.Datasets { ds.Source = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source))) ds.Output = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output))) ds.OutputExtension = normalizeExtension(ds.OutputExtension) ds.ConvertExtensions = normalizeExtensionSlice(ds.ConvertExtensions) music.Datasets[id] = ds } } 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)...) } 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", "head_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.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 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 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 validateMusicConfig(cfg MusicConfig) []error { var failures []error if cfg.Defaults != nil { if cfg.Defaults.MaxStemLength != nil && *cfg.Defaults.MaxStemLength < 3 { failures = append(failures, errors.New("music.defaults.max_stem_length must be at least 3")) } if cfg.Defaults.NamingScheme != "" && cfg.Defaults.NamingScheme != "nwn_bmu" && cfg.Defaults.NamingScheme != "passthrough" { failures = append(failures, fmt.Errorf("music.defaults.naming_scheme %q is not supported", cfg.Defaults.NamingScheme)) } if cfg.Defaults.OutputExtension != "" && !strings.HasPrefix(cfg.Defaults.OutputExtension, ".") { failures = append(failures, errors.New("music.defaults.output_extension must start with .")) } } normalizedRoots := map[string]string{} for root, prefix := range cfg.Prefixes { normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root))) if normalizedRoot == "" { failures = append(failures, errors.New("music.prefixes contains an empty root")) continue } if prior, exists := normalizedRoots[normalizedRoot]; exists { failures = append(failures, fmt.Errorf("music.prefixes roots %q and %q normalize to the same path", prior, root)) } normalizedRoots[normalizedRoot] = root if strings.TrimSpace(prefix) == "" { failures = append(failures, fmt.Errorf("music.prefixes[%q] must not be empty", root)) } } seenSources := map[string]string{} for id, ds := range cfg.Datasets { if strings.TrimSpace(id) == "" { failures = append(failures, errors.New("music.datasets contains an empty key")) } else if strings.Contains(id, "/") || strings.Contains(id, "\\") { failures = append(failures, fmt.Errorf("music.datasets id %q must not contain path separators", id)) } source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source))) if source == "" { failures = append(failures, fmt.Errorf("music.datasets[%q].source is required", id)) } else if pathEscapesRoot(source) { failures = append(failures, fmt.Errorf("music.datasets[%q].source must not escape project root", id)) } else if prior, exists := seenSources[source]; exists { failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior)) } else { seenSources[source] = id } output := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output))) if output != "" && pathEscapesRoot(output) { failures = append(failures, fmt.Errorf("music.datasets[%q].output must not escape project root", id)) } if ds.NamingScheme != "" && ds.NamingScheme != "nwn_bmu" && ds.NamingScheme != "passthrough" { failures = append(failures, fmt.Errorf("music.datasets[%q].naming_scheme %q is not supported", id, ds.NamingScheme)) } if ds.PackageMode != "" && ds.PackageMode != "hak_asset" && ds.PackageMode != "none" { failures = append(failures, fmt.Errorf("music.datasets[%q].package_mode %q is not supported", id, ds.PackageMode)) } if ds.OutputExtension != "" && !strings.HasPrefix(ds.OutputExtension, ".") { failures = append(failures, fmt.Errorf("music.datasets[%q].output_extension must start with .", id)) } } return failures } 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) }