485 lines
13 KiB
Go
485 lines
13 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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/.",
|
|
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 from topdata/data into build/topdata.",
|
|
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)
|
|
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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runBuildHAKs(ctx context) error {
|
|
p, err := loadProject(ctx.cwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := pipeline.BuildHAKsWithProgress(p, func(message string) {
|
|
fmt.Fprintf(ctx.stdout, "[build-haks] %s\n", message)
|
|
})
|
|
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 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)
|
|
if validationReport.HasErrors() {
|
|
emitValidationWarnings(ctx.stderr, validationReport.Diagnostics)
|
|
for _, diagnostic := range validationReport.Diagnostics {
|
|
if diagnostic.Severity == validator.SeverityWarning {
|
|
continue
|
|
}
|
|
fmt.Fprintf(ctx.stderr, "error: %s: %s\n", diagnostic.Path, diagnostic.Message)
|
|
}
|
|
return fmt.Errorf("validation failed with %d error(s)", validationReport.ErrorCount())
|
|
}
|
|
|
|
emitValidationWarnings(ctx.stderr, validationReport.Diagnostics)
|
|
|
|
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 emitValidationWarnings(w io.Writer, diagnostics []validator.Diagnostic) {
|
|
const lowercaseWarning = "resource filenames should be lowercase"
|
|
|
|
lowercasePaths := make([]string, 0)
|
|
for _, diagnostic := range diagnostics {
|
|
if diagnostic.Severity != validator.SeverityWarning {
|
|
continue
|
|
}
|
|
if diagnostic.Message == lowercaseWarning {
|
|
lowercasePaths = append(lowercasePaths, diagnostic.Path)
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "warning: %s: %s\n", diagnostic.Path, diagnostic.Message)
|
|
}
|
|
|
|
if len(lowercasePaths) == 0 {
|
|
return
|
|
}
|
|
slices.Sort(lowercasePaths)
|
|
examples := lowercasePaths
|
|
if len(examples) > 5 {
|
|
examples = examples[:5]
|
|
}
|
|
message := strings.Join(examples, ", ")
|
|
if len(lowercasePaths) > len(examples) {
|
|
message += fmt.Sprintf(", and %d more", len(lowercasePaths)-len(examples))
|
|
}
|
|
fmt.Fprintf(w, "warning: %d resource filenames are not lowercase: %s\n", len(lowercasePaths), message)
|
|
}
|
|
|
|
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)
|
|
for _, diagnostic := range report.Diagnostics {
|
|
if diagnostic.Severity == topdata.SeverityWarning {
|
|
fmt.Fprintf(ctx.stderr, "warning: %s: %s\n", diagnostic.Path, diagnostic.Message)
|
|
continue
|
|
}
|
|
fmt.Fprintf(ctx.stderr, "error: %s: %s\n", diagnostic.Path, diagnostic.Message)
|
|
}
|
|
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.Build(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, "2da files: %d\n", result.Files2DA)
|
|
fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK)
|
|
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)
|
|
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 <command>\n\n", exe)
|
|
fmt.Fprintln(w, "Commands:")
|
|
for _, cmd := range commands {
|
|
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description)
|
|
}
|
|
}
|