Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v7.0.0
with:
submodules: true
# Sitemap lastmod comes from the latest content commit.
fetch-depth: 0

- name: Checkout tago
uses: actions/checkout@v6.0.2
uses: actions/checkout@v7.0.0
with:
repository: tamnd/tago
path: .tago-src
Expand Down Expand Up @@ -107,7 +107,7 @@ jobs:
group: cloudflare-pages-martinfowler-cli
cancel-in-progress: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v7.0.0
with:
fetch-depth: 1
sparse-checkout: scripts/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:

# Tools GoReleaser shells out to for signing and SBOMs.
- uses: sigstore/cosign-installer@v3
- uses: anchore/sbom-action/download-syft@v0
- uses: anchore/sbom-action/download-syft@v0.24.0

- uses: goreleaser/goreleaser-action@v6
with:
Expand Down
27 changes: 27 additions & 0 deletions cli/cmd_article.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
)

func (a *App) articleCmd() *cobra.Command {
return &cobra.Command{
Use: "article <slug>",
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
},
}
}
41 changes: 41 additions & 0 deletions cli/cmd_export.go
Original file line number Diff line number Diff line change
@@ -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()

Check failure on line 32 in cli/cmd_export.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `f.Close` is not checked (errcheck)
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
}
24 changes: 24 additions & 0 deletions cli/cmd_info.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
19 changes: 19 additions & 0 deletions cli/cmd_latest.go
Original file line number Diff line number Diff line change
@@ -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))
},
}
}
20 changes: 20 additions & 0 deletions cli/cmd_search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cli

import "github.com/spf13/cobra"

func (a *App) searchCmd() *cobra.Command {
return &cobra.Command{
Use: "search <query>",
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))
},
}
}
19 changes: 19 additions & 0 deletions cli/cmd_topics.go
Original file line number Diff line number Diff line change
@@ -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))
},
}
}
8 changes: 8 additions & 0 deletions cli/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package cli

func mapFetchErr(err error) error {
if err == nil {
return nil
}
return codeError(exitError, err)
}
34 changes: 34 additions & 0 deletions cli/output.go
Original file line number Diff line number Diff line change
@@ -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)
}
124 changes: 118 additions & 6 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading