Log Refactor

This commit is contained in:
2026-05-07 13:33:25 +02:00
parent 13a615dbd7
commit 7abcbf1a38
6 changed files with 706 additions and 53 deletions
+274 -28
View File
@@ -37,26 +37,50 @@ type spinner struct {
text string
msg []string
painted bool
writer io.Writer
enabled bool
}
var spin = &spinner{
msg: []string{"-", "\\", "|", "/"},
func newSpinner() *spinner {
return &spinner{
msg: []string{"-", "\\", "|", "/"},
}
}
var spin = newSpinner()
func (s *spinner) configure(writer io.Writer, enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.writer = writer
s.enabled = enabled
}
func (s *spinner) start(text string) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || s.writer == nil {
s.on = false
s.painted = false
s.text = text
return
}
s.on = true
s.text = text
go s.run(text)
go s.run()
}
func (s *spinner) stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || s.writer == nil {
s.on = false
s.painted = false
return
}
s.on = false
if s.painted {
fmt.Fprint(os.Stderr, "\r\033[K\n")
fmt.Fprint(s.writer, "\r\033[K")
s.painted = false
}
}
@@ -64,23 +88,28 @@ func (s *spinner) stop() {
func (s *spinner) linebreak() {
s.mu.Lock()
defer s.mu.Unlock()
if s.on && s.painted {
fmt.Fprint(os.Stderr, "\r\033[K\n")
if s.enabled && s.on && s.painted {
fmt.Fprint(s.writer, "\r\033[K")
s.painted = false
}
}
func (s *spinner) run(text string) {
func (s *spinner) update(text string) {
s.mu.Lock()
defer s.mu.Unlock()
s.text = text
}
func (s *spinner) run() {
i := 0
for {
s.mu.Lock()
if !s.on {
s.mu.Unlock()
fmt.Fprintf(os.Stderr, "\r")
return
}
msg := s.msg[i%len(s.msg)]
fmt.Fprintf(os.Stderr, "\r[%s] %s", msg, text)
fmt.Fprintf(s.writer, "\r[%s] %s", msg, s.text)
s.painted = true
s.mu.Unlock()
i++
@@ -88,6 +117,18 @@ func (s *spinner) run(text string) {
}
}
func isInteractiveTTY(w io.Writer) bool {
file, ok := w.(*os.File)
if !ok {
return false
}
info, err := file.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}
var commands = []command{
{
name: "build",
@@ -184,6 +225,7 @@ func Run(args []string) (int, error) {
default:
for _, cmd := range commands {
if cmd.name == args[0] {
spin.configure(ctx.stderr, isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "")
spin.start("running " + cmd.name)
if err := cmd.run(ctx); err != nil {
spin.stop()
@@ -263,6 +305,212 @@ type buildHAKOptions struct {
filteredArchives []string
sourceManifest string
planOnly bool
logLevel logLevel
}
type logLevel int
const (
logLevelNormal logLevel = iota
logLevelQuiet
logLevelVerbose
logLevelDebug
)
type buildHAKConsole struct {
stdout io.Writer
projectRoot string
projectName string
planOnly bool
level logLevel
spinnerEnabled bool
}
func newBuildHAKConsole(ctx context, p *project.Project, opts buildHAKOptions) *buildHAKConsole {
return &buildHAKConsole{
stdout: ctx.stdout,
projectRoot: p.Root,
projectName: p.Config.Module.Name,
planOnly: opts.planOnly,
level: opts.logLevel,
spinnerEnabled: isInteractiveTTY(ctx.stderr) && strings.TrimSpace(os.Getenv("CI")) == "" && opts.logLevel == logLevelNormal,
}
}
func (c *buildHAKConsole) progress(message string) {
if phase := c.phaseLabel(message); phase != "" {
spin.update("Build HAKs: " + phase)
}
if c.level != logLevelDebug {
return
}
spin.linebreak()
fmt.Fprintf(c.stdout, "[debug] %s\n", message)
}
func (c *buildHAKConsole) phaseLabel(message string) string {
switch {
case strings.HasPrefix(message, "Validating project"):
return "validating project"
case strings.HasPrefix(message, "Collecting asset resources"):
return "collecting asset resources"
case strings.HasPrefix(message, "Planning HAK chunks"):
return "planning HAK chunks"
case strings.HasPrefix(message, "Resolving module HAK order"):
return "resolving module HAK order"
case strings.HasPrefix(message, "Reusing unchanged generated HAKs"):
return "reusing unchanged HAKs"
case strings.HasPrefix(message, "Reusing HAK "), strings.HasPrefix(message, "Writing HAK "):
return "writing HAK archives"
case strings.HasPrefix(message, "Cleaning stale generated HAKs"):
return "cleaning stale generated HAKs"
case strings.HasPrefix(message, "Cleaning previous generated HAKs"):
return "cleaning previous generated HAKs"
case strings.HasPrefix(message, "Writing HAK manifest"):
return "writing HAK manifest"
case strings.HasPrefix(message, "Writing autogen manifest "):
return "writing autogen manifests"
default:
return ""
}
}
func (c *buildHAKConsole) emitResult(result pipeline.BuildResult) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Build HAKs ----------")
if c.level != logLevelQuiet {
c.emitCreditsSummary(result)
c.emitCoreSteps(result)
}
c.emitFinalSummary(result)
}
func (c *buildHAKConsole) emitCreditsSummary(result pipeline.BuildResult) {
summary := result.CreditsSummary
if len(summary.ArtifactPaths) == 0 {
return
}
fmt.Fprintln(c.stdout, "Refreshing credits cache")
if len(summary.ScannedDirs) > 0 {
fmt.Fprintf(c.stdout, " scanned: %s\n", strings.Join(summary.ScannedDirs, ", "))
}
fmt.Fprintf(c.stdout, " tracks: %d\n", summary.TrackCount)
if len(summary.GeneratedPaths) > 0 {
for _, path := range summary.GeneratedPaths {
fmt.Fprintf(c.stdout, " output: %s\n", c.relPath(path))
}
}
switch {
case summary.ChangedFiles == 0:
fmt.Fprintln(c.stdout, " status: unchanged")
default:
fmt.Fprintf(c.stdout, " updated: %d artifact(s)\n", summary.ChangedFiles)
}
if summary.TrackCount > 0 {
if c.level >= logLevelVerbose {
fmt.Fprintln(c.stdout, " mappings:")
for _, mapping := range summary.Mappings {
fmt.Fprintf(c.stdout, " %s -> %s\n", mapping.Source, mapping.Output)
}
} else {
fmt.Fprintf(c.stdout, " mapped: %d music file(s); use --verbose to list mappings\n", summary.TrackCount)
}
}
fmt.Fprintln(c.stdout)
}
func (c *buildHAKConsole) emitCoreSteps(result pipeline.BuildResult) {
fmt.Fprintln(c.stdout, "Validating project")
fmt.Fprintln(c.stdout, "Collecting asset resources")
if len(result.AutogenManifestPaths) > 0 {
fmt.Fprintln(c.stdout, "Writing autogen manifests")
for _, path := range result.AutogenManifestPaths {
fmt.Fprintf(c.stdout, " %s\n", filepath.Base(path))
}
fmt.Fprintln(c.stdout)
}
fmt.Fprintln(c.stdout, "Planning HAK chunks")
fmt.Fprintln(c.stdout, "Resolving module HAK order")
if !c.planOnly {
fmt.Fprintln(c.stdout, "Reusing unchanged HAKs")
fmt.Fprintf(c.stdout, " reused: %d / %d\n", result.HAKSummary.Reused, result.HAKSummary.Total)
fmt.Fprintf(c.stdout, " written: %d / %d\n", result.HAKSummary.Written, result.HAKSummary.Total)
fmt.Fprintf(c.stdout, " assets: %s\n", formatCount(result.HAKAssets))
if c.level >= logLevelVerbose {
for _, action := range result.HAKSummary.Actions {
verb := "wrote"
if action.Reused {
verb = "reused"
}
fmt.Fprintf(c.stdout, " %s: %s (%s assets)\n", verb, action.Name, formatCount(action.AssetCount))
}
}
fmt.Fprintln(c.stdout)
fmt.Fprintln(c.stdout, "Cleaning stale generated HAKs")
} else {
fmt.Fprintf(c.stdout, "Planned HAK archives: %d\n", result.HAKSummary.Total)
}
fmt.Fprintln(c.stdout, "Writing HAK manifest")
fmt.Fprintln(c.stdout)
}
func (c *buildHAKConsole) emitFinalSummary(result pipeline.BuildResult) {
fmt.Fprintln(c.stdout, "Summary ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
if c.planOnly {
fmt.Fprintf(c.stdout, "planned archives: %d\n", result.HAKSummary.Total)
} else {
fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
fmt.Fprintf(c.stdout, "hak assets: %s\n", formatCount(result.HAKAssets))
}
if result.Manifest != "" {
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
}
if len(result.CreditsArtifactPaths) > 0 {
fmt.Fprintf(c.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
}
if len(result.AutogenManifestPaths) > 0 {
fmt.Fprintf(c.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
}
status := "complete"
if c.planOnly {
status = "planned"
}
fmt.Fprintf(c.stdout, "status: %s\n", status)
}
func (c *buildHAKConsole) 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)
}
func formatCount(value int) string {
raw := strconv.Itoa(value)
if value < 1000 && value > -1000 {
return raw
}
sign := ""
if strings.HasPrefix(raw, "-") {
sign = "-"
raw = strings.TrimPrefix(raw, "-")
}
parts := make([]string, 0, (len(raw)+2)/3)
for len(raw) > 3 {
parts = append(parts, raw[len(raw)-3:])
raw = raw[:len(raw)-3]
}
parts = append(parts, raw)
slices := make([]string, 0, len(parts))
for index := len(parts) - 1; index >= 0; index-- {
slices = append(slices, parts[index])
}
return sign + strings.Join(slices, ",")
}
func runBuildHAKs(ctx context) error {
@@ -280,16 +528,15 @@ func runBuildHAKs(ctx context) error {
return err
}
progress := func(message string) {
spin.linebreak()
fmt.Fprintf(ctx.stdout, "[build-haks] %s\n", message)
}
console := newBuildHAKConsole(ctx, p, opts)
spin.configure(ctx.stderr, console.spinnerEnabled)
spin.update("Build HAKs: starting")
var result pipeline.BuildResult
if opts.planOnly {
result, err = pipeline.PlanHAKsWithProgress(p, progress)
result, err = pipeline.PlanHAKsWithProgress(p, console.progress)
} else {
result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{
Progress: progress,
Progress: console.progress,
ArchiveNames: opts.filteredArchives,
SourceManifestPath: opts.sourceManifest,
})
@@ -298,18 +545,7 @@ func runBuildHAKs(ctx context) error {
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)
}
if len(result.CreditsArtifactPaths) > 0 {
fmt.Fprintf(ctx.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
}
if len(result.AutogenManifestPaths) > 0 {
fmt.Fprintf(ctx.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
}
console.emitResult(result)
return nil
}
@@ -323,7 +559,7 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
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]")
return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--plan-only] [--quiet|--verbose|--debug]")
case "--hak":
index++
if index >= len(args) {
@@ -338,6 +574,12 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
opts.filteredArchives = append(opts.filteredArchives, args[index])
case "--plan-only":
opts.planOnly = true
case "--quiet":
opts.logLevel = logLevelQuiet
case "--verbose":
opts.logLevel = logLevelVerbose
case "--debug":
opts.logLevel = logLevelDebug
case "--source-manifest":
index++
if index >= len(args) {
@@ -357,6 +599,10 @@ func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
opts.sourceManifest = value
continue
}
if arg == "--quiet=true" {
opts.logLevel = logLevelQuiet
continue
}
return opts, fmt.Errorf("unknown build-haks argument %q", arg)
}
}
+139
View File
@@ -59,6 +59,145 @@ func TestRunBuildTopPackageUsesCachedCompiledOutputs(t *testing.T) {
}
}
func TestRunBuildHAKsEmitsCompactSummary(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "assets", "envi", "music", "westgate"))
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "src"))
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"haks": [
{
"name": "envi",
"priority": 1,
"max_bytes": 1048576,
"split": false,
"include": ["envi/**"]
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n")
ffprobePath := filepath.Join(root, "ffprobe")
writeFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Unknown\",\"title\":\"Broken Metadata\"}}}'\n")
if err := os.Chmod(ffprobePath, 0o755); err != nil {
t.Fatalf("chmod ffprobe: %v", err)
}
ffmpegPath := filepath.Join(root, "ffmpeg")
writeFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'bmu-data' > \"$last\"\n")
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
t.Fatalf("chmod ffmpeg: %v", err)
}
t.Setenv("SOW_FFMPEG", ffmpegPath)
t.Setenv("SOW_FFPROBE", ffprobePath)
var stdout bytes.Buffer
ctx := context{
stdout: &stdout,
stderr: &bytes.Buffer{},
cwd: root,
args: []string{"build-haks"},
}
if err := runBuildHAKs(ctx); err != nil {
t.Fatalf("runBuildHAKs failed: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "Build HAKs ----------") {
t.Fatalf("expected build header, got %q", output)
}
if !strings.Contains(output, "mapped: 1 music file(s); use --verbose to list mappings") {
t.Fatalf("expected compact mapping summary, got %q", output)
}
if strings.Contains(output, "AleandAnecdotes.mp3 -> mus_wg_andnc.bmu") {
t.Fatalf("did not expect verbose mapping in normal mode, got %q", output)
}
if !strings.Contains(output, "manifest: build/haks.json") {
t.Fatalf("expected relative manifest path, got %q", output)
}
}
func TestRunBuildHAKsVerboseListsMappings(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "assets", "envi", "music", "westgate"))
mkdirAll(t, filepath.Join(root, "build"))
mkdirAll(t, filepath.Join(root, "src"))
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{
"module": {
"name": "Test Module",
"resref": "testmod"
},
"paths": {
"source": "src",
"assets": "assets",
"build": "build"
},
"haks": [
{
"name": "envi",
"priority": 1,
"max_bytes": 1048576,
"split": false,
"include": ["envi/**"]
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "AleandAnecdotes.mp3"), "source-mp3")
writeFile(t, filepath.Join(root, "assets", "envi", "music", "westgate", "CREDITS.md"), "# NWN Music Pack Credits\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n| Darren Curtis | Ale and Anecdotes | `mus_wg_andnc.bmu` | `AleandAnecdotes.mp3` | Darren's Commercially Free Music | | Darren Curtis Music | |\n")
ffprobePath := filepath.Join(root, "ffprobe")
writeFile(t, ffprobePath, "#!/bin/sh\nprintf '%s\\n' '{\"format\":{\"tags\":{\"artist\":\"Unknown\",\"title\":\"Broken Metadata\"}}}'\n")
if err := os.Chmod(ffprobePath, 0o755); err != nil {
t.Fatalf("chmod ffprobe: %v", err)
}
ffmpegPath := filepath.Join(root, "ffmpeg")
writeFile(t, ffmpegPath, "#!/bin/sh\nfor last; do :; done\nprintf 'bmu-data' > \"$last\"\n")
if err := os.Chmod(ffmpegPath, 0o755); err != nil {
t.Fatalf("chmod ffmpeg: %v", err)
}
t.Setenv("SOW_FFMPEG", ffmpegPath)
t.Setenv("SOW_FFPROBE", ffprobePath)
var stdout bytes.Buffer
ctx := context{
stdout: &stdout,
stderr: &bytes.Buffer{},
cwd: root,
args: []string{"build-haks", "--verbose"},
}
if err := runBuildHAKs(ctx); err != nil {
t.Fatalf("runBuildHAKs failed: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "mappings:") {
t.Fatalf("expected verbose mappings header, got %q", output)
}
if !strings.Contains(output, "AleandAnecdotes.mp3 -> mus_wg_andnc.bmu") {
t.Fatalf("expected verbose mapping output, got %q", output)
}
if !strings.Contains(output, "wrote: envi (1 assets)") {
t.Fatalf("expected verbose archive action, got %q", output)
}
}
func mkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {