Add shared report rendering
This commit is contained in:
@@ -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
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