From 6591090f27b8eedd8df93234155f0d684bb39506 Mon Sep 17 00:00:00 2001 From: Duc-Tam Nguyen Date: Tue, 16 Jun 2026 11:16:31 +0700 Subject: [PATCH 1/2] Add export and info commands to mf Stats method computes total/first/latest from the Atom feed. New export and info commands expose bulk JSONL download and feed statistics. The render helper newRendererTo enables writing to arbitrary io.Writer. --- cli/cmd_article.go | 27 +++ cli/cmd_export.go | 41 ++++ cli/cmd_info.go | 24 ++ cli/cmd_latest.go | 19 ++ cli/cmd_search.go | 20 ++ cli/cmd_topics.go | 19 ++ cli/errors.go | 8 + cli/output.go | 34 +++ cli/root.go | 124 ++++++++++- go.mod | 29 +++ go.sum | 74 +++++++ martinfowler/martinfowler.go | 320 ++++++++++++++++++++++++--- martinfowler/martinfowler_test.go | 174 +++++++++++++-- martinfowler/types.go | 124 +++++++++++ pkg/render/render.go | 350 ++++++++++++++++++++++++++++++ 15 files changed, 1325 insertions(+), 62 deletions(-) create mode 100644 cli/cmd_article.go create mode 100644 cli/cmd_export.go create mode 100644 cli/cmd_info.go create mode 100644 cli/cmd_latest.go create mode 100644 cli/cmd_search.go create mode 100644 cli/cmd_topics.go create mode 100644 cli/errors.go create mode 100644 cli/output.go create mode 100644 go.sum create mode 100644 martinfowler/types.go create mode 100644 pkg/render/render.go diff --git a/cli/cmd_article.go b/cli/cmd_article.go new file mode 100644 index 0000000..dd35147 --- /dev/null +++ b/cli/cmd_article.go @@ -0,0 +1,27 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func (a *App) articleCmd() *cobra.Command { + return &cobra.Command{ + Use: "article ", + Short: "Show a Martin Fowler article (e.g. articles/microservices)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + a.progressf("fetching article %q...", args[0]) + text, err := a.client.Article(cmd.Context(), args[0]) + if err != nil { + return mapFetchErr(err) + } + if text == "" { + return codeError(exitNoData, nil) + } + _, _ = fmt.Println(text) + return nil + }, + } +} diff --git a/cli/cmd_export.go b/cli/cmd_export.go new file mode 100644 index 0000000..60bc154 --- /dev/null +++ b/cli/cmd_export.go @@ -0,0 +1,41 @@ +package cli + +import ( + "os" + + "github.com/spf13/cobra" +) + +func (a *App) exportCmd() *cobra.Command { + var outFile string + cmd := &cobra.Command{ + Use: "export", + Short: "Export all articles from the Atom feed as JSONL", + Long: `export fetches all entries from the Atom feed and writes one JSON record per line. + +Examples: + mf export > martinfowler.jsonl + mf export --out martinfowler.jsonl + mf export -f csv > martinfowler.csv`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + a.progressf("fetching all articles...") + articles, err := a.client.Latest(cmd.Context(), 0) + if err != nil { + return mapFetchErr(err) + } + if outFile != "" { + f, err := os.Create(outFile) + if err != nil { + return codeError(exitError, err) + } + defer f.Close() + r := a.newRendererTo(f) + return r.Render(articles) + } + return a.renderOrEmpty(articles, len(articles)) + }, + } + cmd.Flags().StringVar(&outFile, "out", "", "write output to FILE instead of stdout") + return cmd +} diff --git a/cli/cmd_info.go b/cli/cmd_info.go new file mode 100644 index 0000000..bfff290 --- /dev/null +++ b/cli/cmd_info.go @@ -0,0 +1,24 @@ +package cli + +import "github.com/spf13/cobra" + +func (a *App) infoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Show Martin Fowler blog statistics", + Long: `info prints aggregate statistics about the blog. + +Examples: + mf info + mf info -f json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + a.progressf("fetching feed stats...") + info, err := a.client.Stats(cmd.Context()) + if err != nil { + return mapFetchErr(err) + } + return a.render(info) + }, + } +} diff --git a/cli/cmd_latest.go b/cli/cmd_latest.go new file mode 100644 index 0000000..ca7223c --- /dev/null +++ b/cli/cmd_latest.go @@ -0,0 +1,19 @@ +package cli + +import "github.com/spf13/cobra" + +func (a *App) latestCmd() *cobra.Command { + return &cobra.Command{ + Use: "latest", + Short: "Show latest Martin Fowler articles", + RunE: func(cmd *cobra.Command, _ []string) error { + n := a.effectiveLimit(10) + a.progressf("fetching latest articles...") + articles, err := a.client.Latest(cmd.Context(), n) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(articles, len(articles)) + }, + } +} diff --git a/cli/cmd_search.go b/cli/cmd_search.go new file mode 100644 index 0000000..c177c3d --- /dev/null +++ b/cli/cmd_search.go @@ -0,0 +1,20 @@ +package cli + +import "github.com/spf13/cobra" + +func (a *App) searchCmd() *cobra.Command { + return &cobra.Command{ + Use: "search ", + Short: "Search Martin Fowler articles", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + n := a.effectiveLimit(10) + a.progressf("searching for %q...", args[0]) + articles, err := a.client.Search(cmd.Context(), args[0], n) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(articles, len(articles)) + }, + } +} diff --git a/cli/cmd_topics.go b/cli/cmd_topics.go new file mode 100644 index 0000000..c94373c --- /dev/null +++ b/cli/cmd_topics.go @@ -0,0 +1,19 @@ +package cli + +import "github.com/spf13/cobra" + +func (a *App) topicsCmd() *cobra.Command { + return &cobra.Command{ + Use: "topics", + Short: "List topics from Martin Fowler's feed", + RunE: func(cmd *cobra.Command, _ []string) error { + n := a.effectiveLimit(20) + a.progressf("fetching topics...") + topics, err := a.client.Topics(cmd.Context(), n) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(topics, len(topics)) + }, + } +} diff --git a/cli/errors.go b/cli/errors.go new file mode 100644 index 0000000..807e024 --- /dev/null +++ b/cli/errors.go @@ -0,0 +1,8 @@ +package cli + +func mapFetchErr(err error) error { + if err == nil { + return nil + } + return codeError(exitError, err) +} diff --git a/cli/output.go b/cli/output.go new file mode 100644 index 0000000..c5b0c8f --- /dev/null +++ b/cli/output.go @@ -0,0 +1,34 @@ +package cli + +import ( + "io" + + "github.com/tamnd/martinfowler-cli/pkg/render" +) + +// Format aliases so command code reads cleanly. +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 +) + +// NewRenderer builds a renderer writing to w. +func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *render.Renderer { + return render.New(w, format, fields, noHeader, tmpl) +} + +// newRendererTo builds a renderer writing to w using the App's settings. +func (a *App) newRendererTo(w io.Writer) *render.Renderer { + format := Format(a.output) + if !format.Valid() { + format = FormatJSONL + } + return NewRenderer(w, format, a.fields, a.noHeader, a.template) +} diff --git a/cli/root.go b/cli/root.go index 022083f..132d6d8 100644 --- a/cli/root.go +++ b/cli/root.go @@ -2,30 +2,142 @@ package cli import ( + "fmt" + "os" + + "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "github.com/tamnd/martinfowler-cli/martinfowler" ) -// Build metadata, set via -ldflags at release time. +// Build metadata, injected via -ldflags at release time. var ( Version = "dev" Commit = "none" Date = "unknown" ) +// exit codes. +const ( + exitError = 1 + exitUsage = 2 + exitNoData = 3 +) + +// ExitError carries a process exit code up to main. +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} } + +// App holds shared state threaded through every command. +type App struct { + client *martinfowler.Client + cfg martinfowler.Config + + output string + fields []string + noHeader bool + template string + limit int + quiet bool +} + // Root builds the root command and its subtree. func Root() *cobra.Command { + app := &App{cfg: martinfowler.DefaultConfig()} + root := &cobra.Command{ Use: "mf", Short: "Browse Martin Fowler's technical articles", - Long: `Browse Martin Fowler's technical articles + Long: `mf reads Martin Fowler's blog at martinfowler.com via its public Atom feed +and article pages. No API key required. It returns records as table, JSON, JSONL, +CSV, TSV, or URLs. -This is a fresh scaffold. Add your commands here on top of the martinfowler -library package, then wire them into Root with root.AddCommand.`, +mf is an independent tool and is not affiliated with Martin Fowler or ThoughtWorks.`, 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 (auto=table on TTY, jsonl piped)") + pf.StringSliceVar(&app.fields, "fields", nil, "comma-separated columns to include") + pf.BoolVar(&app.noHeader, "no-header", false, "omit the header row in table/csv/tsv") + pf.StringVar(&app.template, "template", "", "Go text/template applied 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 sent with each request") + + root.AddCommand( + app.latestCmd(), + app.searchCmd(), + app.articleCmd(), + app.topicsCmd(), + app.exportCmd(), + app.infoCmd(), + 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 = martinfowler.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 (a *App) effectiveLimit(def int) int { + if a.limit > 0 { + return a.limit + } + return def +} diff --git a/go.mod b/go.mod index dfdf880..a16e1fd 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/martinfowler/martinfowler.go b/martinfowler/martinfowler.go index 0b6fe97..5e5e37f 100644 --- a/martinfowler/martinfowler.go +++ b/martinfowler/martinfowler.go @@ -1,52 +1,76 @@ -// Package martinfowler is the library behind the mf command line: -// the HTTP client, request shaping, and the typed data models for martinfowler. +// Package martinfowler is the library behind the mf command: the HTTP client, +// request shaping, and the typed data models for Martin Fowler's blog. // // 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. package martinfowler import ( "context" + "encoding/xml" "fmt" "io" "net/http" + "strings" + "sync" "time" ) -// DefaultUserAgent identifies the client to martinfowler. A real, honest -// User-Agent is both polite and the thing most likely to keep you unblocked. +const ( + defaultBaseURL = "https://martinfowler.com" + feedPath = "/feed.atom" +) + +// DefaultUserAgent identifies the client to martinfowler.com. const DefaultUserAgent = "mf/dev (+https://github.com/tamnd/martinfowler-cli)" -// Client talks to martinfowler over HTTP. -type Client struct { - HTTP *http.Client +// Config holds constructor parameters for the Client. +type Config struct { + BaseURL string UserAgent string - // Rate is the minimum gap between requests. Zero means no pacing. - Rate time.Duration - Retries int - - last time.Time + Rate time.Duration + Retries int + Timeout time.Duration } -// 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 { - return &Client{ - HTTP: &http.Client{Timeout: 30 * time.Second}, +// DefaultConfig returns sensible defaults. +func DefaultConfig() Config { + return Config{ + BaseURL: defaultBaseURL, UserAgent: DefaultUserAgent, Rate: 200 * time.Millisecond, Retries: 5, + Timeout: 30 * time.Second, } } -// 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) { +// Client talks to martinfowler.com over HTTP. +type Client struct { + http *http.Client + userAgent string + baseURL string + rate time.Duration + retries int + mu sync.Mutex + last time.Time +} + +// NewClient returns a Client configured from cfg. +func NewClient(cfg Config) *Client { + return &Client{ + http: &http.Client{Timeout: cfg.Timeout}, + userAgent: cfg.UserAgent, + baseURL: cfg.BaseURL, + rate: cfg.Rate, + retries: cfg.Retries, + } +} + +// get fetches a URL with pacing and retries. +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.retries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): @@ -54,7 +78,7 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { case <-time.After(backoff(attempt)): } } - body, retry, err := c.do(ctx, url) + body, retry, err := c.do(ctx, rawURL) if err == nil { return body, nil } @@ -63,18 +87,19 @@ func (c *Client) Get(ctx context.Context, url string) ([]byte, error) { return nil, err } } - return nil, fmt.Errorf("get %s: %w", url, lastErr) + return nil, fmt.Errorf("get %s: %w", rawURL, 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.userAgent) + req.Header.Set("Accept", "application/atom+xml, application/xml, text/xml, text/html") - resp, err := c.HTTP.Do(req) + resp, err := c.http.Do(req) if err != nil { return nil, true, err } @@ -86,20 +111,20 @@ func (c *Client) do(ctx context.Context, url string) (body []byte, retry bool, e if resp.StatusCode != http.StatusOK { 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.rate <= 0 { return } - if wait := c.Rate - time.Since(c.last); wait > 0 { + if wait := c.rate - time.Since(c.last); wait > 0 { time.Sleep(wait) } c.last = time.Now() @@ -112,3 +137,230 @@ func backoff(attempt int) time.Duration { } return d } + +// fetchFeed fetches and parses the Atom feed, returning raw entries. +func (c *Client) fetchFeed(ctx context.Context) ([]atomEntry, error) { + body, err := c.get(ctx, c.baseURL+feedPath) + if err != nil { + return nil, fmt.Errorf("fetch feed: %w", err) + } + var feed atomFeed + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, fmt.Errorf("parse feed: %w", err) + } + return feed.Entries, nil +} + +// Latest returns the most recent articles from the Atom feed. +func (c *Client) Latest(ctx context.Context, limit int) ([]Article, error) { + entries, err := c.fetchFeed(ctx) + if err != nil { + return nil, err + } + if limit > 0 && limit < len(entries) { + entries = entries[:limit] + } + out := make([]Article, len(entries)) + for i, e := range entries { + out[i] = entryToArticle(e) + } + return out, nil +} + +// Search fetches the feed and filters entries whose title or summary +// contains query (case-insensitive). +func (c *Client) Search(ctx context.Context, query string, limit int) ([]Article, error) { + entries, err := c.fetchFeed(ctx) + if err != nil { + return nil, err + } + q := strings.ToLower(query) + var out []Article + for _, e := range entries { + art := entryToArticle(e) + if strings.Contains(strings.ToLower(art.Title), q) || + strings.Contains(strings.ToLower(art.Summary), q) { + out = append(out, art) + if limit > 0 && len(out) >= limit { + break + } + } + } + return out, nil +} + +// Article fetches and returns text content for the given path slug +// (e.g. "articles/microservices"). +func (c *Client) Article(ctx context.Context, slug string) (string, error) { + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + body, err := c.get(ctx, c.baseURL+slug) + if err != nil { + return "", fmt.Errorf("fetch article: %w", err) + } + text := extractArticleText(string(body)) + return text, nil +} + +// Topics returns a ranked list of unique topics from the feed. +func (c *Client) Topics(ctx context.Context, limit int) ([]Topic, error) { + entries, err := c.fetchFeed(ctx) + if err != nil { + return nil, err + } + counts := map[string]int{} + order := []string{} + for _, e := range entries { + for _, cat := range e.Categories { + t := cat.Term + if t == "" { + continue + } + if counts[t] == 0 { + order = append(order, t) + } + counts[t]++ + } + } + out := make([]Topic, 0, len(order)) + for _, name := range order { + out = append(out, Topic{Name: name, Count: counts[name]}) + } + if limit > 0 && limit < len(out) { + out = out[:limit] + } + return out, nil +} + +// extractArticleText pulls readable text from an HTML page using stdlib only. +// It skips