From c8efdb53d3349c64f18c67fa6c8828f81467bccd Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 24 May 2026 14:57:12 +0000 Subject: [PATCH] Centralize deterministic JSON file writing --- internal/builtin/output.go | 13 +----- internal/jsonfile/jsonfile.go | 28 ++++++++++++ internal/jsonfile/jsonfile_test.go | 69 ++++++++++++++++++++++++++++++ internal/normalize/normalize.go | 19 +------- internal/report/report.go | 15 +------ internal/trim/run.go | 15 +------ 6 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 internal/jsonfile/jsonfile.go create mode 100644 internal/jsonfile/jsonfile_test.go diff --git a/internal/builtin/output.go b/internal/builtin/output.go index 2b46881..2251f99 100644 --- a/internal/builtin/output.go +++ b/internal/builtin/output.go @@ -2,10 +2,9 @@ package builtin import ( "context" - "encoding/json" - "os" "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile" "gitea.maximumdirect.net/eric/seriatim/internal/report" ) @@ -20,15 +19,7 @@ func (jsonOutputWriter) Write(ctx context.Context, out any, rpt report.Report, c return nil, err } - file, err := os.Create(cfg.OutputFile) - if err != nil { - return nil, err - } - defer file.Close() - - enc := json.NewEncoder(file) - enc.SetIndent("", " ") - if err := enc.Encode(out); err != nil { + if err := jsonfile.Write(cfg.OutputFile, out); err != nil { return nil, err } diff --git a/internal/jsonfile/jsonfile.go b/internal/jsonfile/jsonfile.go new file mode 100644 index 0000000..7af466b --- /dev/null +++ b/internal/jsonfile/jsonfile.go @@ -0,0 +1,28 @@ +package jsonfile + +import ( + "encoding/json" + "fmt" + "os" +) + +// Write creates or truncates path and writes deterministic indented JSON. +func Write(path string, value any) (err error) { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %q: %w", path, err) + } + defer func() { + closeErr := file.Close() + if err == nil && closeErr != nil { + err = fmt.Errorf("close %q: %w", path, closeErr) + } + }() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return fmt.Errorf("encode %q: %w", path, err) + } + return nil +} diff --git a/internal/jsonfile/jsonfile_test.go b/internal/jsonfile/jsonfile_test.go new file mode 100644 index 0000000..a5c664b --- /dev/null +++ b/internal/jsonfile/jsonfile_test.go @@ -0,0 +1,69 @@ +package jsonfile + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFormatsWithTwoSpaceIndentAndTrailingNewline(t *testing.T) { + type payload struct { + Name string `json:"name"` + Items []int `json:"items"` + } + + path := filepath.Join(t.TempDir(), "out.json") + value := payload{ + Name: "alpha", + Items: []int{1, 2}, + } + + if err := Write(path, value); err != nil { + t.Fatalf("write failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read output: %v", err) + } + + got := string(data) + want := "{\n \"name\": \"alpha\",\n \"items\": [\n 1,\n 2\n ]\n}\n" + if got != want { + t.Fatalf("formatted JSON mismatch\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestWriteProducesValidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.json") + + value := map[string]any{ + "application": "seriatim", + "segments": []map[string]any{ + { + "id": 1, + "speaker": "A", + "text": "hello", + }, + }, + } + + if err := Write(path, value); err != nil { + t.Fatalf("write failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read output: %v", err) + } + if !strings.HasSuffix(string(data), "\n") { + t.Fatalf("output missing trailing newline: %q", string(data)) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } +} diff --git a/internal/normalize/normalize.go b/internal/normalize/normalize.go index c4d94f7..e46dcf5 100644 --- a/internal/normalize/normalize.go +++ b/internal/normalize/normalize.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" "gitea.maximumdirect.net/eric/seriatim/internal/artifact" "gitea.maximumdirect.net/eric/seriatim/internal/buildinfo" "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile" "gitea.maximumdirect.net/eric/seriatim/internal/report" ) @@ -47,7 +47,7 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error { return err } - if err := writeOutputJSON(cfg.OutputFile, built.Output); err != nil { + if err := jsonfile.Write(cfg.OutputFile, built.Output); err != nil { return err } @@ -118,18 +118,3 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error { return nil } - -func writeOutputJSON(path string, value any) error { - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - if err := encoder.Encode(value); err != nil { - return fmt.Errorf("encode normalize output JSON: %w", err) - } - return nil -} diff --git a/internal/report/report.go b/internal/report/report.go index b9c8141..2a6999a 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -1,9 +1,6 @@ package report -import ( - "encoding/json" - "os" -) +import "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile" // Severity classifies report events. type Severity string @@ -62,13 +59,5 @@ func Warning(stage string, module string, message string) Event { // WriteJSON writes a deterministic JSON report. func WriteJSON(path string, rpt Report) error { - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - enc := json.NewEncoder(file) - enc.SetIndent("", " ") - return enc.Encode(rpt) + return jsonfile.Write(path, rpt) } diff --git a/internal/trim/run.go b/internal/trim/run.go index aca64c3..990d919 100644 --- a/internal/trim/run.go +++ b/internal/trim/run.go @@ -8,6 +8,7 @@ import ( "sort" "gitea.maximumdirect.net/eric/seriatim/internal/config" + "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile" "gitea.maximumdirect.net/eric/seriatim/internal/report" ) @@ -85,7 +86,7 @@ func Run(ctx context.Context, cfg config.TrimConfig) error { return fmt.Errorf("validate trimmed output: %w", err) } - if err := writeOutputJSON(cfg.OutputFile, outputArtifact.Value()); err != nil { + if err := jsonfile.Write(cfg.OutputFile, outputArtifact.Value()); err != nil { return err } @@ -137,18 +138,6 @@ func Run(ctx context.Context, cfg config.TrimConfig) error { return nil } -func writeOutputJSON(path string, value any) error { - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - enc := json.NewEncoder(file) - enc.SetIndent("", " ") - return enc.Encode(value) -} - func orderedIDMapping(mapping map[int]int) []idMapping { keys := make([]int, 0, len(mapping)) for oldID := range mapping {