Files
sow-tools/internal/app/app.go
T
archvillainette c52311ce5f Add build-wiki command for isolated wiki generation
- Add BuildWikiWithOptions in native.go for building wiki from pre-existing topdata
- Add runBuildWiki and parseBuildWikiArgs in app.go for CLI integration
- Add --force flag to force rebuild even when output is current
2026-04-18 11:13:25 +02:00

632 lines
17 KiB
Go

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 .cache/2da and .cache/wiki, then refresh build/sow_top.hak and build/sow_tlk.tlk.",
run: runBuildTopData,
},
{
name: "build-top-package",
description: "Build build/sow_top.hak and build/sow_tlk.tlk from cached native topdata output and authored 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,
},
{
name: "build-wiki",
description: "Build wiki pages from the current topdata state.",
run: runBuildWiki,
},
}
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 <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--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
}
opts, err := parseBuildTopDataArgs("build-topdata", ctx.args[1:])
if err != nil {
return err
}
result, err := topdata.BuildAndPackageWithOptions(p, opts, 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
}
opts, err := parseBuildTopDataArgs("build-top-package", ctx.args[1:])
if err != nil {
return err
}
result, err := topdata.BuildAndPackageWithOptions(p, opts, 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 parseBuildTopDataArgs(commandName string, args []string) (topdata.BuildAndPackageOptions, error) {
opts := topdata.BuildAndPackageOptions{}
for _, arg := range args {
switch arg {
case "--force":
opts.Force = true
case "--wiki":
opts.BuildWiki = true
case "-h", "--help":
return opts, fmt.Errorf("usage: %s [--force] [--wiki]", commandName)
default:
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
}
}
return opts, 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 runBuildWiki(ctx context) error {
p, err := loadProject(ctx.cwd)
if err != nil {
return err
}
opts, err := parseBuildWikiArgs("build-wiki", ctx.args[1:])
if err != nil {
return err
}
result, err := topdata.BuildWikiWithOptions(p, opts, func(message string) {
fmt.Fprintf(ctx.stdout, "[build-wiki] %s\n", message)
})
if err != nil {
return err
}
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.OutputDir)
fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.PageCount)
fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.Status)
return nil
}
func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOptions, error) {
opts := topdata.BuildWikiOptions{}
for _, arg := range args {
switch arg {
case "--force":
opts.Force = true
case "-h", "--help":
return opts, fmt.Errorf("usage: %s [--force]", commandName)
default:
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
}
}
return opts, nil
}
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)
}
}