diff --git a/internal/pipeline/build.go b/internal/pipeline/build.go index b99604d..e1ec38b 100644 --- a/internal/pipeline/build.go +++ b/internal/pipeline/build.go @@ -661,11 +661,17 @@ func collectAssetResources(p *project.Project, requireContent bool, allowed map[ if err != nil { return nil, err } - assetsRelPath, err := filepath.Rel(p.Root, p.AssetsDir()) - if err != nil { - return nil, fmt.Errorf("resolve assets dir relative path: %w", err) + // A module-only project (consumes prebuilt HAKs, has no paths.assets) has no + // asset files to collect — AssetFiles is empty and AssetsDir() is "". Skip the + // relative-path resolve, which would otherwise fail with `Rel: can't make ""`. + assetsRelPath := "" + if p.AssetsDir() != "" { + assetsRelPath, err = filepath.Rel(p.Root, p.AssetsDir()) + if err != nil { + return nil, fmt.Errorf("resolve assets dir relative path: %w", err) + } + assetsRelPath = filepath.ToSlash(assetsRelPath) } - assetsRelPath = filepath.ToSlash(assetsRelPath) for _, rel := range p.Inventory.AssetFiles { if musicAssets != nil { diff --git a/internal/pipeline/music.go b/internal/pipeline/music.go index d16b1f4..7b6e47a 100644 --- a/internal/pipeline/music.go +++ b/internal/pipeline/music.go @@ -435,33 +435,38 @@ func writeCreditsArtifacts(p *project.Project, generated []musicCreditGroup) (cr } } - err := filepath.WalkDir(p.AssetsDir(), func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { + // Authored credits.md files live under the assets tree. A project with no + // configured assets dir (e.g. a module that consumes prebuilt HAKs) has none + // to collect — walking "" would fail with `lstat : no such file or directory`. + if assetsDir := p.AssetsDir(); assetsDir != "" { + err := filepath.WalkDir(assetsDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if strings.ToLower(d.Name()) != "credits.md" { + return nil + } + entries, err := music.ParseCreditsMarkdown(path) + if err != nil { + return fmt.Errorf("parse credits %s: %w", path, err) + } + rel, err := filepath.Rel(p.Root, path) + if err != nil { + return err + } + sources = append(sources, music.CreditsSource{ + Path: filepath.ToSlash(rel), + Kind: "authored", + Entries: entries, + }) return nil - } - if strings.ToLower(d.Name()) != "credits.md" { - return nil - } - entries, err := music.ParseCreditsMarkdown(path) - if err != nil { - return fmt.Errorf("parse credits %s: %w", path, err) - } - rel, err := filepath.Rel(p.Root, path) - if err != nil { - return err - } - sources = append(sources, music.CreditsSource{ - Path: filepath.ToSlash(rel), - Kind: "authored", - Entries: entries, }) - return nil - }) - if err != nil { - return creditsArtifactWriteResult{}, err + if err != nil { + return creditsArtifactWriteResult{}, err + } } sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 3ca8c0a..b9259a2 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -4084,3 +4084,62 @@ haks: t.Fatalf("plan-only should not stage music, stat err=%v", err) } } + +// A module-only project (consumes prebuilt HAKs, no paths.assets) must build via +// the full `crucible module build` (pipeline.Build) without an assets tree. The +// HAK + music stages used to crash on the unset assets dir +// (`lstat : no such file or directory` / `Rel: can't make "" relative`). +func TestBuildModuleOnlyProjectWithoutAssetsDir(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src", "module")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test Module + resref: testmod +paths: + source: src + build: build +`) + 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" + } + ] + } +} +`) + + 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) + } + if p.AssetsDir() != "" { + t.Fatalf("test precondition: expected unset assets dir, got %q", p.AssetsDir()) + } + + result, err := Build(p) + if err != nil { + t.Fatalf("full build of module-only project: %v", err) + } + if _, err := os.Stat(result.ModulePath); err != nil { + t.Fatalf("expected built .mod at %s: %v", result.ModulePath, err) + } + if result.HAKAssets != 0 { + t.Fatalf("module-only project has no assets, expected 0 HAK assets, got %d", result.HAKAssets) + } +}