From 03e2320788236ff8c2153ec2d624ceed03478a66 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 13 May 2026 19:22:07 +0200 Subject: [PATCH] Conversion to full YAML --- CONFIGURATION_REFACTOR_CONTRACT.md | 4 +- README.md | 5 +- internal/app/app.go | 313 ++++++++++++++++++++++------- internal/app/app_test.go | 57 +++++- internal/topdata/convert.go | 46 +++-- internal/topdata/convert_test.go | 216 ++++++++++++++------ 6 files changed, 475 insertions(+), 166 deletions(-) diff --git a/CONFIGURATION_REFACTOR_CONTRACT.md b/CONFIGURATION_REFACTOR_CONTRACT.md index 69c63c2..21eb01f 100644 --- a/CONFIGURATION_REFACTOR_CONTRACT.md +++ b/CONFIGURATION_REFACTOR_CONTRACT.md @@ -257,7 +257,7 @@ Rationale: - Generated and machine-readable JSON is widespread and should remain JSON: - build HAK manifest: `build/haks.json` - topdata authored/generated datasets - - topdata template conversion metadata: `topdata/templates/config.json` + - topdata template conversion metadata: `topdata/templates/config.yaml` - topdata wiki state and deploy manifests - autogen manifest caches under `.cache` - credits inventory output @@ -367,7 +367,7 @@ Keep in code if toolkit-intrinsic: - Convert consumer root configs from `nwn-tool.json` to `nwn-tool.yaml`. - Preserve existing values exactly unless a separate behavior change is documented. -- Keep `topdata/templates/config.json` unchanged for now because it is topdata conversion metadata, not root repository configuration. +- Treat `topdata/templates/config.yaml` as authored converter configuration, distinct from JSON data artifacts and root repository config. - Keep generated datasets, manifests, caches, credits inventory, and GFF JSON unchanged. - Add before/after examples for root config migration. diff --git a/README.md b/README.md index 3ef36e1..105842b 100644 --- a/README.md +++ b/README.md @@ -296,8 +296,9 @@ westgate` to limit conversion to a configured dataset. Generated and machine-readable artifacts remain JSON, including HAK manifests, topdata datasets, caches, credits inventories, build reports, and canonical GFF -documents. Nested metadata such as `topdata/templates/config.json` is not root -repository configuration and is intentionally unchanged. +documents. Nested metadata such as `topdata/templates/config.yaml` remains +valid when it is local workflow configuration rather than root repository +configuration. ## Wiki And Changelog Utilities diff --git a/internal/app/app.go b/internal/app/app.go index a803b49..7f4cd26 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -296,27 +296,18 @@ func runBuild(ctx context) error { if err != nil { return err } - if err := runBuildModulePreflight(ctx, p, nil); err != nil { + preflight, err := runBuildModulePreflight(ctx, p, nil) + if err != nil { return err } + console := newProjectConsole(ctx, p, "build") 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) - } + console.emitBuildResult(result, preflight) return nil } @@ -325,15 +316,16 @@ func runBuildModule(ctx context) error { if err != nil { return err } + console := newProjectConsole(ctx, p, "build-module") + spin.configure(ctx.stderr, console.spinnerEnabled) + spin.start("Build Module: starting") + defer spin.stop() progress := func(message string) { - if ctx.logLevel == logLevelQuiet { - return - } - spin.linebreak() - fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message) + console.progress(message) } - if err := runBuildModulePreflight(ctx, p, progress); err != nil { + preflight, err := runBuildModulePreflight(ctx, p, progress) + if err != nil { return err } @@ -344,26 +336,36 @@ func runBuildModule(ctx context) error { 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) + console.emitBuildModuleResult(result, preflight) return nil } -func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) error { +type buildModulePreflightResult struct { + ManifestStatus string + ManifestPath string + ScriptCompilationStatus string +} + +func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) (buildModulePreflightResult, error) { + result := buildModulePreflightResult{} if progress == nil { progress = func(string) {} } - if err := refreshBuildModuleManifest(ctx, p, progress); err != nil { - return err + manifestStatus, manifestPath, err := refreshBuildModuleManifest(ctx, p, progress) + if err != nil { + return result, err } - if err := prepareBuildModuleCompiler(ctx, p, progress); err != nil { - return err + result.ManifestStatus = manifestStatus + result.ManifestPath = manifestPath + scriptStatus, err := prepareBuildModuleCompiler(ctx, p, progress) + if err != nil { + return result, err } - return nil + result.ScriptCompilationStatus = scriptStatus + return result, nil } -func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) error { +func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) (string, string, error) { manifestPath := p.HAKManifestPath() if override := strings.TrimSpace(os.Getenv("SOW_HAK_MANIFEST_PATH")); override != "" { manifestPath = override @@ -374,36 +376,36 @@ func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(s if envEnabled("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH") { if _, err := os.Stat(manifestPath); err != nil { - return fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath) + return "", "", fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath) } progress(fmt.Sprintf("Using existing hak manifest at %s; skipping published sow-assets refresh.", relPathFromRoot(p.Root, manifestPath))) - return nil + return "reused", manifestPath, nil } progress("Refreshing hak list from the latest published sow-assets manifest...") if err := runProjectScript(ctx, p, []string{"scripts", "fetch-hak-manifest"}, manifestPath); err != nil { - return err + return "", "", err } if _, err := pipeline.ApplyHAKManifest(p, manifestPath); err != nil { - return err + return "", "", err } - return nil + return "refreshed", manifestPath, nil } -func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) error { +func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) (string, error) { if envEnabled("SOW_MODULE_SKIP_NSS_COMPILATION") { progress("SOW_MODULE_SKIP_NSS_COMPILATION is set; skipping NWScript compiler resolution and NSS compilation for this run.") - return nil + return "skipped", nil } if !projectHasScriptSources(p) { - return nil + return "not-needed", nil } compilerPathEnv := p.ScriptCompilerPathEnv() explicitCompiler := strings.TrimSpace(os.Getenv(compilerPathEnv)) if explicitCompiler != "" { if scriptCompilerRunnable(explicitCompiler) { - return nil + return "enabled", nil } if fileExists(explicitCompiler) { return disableBuildModuleCompiler(compilerPathEnv, progress, fmt.Sprintf("Configured NWScript compiler is present but cannot start: %s", explicitCompiler)) @@ -411,7 +413,7 @@ func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(s } if _, ok := findUsableBuildModuleCompiler(p); ok { - return nil + return "enabled", nil } installScript := projectScriptPath(p.Root, "scripts", "install-script-compiler") @@ -424,21 +426,21 @@ func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(s return disableBuildModuleCompiler(compilerPathEnv, progress, "Automatic NWScript compiler install failed.") } if _, ok := findUsableBuildModuleCompiler(p); ok { - return nil + return "installed", nil } return disableBuildModuleCompiler(compilerPathEnv, progress, "Installed NWScript compiler but it still cannot start.") } -func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) error { +func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) (string, error) { if err := os.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "1"); err != nil { - return fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err) + return "", fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err) } progress("Skipping NWScript compilation for this build. " + reason) if compilerPathEnv != "" { _ = os.Unsetenv(compilerPathEnv) } - return nil + return "skipped", nil } func projectHasScriptSources(p *project.Project) bool { @@ -585,6 +587,190 @@ const ( logLevelDebug ) +type projectConsole struct { + stdout io.Writer + projectRoot string + projectName string + commandName string + commandLabel string + level logLevel + spinnerEnabled bool +} + +func newProjectConsole(ctx context, p *project.Project, commandName string) *projectConsole { + return &projectConsole{ + stdout: ctx.stdout, + projectRoot: p.Root, + projectName: p.Config.Module.Name, + commandName: commandName, + commandLabel: projectCommandLabel(commandName), + level: ctx.logLevel, + spinnerEnabled: isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && ctx.logLevel == logLevelNormal, + } +} + +func projectCommandLabel(commandName string) string { + switch commandName { + case "build": + return "Build" + case "build-module": + return "Build Module" + case "extract": + return "Extract" + case "validate": + return "Validate" + case "compare": + return "Compare" + case "apply-hak-manifest": + return "Apply HAK Manifest" + default: + return commandName + } +} + +func (c *projectConsole) progress(message string) { + if phase := c.phaseLabel(message); phase != "" { + spin.update(c.commandLabel + ": " + phase) + } + if c.level != logLevelDebug { + return + } + spin.linebreak() + fmt.Fprintf(c.stdout, "[debug] %s\n", message) +} + +func (c *projectConsole) phaseLabel(message string) string { + switch { + case strings.HasPrefix(message, "Refreshing hak list from the latest published sow-assets manifest"): + return "refreshing HAK manifest" + case strings.HasPrefix(message, "Using existing hak manifest at "): + return "reusing HAK manifest" + case strings.HasPrefix(message, "NWScript compiler not found locally. Installing it now"): + return "installing script compiler" + case strings.HasPrefix(message, "Skipping NWScript compilation for this build."): + return "skipping script compilation" + case strings.HasPrefix(message, "Validating project"): + return "validating project" + case strings.HasPrefix(message, "Resolving module HAK order"): + return "resolving HAK order" + case strings.HasPrefix(message, "Collecting module resources"): + return "collecting module resources" + case strings.HasPrefix(message, "Writing module archive"): + return "writing module archive" + default: + return "" + } +} + +func (c *projectConsole) emitBuildResult(result pipeline.BuildResult, preflight buildModulePreflightResult) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Build ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath)) + fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources) + if preflight.ManifestStatus != "" { + fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus) + if preflight.ManifestPath != "" { + fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath)) + } + fmt.Fprintln(c.stdout) + } + if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" { + fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus) + } + if result.TopPackageHAK != "" { + fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.TopPackageHAK)) + fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.TopPackageTLK)) + } + if len(result.HAKPaths) > 0 { + fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths)) + fmt.Fprintf(c.stdout, "hak assets: %d\n", result.HAKAssets) + fmt.Fprintf(c.stdout, "hak manifest: %s\n", c.relPath(result.Manifest)) + } +} + +func (c *projectConsole) emitBuildModuleResult(result pipeline.BuildResult, preflight buildModulePreflightResult) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Build Module ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath)) + fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources) + if preflight.ManifestStatus != "" { + fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus) + if preflight.ManifestPath != "" { + fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath)) + } + fmt.Fprintln(c.stdout) + } + if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" { + fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus) + } +} + +func (c *projectConsole) emitExtractResult(result pipeline.ExtractResult) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Extract ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath)) + if len(result.HAKPaths) > 0 { + fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths)) + } + if len(result.DeletedArchivePaths) > 0 { + fmt.Fprintf(c.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths)) + } + fmt.Fprintf(c.stdout, "written: %d\n", result.Written) + fmt.Fprintf(c.stdout, "overwritten: %d\n", result.Overwritten) + fmt.Fprintf(c.stdout, "removed: %d\n", result.Removed) + fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped) +} + +func (c *projectConsole) emitValidationResult(report validator.Report, root string, inventoryReport project.InventoryReport) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Validate ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "root: %s\n", c.relPath(root)) + fmt.Fprintf(c.stdout, "source files: %d\n", inventoryReport.SourceFiles) + fmt.Fprintf(c.stdout, "script files: %d\n", inventoryReport.ScriptFiles) + fmt.Fprintf(c.stdout, "asset files: %d\n", inventoryReport.AssetFiles) + fmt.Fprintf(c.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", ")) + if warnings := report.WarningCount(); warnings > 0 { + fmt.Fprintf(c.stdout, "warnings: %d\n", warnings) + } + fmt.Fprintln(c.stdout, "validation: ok") +} + +func (c *projectConsole) emitCompareResult(result pipeline.CompareResult) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Compare ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath)) + if len(result.HAKPaths) > 0 { + fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths)) + } + fmt.Fprintf(c.stdout, "checked resources: %d\n", result.Checked) + fmt.Fprintln(c.stdout, "compare: ok") +} + +func (c *projectConsole) emitApplyManifestResult(result pipeline.ApplyManifestResult) { + spin.linebreak() + fmt.Fprintln(c.stdout, "Apply HAK Manifest ----------") + fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) + fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.ManifestPath)) + fmt.Fprintf(c.stdout, "module source: %s\n", c.relPath(result.ModuleSource)) + fmt.Fprintf(c.stdout, "hak entries: %d\n", result.HAKCount) +} + +func (c *projectConsole) relPath(path string) string { + if path == "" { + return path + } + rel, err := filepath.Rel(c.projectRoot, path) + if err != nil { + return filepath.ToSlash(path) + } + return filepath.ToSlash(rel) +} + type buildHAKConsole struct { stdout io.Writer projectRoot string @@ -1230,6 +1416,7 @@ func runExtract(ctx context) error { if err != nil { return err } + console := newProjectConsole(ctx, p, "extract") files := ctx.args[1:] result, err := pipeline.Extract(p, files...) @@ -1237,18 +1424,7 @@ func runExtract(ctx context) error { 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)) - } - if len(result.DeletedArchivePaths) > 0 { - fmt.Fprintf(ctx.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths)) - } - 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) + console.emitExtractResult(result) return nil } @@ -1257,6 +1433,7 @@ func runValidate(ctx context) error { if err != nil { return err } + console := newProjectConsole(ctx, p, "validate") validationReport := validator.ValidateProject(p) emitValidatorReport(ctx.stderr, validationReport) @@ -1265,16 +1442,7 @@ func runValidate(ctx context) error { } 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") + console.emitValidationResult(validationReport, p.Root, inventoryReport) return nil } @@ -1480,19 +1648,14 @@ func runCompare(ctx context) error { if err != nil { return err } + console := newProjectConsole(ctx, p, "compare") 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") + console.emitCompareResult(result) return nil } @@ -1501,6 +1664,7 @@ func runApplyHAKManifest(ctx context) error { if err != nil { return err } + console := newProjectConsole(ctx, p, "apply-hak-manifest") manifestPath := p.HAKManifestPath() if len(ctx.args) > 1 { @@ -1515,10 +1679,7 @@ func runApplyHAKManifest(ctx context) error { 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) + console.emitApplyManifestResult(result) return nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 18d1e77..67df544 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" ) func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) { @@ -233,6 +235,47 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) { } } +func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) { + var stdout bytes.Buffer + console := &projectConsole{ + stdout: &stdout, + projectRoot: "/workspace/project", + projectName: "Test Module", + commandName: "build-module", + commandLabel: "Build Module", + level: logLevelNormal, + } + + console.progress("Writing module archive...") + + if stdout.String() != "" { + t.Fatalf("expected no normal-mode progress output, got %q", stdout.String()) + } +} + +func TestProjectConsoleEmitsRelativePaths(t *testing.T) { + var stdout bytes.Buffer + console := &projectConsole{ + stdout: &stdout, + projectRoot: "/workspace/project", + projectName: "Test Module", + commandName: "compare", + commandLabel: "Compare", + level: logLevelNormal, + } + + console.emitCompareResult(pipeline.CompareResult{ + ModulePath: "/workspace/project/build/test.mod", + HAKPaths: []string{"/workspace/project/build/core.hak"}, + Checked: 42, + }) + + output := stdout.String() + if !strings.Contains(output, "module: build/test.mod") { + t.Fatalf("expected relative module path, got %q", output) + } +} + func TestRunBuildModuleToolkitPreflightRefreshesManifestAndSkipsBrokenCompiler(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src", "module")) @@ -309,8 +352,11 @@ scripts: if _, err := os.Stat(filepath.Join(root, ".cache", "ncs", "use_thing.ncs")); !os.IsNotExist(err) { t.Fatalf("expected broken explicit compiler to skip compilation, got err=%v", err) } - if !strings.Contains(stdout.String(), "Skipping NWScript compilation for this build.") { - t.Fatalf("expected skip-compilation progress in output, got %q", stdout.String()) + if !strings.Contains(stdout.String(), "script compilation: skipped") { + t.Fatalf("expected skip-compilation summary in output, got %q", stdout.String()) + } + if strings.Contains(stdout.String(), "[build-module]") { + t.Fatalf("did not expect raw build-module progress in normal output, got %q", stdout.String()) } } @@ -385,8 +431,11 @@ scripts: if _, err := os.Stat(compiledScript); err != nil { t.Fatalf("expected installed compiler to compile script: %v", err) } - if !strings.Contains(stdout.String(), "NWScript compiler not found locally. Installing it now...") { - t.Fatalf("expected install progress in output, got %q", stdout.String()) + if !strings.Contains(stdout.String(), "script compilation: installed") { + t.Fatalf("expected install summary in output, got %q", stdout.String()) + } + if strings.Contains(stdout.String(), "[build-module]") { + t.Fatalf("did not expect raw build-module progress in normal output, got %q", stdout.String()) } } diff --git a/internal/topdata/convert.go b/internal/topdata/convert.go index 8f1f8bf..c3b15d2 100644 --- a/internal/topdata/convert.go +++ b/internal/topdata/convert.go @@ -13,6 +13,7 @@ import ( "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" + "gopkg.in/yaml.v3" ) type convertOptions struct { @@ -105,7 +106,7 @@ func printConvertUsage(stdout io.Writer) { _, _ = fmt.Fprintln(stdout, " json-to-2da Convert canonical JSON rows into a 2DA file") _, _ = fmt.Fprintln(stdout, "") _, _ = fmt.Fprintln(stdout, "2da-to-module notes:") - _, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.json declares it for the input table") + _, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.yaml declares it for the input table") _, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: add__.json") _, _ = fmt.Fprintln(stdout, " - flags accept both --flag value and --flag=value forms") _, _ = fmt.Fprintln(stdout, " - unkeyed rows now fail conversion instead of being skipped") @@ -238,7 +239,7 @@ func parseConvertArgs(args []string, allowFormat bool, defaultCollision string) opts.KeyFields = append(opts.KeyFields, ctx.Template.KeyFields...) } if strings.TrimSpace(opts.Namespace) == "" { - return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.json entry for the input table") + return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.yaml entry for the input table") } switch opts.Type { case "entry": @@ -420,7 +421,7 @@ func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) entries[key] = entry } if len(missing) > 0 { - return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.json", strings.Join(missing, ", ")) + return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.yaml", strings.Join(missing, ", ")) } return map[string]any{"entries": entries}, nil } @@ -544,12 +545,12 @@ func normalizeConvertedKeyText(text string) string { } type convertTemplateTable struct { - Namespace string `json:"namespace"` - KeyFields []string `json:"key_fields"` + Namespace string `json:"namespace" yaml:"namespace"` + KeyFields []string `json:"key_fields" yaml:"key_fields"` } type convertTemplateConfig struct { - Tables map[string]convertTemplateTable `json:"tables"` + Tables map[string]convertTemplateTable `json:"tables" yaml:"tables"` } type convertContext struct { @@ -617,20 +618,31 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT } func loadConvertTemplateConfig(p *project.Project) (convertTemplateConfig, error) { - configPath := filepath.Join(p.TopDataSourceDir(), "templates", "config.json") - raw, err := os.ReadFile(configPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return convertTemplateConfig{}, nil + templatesDir := filepath.Join(p.TopDataSourceDir(), "templates") + for _, candidate := range []string{"config.yaml", "config.yml", "config.json"} { + configPath := filepath.Join(templatesDir, candidate) + raw, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return convertTemplateConfig{}, fmt.Errorf("read %s: %w", configPath, err) } - return convertTemplateConfig{}, fmt.Errorf("read %s: %w", configPath, err) - } - var config convertTemplateConfig - if err := json.Unmarshal(raw, &config); err != nil { - return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + var config convertTemplateConfig + switch filepath.Ext(configPath) { + case ".yaml", ".yml": + if err := yaml.Unmarshal(raw, &config); err != nil { + return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + } + default: + if err := json.Unmarshal(raw, &config); err != nil { + return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + } + } + return config, nil } - return config, nil + return convertTemplateConfig{}, nil } func resolveConvertOutputContext(p *project.Project, output string) (convertTemplateTable, error) { diff --git a/internal/topdata/convert_test.go b/internal/topdata/convert_test.go index 3080e3b..73c8889 100644 --- a/internal/topdata/convert_test.go +++ b/internal/topdata/convert_test.go @@ -11,21 +11,27 @@ import ( func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) { root := testProjectRoot(t) - writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ - "module": {"name": "Test", "resref": "test"}, - "paths": {"source": "src", "assets": "assets", "build": "build"}, - "topdata": {"source": "topdata", "build": "build/topdata"} -}`+"\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) mkdirAll(t, filepath.Join(root, "topdata", "templates")) mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules")) - writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ - "tables": { - "template_appearance": { - "namespace": "appearance", - "key_fields": ["LABEL"] - } - } -}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +tables: + template_appearance: + namespace: appearance + key_fields: + - LABEL +`) writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n") oldCwd, err := os.Getwd() @@ -64,20 +70,26 @@ func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) { func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) { root := testProjectRoot(t) - writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ - "module": {"name": "Test", "resref": "test"}, - "paths": {"source": "src", "assets": "assets", "build": "build"}, - "topdata": {"source": "topdata", "build": "build/topdata"} -}`+"\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) mkdirAll(t, filepath.Join(root, "topdata", "templates")) - writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ - "tables": { - "template_appearance": { - "namespace": "appearance", - "key_fields": ["LABEL"] - } - } -}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +tables: + template_appearance: + namespace: appearance + key_fields: + - LABEL +`) inputPath := filepath.Join(root, "topdata", "templates", "template_appearance.2da") writeFile(t, inputPath, "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n") @@ -121,21 +133,28 @@ func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) { func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing.T) { root := testProjectRoot(t) - writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ - "module": {"name": "Test", "resref": "test"}, - "paths": {"source": "src", "assets": "assets", "build": "build"}, - "topdata": {"source": "topdata", "build": "build/topdata"} -}`+"\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) mkdirAll(t, filepath.Join(root, "topdata", "templates")) mkdirAll(t, filepath.Join(root, "topdata", "data", "soundset")) - writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ - "tables": { - "template_soundset": { - "namespace": "soundset", - "key_fields": ["RESREF", "LABEL"] - } - } -}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +tables: + template_soundset: + namespace: soundset + key_fields: + - RESREF + - LABEL +`) inputPath := filepath.Join(root, "scratch_soundset.2da") writeFile(t, inputPath, "2DA V2.0\n\nLABEL RESREF STRREF GENDER TYPE\n0 \"Bandit Male\" nw_bandit 100 1 4\n1 \"Bandit Female\" nw_bandit 101 2 4\n") @@ -179,20 +198,26 @@ func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing. func TestRunConvertCommand2DAToJSONInfersAmbientTemplateKeysFromResource(t *testing.T) { root := testProjectRoot(t) - writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ - "module": {"name": "Test", "resref": "test"}, - "paths": {"source": "src", "assets": "assets", "build": "build"}, - "topdata": {"source": "topdata", "build": "build/topdata"} -}`+"\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) mkdirAll(t, filepath.Join(root, "topdata", "templates")) - writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ - "tables": { - "template_ambientmusic": { - "namespace": "ambientmusic", - "key_fields": ["Resource"] - } - } -}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +tables: + template_ambientmusic: + namespace: ambientmusic + key_fields: + - Resource +`) inputPath := filepath.Join(root, "topdata", "templates", "template_ambientmusic.2da") writeFile(t, inputPath, "2DA V2.0\n\nDescription DisplayName Resource Stinger1 Stinger2 Stinger3\n0 61901 **** **** **** **** ****\n1 61842 **** mus_ruralday1 **** **** ****\n2 61843 **** mus_ruralday2 **** **** ****\n") @@ -239,11 +264,18 @@ func TestRunConvertCommand2DAToJSONInfersAmbientTemplateKeysFromResource(t *test func TestRunConvertCommand2DAToJSONSuffixesDuplicateGeneratedKeys(t *testing.T) { root := testProjectRoot(t) - writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ - "module": {"name": "Test", "resref": "test"}, - "paths": {"source": "src", "assets": "assets", "build": "build"}, - "topdata": {"source": "topdata", "build": "build/topdata"} -}`+"\n") + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) inputPath := filepath.Join(root, "dup.2da") writeFile(t, inputPath, "2DA V2.0\n\nResource\n1 cmp_reserved\n2 cmp_reserved\n") @@ -353,14 +385,13 @@ func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) { "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) - writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ - "tables": { - "template_appearance": { - "namespace": "appearance", - "key_fields": ["STRING_REF"] - } - } -}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +tables: + template_appearance: + namespace: appearance + key_fields: + - STRING_REF +`) writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n") oldCwd, err := os.Getwd() @@ -384,6 +415,61 @@ func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) { } } +func TestRunConvertCommandFallsBackToLegacyTemplateJSONConfig(t *testing.T) { + root := testProjectRoot(t) + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) + mkdirAll(t, filepath.Join(root, "topdata", "templates")) + writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ + "tables": { + "template_appearance": { + "namespace": "appearance", + "key_fields": ["LABEL"] + } + } +}`+"\n") + inputPath := filepath.Join(root, "topdata", "templates", "template_appearance.2da") + writeFile(t, inputPath, "2DA V2.0\n\nLABEL\n960 Zombie_Ash_Hot\n") + + oldCwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldCwd) + }) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + + outputPath := filepath.Join(root, "out", "appearance.json") + if err := RunConvertCommand([]string{ + "2da-to-json", + "topdata/templates/template_appearance.2da", + outputPath, + }, &bytes.Buffer{}); err != nil { + t.Fatalf("RunConvertCommand failed: %v", err) + } + + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + if !strings.Contains(string(raw), `"appearance:zombie_ash_hot"`) { + t.Fatalf("expected legacy JSON template config fallback to be honored, got %s", string(raw)) + } +} + func TestConvert2DAToModuleFailsOnDuplicateGeneratedKeysByDefault(t *testing.T) { _, err := convert2DAToModule(parsed2DA{ Columns: []string{"LABEL"},