diff --git a/internal/project/project.go b/internal/project/project.go index 6f719f8..a206182 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -365,6 +365,7 @@ type AutogenConsumerConfig struct { HeadVisualeffects HeadVisualeffectsConfig `json:"accessory_visualeffects" yaml:"accessory_visualeffects"` Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"` LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"` + ManifestFile string `json:"manifest_file,omitempty" yaml:"manifest_file,omitempty"` } type HeadVisualeffectsConfig struct { diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index 5cdde09..0a8e6f3 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -225,6 +225,9 @@ func autogenConsumerTargetsDataset(dataset nativeCollectedDataset, consumer proj } func resolveAutogenConsumerManifest(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) (*autogenManifest, error) { + if manifestFile := strings.TrimSpace(consumer.ManifestFile); manifestFile != "" { + return readAutogenConsumerManifestFile(p, consumer, manifestFile, progress) + } overrideRoot, err := resolveAutogenLocalOverrideRoot(p, consumer) if err != nil { return nil, err @@ -242,6 +245,37 @@ func resolveAutogenConsumerManifest(p *project.Project, consumer project.Autogen return resolveReleasedAutogenManifest(p, consumer, progress) } +// readAutogenConsumerManifestFile reads a pre-resolved autogenManifest JSON from +// a local file (relative paths resolve against the project root). A missing file +// is an ignorable-optional condition — it returns errAutogenManifestUnavailable +// so an optional consumer fails open and preserves its pinned lock IDs. A present +// but malformed file, or an id that disagrees with the consumer's producer, is a +// hard error. +func readAutogenConsumerManifestFile(p *project.Project, consumer project.AutogenConsumerConfig, manifestFile string, progress func(string)) (*autogenManifest, error) { + path := manifestFile + if !filepath.IsAbs(path) { + path = filepath.Join(p.Root, path) + } + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, unavailableAutogenManifest(fmt.Errorf("manifest file %s not found", path)) + } + return nil, fmt.Errorf("read autogen manifest file %s: %w", path, err) + } + var manifest autogenManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, fmt.Errorf("parse autogen manifest file %s: %w", path, err) + } + if manifest.ID != "" && manifest.ID != consumer.Producer { + return nil, fmt.Errorf("autogen manifest file %s declared id %q, expected %q", path, manifest.ID, consumer.Producer) + } + if progress != nil { + progress(fmt.Sprintf("Reading local autogen manifest for %s from %s...", consumer.ID, path)) + } + return &manifest, nil +} + func resolveAutogenLocalOverrideRoot(p *project.Project, consumer project.AutogenConsumerConfig) (string, error) { root := strings.TrimSpace(consumer.LocalOverrideRoot) if root != "" { diff --git a/internal/topdata/autogen_manifest_file_test.go b/internal/topdata/autogen_manifest_file_test.go new file mode 100644 index 0000000..b581ec3 --- /dev/null +++ b/internal/topdata/autogen_manifest_file_test.go @@ -0,0 +1,103 @@ +package topdata + +import ( + "path/filepath" + "testing" + + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" +) + +// The manifest_file branch reads a local autogenManifest JSON, augments the +// visualeffects dataset from its entries, and assigns lock IDs — no network. +func TestApplyAutogenConsumersReadsManifestFile(t *testing.T) { + root := testProjectRoot(t) + manifestPath := filepath.Join(root, ".cache", "sow-accessory-vfx-manifest.json") + mkdirAll(t, filepath.Join(root, ".cache")) + writeFile(t, manifestPath, `{ + "id": "accessory_visualeffects", + "repo": "ShadowsOverWestgate/sow-assets-manifest", + "ref": "v1.2.3", + "generated_at": "2026-06-21T00:00:00Z", + "entries": [ + {"source": "head_accessories/hat/hfx_bandana.mdl", "model_stem": "hfx_bandana", "group": "head_accessories", "subgroup": "hat"} + ] +}`+"\n") + + p := testProject(root) + p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{ + { + ID: "accessory_visualeffects", + Producer: "accessory_visualeffects", + Dataset: "visualeffects", + Mode: "accessory_visualeffects", + Optional: true, + ManifestFile: ".cache/sow-accessory-vfx-manifest.json", + }, + } + + collected := []nativeCollectedDataset{ + { + Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase}, + Columns: []string{"Label", "Type_FD", "OrientWithGround", "Imp_HeadCon_Node", "OrientWithObject"}, + Rows: nil, + LockData: map[string]int{ + "visualeffects:existing": 17, + }, + }, + } + + got, err := applyAutogenConsumers(p, collected, nil) + if err != nil { + t.Fatalf("applyAutogenConsumers failed: %v", err) + } + if len(got) != 1 || len(got[0].Rows) != 1 { + t.Fatalf("expected one autogenerated row, got %#v", got) + } + row := got[0].Rows[0] + if row["key"] != "visualeffects:head_accessories/hat/bandana" || row["Imp_HeadCon_Node"] != "hfx_bandana" { + t.Fatalf("unexpected generated row: %#v", row) + } + if _, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok { + t.Fatalf("expected lock id for generated accessory, got %#v", got[0].LockData) + } + // Authored key untouched. + if got[0].LockData["visualeffects:existing"] != 17 { + t.Fatalf("expected authored lock id retained at 17, got %#v", got[0].LockData) + } +} + +// A missing manifest_file is an ignorable-optional condition: the optional +// consumer fails open (no rows) and preserves existing lock IDs. +func TestApplyAutogenConsumersMissingManifestFileFailsOpen(t *testing.T) { + root := testProjectRoot(t) + lockPath := filepath.Join(root, "visualeffects-lock.json") + writeFile(t, lockPath, `{"visualeffects:head_accessories/hat/bandana":10101}`+"\n") + + p := testProject(root) + p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{ + { + ID: "accessory_visualeffects", + Producer: "accessory_visualeffects", + Dataset: "visualeffects", + Mode: "accessory_visualeffects", + Optional: true, + ManifestFile: ".cache/does-not-exist.json", + }, + } + collected := []nativeCollectedDataset{ + { + Dataset: nativeDataset{Name: "visualeffects", Kind: nativeDatasetBase, LockPath: lockPath}, + Columns: []string{"Label"}, + Rows: nil, + LockData: map[string]int{}, + }, + } + + got, err := applyAutogenConsumers(p, collected, nil) + if err != nil { + t.Fatalf("expected fail-open, got error: %v", err) + } + if id, ok := got[0].LockData["visualeffects:head_accessories/hat/bandana"]; !ok || id != 10101 { + t.Fatalf("expected preserved lock id 10101, got %v (present=%v)", id, ok) + } +}