diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 85fd7d8..c040c60 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/report.go b/report.go new file mode 100644 index 0000000..87c7d4f --- /dev/null +++ b/report.go @@ -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 +} diff --git a/report_test.go b/report_test.go new file mode 100644 index 0000000..4f1953f --- /dev/null +++ b/report_test.go @@ -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) + } +}