Add shared report rendering
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -97,6 +97,10 @@ logical values, not YAML quoting or mapping order.
|
||||
|
||||
## 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:
|
||||
|
||||
- `Format` with `FormatText`, `FormatYAML`, and `FormatJSON`; its zero value means
|
||||
|
||||
121
report.go
Normal file
121
report.go
Normal 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
188
report_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user