diff --git a/internal/app/app.go b/internal/app/app.go index edf6c45..37066fc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -180,6 +180,7 @@ func runBuildModule(ctx context) error { type buildHAKOptions struct { filteredHAKs []string + filteredArchives []string planOnly bool } @@ -205,7 +206,10 @@ func runBuildHAKs(ctx context) error { if opts.planOnly { result, err = pipeline.PlanHAKsWithProgress(p, progress) } else { - result, err = pipeline.BuildHAKsWithProgress(p, progress) + result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{ + Progress: progress, + ArchiveNames: opts.filteredArchives, + }) } if err != nil { return err @@ -230,13 +234,19 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) { arg := args[index] switch arg { case "-h", "--help": - return opts, errors.New("usage: build-haks [--hak ...] [--plan-only]") + return opts, errors.New("usage: build-haks [--hak ...] [--archive ...] [--plan-only]") case "--hak": index++ if index >= len(args) { return opts, errors.New("--hak requires a value") } opts.filteredHAKs = append(opts.filteredHAKs, args[index]) + case "--archive": + index++ + if index >= len(args) { + return opts, errors.New("--archive requires a value") + } + opts.filteredArchives = append(opts.filteredArchives, args[index]) case "--plan-only": opts.planOnly = true default: @@ -244,6 +254,10 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) { opts.filteredHAKs = append(opts.filteredHAKs, value) continue } + if value, ok := parseInlineFlagValue(arg, "--archive"); ok { + opts.filteredArchives = append(opts.filteredArchives, value) + continue + } return opts, fmt.Errorf("unknown build-haks argument %q", arg) } } diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index d9726a2..23d933a 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -66,6 +66,11 @@ type hakChunk struct { type ProgressFunc func(string) +type BuildHAKOptions struct { + Progress ProgressFunc + ArchiveNames []string +} + func Build(p *project.Project) (BuildResult, error) { var topPackage topdata.PackageResult var err error @@ -145,6 +150,10 @@ func BuildHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResu return buildHAKs(p, progress) } +func BuildHAKsWithOptions(p *project.Project, opts BuildHAKOptions) (BuildResult, error) { + return planOrBuildHAKs(p, opts.Progress, true, opts.ArchiveNames) +} + func PlanHAKs(p *project.Project) (BuildResult, error) { return planHAKs(p, nil) } @@ -154,14 +163,14 @@ func PlanHAKsWithProgress(p *project.Project, progress ProgressFunc) (BuildResul } func buildHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) { - return planOrBuildHAKs(p, progress, true) + return planOrBuildHAKs(p, progress, true, nil) } func planHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) { - return planOrBuildHAKs(p, progress, false) + return planOrBuildHAKs(p, progress, false, nil) } -func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool) (BuildResult, error) { +func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, archiveNames []string) (BuildResult, error) { preserveExistingHAKs := envBool("SOW_BUILD_HAKS_KEEP_EXISTING") progressf(progress, "Validating project...") @@ -189,6 +198,10 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo if err != nil { return BuildResult{}, err } + chunks, err = filterHAKChunksByName(chunks, archiveNames) + if err != nil { + return BuildResult{}, err + } progressf(progress, "Resolving module HAK order...") moduleHakOrder, err := resolveModuleHAKOrder(p, chunks) if err != nil { @@ -274,6 +287,50 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo return result, nil } +func filterHAKChunksByName(chunks []hakChunk, archiveNames []string) ([]hakChunk, error) { + if len(archiveNames) == 0 { + return chunks, nil + } + + wanted := make(map[string]struct{}, len(archiveNames)) + for _, name := range archiveNames { + trimmed := strings.TrimSpace(strings.ToLower(name)) + if trimmed == "" { + continue + } + wanted[trimmed] = struct{}{} + } + if len(wanted) == 0 { + return chunks, nil + } + + filtered := make([]hakChunk, 0, len(wanted)) + found := make(map[string]struct{}, len(wanted)) + for _, chunk := range chunks { + key := strings.ToLower(strings.TrimSpace(chunk.Name)) + if _, ok := wanted[key]; !ok { + continue + } + filtered = append(filtered, chunk) + found[key] = struct{}{} + } + + var missing []string + for _, name := range archiveNames { + 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 archive(s): %s", strings.Join(missing, ", ")) + } + return filtered, nil +} + func progressf(progress ProgressFunc, message string) { if progress != nil { progress(message) diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index d168bfe..f76a0bd 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -3,6 +3,7 @@ package pipeline import ( "bytes" "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -935,7 +936,6 @@ func TestBuildSplitsMultipleHAKGroupsDeterministically(t *testing.T) { } ] } - `) mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ "file_type": "IFO ", @@ -1039,6 +1039,87 @@ func TestBuildSplitsMultipleHAKGroupsDeterministically(t *testing.T) { } } +func TestBuildHAKsWithOptionsBuildsSingleArchiveByName(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src")) + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "assets", "core")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": { + "name": "Test Module", + "resref": "testmod" + }, + "paths": { + "source": "src", + "assets": "assets", + "build": "build" + }, + "haks": [ + { + "name": "core", + "priority": 1, + "max_bytes": 310, + "split": true, + "include": ["core/**"] + } + ] +} +`) + mustWriteFile(t, filepath.Join(root, "src", "module", "module.ifo.json"), `{ + "file_type": "IFO ", + "file_version": "V3.2", + "root": { + "struct_type": 0, + "fields": [ + { + "label": "Mod_Name", + "type": "CExoString", + "value": "Test Module" + }, + { + "label": "Mod_HakList", + "type": "List", + "value": [] + } + ] + } +} +`) + mustWriteFile(t, filepath.Join(root, "assets", "core", "a.tga"), strings.Repeat("a", 80)) + mustWriteFile(t, filepath.Join(root, "assets", "core", "b.tga"), strings.Repeat("b", 80)) + mustWriteFile(t, filepath.Join(root, "assets", "core", "c.tga"), strings.Repeat("c", 80)) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + if err := p.ValidateLayout(); err != nil { + t.Fatalf("validate layout: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("scan: %v", err) + } + + result, err := BuildHAKsWithOptions(p, BuildHAKOptions{ArchiveNames: []string{"core_02"}}) + if err != nil { + t.Fatalf("build filtered haks: %v", err) + } + if len(result.HAKPaths) != 1 { + t.Fatalf("expected 1 hak output, got %d", len(result.HAKPaths)) + } + if got := filepath.Base(result.HAKPaths[0]); got != "core_02.hak" { + t.Fatalf("unexpected hak output %q", got) + } + if _, err := os.Stat(filepath.Join(root, "build", "core_01.hak")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected core_01.hak to be absent, got err=%v", err) + } + if _, err := os.Stat(filepath.Join(root, "build", "core.hak")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected unsplit core.hak to be absent, got err=%v", err) + } +} + func TestBuildExcludesOptionalHAKsFromModuleOrder(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src"))