package app import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator" ) type command struct { name string description string run func(context) error } type context struct { stdout io.Writer stderr io.Writer cwd string args []string } var commands = []command{ { name: "build", description: "Build module and HAK outputs into build/, plus top package output when topdata is configured.", run: runBuild, }, { name: "build-module", description: "Build only the module archive from src/ into build/.", run: runBuildModule, }, { name: "build-haks", description: "Build only HAK archives and manifest from assets/ into build/.", run: runBuildHAKs, }, { name: "extract", description: "Extract a module archive back into the src/ tree.", run: runExtract, }, { name: "validate", description: "Validate project layout, config, and source inventory.", run: runValidate, }, { name: "compare", description: "Compare current source content against the built archives.", run: runCompare, }, { name: "apply-hak-manifest", description: "Apply a generated HAK manifest to src/module/module.ifo.json.", run: runApplyHAKManifest, }, { name: "validate-topdata", description: "Validate topdata source layout and canonical JSON compatibility.", run: runValidateTopData, }, { name: "build-topdata", description: "Build canonical topdata outputs into topdata/assets/2da and build/sow_tlk.tlk, then package sow_top.hak.", run: runBuildTopData, }, { name: "build-top-package", description: "Build sow_top.hak and sow_tlk.tlk from native topdata output and topdata/assets.", run: runBuildTopPackage, }, { name: "compare-topdata", description: "Rebuild topdata natively and compare the current build output against that fresh native result.", run: runCompareTopData, }, { name: "convert-topdata", description: "Convert between 2DA and native topdata JSON/module formats.", run: runConvertTopData, }, } func Run(args []string) (int, error) { ctx, err := newContext() if err != nil { return 1, err } if len(args) == 0 { printUsage(ctx.stdout) return 0, nil } switch args[0] { case "-h", "--help", "help": printUsage(ctx.stdout) return 0, nil default: for _, cmd := range commands { if cmd.name == args[0] { if err := cmd.run(ctx); err != nil { return 1, err } return 0, nil } } } return 1, fmt.Errorf("unknown command %q", args[0]) } func newContext() (context, error) { cwd, err := os.Getwd() if err != nil { return context{}, fmt.Errorf("resolve working directory: %w", err) } return context{ stdout: os.Stdout, stderr: os.Stderr, cwd: cwd, args: os.Args[1:], }, nil } func runBuild(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.Build(p) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources) if result.TopPackageHAK != "" { fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.TopPackageHAK) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.TopPackageTLK) } if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets) fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest) } return nil } func runBuildModule(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.BuildModuleWithProgress(p, func(message string) { fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources) return nil } type buildHAKOptions struct { filteredHAKs []string filteredArchives []string sourceManifest string planOnly bool } func runBuildHAKs(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseBuildHAKArgs(ctx.args[1:]) if err != nil { return err } p, err = p.CloneWithHAKNames(opts.filteredHAKs) if err != nil { return err } progress := func(message string) { fmt.Fprintf(ctx.stdout, "[build-haks] %s\n", message) } var result pipeline.BuildResult if opts.planOnly { result, err = pipeline.PlanHAKsWithProgress(p, progress) } else { result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{ Progress: progress, ArchiveNames: opts.filteredArchives, SourceManifestPath: opts.sourceManifest, }) } if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets) if result.Manifest != "" { fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest) } return nil } func parseBuildHAKArgs(args []string) (buildHAKOptions, error) { opts := buildHAKOptions{} if len(args) == 0 { return opts, nil } for index := 0; index < len(args); index++ { arg := args[index] switch arg { case "-h", "--help": return opts, errors.New("usage: build-haks [--hak ...] [--archive ...] [--source-manifest ] [--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 case "--source-manifest": index++ if index >= len(args) { return opts, errors.New("--source-manifest requires a value") } opts.sourceManifest = args[index] default: if value, ok := parseInlineFlagValue(arg, "--hak"); ok { opts.filteredHAKs = append(opts.filteredHAKs, value) continue } if value, ok := parseInlineFlagValue(arg, "--archive"); ok { opts.filteredArchives = append(opts.filteredArchives, value) continue } if value, ok := parseInlineFlagValue(arg, "--source-manifest"); ok { opts.sourceManifest = value continue } return opts, fmt.Errorf("unknown build-haks argument %q", arg) } } return opts, nil } func parseInlineFlagValue(arg, name string) (string, bool) { prefix := name + "=" if !strings.HasPrefix(arg, prefix) { return "", false } return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true } func runExtract(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.Extract(p) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) } fmt.Fprintf(ctx.stdout, "written: %d\n", result.Written) fmt.Fprintf(ctx.stdout, "overwritten: %d\n", result.Overwritten) fmt.Fprintf(ctx.stdout, "removed: %d\n", result.Removed) fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped) return nil } func runValidate(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } validationReport := validator.ValidateProject(p) emitValidatorReport(ctx.stderr, validationReport) if validationReport.HasErrors() { return fmt.Errorf("validation failed with %d error(s)", validationReport.ErrorCount()) } inventoryReport := p.Inventory.Report() fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "root: %s\n", p.Root) fmt.Fprintf(ctx.stdout, "source files: %d\n", inventoryReport.SourceFiles) fmt.Fprintf(ctx.stdout, "script files: %d\n", inventoryReport.ScriptFiles) fmt.Fprintf(ctx.stdout, "asset files: %d\n", inventoryReport.AssetFiles) fmt.Fprintf(ctx.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", ")) if warnings := validationReport.WarningCount(); warnings > 0 { fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings) } fmt.Fprintf(ctx.stdout, "validation: ok\n") return nil } func emitValidatorReport(w io.Writer, report validator.Report) { lines := report.SummaryLines(5) emitSummaryLines(w, lines, 20, "validation diagnostics") if line := report.DecompiledSummaryLine(12); line != "" { fmt.Fprintln(w, line) } } func emitTopdataValidationReport(w io.Writer, report topdata.ValidationReport) { lines := report.SummaryLines(5) emitSummaryLines(w, lines, 20, "topdata validation diagnostics") } func emitSummaryLines(w io.Writer, lines []string, maxLines int, label string) { if maxLines <= 0 || len(lines) <= maxLines { for _, line := range lines { fmt.Fprintln(w, line) } return } for _, line := range lines[:maxLines] { fmt.Fprintln(w, line) } fmt.Fprintf(w, "info: %d additional %s omitted\n", len(lines)-maxLines, label) } func runCompare(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.Compare(p) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) } fmt.Fprintf(ctx.stdout, "checked resources: %d\n", result.Checked) fmt.Fprintf(ctx.stdout, "compare: ok\n") return nil } func runApplyHAKManifest(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } manifestPath := filepath.Join(p.BuildDir(), "haks.json") if len(ctx.args) > 1 { manifestPath = ctx.args[1] if !filepath.IsAbs(manifestPath) { manifestPath = filepath.Join(ctx.cwd, manifestPath) } } result, err := pipeline.ApplyHAKManifest(p, manifestPath) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.ManifestPath) fmt.Fprintf(ctx.stdout, "module source: %s\n", result.ModuleSource) fmt.Fprintf(ctx.stdout, "hak entries: %d\n", result.HAKCount) return nil } func runValidateTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } report := topdata.ValidateProject(p) emitTopdataValidationReport(ctx.stderr, report) if report.HasErrors() { return fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount()) } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "topdata root: %s\n", p.TopDataSourceDir()) fmt.Fprintf(ctx.stdout, "topdata files: %d\n", report.Files) fmt.Fprintf(ctx.stdout, "data files: %d\n", report.DataFiles) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", report.TLKFiles) if warnings := report.WarningCount(); warnings > 0 { fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings) } fmt.Fprintf(ctx.stdout, "topdata validation: ok\n") return nil } func runBuildTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := topdata.BuildAndPackage(p, func(message string) { fmt.Fprintf(ctx.stdout, "[build-topdata] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir) fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir) fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath) fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources) fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles) if result.WikiOutputDir != "" { fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) } return nil } func runBuildTopPackage(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := topdata.BuildAndPackage(p, func(message string) { fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir) fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir) fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath) fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources) fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles) if result.WikiOutputDir != "" { fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) } return nil } func runCompareTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := topdata.Compare(p, func(message string) { fmt.Fprintf(ctx.stdout, "[compare-topdata] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "checked 2da files: %d\n", result.Compared2DA) fmt.Fprintf(ctx.stdout, "checked tlk files: %d\n", result.ComparedTLK) fmt.Fprintf(ctx.stdout, "native-pass: %d\n", result.NativePass) fmt.Fprintf(ctx.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical) fmt.Fprintf(ctx.stdout, "native-mismatch: %d\n", result.NativeMismatch) fmt.Fprintf(ctx.stdout, "topdata compare: ok\n") return nil } func runConvertTopData(ctx context) error { if len(ctx.args) < 2 { return errors.New("usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...") } return topdata.RunConvertCommand(ctx.args[1:], ctx.stdout) } func loadProject(cwd string) (*project.Project, error) { root, err := project.FindRoot(cwd) if err != nil { return nil, err } p, err := project.Load(root) if err != nil { return nil, err } var failures []error if err := p.ValidateLayout(); err != nil { failures = append(failures, err) } if err := p.Scan(); err != nil { failures = append(failures, err) } if len(failures) > 0 { return nil, errors.Join(failures...) } return p, nil } func printUsage(w io.Writer) { exe := filepath.Base(os.Args[0]) fmt.Fprintf(w, "%s \n\n", exe) fmt.Fprintln(w, "Commands:") for _, cmd := range commands { fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description) } }