Add shared report rendering

This commit is contained in:
2026-11-07 03:40:09 +00:00
parent 7693482a8b
commit 190bfbd77b
3 changed files with 314 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
# Shared reporting and PromptKit v0.20.0 implementation plan # Shared reporting and PromptKit v0.20.0 implementation plan
Status: implementation in progress; Stage 1 complete. Status: implementation in progress; Stages 1–2 complete.
## Scope, authority, and execution ## Scope, authority, and execution
@@ -97,6 +97,10 @@ logical values, not YAML quoting or mapping order.
## Stage 2: Writer rendering and standard text templates ## Stage 2: Writer rendering and standard text templates
**Complete.** Writer rendering and reusable text templates are implemented.
Focused rendering tests and the root workspace suite (`GOTOOLCHAIN=local go
test ./...` with Go 1.27.0) passed.
Add these public root-package contracts: Add these public root-package contracts:
- `Format` with `FormatText`, `FormatYAML`, and `FormatJSON`; its zero value means - `Format` with `FormatText`, `FormatYAML`, and `FormatJSON`; its zero value means

121
report.go Normal file
View File

@@ -0,0 +1,121 @@
package commandkit
import (
"encoding/json"
"fmt"
"io"
"text/template"
"go.yaml.in/yaml/v4"
)
// Format selects a report representation. The zero value selects automatically.
type Format string
const (
// FormatText renders a text template or the default text representation.
FormatText Format = "text"
// FormatYAML renders the report as YAML.
FormatYAML Format = "yaml"
// FormatJSON renders the report as JSON.
FormatJSON Format = "json"
)
// Report contains application-selected content and an optional text presentation.
// Templates receive Value itself; structured formats use its JSON representation.
type Report struct {
Value any
TextTemplate *template.Template
}
// ParseTextTemplate prepares a reusable standard text template for a report.
func ParseTextTemplate(name, source string, funcs template.FuncMap) (*template.Template, error) {
return template.New(name).Funcs(funcs).Parse(source)
}
// RenderReport writes a report in the selected format. An automatic format uses
// a supplied text template, otherwise it selects text for scalars and nonempty
// string lists and YAML for other values. Text without a template uses YAML for
// structured values. Rendering and writer errors are returned to the caller.
func RenderReport(w io.Writer, report Report, format Format) error {
if format != "" && format != FormatText && format != FormatYAML && format != FormatJSON {
return fmt.Errorf("unknown report format %q", format)
}
writer := reportWriter{w}
if report.TextTemplate != nil && (format == "" || format == FormatText) {
return report.TextTemplate.Execute(writer, report.Value)
}
value, err := normalizeReport(report.Value)
if err != nil {
return err
}
if format == "" {
if reportIsPlainText(value) {
format = FormatText
} else {
format = FormatYAML
}
}
if format == FormatText {
if reportIsPlainText(value) {
switch v := value.(type) {
case []any:
for _, item := range v {
if _, err := fmt.Fprintln(writer, item); err != nil {
return err
}
}
return nil
default:
_, err := fmt.Fprintln(writer, value)
return err
}
}
format = FormatYAML
}
if format == FormatJSON {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
node, err := reportYAMLNode(value)
if err != nil {
return err
}
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
if err := encoder.Encode(node); err != nil {
return err
}
return encoder.Close()
}
func reportIsPlainText(value any) bool {
switch v := value.(type) {
case string, bool, json.Number:
return true
case []any:
if len(v) == 0 {
return false
}
for _, item := range v {
if _, ok := item.(string); !ok {
return false
}
}
return true
default:
return false
}
}
// reportWriter makes short writes observable even for writers that return no error.
type reportWriter struct{ io.Writer }
func (w reportWriter) Write(p []byte) (int, error) {
n, err := w.Writer.Write(p)
if n < len(p) && err == nil {
return n, io.ErrShortWrite
}
return n, err
}

188
report_test.go Normal file
View File

@@ -0,0 +1,188 @@
package commandkit
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"text/template"
)
func TestRenderReportAutomaticShapes(t *testing.T) {
for _, tc := range []struct {
name string
value any
text string
yaml bool
}{
{"string", "hello", "hello\n", false},
{"number", json.Number("123456789012345678901234567890"), "123456789012345678901234567890\n", false},
{"boolean", false, "false\n", false},
{"strings", []string{"one", "two"}, "one\ntwo\n", false},
{"empty strings", []string{}, "", true},
{"empty map", map[string]any{}, "", true},
{"null", nil, "", true},
{"mixed", []any{"a", false}, "", true},
{"map", map[string]any{"n": json.Number("9007199254740993")}, "", true},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
if err := RenderReport(&out, Report{Value: tc.value}, ""); err != nil {
t.Fatal(err)
}
if !tc.yaml {
if out.String() != tc.text {
t.Fatalf("got %q want %q", out.String(), tc.text)
}
return
}
got, err := DecodeYAMLReport(&out)
if err != nil {
t.Fatal(err)
}
want, err := normalizeReport(tc.value)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
})
}
}
func TestRenderReportTemplatesAndFormats(t *testing.T) {
tmpl, err := ParseTextTemplate("report", `{{upper .Secret}}/{{.Name}}`, template.FuncMap{"upper": strings.ToUpper})
if err != nil {
t.Fatal(err)
}
value := struct {
Name string `json:"name"`
Secret string `json:"-"`
}{"public", "private"}
var out bytes.Buffer
if err := RenderReport(&out, Report{value, tmpl}, ""); err != nil {
t.Fatal(err)
}
if out.String() != "PRIVATE/public" {
t.Fatal(out.String())
}
for _, format := range []Format{FormatYAML, FormatJSON} {
out.Reset()
if err := RenderReport(&out, Report{value, tmpl}, format); err != nil {
t.Fatal(err)
}
var got any
if format == FormatYAML {
got, err = DecodeYAMLReport(&out)
} else {
d := json.NewDecoder(&out)
d.UseNumber()
err = d.Decode(&got)
}
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, map[string]any{"name": "public"}) {
t.Fatalf("got %#v", got)
}
}
out.Reset()
if err := RenderReport(&out, Report{Value: map[string]any{"n": json.Number("123456789012345678901234567890")}}, FormatText); err != nil {
t.Fatal(err)
}
got, err := DecodeYAMLReport(&out)
if err != nil || !reflect.DeepEqual(got, map[string]any{"n": json.Number("123456789012345678901234567890")}) {
t.Fatalf("got %#v: %v", got, err)
}
}
type reportShortWriter struct{}
func (reportShortWriter) Write(p []byte) (int, error) { return len(p) / 2, nil }
type reportErrorWriter struct{ err error }
func (w reportErrorWriter) Write([]byte) (int, error) { return 0, w.err }
func TestRenderReportErrors(t *testing.T) {
if _, err := ParseTextTemplate("bad", `{{`, nil); err == nil {
t.Fatal("invalid template accepted")
}
tmpl, err := ParseTextTemplate("bad-execution", `{{.Missing}}`, nil)
if err != nil {
t.Fatal(err)
}
if err := RenderReport(io.Discard, Report{Value: struct{}{}, TextTemplate: tmpl}, ""); err == nil {
t.Fatal("template execution succeeded")
}
if err := RenderReport(io.Discard, Report{Value: make(chan int)}, FormatJSON); err == nil {
t.Fatal("unsupported value accepted")
}
if err := RenderReport(io.Discard, Report{}, Format("bad")); err == nil {
t.Fatal("unknown format accepted")
}
tmpl, err = ParseTextTemplate("skip-json", `{{if .}}ok{{end}}`, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := RenderReport(&out, Report{Value: make(chan int), TextTemplate: tmpl}, FormatText); err != nil || out.String() != "ok" {
t.Fatalf("templated unsupported value: %q, %v", &out, err)
}
failure := errors.New("writer failure")
for _, format := range []Format{FormatText, FormatYAML, FormatJSON} {
t.Run(string(format), func(t *testing.T) {
for _, writer := range []struct {
w io.Writer
want error
}{{reportShortWriter{}, io.ErrShortWrite}, {reportErrorWriter{failure}, failure}} {
err := RenderReport(writer.w, Report{Value: "content"}, format)
if !errors.Is(err, writer.want) {
t.Fatalf("%s: got %v, want %v", fmt.Sprint(writer.w), err, writer.want)
}
}
})
}
}
func TestRenderReportExplicitFormatsSkipTemplateAndPreserveNumbers(t *testing.T) {
tmpl, err := ParseTextTemplate("invalid-at-execution", `{{.Missing}}`, nil)
if err != nil {
t.Fatal(err)
}
value := struct {
Number json.Number `json:"number"`
}{json.Number("123456789012345678901234567890")}
want := map[string]any{"number": json.Number("123456789012345678901234567890")}
for _, format := range []Format{FormatYAML, FormatJSON} {
t.Run(string(format), func(t *testing.T) {
var out bytes.Buffer
if err := RenderReport(&out, Report{Value: value, TextTemplate: tmpl}, format); err != nil {
t.Fatal(err)
}
var got any
if format == FormatYAML {
got, err = DecodeYAMLReport(&out)
} else {
dec := json.NewDecoder(&out)
dec.UseNumber()
err = dec.Decode(&got)
}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, %v", got, err)
}
})
}
plain, err := ParseTextTemplate("plain", "content", nil)
if err != nil {
t.Fatal(err)
}
if err := RenderReport(reportShortWriter{}, Report{TextTemplate: plain}, FormatText); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("got %v", err)
}
}