package pipeline import ( "bytes" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "slices" "strings" "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type CompareResult struct { ModulePath string HAKPaths []string Checked int } type resourceExpectation struct { Key string Kind string Bytes []byte } func Compare(p *project.Project) (CompareResult, error) { modulePath := p.ModuleArchivePath() var hakPaths []string if len(p.Inventory.AssetFiles) > 0 { var err error hakPaths, err = manifestHAKPaths(p) if err != nil { return CompareResult{}, err } } if err := ensureBuildIsCurrent(p, modulePath, hakPaths); err != nil { return CompareResult{ModulePath: modulePath, HAKPaths: hakPaths}, err } moduleArchive, err := readArchive(modulePath) if err != nil { return CompareResult{}, err } expected, err := expectedResources(p) if err != nil { return CompareResult{}, err } actual := archiveIndex(moduleArchive) for _, hakPath := range hakPaths { hakArchive, err := readArchive(hakPath) if err != nil { return CompareResult{}, err } for key, value := range archiveIndex(hakArchive) { actual[key] = value } } var diagnostics []error checked := 0 expectedKeys := make([]string, 0, len(expected)) for key := range expected { expectedKeys = append(expectedKeys, key) } slices.Sort(expectedKeys) for _, key := range expectedKeys { want := expected[key] got, ok := actual[key] if !ok { diagnostics = append(diagnostics, fmt.Errorf("missing resource in built archives: %s", key)) continue } if !bytes.Equal(want.Bytes, got.Data) { diagnostics = append(diagnostics, fmt.Errorf("resource content mismatch: %s", key)) continue } checked++ delete(actual, key) } if len(actual) > 0 { extraKeys := make([]string, 0, len(actual)) for key := range actual { extraKeys = append(extraKeys, key) } slices.Sort(extraKeys) for _, key := range extraKeys { diagnostics = append(diagnostics, fmt.Errorf("unexpected extra resource in built archives: %s", key)) } } result := CompareResult{ ModulePath: modulePath, HAKPaths: hakPaths, Checked: checked, } if len(diagnostics) > 0 { return result, errors.Join(diagnostics...) } return result, nil } func expectedResources(p *project.Project) (map[string]resourceExpectation, error) { out := map[string]resourceExpectation{} moduleHakOrder, err := plannedModuleHAKOrder(p) if err != nil { return nil, err } for _, rel := range p.Inventory.SourceFiles { abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel)) name, extension, err := splitSourceName(abs) if err != nil { return nil, err } raw, err := os.ReadFile(abs) if err != nil { return nil, fmt.Errorf("read %s: %w", abs, err) } var document gff.Document if err := json.Unmarshal(raw, &document); err != nil { return nil, fmt.Errorf("parse gff json %s: %w", abs, err) } if extension == ".ifo" && strings.EqualFold(name, "module") && len(moduleHakOrder) > 0 { setModuleHAKList(&document, moduleHakOrder) } canonical, err := json.Marshal(document) if err != nil { return nil, fmt.Errorf("marshal canonical gff json %s: %w", abs, err) } out[resourceKey(name, extension)] = resourceExpectation{ Key: resourceKey(name, extension), Kind: "gff-json", Bytes: canonical, } } for _, rel := range p.Inventory.ScriptFiles { abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel)) data, err := os.ReadFile(abs) if err != nil { return nil, fmt.Errorf("read %s: %w", abs, err) } name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))) extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".") out[resourceKey(name, extension)] = resourceExpectation{ Key: resourceKey(name, extension), Kind: "raw", Bytes: data, } } for _, rel := range p.Inventory.AssetFiles { abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)) data, err := os.ReadFile(abs) if err != nil { return nil, fmt.Errorf("read %s: %w", abs, err) } name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs))) extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".") out[resourceKey(name, extension)] = resourceExpectation{ Key: resourceKey(name, extension), Kind: "raw", Bytes: data, } } return out, nil } func ensureBuildIsCurrent(p *project.Project, modulePath string, hakPaths []string) error { archivePaths := make([]string, 0, 1+len(hakPaths)) archivePaths = append(archivePaths, modulePath) archivePaths = append(archivePaths, hakPaths...) oldestArchive := time.Time{} for _, path := range archivePaths { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat archive %s: %w", path, err) } if oldestArchive.IsZero() || info.ModTime().Before(oldestArchive) { oldestArchive = info.ModTime() } } sourcePaths := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles)) for _, rel := range p.Inventory.SourceFiles { sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) } for _, rel := range p.Inventory.ScriptFiles { sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) } for _, rel := range p.Inventory.AssetFiles { sourcePaths = append(sourcePaths, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) } newestSource := time.Time{} newestPath := "" for _, path := range sourcePaths { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat source %s: %w", path, err) } if newestSource.IsZero() || info.ModTime().After(newestSource) { newestSource = info.ModTime() newestPath = path } } if !newestSource.IsZero() && newestSource.After(oldestArchive) { return fmt.Errorf("built archives are older than source file %s; run build-module or build-haks before compare", newestPath) } return nil } func readArchive(path string) (erf.Archive, error) { input, err := os.Open(path) if err != nil { return erf.Archive{}, fmt.Errorf("open archive %s: %w", path, err) } defer input.Close() archive, err := erf.Read(input) if err != nil { return erf.Archive{}, fmt.Errorf("read archive %s: %w", path, err) } return archive, nil } func archiveIndex(archive erf.Archive) map[string]erf.Resource { out := map[string]erf.Resource{} for _, resource := range archive.Resources { extension, ok := erf.ExtensionForResourceType(resource.Type) if !ok { continue } key := resourceKey(strings.ToLower(resource.Name), extension) switch extension { case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw", "are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl": document, err := gff.Read(bytes.NewReader(resource.Data)) if err != nil { out[key] = resource continue } canonical, err := json.Marshal(document) if err != nil { out[key] = resource continue } resource.Data = canonical } out[key] = resource } return out } func resourceKey(name, extension string) string { return strings.ToLower(name) + "." + strings.TrimPrefix(strings.ToLower(extension), ".") } func manifestHAKPaths(p *project.Project) ([]string, error) { manifestPath := p.HAKManifestPath() raw, err := os.ReadFile(manifestPath) if err == nil { var manifest BuildManifest if err := json.Unmarshal(raw, &manifest); err != nil { return nil, fmt.Errorf("parse hak manifest: %w", err) } paths := make([]string, 0, len(manifest.HAKs)) for _, hak := range manifest.HAKs { paths = append(paths, p.HAKArchivePath(hak.Name)) } return paths, nil } if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("read hak manifest: %w", err) } legacy := filepath.Join(p.BuildDir(), "*.hak") paths, err := filepath.Glob(legacy) if err != nil { return nil, fmt.Errorf("scan hak archives: %w", err) } filtered := make([]string, 0, len(paths)) for _, path := range paths { if filepath.Base(path) == p.TopDataPackageHAKName() { continue } filtered = append(filtered, path) } slices.Sort(filtered) return filtered, nil } func topPackageSourcePaths(p *project.Project) ([]string, error) { sourceDir := p.TopDataSourceDir() generated2DADir := filepath.Join(sourceDir, "assets", "2da") paths := make([]string, 0) for _, candidate := range []string{ filepath.Join(sourceDir, ".tlk_state.json"), filepath.Join(sourceDir, "base_dialog.json"), } { if _, err := os.Stat(candidate); err == nil { paths = append(paths, candidate) } else if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("stat topdata source %s: %w", candidate, err) } } for _, dir := range []string{ filepath.Join(sourceDir, "data"), filepath.Join(sourceDir, "assets"), } { info, err := os.Stat(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } return nil, fmt.Errorf("stat topdata path %s: %w", dir, err) } if !info.IsDir() { continue } err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() && path == generated2DADir { return filepath.SkipDir } if d.IsDir() { return nil } paths = append(paths, path) return nil }) if err != nil { return nil, fmt.Errorf("scan topdata path %s: %w", dir, err) } } return paths, nil }