Files
sow-tools/internal/project/project_test.go
T
archvillainette fa4c9116ae Hardening audit
Key hardening changes:

  - Config validation now rejects paths.source: . and paths.assets: ., so source/asset roots cannot
    resolve to the repository root.
  - apply-hak-manifest now refuses to run when paths.source is unset or unsafe, instead of potentially
    writing under a root-level module/.
  - Inline flags across utility parsers now reject empty values consistently, e.g. --dataset=, --hak=,
    --endpoint=, --output=.
  - build-changelog now supports --flag=value inline syntax like the other utilities.
2026-05-08 00:31:17 +02:00

868 lines
24 KiB
Go

package project
import (
"bytes"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestLoadYAMLConfig(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
assets: assets
build: output
haks:
- name: core
priority: 1
max_bytes: 1024
split: false
include:
- core/**
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if proj.ConfigSource.Name != ConfigFile || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy {
t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
}
if got, want := proj.Config.Module.Name, "Test Module"; got != want {
t.Fatalf("expected module name %q, got %q", want, got)
}
if got, want := proj.BuildDir(), filepath.Join(root, "output"); got != want {
t.Fatalf("expected build dir %s, got %s", want, got)
}
}
func TestEffectiveConfigAppliesVisibleToolkitDefaults(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
effective := proj.EffectiveConfig()
if got, want := effective.Paths.Build, "build"; got != want {
t.Fatalf("expected default build path %q, got %q", want, got)
}
if got, want := effective.Outputs.HAKManifest, "haks.json"; got != want {
t.Fatalf("expected default HAK manifest %q, got %q", want, got)
}
if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want {
t.Fatalf("expected default topdata HAK %q, got %q", want, got)
}
if got, want := strings.Join(effective.Validation.BuiltinScriptPrefixes, ","), "ga_,gc_,gen_,gui_,nw_,nwg_,ta_,x0_,x1_,x2_,x3_"; got != want {
t.Fatalf("expected default built-in script prefixes %q, got %q", want, got)
}
if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" {
t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields)
}
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
}
if prov := effective.Provenance["module.name"]; prov.Source != "yaml" {
t.Fatalf("expected module.name YAML provenance, got %#v", prov)
}
}
func TestEffectiveConfigHonorsValidationConfig(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
validation:
builtin_script_prefixes:
- custom_
- NW_
required_fields:
ifo:
- Mod_Name
- Mod_Hak
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
got := strings.Join(proj.EffectiveConfig().Validation.BuiltinScriptPrefixes, ",")
if want := "custom_,nw_"; got != want {
t.Fatalf("expected configured validation prefixes %q, got %q", want, got)
}
if got := strings.Join(proj.EffectiveConfig().Validation.RequiredFields["ifo"], ","); got != "Mod_Hak,Mod_Name" {
t.Fatalf("expected configured required fields, got %#v", proj.EffectiveConfig().Validation.RequiredFields)
}
}
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
build: output
cache: cache
outputs:
module_archive: modules/{module.resref}.mod
hak_manifest: manifests/haks.json
hak_archive: haks/{hak.name}.hak
scripts:
cache: "{paths.cache}/compiled"
topdata:
source: topdata
build: "{paths.cache}"
compiled_2da_dir: tables
compiled_tlk: dialog.tlk
package_hak: custom_top.hak
package_tlk: custom_dialog.tlk
wiki:
output_root: docs
pages_dir: pages
state_file: wiki-state.json
autogen:
cache:
root: "{paths.cache}/autogen"
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got, want := proj.ModuleArchivePath(), filepath.Join(root, "output", "modules", "testmod.mod"); got != want {
t.Fatalf("expected module archive path %s, got %s", want, got)
}
if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "manifests", "haks.json"); got != want {
t.Fatalf("expected HAK manifest path %s, got %s", want, got)
}
if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "haks", "core_01.hak"); got != want {
t.Fatalf("expected HAK archive path %s, got %s", want, got)
}
if got, want := proj.ScriptCacheDir(), filepath.Join(root, "cache", "compiled"); got != want {
t.Fatalf("expected script cache path %s, got %s", want, got)
}
if got, want := proj.TopDataCompiled2DADir(), filepath.Join(root, "cache", "tables"); got != want {
t.Fatalf("expected topdata 2da path %s, got %s", want, got)
}
if got, want := proj.TopDataWikiStatePath(), filepath.Join(root, "cache", "docs", "wiki-state.json"); got != want {
t.Fatalf("expected wiki state path %s, got %s", want, got)
}
if got, want := proj.AutogenCachePath("manifest.json"), filepath.Join(root, "cache", "autogen", "manifest.json"); got != want {
t.Fatalf("expected autogen cache path %s, got %s", want, got)
}
}
func TestEffectiveConfigHonorsConfiguredCompilerExtractWikiAndAutogenPolicy(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
tools: custom-tools
scripts:
source_dir: module-scripts
compiler:
path: bin/compiler
search:
- "{paths.tools}/compiler"
env:
path: TEST_COMPILER
nwn_root:
- TEST_NWN_ROOT
nwn_user_directory:
- TEST_NWN_USER
extract:
layout: nwn_canonical_json
hak_discovery: configured_haks
cleanup_stale: false
topdata:
wiki:
managed_namespaces:
- skills
deploy_manifest: wiki-manifest.json
deploy_edit_summary: Custom summary
autogen:
cache:
root: "{paths.cache}/released"
max_age: 30m
refresh_env: TEST_AUTOGEN_REFRESH
release_source:
provider: gitea
server_url_env: TEST_ASSETS_SERVER
repo_env: TEST_ASSETS_REPO
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
effective := proj.EffectiveConfig()
if got, want := proj.ScriptSourceDir(), filepath.Join(root, "src", "module-scripts"); got != want {
t.Fatalf("expected script source dir %s, got %s", want, got)
}
if got, want := proj.ScriptCompilerPath(), filepath.Join(root, "bin", "compiler"); got != want {
t.Fatalf("expected compiler path %s, got %s", want, got)
}
if got, want := effective.Scripts.Compiler.Search[0], "custom-tools/compiler"; got != want {
t.Fatalf("expected compiler search %q, got %q", want, got)
}
if got, want := effective.Scripts.Compiler.Env.Path, "TEST_COMPILER"; got != want {
t.Fatalf("expected compiler env %q, got %q", want, got)
}
if got, want := effective.Extract.HAKDiscovery, "configured_haks"; got != want {
t.Fatalf("expected extract hak discovery %q, got %q", want, got)
}
if effective.Extract.CleanupStale == nil || *effective.Extract.CleanupStale {
t.Fatalf("expected cleanup_stale false, got %#v", effective.Extract.CleanupStale)
}
if got, want := effective.TopData.Wiki.DeployManifest, "wiki-manifest.json"; got != want {
t.Fatalf("expected wiki deploy manifest %q, got %q", want, got)
}
if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want {
t.Fatalf("expected autogen cache max age %q, got %q", want, got)
}
if got, want := proj.AutogenRefreshEnv(), "TEST_AUTOGEN_REFRESH"; got != want {
t.Fatalf("expected autogen refresh env %q, got %q", want, got)
}
}
func TestActiveOverridesAreVisibleAndSensitiveValuesAreMasked(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
`)
t.Setenv("SOW_BUILD_HAKS_KEEP_EXISTING", "1")
t.Setenv("NODEBB_API_TOKEN", "secret-token")
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
overrides := proj.ActiveOverrides()
var sawKeepExisting, sawMaskedToken bool
for _, override := range overrides {
if override.Key == "build.keep_existing_haks" && override.Value == "1" {
sawKeepExisting = true
}
if override.Key == "wiki.token" && override.Value == "<set>" {
sawMaskedToken = true
}
}
if !sawKeepExisting {
t.Fatalf("expected build keep existing override in %#v", overrides)
}
if !sawMaskedToken {
t.Fatalf("expected masked wiki token override in %#v", overrides)
}
}
func TestEffectiveConfigJSONIsDeterministic(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
first, err := proj.EffectiveConfigJSON()
if err != nil {
t.Fatalf("EffectiveConfigJSON returned error: %v", err)
}
second, err := proj.EffectiveConfigJSON()
if err != nil {
t.Fatalf("EffectiveConfigJSON returned error: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatalf("effective config JSON is not deterministic")
}
if !bytes.Contains(first, []byte(`"hak_manifest": "haks.json"`)) {
t.Fatalf("effective config JSON missing HAK manifest default: %s", first)
}
}
func TestLoadYMLConfig(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFileYML), `
module:
name: Test Module
resref: testmod
paths:
source: src
build: build
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if proj.ConfigSource.Name != ConfigFileYML || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy {
t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
}
}
func TestLoadLegacyJSONConfig(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{
"module": {
"name": "Legacy Module",
"resref": "legacy"
},
"paths": {
"source": "src",
"build": "build"
}
}`+"\n")
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if proj.ConfigSource.Name != LegacyConfigFile || proj.ConfigSource.Format != "json" || !proj.ConfigSource.Legacy {
t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
}
}
func TestLoadPrefersYAMLOverLegacyJSON(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{
"module": {
"name": "Legacy Module",
"resref": "legacy"
}
}`+"\n")
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: YAML Module
resref: yamlmod
paths:
source: src
build: build
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if proj.ConfigSource.Name != ConfigFile {
t.Fatalf("expected YAML config to win, got %#v", proj.ConfigSource)
}
if got, want := proj.Config.Module.ResRef, "yamlmod"; got != want {
t.Fatalf("expected resref %q, got %q", want, got)
}
}
func TestLoadRejectsMalformedYAML(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n name: Test\n resref: bad\n")
if _, err := Load(root); err == nil {
t.Fatal("expected malformed YAML error")
}
}
func TestLoadRejectsUnknownYAMLFields(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
build: build
unexpected: true
`)
err := loadAndValidate(root)
if err == nil {
t.Fatal("expected unknown field error")
}
if !strings.Contains(err.Error(), "field unexpected not found") {
t.Fatalf("expected unknown field error, got %v", err)
}
}
func TestFindRootUsesYAMLConfig(t *testing.T) {
root := t.TempDir()
child := filepath.Join(root, "nested", "dir")
mkdirAll(t, child)
writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n name: Test\n resref: test\n")
got, err := FindRoot(child)
if err != nil {
t.Fatalf("FindRoot returned error: %v", err)
}
if got != root {
t.Fatalf("expected root %s, got %s", root, got)
}
}
func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "custom-assets"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: TopDataConfig{
Assets: "custom-assets",
},
},
}
result := proj.TopDataAssetsDir()
expected := filepath.Join(root, "custom-assets")
if result != expected {
t.Errorf("expected %s, got %s", expected, result)
}
}
func TestTopDataAssetsDirDoesNotAssumeSiblingRepo(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
parentDir := filepath.Dir(root)
siblingPath := filepath.Join(parentDir, "sow-assets")
mkdirAll(t, siblingPath)
mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: TopDataConfig{
Assets: "sow-assets",
},
},
}
result := proj.TopDataAssetsDir()
expected := filepath.Join(root, "sow-assets")
if result != expected {
t.Errorf("expected unresolved local path %s, got %s", expected, result)
}
}
func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
TopData: TopDataConfig{},
},
}
result := proj.TopDataAssetsDir()
if result != "" {
t.Errorf("expected empty string, got %s", result)
}
}
func TestModuleArchivePathUsesConfiguredBuildDirAndResRef(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "output"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
Paths: PathConfig{Build: "output"},
},
}
got := proj.ModuleArchivePath()
want := filepath.Join(root, "output", "testmod.mod")
if got != want {
t.Fatalf("expected %s, got %s", want, got)
}
}
func TestHAKPathsUseConfiguredBuildDir(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "output"))
proj := &Project{
Root: root,
Config: Config{
Paths: PathConfig{Build: "output"},
},
}
if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "haks.json"); got != want {
t.Fatalf("expected manifest path %s, got %s", want, got)
}
if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "core_01.hak"); got != want {
t.Fatalf("expected hak path %s, got %s", want, got)
}
}
func TestTopDataPackagePathsUseConfiguredNamesAndBuildDir(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "output"))
proj := &Project{
Root: root,
Config: Config{
Paths: PathConfig{Build: "output"},
TopData: TopDataConfig{
PackageHAK: "custom_top.hak",
PackageTLK: "custom_dialog.tlk",
},
},
}
if got, want := proj.TopDataPackageHAKPath(), filepath.Join(root, "output", "custom_top.hak"); got != want {
t.Fatalf("expected top hak path %s, got %s", want, got)
}
if got, want := proj.TopDataPackageTLKPath(), filepath.Join(root, "output", "custom_dialog.tlk"); got != want {
t.Fatalf("expected top tlk path %s, got %s", want, got)
}
}
func TestValidateLayoutAllowsMissingAssetsDir(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
},
}
if err := proj.ValidateLayout(); err != nil {
t.Fatalf("ValidateLayout returned error: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "assets")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected missing assets dir to remain absent, got err=%v", err)
}
}
func TestValidateLayoutRejectsInvalidTopDataPackageOutputNames(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Build: "build"},
TopData: TopDataConfig{
Source: "topdata",
PackageHAK: "nested/custom_top.hak",
PackageTLK: "custom_dialog.txt",
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected topdata output validation error")
}
if !strings.Contains(err.Error(), "topdata.package_hak") {
t.Fatalf("expected package_hak validation error, got %v", err)
}
if !strings.Contains(err.Error(), "topdata.package_tlk") {
t.Fatalf("expected package_tlk validation error, got %v", err)
}
}
func TestValidateLayoutRejectsUnsafeGeneratedOutputPaths(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Build: "build"},
Outputs: OutputConfig{
HAKManifest: "../haks.json",
},
TopData: TopDataConfig{
Source: "topdata",
Compiled2DADir: "../2da",
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected unsafe output path validation error")
}
if !strings.Contains(err.Error(), "outputs.hak_manifest") {
t.Fatalf("expected HAK manifest path validation error, got %v", err)
}
if !strings.Contains(err.Error(), "topdata.compiled_2da_dir") {
t.Fatalf("expected topdata path validation error, got %v", err)
}
}
func TestValidateLayoutRejectsRootSourceAndAssetPaths(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: ".", Assets: ".", Build: "build"},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected root source and asset path validation error")
}
if !strings.Contains(err.Error(), "paths.source") {
t.Fatalf("expected source path validation error, got %v", err)
}
if !strings.Contains(err.Error(), "paths.assets") {
t.Fatalf("expected assets path validation error, got %v", err)
}
}
func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Build: "build"},
Autogen: AutogenConfig{
Consumers: []AutogenConsumerConfig{
{
ID: "head_visualeffects",
Producer: "head_visualeffects",
Dataset: "visualeffects",
Mode: "unsupported",
Root: "vfxs",
Include: []string{"head_accessories/**/*.mdl"},
Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"},
Manifest: AutogenManifestConfig{
ReleaseTag: "head-vfx-manifest-current",
AssetName: "nested/sow-head-vfx-manifest.json",
CacheName: "sow-head-vfx-manifest.txt",
},
},
},
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected autogen validation error")
}
if !strings.Contains(err.Error(), "autogen.consumers[0].mode") {
t.Fatalf("expected autogen mode validation error, got %v", err)
}
if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.asset_name") {
t.Fatalf("expected autogen asset_name validation error, got %v", err)
}
if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.cache_name") {
t.Fatalf("expected autogen cache_name validation error, got %v", err)
}
}
func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Build: "build"},
HAKs: []HAKConfig{
{Name: "core", Priority: 1, Include: []string{"core/**"}},
{Name: "CORE", Priority: 2, Include: []string{"other/**"}},
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected duplicate hak validation error")
}
if !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("expected duplicate validation error, got %v", err)
}
}
func TestValidateLayoutRejectsAmbiguousMusicPrefixes(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "assets"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Assets: "assets", Build: "build"},
Music: MusicConfig{
Prefixes: map[string]string{
"envi/music": "mus_",
"/envi/music": "mus2_",
},
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected music prefix validation error")
}
if !strings.Contains(err.Error(), "normalize to the same path") {
t.Fatalf("expected ambiguous prefix validation error, got %v", err)
}
}
func TestScanAllowsMissingAssetsDir(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
},
}
if err := proj.Scan(); err != nil {
t.Fatalf("Scan returned error: %v", err)
}
if len(proj.Inventory.AssetFiles) != 0 {
t.Fatalf("expected no asset files, got %#v", proj.Inventory.AssetFiles)
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
}
func TestCloneWithHAKNamesFiltersConfiguredHAKs(t *testing.T) {
proj := &Project{
Root: "/tmp/test",
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"},
HAKs: []HAKConfig{
{Name: "sow_over", Priority: 1, Include: []string{"over/**"}},
{Name: "sow_core", Priority: 2, Include: []string{"core/**"}},
{Name: "sow_item", Priority: 3, Include: []string{"item/**"}},
},
},
}
filtered, err := proj.CloneWithHAKNames([]string{"sow_core", "sow_item"})
if err != nil {
t.Fatalf("CloneWithHAKNames returned error: %v", err)
}
if filtered == proj {
t.Fatalf("expected filtered clone, got original pointer")
}
got := make([]string, 0, len(filtered.Config.HAKs))
for _, hak := range filtered.Config.HAKs {
got = append(got, hak.Name)
}
want := []string{"sow_core", "sow_item"}
if !slices.Equal(got, want) {
t.Fatalf("unexpected filtered haks: got %v want %v", got, want)
}
if len(proj.Config.HAKs) != 3 {
t.Fatalf("original project config should remain unchanged, got %d haks", len(proj.Config.HAKs))
}
}
func TestCloneWithHAKNamesRejectsUnknownHAKs(t *testing.T) {
proj := &Project{
Config: Config{
HAKs: []HAKConfig{
{Name: "sow_core", Priority: 1, Include: []string{"core/**"}},
},
},
}
if _, err := proj.CloneWithHAKNames([]string{"missing_hak"}); err == nil {
t.Fatal("expected error for unknown hak name")
}
}
func loadAndValidate(root string) error {
proj, err := Load(root)
if err != nil {
return err
}
return proj.ValidateLayout()
}
func writeProjectFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(strings.TrimPrefix(content, "\n")), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}