From 15881fb1329235eea7f9b0e8adb38c5738c1d8ca Mon Sep 17 00:00:00 2001
From: Duc-Tam Nguyen <tamnd87@gmail.com>
Date: Tue, 16 Jun 2026 10:24:30 +0700
Subject: [PATCH] add OUP Open Access commands: recent, search

Wire up recent and search subcommands over the CrossRef member/286
API, plus the renderer and typed Paper model.
---
 cli/cmd_recent.go       |  20 +++
 cli/cmd_search.go       |  27 ++++
 cli/errors.go           |   3 +
 cli/output.go           |  23 +++
 cli/root.go             | 127 +++++++++++++--
 go.mod                  |  29 ++++
 go.sum                  |  74 +++++++++
 oupopen/oupopen.go      | 187 ++++++++++++++++-----
 oupopen/oupopen_test.go | 127 +++++++++++----
 oupopen/types.go        |  44 +++++
 pkg/render/render.go    | 350 ++++++++++++++++++++++++++++++++++++++++
 11 files changed, 934 insertions(+), 77 deletions(-)
 create mode 100644 cli/cmd_recent.go
 create mode 100644 cli/cmd_search.go
 create mode 100644 cli/errors.go
 create mode 100644 cli/output.go
 create mode 100644 go.sum
 create mode 100644 oupopen/types.go
 create mode 100644 pkg/render/render.go

diff --git a/cli/cmd_recent.go b/cli/cmd_recent.go
new file mode 100644
index 0000000..891d17b
--- /dev/null
+++ b/cli/cmd_recent.go
@@ -0,0 +1,20 @@
+package cli
+
+import (
+	"github.com/spf13/cobra"
+)
+
+func (a *App) recentCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "recent",
+		Short: "List recently published OUP open-access papers",
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			limit := a.effectiveLimit(20)
+			papers, err := a.client.Recent(cmd.Context(), limit)
+			if err != nil {
+				return mapFetchErr(err)
+			}
+			return a.renderOrEmpty(papers, len(papers))
+		},
+	}
+}
diff --git a/cli/cmd_search.go b/cli/cmd_search.go
new file mode 100644
index 0000000..2b7f9a0
--- /dev/null
+++ b/cli/cmd_search.go
@@ -0,0 +1,27 @@
+package cli
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+func (a *App) searchCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "search <query>",
+		Short: "Search OUP open-access papers by keyword",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			query := args[0]
+			if query == "" {
+				return codeError(exitUsage, fmt.Errorf("query cannot be empty"))
+			}
+			limit := a.effectiveLimit(20)
+			papers, err := a.client.Search(cmd.Context(), query, limit)
+			if err != nil {
+				return mapFetchErr(err)
+			}
+			return a.renderOrEmpty(papers, len(papers))
+		},
+	}
+}
diff --git a/cli/errors.go b/cli/errors.go
new file mode 100644
index 0000000..d1ed14a
--- /dev/null
+++ b/cli/errors.go
@@ -0,0 +1,3 @@
+package cli
+
+func isNotFound(_ error) bool { return false }
diff --git a/cli/output.go b/cli/output.go
new file mode 100644
index 0000000..1582a9b
--- /dev/null
+++ b/cli/output.go
@@ -0,0 +1,23 @@
+package cli
+
+import (
+	"io"
+
+	"github.com/tamnd/oupopen-cli/pkg/render"
+)
+
+type Format = render.Format
+
+const (
+	FormatTable = render.FormatTable
+	FormatJSON  = render.FormatJSON
+	FormatJSONL = render.FormatJSONL
+	FormatCSV   = render.FormatCSV
+	FormatTSV   = render.FormatTSV
+	FormatURL   = render.FormatURL
+	FormatRaw   = render.FormatRaw
+)
+
+func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *render.Renderer {
+	return render.New(w, format, fields, noHeader, tmpl)
+}
diff --git a/cli/root.go b/cli/root.go
index f99f85f..740caf4 100644
--- a/cli/root.go
+++ b/cli/root.go
@@ -2,30 +2,137 @@
 package cli
 
 import (
+	"fmt"
+	"os"
+
+	"github.com/mattn/go-isatty"
 	"github.com/spf13/cobra"
+	"github.com/tamnd/oupopen-cli/oupopen"
 )
 
-// Build metadata, set via -ldflags at release time.
 var (
 	Version = "dev"
 	Commit  = "none"
 	Date    = "unknown"
 )
 
-// Root builds the root command and its subtree.
+const (
+	exitError  = 1
+	exitUsage  = 2
+	exitNoData = 3
+)
+
+type ExitError struct {
+	Code int
+	Err  error
+}
+
+func (e *ExitError) Error() string {
+	if e.Err != nil {
+		return e.Err.Error()
+	}
+	return fmt.Sprintf("exit %d", e.Code)
+}
+
+func (e *ExitError) Unwrap() error { return e.Err }
+
+func codeError(code int, err error) error { return &ExitError{Code: code, Err: err} }
+
+type App struct {
+	client *oupopen.Client
+	cfg    oupopen.Config
+
+	output   string
+	fields   []string
+	noHeader bool
+	template string
+	limit    int
+	quiet    bool
+}
+
 func Root() *cobra.Command {
-	root := &cobra.Command{
-		Use:   "oupopen",
-		Short: "A command line for oupopen.",
-		Long: `A command line for oupopen.
+	app := &App{cfg: oupopen.DefaultConfig()}
 
-This is a fresh scaffold. Add your commands here on top of the oupopen
-library package, then wire them into Root with root.AddCommand.`,
+	root := &cobra.Command{
+		Use:           "oupopen",
+		Short:         "Browse Oxford University Press open-access journals",
 		SilenceUsage:  true,
 		SilenceErrors: true,
+		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+			return app.setup()
+		},
 	}
 
-	root.AddCommand(newVersionCmd())
-	// TODO: root.AddCommand(newGetCmd()), etc.
+	pf := root.PersistentFlags()
+	pf.StringVarP(&app.output, "output", "o", "auto", "output: table|json|jsonl|csv|tsv|url|raw")
+	pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include")
+	pf.BoolVar(&app.noHeader, "no-header", false, "omit header row in table/csv/tsv")
+	pf.StringVar(&app.template, "template", "", "Go text/template per record")
+	pf.IntVarP(&app.limit, "limit", "n", 0, "limit number of records (0 = command default)")
+	pf.BoolVarP(&app.quiet, "quiet", "q", false, "suppress progress on stderr")
+	pf.DurationVar(&app.cfg.Rate, "delay", app.cfg.Rate, "minimum spacing between requests")
+	pf.DurationVar(&app.cfg.Timeout, "timeout", app.cfg.Timeout, "per-request timeout")
+	pf.IntVar(&app.cfg.Retries, "retries", app.cfg.Retries, "retry attempts on 429/5xx")
+	pf.StringVar(&app.cfg.UserAgent, "user-agent", app.cfg.UserAgent, "User-Agent header")
+
+	root.AddCommand(
+		app.recentCmd(),
+		app.searchCmd(),
+		newVersionCmd(),
+	)
 	return root
 }
+
+func (a *App) setup() error {
+	if a.output == "" || a.output == "auto" {
+		if isatty.IsTerminal(os.Stdout.Fd()) {
+			a.output = string(FormatTable)
+		} else {
+			a.output = string(FormatJSONL)
+		}
+	}
+	if !Format(a.output).Valid() {
+		return codeError(exitUsage, fmt.Errorf("unknown output format %q", a.output))
+	}
+	a.client = oupopen.NewClient(a.cfg)
+	return nil
+}
+
+func (a *App) render(records any) error {
+	r := NewRenderer(os.Stdout, Format(a.output), a.fields, a.noHeader, a.template)
+	return r.Render(records)
+}
+
+func (a *App) renderOrEmpty(records any, n int) error {
+	if err := a.render(records); err != nil {
+		return err
+	}
+	if n == 0 {
+		return codeError(exitNoData, nil)
+	}
+	return nil
+}
+
+func (a *App) progressf(format string, args ...any) {
+	if a.quiet {
+		return
+	}
+	_, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
+
+func mapFetchErr(err error) error {
+	if err == nil {
+		return nil
+	}
+	if isNotFound(err) {
+		return codeError(exitNoData, err)
+	}
+	return codeError(exitError, err)
+}
+
+func (a *App) effectiveLimit(def int) int {
+	if a.limit > 0 {
+		return a.limit
+	}
+	return def
+}
diff --git a/go.mod b/go.mod
index 08d26b2..2b8a73f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,5 +4,34 @@ go 1.26
 
 require (
 	github.com/charmbracelet/fang v1.0.0
+	github.com/mattn/go-isatty v0.0.22
 	github.com/spf13/cobra v1.10.2
 )
+
+require (
+	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect
+	github.com/charmbracelet/colorprofile v0.3.3 // indirect
+	github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect
+	github.com/charmbracelet/x/ansi v0.11.0 // indirect
+	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
+	github.com/charmbracelet/x/term v0.2.2 // indirect
+	github.com/charmbracelet/x/termios v0.1.1 // indirect
+	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/clipperhouse/displaywidth v0.4.1 // indirect
+	github.com/clipperhouse/stringish v0.1.1 // indirect
+	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/mango v0.1.0 // indirect
+	github.com/muesli/mango-cobra v1.2.0 // indirect
+	github.com/muesli/mango-pflag v0.1.0 // indirect
+	github.com/muesli/roff v0.1.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/sync v0.17.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/text v0.24.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e7d4564
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,74 @@
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
+github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
+github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
+github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
+github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ=
+github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0=
+github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
+github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
+github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU=
+github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
+github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
+github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
+github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
+github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/oupopen/oupopen.go b/oupopen/oupopen.go
index fb48471..b5bc14f 100644
--- a/oupopen/oupopen.go
+++ b/oupopen/oupopen.go
@@ -1,52 +1,61 @@
 // Package oupopen is the library behind the oupopen command line:
-// the HTTP client, request shaping, and the typed data models for oupopen.
-//
-// The Client here is the spine every command shares. It sets a real
-// User-Agent, paces requests so a busy session stays polite, and retries the
-// transient failures (429 and 5xx) that any public site throws under load.
-// Build your endpoint calls and JSON decoding on top of it.
+// the HTTP client, request shaping, and the typed data models for Oxford
+// University Press open-access papers fetched via the CrossRef API.
 package oupopen
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/url"
+	"strings"
+	"sync"
 	"time"
 )
 
-// DefaultUserAgent identifies the client to oupopen. A real, honest
-// User-Agent is both polite and the thing most likely to keep you unblocked.
-const DefaultUserAgent = "oupopen/dev (+https://github.com/tamnd/oupopen-cli)"
+const DefaultUserAgent = "Mozilla/5.0 (compatible; oupopen-cli/0.1; +https://github.com/tamnd/oupopen-cli)"
 
-// Client talks to oupopen over HTTP.
-type Client struct {
-	HTTP      *http.Client
+// Config holds constructor parameters.
+type Config struct {
+	BaseURL   string
 	UserAgent string
-	// Rate is the minimum gap between requests. Zero means no pacing.
-	Rate    time.Duration
-	Retries int
+	Rate      time.Duration
+	Retries   int
+	Timeout   time.Duration
+}
+
+// DefaultConfig returns sensible defaults.
+func DefaultConfig() Config {
+	return Config{
+		BaseURL:   "https://api.crossref.org",
+		UserAgent: DefaultUserAgent,
+		Rate:      500 * time.Millisecond,
+		Retries:   3,
+		Timeout:   30 * time.Second,
+	}
+}
 
-	last time.Time
+// Client talks to the CrossRef API for OUP papers.
+type Client struct {
+	cfg        Config
+	httpClient *http.Client
+	mu         sync.Mutex
+	last       time.Time
 }
 
-// NewClient returns a Client with sensible defaults: a 30s timeout, a 200ms
-// minimum gap between requests, and five retries on transient errors.
-func NewClient() *Client {
+// NewClient returns a Client with the given config.
+func NewClient(cfg Config) *Client {
 	return &Client{
-		HTTP:      &http.Client{Timeout: 30 * time.Second},
-		UserAgent: DefaultUserAgent,
-		Rate:      200 * time.Millisecond,
-		Retries:   5,
+		cfg:        cfg,
+		httpClient: &http.Client{Timeout: cfg.Timeout},
 	}
 }
 
-// Get fetches url and returns the response body. It paces and retries according
-// to the client's settings. The caller owns nothing extra; the body is read
-// fully and closed here.
-func (c *Client) Get(ctx context.Context, url string) ([]byte, error) {
+func (c *Client) get(ctx context.Context, rawURL string) ([]byte, error) {
 	var lastErr error
-	for attempt := 0; attempt <= c.Retries; attempt++ {
+	for attempt := 0; attempt <= c.cfg.Retries; attempt++ {
 		if attempt > 0 {
 			select {
 			case <-ctx.Done():
@@ -54,27 +63,27 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) {
 			case <-time.After(backoff(attempt)):
 			}
 		}
-		body, retry, err := c.do(ctx, url)
+		b, retry, err := c.do(ctx, rawURL)
 		if err == nil {
-			return body, nil
+			return b, nil
 		}
 		lastErr = err
 		if !retry {
 			return nil, err
 		}
 	}
-	return nil, fmt.Errorf("get %s: %w", url, lastErr)
+	return nil, fmt.Errorf("get: %w", lastErr)
 }
 
-func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, err error) {
+func (c *Client) do(ctx context.Context, rawURL string) ([]byte, bool, error) {
 	c.pace()
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
 	if err != nil {
 		return nil, false, err
 	}
-	req.Header.Set("User-Agent", c.UserAgent)
+	req.Header.Set("User-Agent", c.cfg.UserAgent)
 
-	resp, err := c.HTTP.Do(req)
+	resp, err := c.httpClient.Do(req)
 	if err != nil {
 		return nil, true, err
 	}
@@ -87,19 +96,20 @@ func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, e
 		return nil, false, fmt.Errorf("http %d", resp.StatusCode)
 	}
 
-	b, err := io.ReadAll(resp.Body)
+	b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
 	if err != nil {
 		return nil, true, err
 	}
 	return b, false, nil
 }
 
-// pace blocks until at least Rate has passed since the previous request.
 func (c *Client) pace() {
-	if c.Rate <= 0 {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if c.cfg.Rate <= 0 {
 		return
 	}
-	if wait := c.Rate - time.Since(c.last); wait > 0 {
+	if wait := c.cfg.Rate - time.Since(c.last); wait > 0 {
 		time.Sleep(wait)
 	}
 	c.last = time.Now()
@@ -112,3 +122,102 @@ func backoff(attempt int) time.Duration {
 	}
 	return d
 }
+
+// Recent fetches recently published OUP open-access papers.
+func (c *Client) Recent(ctx context.Context, limit int) ([]Paper, error) {
+	if limit <= 0 {
+		limit = 20
+	}
+	apiURL := fmt.Sprintf("%s/members/286/works?rows=%d&sort=published&order=desc", c.cfg.BaseURL, limit)
+	return c.fetch(ctx, apiURL, limit)
+}
+
+// Search searches OUP papers by keyword.
+func (c *Client) Search(ctx context.Context, query string, limit int) ([]Paper, error) {
+	if limit <= 0 {
+		limit = 20
+	}
+	apiURL := fmt.Sprintf(
+		"%s/members/286/works?rows=%d&sort=published&order=desc&query=%s",
+		c.cfg.BaseURL, limit, url.QueryEscape(query),
+	)
+	return c.fetch(ctx, apiURL, limit)
+}
+
+func (c *Client) fetch(ctx context.Context, apiURL string, limit int) ([]Paper, error) {
+	raw, err := c.get(ctx, apiURL)
+	if err != nil {
+		return nil, err
+	}
+	var resp wireResponse
+	if err := json.Unmarshal(raw, &resp); err != nil {
+		return nil, fmt.Errorf("decode response: %w", err)
+	}
+	papers := make([]Paper, 0, len(resp.Message.Items))
+	for i, w := range resp.Message.Items {
+		if limit > 0 && i >= limit {
+			break
+		}
+		papers = append(papers, wireToPaper(w, i+1))
+	}
+	return papers, nil
+}
+
+func wireToPaper(w wireItem, rank int) Paper {
+	title := ""
+	if len(w.Title) > 0 {
+		title = w.Title[0]
+	}
+	journal := ""
+	if len(w.ContainerTitle) > 0 {
+		journal = w.ContainerTitle[0]
+	}
+	authors := formatAuthors(w.Author)
+	published := formatDate(w.Published.DateParts)
+	return Paper{
+		Rank:      rank,
+		DOI:       w.DOI,
+		Title:     title,
+		Authors:   authors,
+		Journal:   journal,
+		Published: published,
+		Cited:     w.IsReferencedBy,
+		URL:       "https://doi.org/" + w.DOI,
+	}
+}
+
+func formatAuthors(authors []wireAuthor) string {
+	if len(authors) == 0 {
+		return ""
+	}
+	max := len(authors)
+	suffix := ""
+	if max > 3 {
+		max = 3
+		suffix = " et al."
+	}
+	parts := make([]string, 0, max)
+	for _, a := range authors[:max] {
+		given := ""
+		if len(a.Given) > 0 {
+			given = " " + string([]rune(a.Given)[:1])
+		}
+		parts = append(parts, a.Family+given)
+	}
+	s := strings.Join(parts, ", ") + suffix
+	if len([]rune(s)) > 80 {
+		s = string([]rune(s)[:79]) + "…"
+	}
+	return s
+}
+
+func formatDate(parts [][]int) string {
+	if len(parts) == 0 || len(parts[0]) == 0 {
+		return ""
+	}
+	row := parts[0]
+	if len(row) >= 2 {
+		return fmt.Sprintf("%04d-%02d", row[0], row[1])
+	}
+	return fmt.Sprintf("%04d", row[0])
+}
diff --git a/oupopen/oupopen_test.go b/oupopen/oupopen_test.go
index a7f626a..d418818 100644
--- a/oupopen/oupopen_test.go
+++ b/oupopen/oupopen_test.go
@@ -1,62 +1,133 @@
-package oupopen
+package oupopen_test
 
 import (
 	"context"
+	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"testing"
-	"time"
+
+	"github.com/tamnd/oupopen-cli/oupopen"
 )
 
-func TestGet(t *testing.T) {
+func papersPayload(items []map[string]any) []byte {
+	b, _ := json.Marshal(map[string]any{
+		"message": map[string]any{
+			"total-results": len(items),
+			"items":         items,
+		},
+	})
+	return b
+}
+
+func TestRecent(t *testing.T) {
+	payload := papersPayload([]map[string]any{
+		{
+			"DOI":                    "10.1093/nar/gkac001",
+			"title":                  []string{"Genomic Analysis of Open-Access Data"},
+			"author":                 []map[string]any{{"given": "Alice", "family": "Smith"}},
+			"published":              map[string]any{"date-parts": [][]int{{2026, 6}}},
+			"type":                   "journal-article",
+			"container-title":        []string{"Nucleic Acids Research"},
+			"is-referenced-by-count": 3,
+		},
+	})
+
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if r.Header.Get("User-Agent") == "" {
 			t.Error("request carried no User-Agent")
 		}
-		_, _ = w.Write([]byte("ok"))
+		_, _ = w.Write(payload)
 	}))
 	defer srv.Close()
 
-	c := NewClient()
-	c.Rate = 0 // no pacing in the test
+	cfg := oupopen.DefaultConfig()
+	cfg.BaseURL = srv.URL
+	cfg.Rate = 0
 
-	body, err := c.Get(context.Background(), srv.URL)
+	c := oupopen.NewClient(cfg)
+	papers, err := c.Recent(context.Background(), 10)
 	if err != nil {
 		t.Fatal(err)
 	}
-	if string(body) != "ok" {
-		t.Errorf("body = %q, want %q", body, "ok")
+	if len(papers) != 1 {
+		t.Fatalf("got %d papers, want 1", len(papers))
+	}
+	if papers[0].Title != "Genomic Analysis of Open-Access Data" {
+		t.Errorf("title = %q", papers[0].Title)
+	}
+	if papers[0].Rank != 1 {
+		t.Errorf("rank = %d, want 1", papers[0].Rank)
+	}
+	if papers[0].Published != "2026-06" {
+		t.Errorf("published = %q, want 2026-06", papers[0].Published)
+	}
+	if papers[0].URL != "https://doi.org/10.1093/nar/gkac001" {
+		t.Errorf("url = %q", papers[0].URL)
 	}
 }
 
-func TestGetRetriesOn503(t *testing.T) {
-	var hits int
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		hits++
-		if hits < 3 {
-			w.WriteHeader(http.StatusServiceUnavailable)
-			return
+func TestRecentLimit(t *testing.T) {
+	items := make([]map[string]any, 5)
+	for i := range items {
+		items[i] = map[string]any{
+			"DOI":                    "10.1093/test/001",
+			"title":                  []string{"Test Paper"},
+			"published":              map[string]any{"date-parts": [][]int{{2025}}},
+			"is-referenced-by-count": 0,
 		}
-		_, _ = w.Write([]byte("recovered"))
+	}
+	payload := papersPayload(items)
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write(payload)
 	}))
 	defer srv.Close()
 
-	c := NewClient()
-	c.Rate = 0
-	c.Retries = 5
+	cfg := oupopen.DefaultConfig()
+	cfg.BaseURL = srv.URL
+	cfg.Rate = 0
 
-	start := time.Now()
-	body, err := c.Get(context.Background(), srv.URL)
+	c := oupopen.NewClient(cfg)
+	papers, err := c.Recent(context.Background(), 3)
 	if err != nil {
 		t.Fatal(err)
 	}
-	if string(body) != "recovered" {
-		t.Errorf("body = %q after retries", body)
+	if len(papers) != 3 {
+		t.Fatalf("got %d papers, want 3 (limit applied)", len(papers))
+	}
+}
+
+func TestSearch(t *testing.T) {
+	payload := papersPayload([]map[string]any{
+		{
+			"DOI":                    "10.1093/bioinformatics/ml01",
+			"title":                  []string{"Deep Learning for Genomic Classification"},
+			"author":                 []map[string]any{{"given": "Bob", "family": "Jones"}},
+			"published":              map[string]any{"date-parts": [][]int{{2024, 3}}},
+			"container-title":        []string{"Bioinformatics"},
+			"is-referenced-by-count": 10,
+		},
+	})
+
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write(payload)
+	}))
+	defer srv.Close()
+
+	cfg := oupopen.DefaultConfig()
+	cfg.BaseURL = srv.URL
+	cfg.Rate = 0
+
+	c := oupopen.NewClient(cfg)
+	papers, err := c.Search(context.Background(), "deep learning", 10)
+	if err != nil {
+		t.Fatal(err)
 	}
-	if hits != 3 {
-		t.Errorf("server saw %d hits, want 3", hits)
+	if len(papers) != 1 {
+		t.Fatalf("got %d papers, want 1", len(papers))
 	}
-	if time.Since(start) < 500*time.Millisecond {
-		t.Error("retries did not back off")
+	if papers[0].Authors != "Jones B" {
+		t.Errorf("authors = %q, want 'Jones B'", papers[0].Authors)
 	}
 }
diff --git a/oupopen/types.go b/oupopen/types.go
new file mode 100644
index 0000000..116f3cf
--- /dev/null
+++ b/oupopen/types.go
@@ -0,0 +1,44 @@
+package oupopen
+
+// Paper is an academic paper from Oxford University Press via CrossRef.
+type Paper struct {
+	Rank      int    `json:"rank"`
+	DOI       string `json:"doi"`
+	Title     string `json:"title"`
+	Authors   string `json:"authors"`
+	Journal   string `json:"journal"`
+	Published string `json:"published"`
+	Cited     int    `json:"cited"`
+	URL       string `json:"url"`
+}
+
+// wire types for CrossRef API
+
+type wireResponse struct {
+	Message wireMessage `json:"message"`
+}
+
+type wireMessage struct {
+	TotalResults int        `json:"total-results"`
+	NextCursor   string     `json:"next-cursor"`
+	Items        []wireItem `json:"items"`
+}
+
+type wireItem struct {
+	DOI            string       `json:"DOI"`
+	Title          []string     `json:"title"`
+	Author         []wireAuthor `json:"author"`
+	Published      wireDate     `json:"published"`
+	Type           string       `json:"type"`
+	ContainerTitle []string     `json:"container-title"`
+	IsReferencedBy int          `json:"is-referenced-by-count"`
+}
+
+type wireAuthor struct {
+	Given  string `json:"given"`
+	Family string `json:"family"`
+}
+
+type wireDate struct {
+	DateParts [][]int `json:"date-parts"`
+}
diff --git a/pkg/render/render.go b/pkg/render/render.go
new file mode 100644
index 0000000..34393b6
--- /dev/null
+++ b/pkg/render/render.go
@@ -0,0 +1,350 @@
+// Package render turns slices of record structs into one of the output formats
+// hackernews-cli supports: table, json, jsonl, csv, tsv, url, and raw. It works
+// off struct reflection and json tags, so any record type renders without
+// per-type code.
+package render
+
+import (
+	"encoding/csv"
+	"encoding/json"
+	"fmt"
+	"io"
+	"reflect"
+	"strconv"
+	"strings"
+	"text/tabwriter"
+	"text/template"
+	"time"
+)
+
+// Format is an output rendering format.
+type Format string
+
+const (
+	FormatTable Format = "table"
+	FormatJSON  Format = "json"
+	FormatJSONL Format = "jsonl"
+	FormatCSV   Format = "csv"
+	FormatTSV   Format = "tsv"
+	FormatURL   Format = "url"
+	FormatRaw   Format = "raw"
+)
+
+// Valid reports whether f is one of the supported formats.
+func (f Format) Valid() bool {
+	switch f {
+	case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw:
+		return true
+	}
+	return false
+}
+
+// Renderer writes records in a chosen format.
+type Renderer struct {
+	Format   Format
+	Fields   []string
+	NoHeader bool
+	Template string
+	w        io.Writer
+}
+
+// New builds a Renderer writing to w.
+func New(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer {
+	return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w}
+}
+
+// Render writes records (a slice of structs, or a single struct) in the configured format.
+func (r *Renderer) Render(records any) error {
+	rv := reflect.ValueOf(records)
+	if rv.Kind() == reflect.Pointer {
+		rv = rv.Elem()
+	}
+	if rv.Kind() != reflect.Slice {
+		s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1)
+		s.Index(0).Set(rv)
+		rv = s
+	}
+	n := rv.Len()
+	items := make([]any, n)
+	for i := 0; i < n; i++ {
+		items[i] = rv.Index(i).Interface()
+	}
+
+	if r.Template != "" {
+		return r.renderTemplate(items)
+	}
+	switch r.Format {
+	case FormatJSON:
+		return r.renderJSON(items)
+	case FormatJSONL:
+		return r.renderJSONL(items)
+	case FormatCSV:
+		return r.renderDelimited(items, ',')
+	case FormatTSV:
+		return r.renderDelimited(items, '\t')
+	case FormatURL:
+		return r.renderURL(items)
+	case FormatRaw:
+		return r.renderRaw(items)
+	default:
+		return r.renderTable(items)
+	}
+}
+
+func (r *Renderer) renderJSON(items []any) error {
+	enc := json.NewEncoder(r.w)
+	enc.SetIndent("", "  ")
+	if len(items) == 1 {
+		return enc.Encode(items[0])
+	}
+	return enc.Encode(items)
+}
+
+func (r *Renderer) renderJSONL(items []any) error {
+	enc := json.NewEncoder(r.w)
+	for _, it := range items {
+		if err := enc.Encode(it); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *Renderer) renderTemplate(items []any) error {
+	t, err := template.New("row").Funcs(template.FuncMap{
+		"join": func(sep string, v any) string { return joinAny(sep, v) },
+	}).Parse(r.Template)
+	if err != nil {
+		return fmt.Errorf("parse --template: %w", err)
+	}
+	for _, it := range items {
+		if err := t.Execute(r.w, toAnyMap(it)); err != nil {
+			return err
+		}
+		_, _ = fmt.Fprintln(r.w)
+	}
+	return nil
+}
+
+func (r *Renderer) renderURL(items []any) error {
+	for _, it := range items {
+		m := toMap(it)
+		if u := firstNonEmpty(m["url"], m["hn_url"], m["permalink"]); u != "" {
+			_, _ = fmt.Fprintln(r.w, u)
+		}
+	}
+	return nil
+}
+
+func (r *Renderer) renderRaw(items []any) error {
+	cols := r.columns(items)
+	for _, it := range items {
+		m := toMap(it)
+		vals := make([]string, 0, len(cols))
+		for _, c := range cols {
+			vals = append(vals, m[c])
+		}
+		_, _ = fmt.Fprintln(r.w, strings.Join(vals, " "))
+	}
+	return nil
+}
+
+func (r *Renderer) renderTable(items []any) error {
+	if len(items) == 0 {
+		return nil
+	}
+	cols := r.columns(items)
+	tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0)
+	if !r.NoHeader {
+		_, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t"))
+	}
+	for _, it := range items {
+		m := toMap(it)
+		cells := make([]string, len(cols))
+		for i, c := range cols {
+			cells[i] = truncate(m[c], 60)
+		}
+		_, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
+	}
+	return tw.Flush()
+}
+
+func (r *Renderer) renderDelimited(items []any, comma rune) error {
+	if len(items) == 0 {
+		return nil
+	}
+	cols := r.columns(items)
+	cw := csv.NewWriter(r.w)
+	cw.Comma = comma
+	if !r.NoHeader {
+		if err := cw.Write(cols); err != nil {
+			return err
+		}
+	}
+	for _, it := range items {
+		m := toMap(it)
+		row := make([]string, len(cols))
+		for i, c := range cols {
+			row[i] = m[c]
+		}
+		if err := cw.Write(row); err != nil {
+			return err
+		}
+	}
+	cw.Flush()
+	return cw.Error()
+}
+
+func (r *Renderer) columns(items []any) []string {
+	if len(r.Fields) > 0 {
+		return r.Fields
+	}
+	if len(items) == 0 {
+		return nil
+	}
+	return structJSONKeys(items[0])
+}
+
+func toAnyMap(v any) any {
+	data, err := json.Marshal(v)
+	if err != nil {
+		return v
+	}
+	var m map[string]any
+	if err := json.Unmarshal(data, &m); err != nil {
+		return v
+	}
+	return m
+}
+
+func joinAny(sep string, v any) string {
+	switch vv := v.(type) {
+	case nil:
+		return ""
+	case []string:
+		return strings.Join(vv, sep)
+	case []any:
+		parts := make([]string, len(vv))
+		for i, e := range vv {
+			parts[i] = fmt.Sprintf("%v", e)
+		}
+		return strings.Join(parts, sep)
+	default:
+		return fmt.Sprintf("%v", v)
+	}
+}
+
+func toMap(v any) map[string]string {
+	out := map[string]string{}
+	rv := reflect.ValueOf(v)
+	if rv.Kind() == reflect.Pointer {
+		rv = rv.Elem()
+	}
+	if rv.Kind() != reflect.Struct {
+		return out
+	}
+	rt := rv.Type()
+	for i := 0; i < rt.NumField(); i++ {
+		f := rt.Field(i)
+		if f.PkgPath != "" {
+			continue
+		}
+		key := jsonKey(f)
+		if key == "-" {
+			continue
+		}
+		out[key] = formatValue(rv.Field(i))
+	}
+	return out
+}
+
+func structJSONKeys(v any) []string {
+	rv := reflect.ValueOf(v)
+	if rv.Kind() == reflect.Pointer {
+		rv = rv.Elem()
+	}
+	if rv.Kind() != reflect.Struct {
+		return nil
+	}
+	rt := rv.Type()
+	var keys []string
+	for i := 0; i < rt.NumField(); i++ {
+		f := rt.Field(i)
+		if f.PkgPath != "" {
+			continue
+		}
+		key := jsonKey(f)
+		if key == "-" {
+			continue
+		}
+		keys = append(keys, key)
+	}
+	return keys
+}
+
+func jsonKey(f reflect.StructField) string {
+	tag := f.Tag.Get("json")
+	if tag == "" {
+		return f.Name
+	}
+	name := strings.Split(tag, ",")[0]
+	if name == "" {
+		return f.Name
+	}
+	return name
+}
+
+func formatValue(v reflect.Value) string {
+	switch v.Kind() {
+	case reflect.String:
+		return v.String()
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		return strconv.FormatInt(v.Int(), 10)
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		return strconv.FormatUint(v.Uint(), 10)
+	case reflect.Float32, reflect.Float64:
+		return strconv.FormatFloat(v.Float(), 'g', -1, 64)
+	case reflect.Bool:
+		return strconv.FormatBool(v.Bool())
+	case reflect.Slice:
+		parts := make([]string, v.Len())
+		for i := 0; i < v.Len(); i++ {
+			parts[i] = formatValue(v.Index(i))
+		}
+		return strings.Join(parts, ";")
+	case reflect.Struct:
+		if t, ok := v.Interface().(time.Time); ok {
+			if t.IsZero() {
+				return ""
+			}
+			return t.Format(time.RFC3339)
+		}
+	}
+	return fmt.Sprintf("%v", v.Interface())
+}
+
+func upperAll(ss []string) []string {
+	out := make([]string, len(ss))
+	for i, s := range ss {
+		out[i] = strings.ToUpper(s)
+	}
+	return out
+}
+
+func firstNonEmpty(ss ...string) string {
+	for _, s := range ss {
+		if s != "" {
+			return s
+		}
+	}
+	return ""
+}
+
+func truncate(s string, n int) string {
+	s = strings.ReplaceAll(s, "\n", " ")
+	if len([]rune(s)) <= n {
+		return s
+	}
+	rs := []rune(s)
+	return string(rs[:n-1]) + "..."
+}
